Skip to content

Commit 21ef175

Browse files
ok
1 parent f97123d commit 21ef175

File tree

5 files changed

+370
-174
lines changed

5 files changed

+370
-174
lines changed

mcpjam-inspector/client/src/components/evals/ci-suite-list-sidebar.tsx

Lines changed: 238 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState, useEffect } from "react";
1+
import { useState, useEffect, useMemo } from "react";
22
import { BarChart3 } from "lucide-react";
33
import { cn } from "@/lib/utils";
44
import 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

Comments
 (0)