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" ;
@@ -94,25 +94,37 @@ export function CiSuiteListSidebar({
9494 ? suites . filter ( ( e ) => e . suite . tags ?. includes ( filterTag ) )
9595 : suites ;
9696
97+ // Group suites by name, keeping the most recent one as the "primary" entry
98+ const groupedSuites = useMemo ( ( ) => {
99+ const groups = new Map < string , EvalSuiteOverviewEntry [ ] > ( ) ;
100+ for ( const entry of filteredSuites ) {
101+ const name = entry . suite . name || "Untitled suite" ;
102+ if ( ! groups . has ( name ) ) {
103+ groups . set ( name , [ ] ) ;
104+ }
105+ groups . get ( name ) ! . push ( entry ) ;
106+ }
107+ // Sort each group by latest run time (most recent first)
108+ for ( const entries of groups . values ( ) ) {
109+ entries . sort ( ( a , b ) => {
110+ const aTime = a . latestRun ?. completedAt ?? a . latestRun ?. createdAt ?? a . suite . updatedAt ?? 0 ;
111+ const bTime = b . latestRun ?. completedAt ?? b . latestRun ?. createdAt ?? b . suite . updatedAt ?? 0 ;
112+ return bTime - aTime ;
113+ } ) ;
114+ }
115+ return groups ;
116+ } , [ filteredSuites ] ) ;
117+
118+ const uniqueSuiteCount = groupedSuites . size ;
119+
120+ const failCount = suites . filter (
121+ ( e ) => e . latestRun ?. result === "failed" ,
122+ ) . length ;
123+
97124 return (
98125 < div className = "flex h-full flex-col" >
99- < div className = "border-b px-4 py-3" >
100- < div className = "flex items-center justify-between" >
101- < h2 className = "text-sm font-semibold" >
102- { sidebarMode === "suites" ? "Eval suites" : "Runs by commit" }
103- </ h2 >
104- { sidebarMode === "suites" && filteredSuites . length > 0 && (
105- < span className = "text-[10px] text-muted-foreground tabular-nums" >
106- { filteredSuites . length }
107- </ span >
108- ) }
109- { sidebarMode === "runs" && commitGroups . length > 0 && (
110- < span className = "text-[10px] text-muted-foreground tabular-nums" >
111- { commitGroups . length }
112- </ span >
113- ) }
114- </ div >
115- < div className = "mt-2 flex rounded-md border bg-muted/50 p-0.5" >
126+ < div className = "border-b px-4 py-3 space-y-2" >
127+ < div className = "flex rounded-md border bg-muted/50 p-0.5" >
116128 < button
117129 onClick = { ( ) => onSidebarModeChange ( "runs" ) }
118130 className = { cn (
@@ -122,7 +134,7 @@ export function CiSuiteListSidebar({
122134 : "text-muted-foreground hover:text-foreground" ,
123135 ) }
124136 >
125- Runs
137+ By Commit
126138 </ button >
127139 < button
128140 onClick = { ( ) => onSidebarModeChange ( "suites" ) }
@@ -133,39 +145,31 @@ export function CiSuiteListSidebar({
133145 : "text-muted-foreground hover:text-foreground" ,
134146 ) }
135147 >
136- Suites
148+ By Suite
137149 </ button >
138150 </ div >
139151 </ div >
140152
141- { /* Overview button — always visible regardless of sidebar mode */ }
142- < button
143- onClick = { onSelectOverview }
144- className = { cn (
145- "w-full px-4 py-2.5 text-left transition-colors hover:bg-accent/50 border-b" ,
146- isOverviewSelected && "bg-accent" ,
147- ) }
148- >
149- < div className = "flex items-center gap-2.5" >
150- < BarChart3 className = "h-4 w-4 shrink-0 text-muted-foreground" />
151- < div className = "min-w-0 flex-1" >
152- < div className = "text-sm font-medium" > Overview</ div >
153- < div className = "text-[11px] text-muted-foreground" >
154- Suite health & status
155- </ div >
156- </ div >
157- { ( ( ) => {
158- const failCount = suites . filter (
159- ( e ) => e . latestRun ?. result === "failed" ,
160- ) . length ;
161- return failCount > 0 ? (
162- < 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" >
163- { failCount }
164- </ span >
165- ) : null ;
166- } ) ( ) }
167- </ div >
168- </ button >
153+ { /* Dashboard button — always visible regardless of sidebar mode */ }
154+ < div className = "px-3 py-2 border-b" >
155+ < button
156+ onClick = { onSelectOverview }
157+ className = { cn (
158+ "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" ,
159+ isOverviewSelected
160+ ? "bg-primary/15 text-primary border-primary/30"
161+ : "text-muted-foreground hover:bg-accent hover:text-foreground hover:border-border" ,
162+ ) }
163+ >
164+ < BarChart3 className = "h-3.5 w-3.5 shrink-0" />
165+ < span className = "flex-1" > Dashboard</ span >
166+ { failCount > 0 && (
167+ < 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" >
168+ { failCount }
169+ </ span >
170+ ) }
171+ </ button >
172+ </ div >
169173
170174 { sidebarMode === "runs" ? (
171175 < CommitListSidebar
@@ -186,71 +190,196 @@ export function CiSuiteListSidebar({
186190 </ div >
187191 ) : (
188192 < div >
189- { filteredSuites . map ( ( entry ) => {
190- const latestRun = entry . latestRun ;
191- const status = getStatusInfo ( entry ) ;
192- const trend = entry . passRateTrend
193- . slice ( - 12 )
194- . map ( ( value ) => toPercent ( value ) ) ;
195- const timestamp = formatRelativeTime (
196- latestRun ?. completedAt ??
197- latestRun ?. createdAt ??
198- entry . suite . updatedAt ,
199- ) ;
200-
201- return (
202- < button
203- key = { entry . suite . _id }
204- onClick = { ( ) => onSelectSuite ( entry . suite . _id ) }
205- className = { cn (
206- "w-full px-4 py-2.5 text-left transition-colors hover:bg-accent/50" ,
207- selectedSuiteId === entry . suite . _id && "bg-accent" ,
208- ) }
209- >
210- < div className = "flex items-center gap-2.5" >
211- < div className = "flex flex-col items-center gap-0.5 shrink-0 w-[3.25rem]" >
212- < div
213- className = { cn (
214- "h-2 w-2 rounded-full" ,
215- status . dotClass ,
216- ) }
217- />
218- < span className = { cn ( "text-[9px] font-medium leading-none" , status . labelClass ) } >
219- { status . label }
220- </ span >
221- </ div >
222- < div className = "min-w-0 flex-1" >
223- < div className = "truncate text-sm font-medium" >
224- { entry . suite . name || "Untitled suite" }
225- </ div >
226- { entry . suite . tags && entry . suite . tags . length > 0 && (
227- < TagBadges tags = { entry . suite . tags } className = "mt-0.5" />
228- ) }
229- < div className = "text-[11px] text-muted-foreground" >
230- { timestamp }
231- </ div >
232- </ div >
233- { trend . length > 0 && (
234- < div className = "flex h-5 shrink-0 items-end gap-px" >
235- { trend . map ( ( value , idx ) => (
236- < div
237- key = { `${ entry . suite . _id } -t-${ idx } ` }
238- className = "w-1 rounded-sm bg-primary/70"
239- style = { {
240- height : `${ Math . max ( 3 , ( value / 100 ) * 20 ) } px` ,
241- } }
242- />
243- ) ) }
244- </ div >
245- ) }
246- </ div >
247- </ button >
248- ) ;
249- } ) }
193+ { Array . from ( groupedSuites . entries ( ) ) . map ( ( [ suiteName , entries ] ) => (
194+ < SuiteGroupItem
195+ key = { suiteName }
196+ suiteName = { suiteName }
197+ entries = { entries }
198+ selectedSuiteId = { selectedSuiteId }
199+ onSelectSuite = { onSelectSuite }
200+ />
201+ ) ) }
250202 </ div >
251203 ) }
252204 </ div >
253205 ) }
254206 </ div >
255207 ) ;
256208}
209+
210+ function SuiteGroupItem ( {
211+ suiteName,
212+ entries,
213+ selectedSuiteId,
214+ onSelectSuite,
215+ } : {
216+ suiteName : string ;
217+ entries : EvalSuiteOverviewEntry [ ] ;
218+ selectedSuiteId : string | null ;
219+ onSelectSuite : ( suiteId : string ) => void ;
220+ } ) {
221+ const primary = entries [ 0 ] ; // most recent
222+ const hasMultiple = entries . length > 1 ;
223+ const isAnySelected = entries . some ( ( e ) => e . suite . _id === selectedSuiteId ) ;
224+ const [ expanded , setExpanded ] = useState ( false ) ;
225+
226+ const latestRun = primary . latestRun ;
227+ const status = getStatusInfo ( primary ) ;
228+ const trend = primary . passRateTrend . slice ( - 12 ) . map ( ( value ) => toPercent ( value ) ) ;
229+ const timestamp = formatRelativeTime (
230+ latestRun ?. completedAt ?? latestRun ?. createdAt ?? primary . suite . updatedAt ,
231+ ) ;
232+
233+ // For single-entry groups, render directly
234+ if ( ! hasMultiple ) {
235+ return (
236+ < SuiteEntryButton
237+ entry = { primary }
238+ isSelected = { selectedSuiteId === primary . suite . _id }
239+ onSelect = { ( ) => onSelectSuite ( primary . suite . _id ) }
240+ status = { status }
241+ trend = { trend }
242+ timestamp = { timestamp }
243+ />
244+ ) ;
245+ }
246+
247+ // For multi-entry groups, render as expandable group
248+ return (
249+ < div >
250+ < button
251+ onClick = { ( ) => {
252+ if ( ! isAnySelected ) {
253+ // Click selects the most recent entry
254+ onSelectSuite ( primary . suite . _id ) ;
255+ } else {
256+ setExpanded ( ! expanded ) ;
257+ }
258+ } }
259+ className = { cn (
260+ "w-full px-4 py-2.5 text-left transition-colors hover:bg-accent/50" ,
261+ isAnySelected && "bg-accent shadow-sm" ,
262+ ) }
263+ >
264+ < div className = "flex items-center gap-2.5" >
265+ < div className = "flex flex-col items-center gap-0.5 shrink-0 w-[3.25rem]" >
266+ < div className = { cn ( "h-2 w-2 rounded-full" , status . dotClass ) } />
267+ < span className = { cn ( "text-[9px] font-medium leading-none" , status . labelClass ) } >
268+ { status . label }
269+ </ span >
270+ </ div >
271+ < div className = "min-w-0 flex-1" >
272+ < div className = "flex items-center gap-1.5" >
273+ < span className = { cn ( "truncate text-sm font-medium" , isAnySelected && "font-semibold" ) } >
274+ { suiteName }
275+ </ span >
276+ < 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" >
277+ { entries . length }
278+ </ span >
279+ </ div >
280+ { primary . suite . tags && primary . suite . tags . length > 0 && (
281+ < TagBadges tags = { primary . suite . tags } className = "mt-0.5" />
282+ ) }
283+ < div className = "text-[11px] text-muted-foreground" > { timestamp } </ div >
284+ </ div >
285+ { trend . length >= 3 && (
286+ < div className = "flex h-5 shrink-0 items-end gap-px" >
287+ { trend . map ( ( value , idx ) => (
288+ < div
289+ key = { `${ primary . suite . _id } -t-${ idx } ` }
290+ className = { cn ( "w-1 rounded-sm" , value >= 80 ? "bg-emerald-500/70" : value >= 50 ? "bg-amber-500/70" : "bg-destructive/70" ) }
291+ style = { { height : `${ Math . max ( 3 , ( value / 100 ) * 20 ) } px` } }
292+ />
293+ ) ) }
294+ </ div >
295+ ) }
296+ </ div >
297+ </ button >
298+ { ( expanded || isAnySelected ) && entries . length > 1 && (
299+ < div className = "border-l-2 border-muted ml-6" >
300+ { entries . map ( ( entry ) => {
301+ const entryStatus = getStatusInfo ( entry ) ;
302+ const entryTimestamp = formatRelativeTime (
303+ entry . latestRun ?. completedAt ?? entry . latestRun ?. createdAt ?? entry . suite . updatedAt ,
304+ ) ;
305+ return (
306+ < button
307+ key = { entry . suite . _id }
308+ onClick = { ( ) => onSelectSuite ( entry . suite . _id ) }
309+ className = { cn (
310+ "w-full px-3 py-1.5 text-left transition-colors hover:bg-accent/50" ,
311+ selectedSuiteId === entry . suite . _id && "bg-primary/10 border-r-2 border-r-primary" ,
312+ ) }
313+ >
314+ < div className = "flex items-center gap-2" >
315+ < div className = { cn ( "h-1.5 w-1.5 rounded-full shrink-0" , entryStatus . dotClass ) } />
316+ < span className = "text-[11px] text-muted-foreground truncate flex-1" >
317+ { entryTimestamp }
318+ </ span >
319+ < span className = { cn ( "text-[10px] font-medium" , entryStatus . labelClass ) } >
320+ { entryStatus . label }
321+ </ span >
322+ </ div >
323+ </ button >
324+ ) ;
325+ } ) }
326+ </ div >
327+ ) }
328+ </ div >
329+ ) ;
330+ }
331+
332+ function SuiteEntryButton ( {
333+ entry,
334+ isSelected,
335+ onSelect,
336+ status,
337+ trend,
338+ timestamp,
339+ } : {
340+ entry : EvalSuiteOverviewEntry ;
341+ isSelected : boolean ;
342+ onSelect : ( ) => void ;
343+ status : { label : string ; dotClass : string ; labelClass : string } ;
344+ trend : number [ ] ;
345+ timestamp : string ;
346+ } ) {
347+ return (
348+ < button
349+ onClick = { onSelect }
350+ className = { cn (
351+ "w-full px-4 py-2.5 text-left transition-colors hover:bg-accent/50" ,
352+ isSelected && "bg-accent shadow-sm" ,
353+ ) }
354+ >
355+ < div className = "flex items-center gap-2.5" >
356+ < div className = "flex flex-col items-center gap-0.5 shrink-0 w-[3.25rem]" >
357+ < div className = { cn ( "h-2 w-2 rounded-full" , status . dotClass ) } />
358+ < span className = { cn ( "text-[9px] font-medium leading-none" , status . labelClass ) } >
359+ { status . label }
360+ </ span >
361+ </ div >
362+ < div className = "min-w-0 flex-1" >
363+ < div className = { cn ( "truncate text-sm font-medium" , isSelected && "font-semibold" ) } >
364+ { entry . suite . name || "Untitled suite" }
365+ </ div >
366+ { entry . suite . tags && entry . suite . tags . length > 0 && (
367+ < TagBadges tags = { entry . suite . tags } className = "mt-0.5" />
368+ ) }
369+ < div className = "text-[11px] text-muted-foreground" > { timestamp } </ div >
370+ </ div >
371+ { trend . length >= 3 && (
372+ < div className = "flex h-5 shrink-0 items-end gap-px" >
373+ { trend . map ( ( value , idx ) => (
374+ < div
375+ key = { `${ entry . suite . _id } -t-${ idx } ` }
376+ className = "w-1 rounded-sm bg-primary/70"
377+ style = { { height : `${ Math . max ( 3 , ( value / 100 ) * 20 ) } px` } }
378+ />
379+ ) ) }
380+ </ div >
381+ ) }
382+ </ div >
383+ </ button >
384+ ) ;
385+ }
0 commit comments