6262 main {
6363 height : calc (100vh - 56px );
6464 display : grid;
65- grid-template-columns : minmax (0 , 1fr ) 410px ;
65+ grid-template-columns : 280 px minmax (0 , 1fr ) 410px ;
6666 min-height : 0 ;
6767 }
68+ main .run-collapsed {
69+ grid-template-columns : 58px minmax (0 , 1fr ) 410px ;
70+ }
71+ .run-panel {
72+ min-width : 0 ;
73+ overflow : auto;
74+ border-right : 1px solid var (--line );
75+ background : var (--panel );
76+ padding : 12px ;
77+ }
78+ .run-panel-header {
79+ display : flex;
80+ align-items : center;
81+ gap : 6px ;
82+ margin-bottom : 10px ;
83+ font-weight : 650 ;
84+ cursor : pointer;
85+ user-select : none;
86+ }
87+ .run-panel .collapsed {
88+ width : 42px ;
89+ padding : 12px 8px ;
90+ }
91+ .run-panel .collapsed .run-tree {
92+ display : none;
93+ }
94+ .run-panel .collapsed .run-panel-title {
95+ writing-mode : vertical-rl;
96+ transform : rotate (180deg );
97+ }
98+ .run-toggle {
99+ display : inline-block;
100+ width : 0 ;
101+ height : 0 ;
102+ border-left : 5px solid transparent;
103+ border-right : 5px solid transparent;
104+ border-top : 7px solid # 334155 ;
105+ transition : transform 120ms ease;
106+ }
107+ .run-panel .collapsed .run-toggle {
108+ transform : rotate (-90deg );
109+ }
110+ .run-tree {
111+ font-size : 13px ;
112+ line-height : 1.4 ;
113+ }
114+ .tree-node {
115+ margin : 2px 0 ;
116+ }
117+ .tree-row {
118+ display : flex;
119+ align-items : center;
120+ gap : 5px ;
121+ min-height : 24px ;
122+ padding : 2px 4px ;
123+ border-radius : 5px ;
124+ cursor : pointer;
125+ user-select : none;
126+ }
127+ .tree-row : hover {
128+ background : # eef2f7 ;
129+ }
130+ .tree-row .active {
131+ background : # dfeeea ;
132+ color : # 0f5f56 ;
133+ font-weight : 650 ;
134+ }
135+ .tree-children {
136+ margin-left : 14px ;
137+ }
138+ .tree-node .collapsed > .tree-children {
139+ display : none;
140+ }
141+ .tree-caret {
142+ width : 12px ;
143+ color : # 64748b ;
144+ flex : 0 0 12px ;
145+ }
146+ .tree-name {
147+ overflow : hidden;
148+ text-overflow : ellipsis;
149+ white-space : nowrap;
150+ min-width : 0 ;
151+ }
152+ .tree-meta {
153+ margin-left : auto;
154+ color : var (--muted );
155+ font-size : 11px ;
156+ }
68157 # graph-wrap {
69158 position : relative;
70159 min-width : 0 ;
355444 font-family : ui-monospace, SFMono-Regular, Consolas, "Liberation Mono" , monospace;
356445 }
357446 @media (max-width : 900px ) {
358- main { grid-template-columns : 1fr ; grid-template-rows : minmax (430px , 58vh ) minmax (320px , 1fr ); }
447+ main { grid-template-columns : 1fr ; grid-template-rows : auto minmax (430px , 58vh ) minmax (320px , 1fr ); }
448+ .run-panel { border-right : 0 ; border-bottom : 1px solid var (--line ); max-height : 240px ; }
449+ .run-panel .collapsed { width : auto; }
450+ .run-panel .collapsed .run-panel-title { writing-mode : horizontal-tb; transform : none; }
359451 aside { border-left : 0 ; border-top : 1px solid var (--line ); }
360452 header { flex-wrap : wrap; }
361453 .legend { position : static; width : auto; margin : 10px ; }
365457< body >
366458 < header >
367459 < h1 > SR Agent Search Viewer</ h1 >
368- < select id ="run-select " aria-label ="Run "> </ select >
369460 < button id ="refresh "> Refresh</ button >
370461 < button id ="layout "> Auto Layout</ button >
371462 < button id ="weak-edges "> Hide Weak Edges</ button >
@@ -379,6 +470,13 @@ <h1>SR Agent Search Viewer</h1>
379470 < span id ="status " class ="muted "> </ span >
380471 </ header >
381472 < main >
473+ < nav id ="run-panel " class ="run-panel " aria-label ="Runs ">
474+ < div id ="run-panel-header " class ="run-panel-header ">
475+ < span class ="run-toggle "> </ span >
476+ < span class ="run-panel-title "> Runs</ span >
477+ </ div >
478+ < div id ="run-tree " class ="run-tree "> </ div >
479+ </ nav >
382480 < section id ="graph-wrap ">
383481 < div id ="legend " class ="legend ">
384482 < strong id ="legend-title "> < span class ="legend-toggle "> </ span > < span > Legend</ span > </ strong >
@@ -420,7 +518,6 @@ <h1>SR Agent Search Viewer</h1>
420518 const ROOT_PREFIX = "__root__:" ;
421519 const LEVEL_GAP = 118 ;
422520 const SIBLING_GAP = 64 ;
423- const runSelect = document . getElementById ( "run-select" ) ;
424521 const refreshButton = document . getElementById ( "refresh" ) ;
425522 const layoutButton = document . getElementById ( "layout" ) ;
426523 const weakEdgesButton = document . getElementById ( "weak-edges" ) ;
@@ -429,6 +526,10 @@ <h1>SR Agent Search Viewer</h1>
429526 const exportButton = document . getElementById ( "export" ) ;
430527 const legend = document . getElementById ( "legend" ) ;
431528 const legendTitle = document . getElementById ( "legend-title" ) ;
529+ const main = document . querySelector ( "main" ) ;
530+ const runPanel = document . getElementById ( "run-panel" ) ;
531+ const runPanelHeader = document . getElementById ( "run-panel-header" ) ;
532+ const runTree = document . getElementById ( "run-tree" ) ;
432533 const statusEl = document . getElementById ( "status" ) ;
433534 const svg = document . getElementById ( "graph" ) ;
434535 const state = {
@@ -442,7 +543,9 @@ <h1>SR Agent Search Viewer</h1>
442543 panning : null ,
443544 view : { x : 0 , y : 0 , scale : 1 } ,
444545 showWeakEdges : true ,
445- showLabels : true
546+ showLabels : true ,
547+ runs : [ ] ,
548+ collapsedRunNodes : new Set ( )
446549 } ;
447550 const mathJaxReady = new Promise ( resolve => {
448551 const wait = ( ) => {
@@ -470,7 +573,10 @@ <h1>SR Agent Search Viewer</h1>
470573 } ) ;
471574 exportButton . addEventListener ( "click" , ( ) => exportScene ( exportFormat . value ) ) ;
472575 legendTitle . addEventListener ( "click" , ( ) => legend . classList . toggle ( "collapsed" ) ) ;
473- runSelect . addEventListener ( "change" , ( ) => selectRun ( runSelect . value ) ) ;
576+ runPanelHeader . addEventListener ( "click" , ( ) => {
577+ runPanel . classList . toggle ( "collapsed" ) ;
578+ main . classList . toggle ( "run-collapsed" , runPanel . classList . contains ( "collapsed" ) ) ;
579+ } ) ;
474580 svg . addEventListener ( "pointermove" , onPointerMove ) ;
475581 svg . addEventListener ( "pointerup" , onPointerUp ) ;
476582 svg . addEventListener ( "pointerleave" , onPointerUp ) ;
@@ -479,23 +585,113 @@ <h1>SR Agent Search Viewer</h1>
479585
480586 async function loadRuns ( ) {
481587 const data = await fetchJson ( "/api/runs" ) ;
482- runSelect . innerHTML = "" ;
483- for ( const run of data . runs ) {
484- const option = document . createElement ( "option" ) ;
485- option . value = run . run_id ;
486- option . textContent = `${ run . run_id } (${ run . record_count } )` ;
487- runSelect . appendChild ( option ) ;
488- }
489- if ( data . runs . length && ( ! state . runId || ! data . runs . some ( run => run . run_id === state . runId ) ) ) {
490- selectRun ( data . runs [ 0 ] . run_id ) ;
588+ state . runs = data . runs || [ ] ;
589+ renderRunTree ( state . runs ) ;
590+ if ( data . runs . length && ( ! state . runId || ! data . runs . some ( run => runKey ( run ) === state . runId ) ) ) {
591+ selectRun ( runKey ( data . runs [ 0 ] ) ) ;
491592 }
492593 statusEl . textContent = data . runs . length ? "" : "No runs with records.jsonl found" ;
493594 }
494595
596+ function renderRunTree ( runs ) {
597+ runTree . innerHTML = "" ;
598+ if ( ! runs . length ) {
599+ const empty = document . createElement ( "div" ) ;
600+ empty . className = "muted" ;
601+ empty . textContent = "No records.jsonl found." ;
602+ runTree . appendChild ( empty ) ;
603+ return ;
604+ }
605+ const tree = buildRunTree ( runs ) ;
606+ runTree . appendChild ( renderTreeChildren ( tree . children ) ) ;
607+ }
608+
609+ function buildRunTree ( runs ) {
610+ const root = { name : "" , key : "" , children : new Map ( ) , run : null } ;
611+ for ( const run of runs ) {
612+ const parts = runPathParts ( run ) ;
613+ let node = root ;
614+ let pathKey = "" ;
615+ for ( const part of parts ) {
616+ pathKey = pathKey ? `${ pathKey } /${ part } ` : part ;
617+ if ( ! node . children . has ( part ) ) {
618+ node . children . set ( part , { name : part , key : pathKey , children : new Map ( ) , run : null } ) ;
619+ }
620+ node = node . children . get ( part ) ;
621+ }
622+ node . run = run ;
623+ }
624+ return root ;
625+ }
626+
627+ function runPathParts ( run ) {
628+ const path = String ( run . path || run . run_id || "" ) . replaceAll ( "\\" , "/" ) ;
629+ const marker = "/logs/" ;
630+ const index = path . toLowerCase ( ) . lastIndexOf ( marker ) ;
631+ const rel = index >= 0 ? path . slice ( index + marker . length ) : ( run . run_id || path . split ( "/" ) . pop ( ) ) ;
632+ return rel . split ( "/" ) . filter ( Boolean ) ;
633+ }
634+
635+ function runKey ( run ) {
636+ return run . run_key || run . run_id ;
637+ }
638+
639+ function renderTreeChildren ( children ) {
640+ const container = document . createElement ( "div" ) ;
641+ [ ...children . values ( ) ]
642+ . sort ( ( a , b ) => a . name . localeCompare ( b . name ) )
643+ . forEach ( node => container . appendChild ( renderTreeNode ( node ) ) ) ;
644+ return container ;
645+ }
646+
647+ function renderTreeNode ( node ) {
648+ const wrap = document . createElement ( "div" ) ;
649+ wrap . className = "tree-node" ;
650+ if ( ! node . run && state . collapsedRunNodes . has ( node . key ) ) wrap . classList . add ( "collapsed" ) ;
651+ const row = document . createElement ( "div" ) ;
652+ row . className = "tree-row" ;
653+ if ( node . run && runKey ( node . run ) === state . runId ) row . classList . add ( "active" ) ;
654+ const caret = document . createElement ( "span" ) ;
655+ caret . className = "tree-caret" ;
656+ caret . textContent = node . children . size
657+ ? ( wrap . classList . contains ( "collapsed" ) ? "▸" : "▾" )
658+ : "•" ;
659+ const name = document . createElement ( "span" ) ;
660+ name . className = "tree-name" ;
661+ name . textContent = node . name ;
662+ row . append ( caret , name ) ;
663+ if ( node . run ) {
664+ const meta = document . createElement ( "span" ) ;
665+ meta . className = "tree-meta" ;
666+ meta . textContent = node . run . record_count ;
667+ row . appendChild ( meta ) ;
668+ row . addEventListener ( "click" , event => {
669+ event . stopPropagation ( ) ;
670+ selectRun ( runKey ( node . run ) ) ;
671+ } ) ;
672+ } else {
673+ row . addEventListener ( "click" , event => {
674+ event . stopPropagation ( ) ;
675+ wrap . classList . toggle ( "collapsed" ) ;
676+ if ( wrap . classList . contains ( "collapsed" ) ) state . collapsedRunNodes . add ( node . key ) ;
677+ else state . collapsedRunNodes . delete ( node . key ) ;
678+ caret . textContent = wrap . classList . contains ( "collapsed" ) ? "▸" : "▾" ;
679+ } ) ;
680+ }
681+ wrap . appendChild ( row ) ;
682+ if ( node . children . size ) {
683+ const childWrap = renderTreeChildren ( node . children ) ;
684+ childWrap . className = "tree-children" ;
685+ wrap . appendChild ( childWrap ) ;
686+ }
687+ return wrap ;
688+ }
689+
495690 async function selectRun ( runId ) {
496691 if ( ! runId ) return ;
497692 if ( state . source ) state . source . close ( ) ;
498693 state . runId = runId ;
694+ renderRunTree ( state . runs ) ;
499695 state . records . clear ( ) ;
500696 state . positions . clear ( ) ;
501697 state . userMoved . clear ( ) ;
0 commit comments