1- import { useState , useEffect } from "react" ;
1+ import { useState , useEffect , useMemo } from "react" ;
22import { BarChart3 } from "lucide-react" ;
33import { cn } from "@/lib/utils" ;
44import type { CommitGroup , EvalSuiteOverviewEntry } from "./types" ;
@@ -114,25 +114,37 @@ export function CiSuiteListSidebar({
114114 ? suites . filter ( ( e ) => e . suite . tags ?. includes ( filterTag ) )
115115 : suites ;
116116
117+ // Group suites by name, keeping the most recent one as the "primary" entry
118+ const groupedSuites = useMemo ( ( ) => {
119+ const groups = new Map < string , EvalSuiteOverviewEntry [ ] > ( ) ;
120+ for ( const entry of filteredSuites ) {
121+ const name = entry . suite . name || "Untitled suite" ;
122+ if ( ! groups . has ( name ) ) {
123+ groups . set ( name , [ ] ) ;
124+ }
125+ groups . get ( name ) ! . push ( entry ) ;
126+ }
127+ // Sort each group by latest run time (most recent first)
128+ for ( const entries of groups . values ( ) ) {
129+ entries . sort ( ( a , b ) => {
130+ const aTime = a . latestRun ?. completedAt ?? a . latestRun ?. createdAt ?? a . suite . updatedAt ?? 0 ;
131+ const bTime = b . latestRun ?. completedAt ?? b . latestRun ?. createdAt ?? b . suite . updatedAt ?? 0 ;
132+ return bTime - aTime ;
133+ } ) ;
134+ }
135+ return groups ;
136+ } , [ filteredSuites ] ) ;
137+
138+ const uniqueSuiteCount = groupedSuites . size ;
139+
140+ const failCount = suites . filter (
141+ ( e ) => e . latestRun ?. result === "failed" ,
142+ ) . length ;
143+
117144 return (
118145 < div className = "flex h-full flex-col" >
119- < div className = "border-b px-4 py-3" >
120- < div className = "flex items-center justify-between" >
121- < h2 className = "text-sm font-semibold" >
122- { sidebarMode === "suites" ? "Eval suites" : "Runs by commit" }
123- </ h2 >
124- { sidebarMode === "suites" && filteredSuites . length > 0 && (
125- < span className = "text-[10px] text-muted-foreground tabular-nums" >
126- { filteredSuites . length }
127- </ span >
128- ) }
129- { sidebarMode === "runs" && commitGroups . length > 0 && (
130- < span className = "text-[10px] text-muted-foreground tabular-nums" >
131- { commitGroups . length }
132- </ span >
133- ) }
134- </ div >
135- < div className = "mt-2 flex rounded-md border bg-muted/50 p-0.5" >
146+ < div className = "border-b px-4 py-3 space-y-2" >
147+ < div className = "flex rounded-md border bg-muted/50 p-0.5" >
136148 < button
137149 onClick = { ( ) => onSidebarModeChange ( "runs" ) }
138150 className = { cn (
@@ -142,7 +154,7 @@ export function CiSuiteListSidebar({
142154 : "text-muted-foreground hover:text-foreground" ,
143155 ) }
144156 >
145- Runs
157+ By Commit
146158 </ button >
147159 < button
148160 onClick = { ( ) => onSidebarModeChange ( "suites" ) }
@@ -153,39 +165,31 @@ export function CiSuiteListSidebar({
153165 : "text-muted-foreground hover:text-foreground" ,
154166 ) }
155167 >
156- Suites
168+ By Suite
157169 </ button >
158170 </ div >
159171 </ div >
160172
161- { /* Overview button — always visible regardless of sidebar mode */ }
162- < button
163- onClick = { onSelectOverview }
164- className = { cn (
165- "w-full px-4 py-2.5 text-left transition-colors hover:bg-accent/50 border-b" ,
166- isOverviewSelected && "bg-accent" ,
167- ) }
168- >
169- < div className = "flex items-center gap-2.5" >
170- < BarChart3 className = "h-4 w-4 shrink-0 text-muted-foreground" />
171- < div className = "min-w-0 flex-1" >
172- < div className = "text-sm font-medium" > Overview</ div >
173- < div className = "text-[11px] text-muted-foreground" >
174- Suite health & status
175- </ div >
176- </ div >
177- { ( ( ) => {
178- const failCount = suites . filter (
179- ( e ) => e . latestRun ?. result === "failed" ,
180- ) . length ;
181- return failCount > 0 ? (
182- < span className = "shrink-0 flex h-5 min-w-[20px] items-center justify-center rounded-full bg-destructive px-1.5 text-[10px] font-bold text-destructive-foreground" >
183- { failCount }
184- </ span >
185- ) : null ;
186- } ) ( ) }
187- </ div >
188- </ button >
173+ { /* Dashboard button — always visible regardless of sidebar mode */ }
174+ < div className = "px-3 py-2 border-b" >
175+ < button
176+ onClick = { onSelectOverview }
177+ className = { cn (
178+ "w-full flex items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-xs font-medium transition-colors cursor-pointer border border-transparent" ,
179+ isOverviewSelected
180+ ? "bg-primary/15 text-primary border-primary/30"
181+ : "text-muted-foreground hover:bg-accent hover:text-foreground hover:border-border" ,
182+ ) }
183+ >
184+ < BarChart3 className = "h-3.5 w-3.5 shrink-0" />
185+ < span className = "flex-1" > Dashboard</ span >
186+ { failCount > 0 && (
187+ < span className = "shrink-0 flex h-4 min-w-[16px] items-center justify-center rounded-full bg-destructive px-1 text-[9px] font-bold text-destructive-foreground" >
188+ { failCount }
189+ </ span >
190+ ) }
191+ </ button >
192+ </ div >
189193
190194 { sidebarMode === "runs" ? (
191195 < CommitListSidebar
@@ -206,73 +210,196 @@ export function CiSuiteListSidebar({
206210 </ div >
207211 ) : (
208212 < div >
209- { filteredSuites . map ( ( entry ) => {
210- const latestRun = entry . latestRun ;
211- const status = getStatusInfo ( entry ) ;
212- const trend = entry . passRateTrend
213- . slice ( - 12 )
214- . map ( ( value ) => toPercent ( value ) ) ;
215- const timestamp = formatRelativeTime (
216- latestRun ?. completedAt ??
217- latestRun ?. createdAt ??
218- entry . suite . updatedAt ,
219- ) ;
220-
221- return (
222- < button
223- key = { entry . suite . _id }
224- onClick = { ( ) => onSelectSuite ( entry . suite . _id ) }
225- className = { cn (
226- "w-full px-4 py-2.5 text-left transition-colors hover:bg-accent/50" ,
227- selectedSuiteId === entry . suite . _id && "bg-accent" ,
228- ) }
229- >
230- < div className = "flex items-center gap-2.5" >
231- < div className = "flex flex-col items-center gap-0.5 shrink-0 w-[3.25rem]" >
232- < div
233- className = { cn ( "h-2 w-2 rounded-full" , status . dotClass ) }
234- />
235- < span
236- className = { cn (
237- "text-[9px] font-medium leading-none" ,
238- status . labelClass ,
239- ) }
240- >
241- { status . label }
242- </ span >
243- </ div >
244- < div className = "min-w-0 flex-1" >
245- < div className = "truncate text-sm font-medium" >
246- { entry . suite . name || "Untitled suite" }
247- </ div >
248- { entry . suite . tags && entry . suite . tags . length > 0 && (
249- < TagBadges tags = { entry . suite . tags } className = "mt-0.5" />
250- ) }
251- < div className = "text-[11px] text-muted-foreground" >
252- { timestamp }
253- </ div >
254- </ div >
255- { trend . length > 0 && (
256- < div className = "flex h-5 shrink-0 items-end gap-px" >
257- { trend . map ( ( value , idx ) => (
258- < div
259- key = { `${ entry . suite . _id } -t-${ idx } ` }
260- className = "w-1 rounded-sm bg-primary/70"
261- style = { {
262- height : `${ Math . max ( 3 , ( value / 100 ) * 20 ) } px` ,
263- } }
264- />
265- ) ) }
266- </ div >
267- ) }
268- </ div >
269- </ button >
270- ) ;
271- } ) }
213+ { Array . from ( groupedSuites . entries ( ) ) . map ( ( [ suiteName , entries ] ) => (
214+ < SuiteGroupItem
215+ key = { suiteName }
216+ suiteName = { suiteName }
217+ entries = { entries }
218+ selectedSuiteId = { selectedSuiteId }
219+ onSelectSuite = { onSelectSuite }
220+ />
221+ ) ) }
272222 </ div >
273223 ) }
274224 </ div >
275225 ) }
276226 </ div >
277227 ) ;
278228}
229+
230+ function SuiteGroupItem ( {
231+ suiteName,
232+ entries,
233+ selectedSuiteId,
234+ onSelectSuite,
235+ } : {
236+ suiteName : string ;
237+ entries : EvalSuiteOverviewEntry [ ] ;
238+ selectedSuiteId : string | null ;
239+ onSelectSuite : ( suiteId : string ) => void ;
240+ } ) {
241+ const primary = entries [ 0 ] ; // most recent
242+ const hasMultiple = entries . length > 1 ;
243+ const isAnySelected = entries . some ( ( e ) => e . suite . _id === selectedSuiteId ) ;
244+ const [ expanded , setExpanded ] = useState ( false ) ;
245+
246+ const latestRun = primary . latestRun ;
247+ const status = getStatusInfo ( primary ) ;
248+ const trend = primary . passRateTrend . slice ( - 12 ) . map ( ( value ) => toPercent ( value ) ) ;
249+ const timestamp = formatRelativeTime (
250+ latestRun ?. completedAt ?? latestRun ?. createdAt ?? primary . suite . updatedAt ,
251+ ) ;
252+
253+ // For single-entry groups, render directly
254+ if ( ! hasMultiple ) {
255+ return (
256+ < SuiteEntryButton
257+ entry = { primary }
258+ isSelected = { selectedSuiteId === primary . suite . _id }
259+ onSelect = { ( ) => onSelectSuite ( primary . suite . _id ) }
260+ status = { status }
261+ trend = { trend }
262+ timestamp = { timestamp }
263+ />
264+ ) ;
265+ }
266+
267+ // For multi-entry groups, render as expandable group
268+ return (
269+ < div >
270+ < button
271+ onClick = { ( ) => {
272+ if ( ! isAnySelected ) {
273+ // Click selects the most recent entry
274+ onSelectSuite ( primary . suite . _id ) ;
275+ } else {
276+ setExpanded ( ! expanded ) ;
277+ }
278+ } }
279+ className = { cn (
280+ "w-full px-4 py-2.5 text-left transition-colors hover:bg-accent/50" ,
281+ isAnySelected && "bg-accent shadow-sm" ,
282+ ) }
283+ >
284+ < div className = "flex items-center gap-2.5" >
285+ < div className = "flex flex-col items-center gap-0.5 shrink-0 w-[3.25rem]" >
286+ < div className = { cn ( "h-2 w-2 rounded-full" , status . dotClass ) } />
287+ < span className = { cn ( "text-[9px] font-medium leading-none" , status . labelClass ) } >
288+ { status . label }
289+ </ span >
290+ </ div >
291+ < div className = "min-w-0 flex-1" >
292+ < div className = "flex items-center gap-1.5" >
293+ < span className = { cn ( "truncate text-sm font-medium" , isAnySelected && "font-semibold" ) } >
294+ { suiteName }
295+ </ span >
296+ < span className = "shrink-0 flex h-4 min-w-[16px] items-center justify-center rounded-full bg-muted px-1 text-[9px] font-medium text-muted-foreground" >
297+ { entries . length }
298+ </ span >
299+ </ div >
300+ { primary . suite . tags && primary . suite . tags . length > 0 && (
301+ < TagBadges tags = { primary . suite . tags } className = "mt-0.5" />
302+ ) }
303+ < div className = "text-[11px] text-muted-foreground" > { timestamp } </ div >
304+ </ div >
305+ { trend . length >= 3 && (
306+ < div className = "flex h-5 shrink-0 items-end gap-px" >
307+ { trend . map ( ( value , idx ) => (
308+ < div
309+ key = { `${ primary . suite . _id } -t-${ idx } ` }
310+ className = { cn ( "w-1 rounded-sm" , value >= 80 ? "bg-emerald-500/70" : value >= 50 ? "bg-amber-500/70" : "bg-destructive/70" ) }
311+ style = { { height : `${ Math . max ( 3 , ( value / 100 ) * 20 ) } px` } }
312+ />
313+ ) ) }
314+ </ div >
315+ ) }
316+ </ div >
317+ </ button >
318+ { ( expanded || isAnySelected ) && entries . length > 1 && (
319+ < div className = "border-l-2 border-muted ml-6" >
320+ { entries . map ( ( entry ) => {
321+ const entryStatus = getStatusInfo ( entry ) ;
322+ const entryTimestamp = formatRelativeTime (
323+ entry . latestRun ?. completedAt ?? entry . latestRun ?. createdAt ?? entry . suite . updatedAt ,
324+ ) ;
325+ return (
326+ < button
327+ key = { entry . suite . _id }
328+ onClick = { ( ) => onSelectSuite ( entry . suite . _id ) }
329+ className = { cn (
330+ "w-full px-3 py-1.5 text-left transition-colors hover:bg-accent/50" ,
331+ selectedSuiteId === entry . suite . _id && "bg-primary/10 border-r-2 border-r-primary" ,
332+ ) }
333+ >
334+ < div className = "flex items-center gap-2" >
335+ < div className = { cn ( "h-1.5 w-1.5 rounded-full shrink-0" , entryStatus . dotClass ) } />
336+ < span className = "text-[11px] text-muted-foreground truncate flex-1" >
337+ { entryTimestamp }
338+ </ span >
339+ < span className = { cn ( "text-[10px] font-medium" , entryStatus . labelClass ) } >
340+ { entryStatus . label }
341+ </ span >
342+ </ div >
343+ </ button >
344+ ) ;
345+ } ) }
346+ </ div >
347+ ) }
348+ </ div >
349+ ) ;
350+ }
351+
352+ function SuiteEntryButton ( {
353+ entry,
354+ isSelected,
355+ onSelect,
356+ status,
357+ trend,
358+ timestamp,
359+ } : {
360+ entry : EvalSuiteOverviewEntry ;
361+ isSelected : boolean ;
362+ onSelect : ( ) => void ;
363+ status : { label : string ; dotClass : string ; labelClass : string } ;
364+ trend : number [ ] ;
365+ timestamp : string ;
366+ } ) {
367+ return (
368+ < button
369+ onClick = { onSelect }
370+ className = { cn (
371+ "w-full px-4 py-2.5 text-left transition-colors hover:bg-accent/50" ,
372+ isSelected && "bg-accent shadow-sm" ,
373+ ) }
374+ >
375+ < div className = "flex items-center gap-2.5" >
376+ < div className = "flex flex-col items-center gap-0.5 shrink-0 w-[3.25rem]" >
377+ < div className = { cn ( "h-2 w-2 rounded-full" , status . dotClass ) } />
378+ < span className = { cn ( "text-[9px] font-medium leading-none" , status . labelClass ) } >
379+ { status . label }
380+ </ span >
381+ </ div >
382+ < div className = "min-w-0 flex-1" >
383+ < div className = { cn ( "truncate text-sm font-medium" , isSelected && "font-semibold" ) } >
384+ { entry . suite . name || "Untitled suite" }
385+ </ div >
386+ { entry . suite . tags && entry . suite . tags . length > 0 && (
387+ < TagBadges tags = { entry . suite . tags } className = "mt-0.5" />
388+ ) }
389+ < div className = "text-[11px] text-muted-foreground" > { timestamp } </ div >
390+ </ div >
391+ { trend . length >= 3 && (
392+ < div className = "flex h-5 shrink-0 items-end gap-px" >
393+ { trend . map ( ( value , idx ) => (
394+ < div
395+ key = { `${ entry . suite . _id } -t-${ idx } ` }
396+ className = "w-1 rounded-sm bg-primary/70"
397+ style = { { height : `${ Math . max ( 3 , ( value / 100 ) * 20 ) } px` } }
398+ />
399+ ) ) }
400+ </ div >
401+ ) }
402+ </ div >
403+ </ button >
404+ ) ;
405+ }
0 commit comments