Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -207,10 +207,10 @@ export function CommitDetailView({

// Auto-request triage when failures exist and no result yet
useEffect(() => {
if (failedRunIds.length > 0 && !aiTriage.summary && !aiTriage.loading) {
if (failedRunIds.length > 0 && !aiTriage.summary && !aiTriage.loading && !aiTriage.unavailable) {
aiTriage.requestTriage();
}
}, [failedRunIds.length, aiTriage.summary, aiTriage.loading, aiTriage.requestTriage]);
}, [failedRunIds.length, aiTriage.summary, aiTriage.loading, aiTriage.unavailable, aiTriage.requestTriage]);

// Triage summary — shows when any cases failed
const triageSummary = useMemo(() => {
Expand Down Expand Up @@ -372,7 +372,7 @@ export function CommitDetailView({
</div>

{/* === AI TRIAGE PANEL === */}
{triageSummary && (aiTriage.summary || aiTriage.loading || aiTriage.error) && (
{triageSummary && !aiTriage.unavailable && (aiTriage.summary || aiTriage.loading || aiTriage.error) && (
<div className="relative rounded-lg border border-orange-200/60 bg-orange-50/30 p-6 shadow-sm dark:border-orange-900/40 dark:bg-orange-950/10">
<div className="absolute top-0 left-0 right-0 h-[3px] rounded-t-lg ai-shimmer-bar" />
<div className="flex items-center gap-2.5 mb-4">
Expand Down
199 changes: 93 additions & 106 deletions mcpjam-inspector/client/src/components/evals/overview-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -276,12 +276,12 @@ export function OverviewPanel({

const aiOverviewTriage = useCommitTriage(failedOverviewRunIds);

// Auto-request triage when failures exist
// Auto-request triage when failures exist (skip if already unavailable)
useEffect(() => {
if (failedOverviewRunIds.length > 0 && !aiOverviewTriage.summary && !aiOverviewTriage.loading) {
if (failedOverviewRunIds.length > 0 && !aiOverviewTriage.summary && !aiOverviewTriage.loading && !aiOverviewTriage.unavailable) {
aiOverviewTriage.requestTriage();
}
}, [failedOverviewRunIds.length, aiOverviewTriage.summary, aiOverviewTriage.loading, aiOverviewTriage.requestTriage]);
}, [failedOverviewRunIds.length, aiOverviewTriage.summary, aiOverviewTriage.loading, aiOverviewTriage.unavailable, aiOverviewTriage.requestTriage]);

// Pre-compute inline failure tags for the failure feed
// Tags suites with failed cases OR failed result
Expand Down Expand Up @@ -491,7 +491,7 @@ export function OverviewPanel({
</div>

{/* AI Overview Summary — only when failures exist and triage is active */}
{failedOverviewRunIds.length > 0 && (aiOverviewTriage.summary || aiOverviewTriage.loading || aiOverviewTriage.error) && (
{failedOverviewRunIds.length > 0 && !aiOverviewTriage.unavailable && (aiOverviewTriage.summary || aiOverviewTriage.loading || aiOverviewTriage.error) && (
<div className="relative rounded-lg border border-orange-200/60 bg-orange-50/30 shadow-sm dark:border-orange-900/40 dark:bg-orange-950/10">
<div className="absolute top-0 left-0 right-0 h-[3px] rounded-t-lg ai-shimmer-bar" />
<div className="px-4 py-3">
Expand Down Expand Up @@ -627,115 +627,102 @@ export function OverviewPanel({
</div>
)}

{/* Section C: Failure Feed (Needs Attention) */}
<Collapsible
open={hasFailures && failureFeedOpen}
onOpenChange={setFailureFeedOpen}
>
<div className="rounded-xl border bg-card">
<CollapsibleTrigger className="w-full flex items-center justify-between px-4 py-3 hover:bg-muted/50 transition-colors rounded-xl">
<div className="flex items-center gap-2">
{failureFeedOpen && hasFailures ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
<span className="text-sm font-semibold">Needs Attention</span>
{hasFailures && (
{/* Section C: Failure Feed (Needs Attention) — hidden when nothing needs attention */}
{hasFailures && (
<Collapsible open={failureFeedOpen} onOpenChange={setFailureFeedOpen}>
<div className="rounded-xl border bg-card">
<CollapsibleTrigger className="w-full flex items-center justify-between px-4 py-3 hover:bg-muted/50 transition-colors rounded-xl">
<div className="flex items-center gap-2">
{failureFeedOpen ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
<span className="text-sm font-semibold">Needs Attention</span>
<span className="text-xs text-muted-foreground">
({failureEntries.length})
</span>
)}
</div>
{!hasFailures && (
<div className="flex items-center gap-1.5 text-xs text-emerald-500">
<CheckCircle2 className="h-3.5 w-3.5" />
All clear
</div>
)}
</CollapsibleTrigger>

<CollapsibleContent>
<div className="border-t divide-y">
{failureEntries.map((entry) => {
const isFailed = entry.latestRun?.result === "failed";
const isNeverRun = !entry.latestRun;

return (
<button
key={entry.suite._id}
onClick={() => onSelectSuite?.(entry.suite._id)}
className="w-full px-4 py-3 text-left hover:bg-muted/50 transition-colors"
>
<div className="flex items-start gap-2.5">
{isFailed ? (
<XCircle className="h-4 w-4 text-destructive shrink-0 mt-0.5" />
) : (
<MinusCircle className="h-4 w-4 text-muted-foreground shrink-0 mt-0.5" />
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="text-sm font-medium truncate">
{entry.suite.name}
</span>
{isFailed &&
failureTagMap.get(entry.suite._id)?.map((tag) => (
<InlineFailureTag key={tag} tag={tag} />
))}
</div>
{isFailed && entry.latestRun?.summary && (
<div className="text-xs text-muted-foreground mt-0.5">
{entry.latestRun.summary.passed}/
{entry.latestRun.summary.total} tests passed
{entry.latestRun.summary.passRate !== undefined &&
` (${Math.round(entry.latestRun.summary.passRate)}%)`}
</div>
</CollapsibleTrigger>

<CollapsibleContent>
<div className="border-t divide-y">
{failureEntries.map((entry) => {
const isFailed = entry.latestRun?.result === "failed";
const isNeverRun = !entry.latestRun;

return (
<button
key={entry.suite._id}
onClick={() => onSelectSuite?.(entry.suite._id)}
className="w-full px-4 py-3 text-left hover:bg-muted/50 transition-colors"
>
<div className="flex items-start gap-2.5">
{isFailed ? (
<XCircle className="h-4 w-4 text-destructive shrink-0 mt-0.5" />
) : (
<MinusCircle className="h-4 w-4 text-muted-foreground shrink-0 mt-0.5" />
)}
{isFailed && entry.latestRun?.ciMetadata && (
<div className="text-[11px] text-muted-foreground mt-0.5">
{entry.latestRun.ciMetadata.branch && (
<span>{entry.latestRun.ciMetadata.branch}</span>
)}
{entry.latestRun.ciMetadata.commitSha && (
<span>
{" "}
@{" "}
{entry.latestRun.ciMetadata.commitSha.slice(
0,
7,
)}
</span>
)}
{" · "}
{formatRelativeTime(
entry.latestRun.completedAt ??
entry.latestRun.createdAt,
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="text-sm font-medium truncate">
{entry.suite.name}
</span>
{isFailed &&
failureTagMap.get(entry.suite._id)?.map((tag) => (
<InlineFailureTag key={tag} tag={tag} />
))}
</div>
)}
{isFailed && !entry.latestRun?.ciMetadata && (
<div className="text-[11px] text-muted-foreground mt-0.5">
{formatRelativeTime(
entry.latestRun?.completedAt ??
entry.latestRun?.createdAt,
)}
</div>
)}
{isNeverRun && (
<div className="text-xs text-muted-foreground mt-0.5">
Never run
</div>
)}
{isFailed && entry.latestRun?.summary && (
<div className="text-xs text-muted-foreground mt-0.5">
{entry.latestRun.summary.passed}/
{entry.latestRun.summary.total} tests passed
{entry.latestRun.summary.passRate !== undefined &&
` (${Math.round(entry.latestRun.summary.passRate)}%)`}
</div>
)}
{isFailed && entry.latestRun?.ciMetadata && (
<div className="text-[11px] text-muted-foreground mt-0.5">
{entry.latestRun.ciMetadata.branch && (
<span>{entry.latestRun.ciMetadata.branch}</span>
)}
{entry.latestRun.ciMetadata.commitSha && (
<span>
{" "}
@ {entry.latestRun.ciMetadata.commitSha.slice(0, 7)}
</span>
)}
{" · "}
{formatRelativeTime(
entry.latestRun.completedAt ??
entry.latestRun.createdAt,
)}
</div>
)}
{isFailed && !entry.latestRun?.ciMetadata && (
<div className="text-[11px] text-muted-foreground mt-0.5">
{formatRelativeTime(
entry.latestRun?.completedAt ??
entry.latestRun?.createdAt,
)}
</div>
)}
{isNeverRun && (
<div className="text-xs text-muted-foreground mt-0.5">
Never run
</div>
)}
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground shrink-0 mt-0.5" />
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground shrink-0 mt-0.5" />
</div>
</button>
);
})}
</div>
</CollapsibleContent>
</div>
</Collapsible>
</button>
);
})}
</div>
</CollapsibleContent>
</div>
</Collapsible>
)}

{/* Section D: Suite Table */}
<div className="rounded-xl border bg-card">
Expand Down
43 changes: 31 additions & 12 deletions mcpjam-inspector/client/src/components/evals/use-ai-triage.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useCallback, useMemo, useEffect } from "react";
import { useState, useCallback, useEffect, useRef } from "react";
import { useMutation } from "convex/react";

// ---------------------------------------------------------------------------
Expand All @@ -25,38 +25,57 @@ export interface TriageResult {
* The backend generates the summary asynchronously (action → AI SDK → save).
*
* Until the backend mutation is deployed, requests fail gracefully and the
* panel shows "AI triage unavailable" instead of crashing.
* panel stays hidden instead of flashing briefly.
*/
export function useCommitTriage(
failedRunIds: string[],
): {
summary: string | null;
loading: boolean;
error: string | null;
unavailable: boolean;
requestTriage: () => void;
} {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [summary, setSummary] = useState<string | null>(null);
const [unavailable, setUnavailable] = useState(false);

// Reset state when the run IDs change (navigating to a different commit)
const runKey = failedRunIds.join(",");
useEffect(() => {
setSummary(null);
setError(null);
setLoading(false);
setUnavailable(false);
}, [runKey]);

// Track whether the mutation exists at the module level (survives re-renders)
let requestTriageMutation: ReturnType<typeof useMutation> | null = null;
let mutationExists = true;
try {
// eslint-disable-next-line react-hooks/rules-of-hooks
requestTriageMutation = useMutation("testSuites:requestTriage" as any);
} catch {
// Mutation not registered yet — backend not deployed
mutationExists = false;
}

// Once we know the mutation doesn't exist, mark unavailable permanently
// (no state update needed on subsequent renders since unavailable is already true)
const mutationExistsRef = useRef(mutationExists);
mutationExistsRef.current = mutationExists;

useEffect(() => {
if (!mutationExistsRef.current) {
setUnavailable(true);
}
}, []);

// Reset state when the run IDs change (navigating to a different commit),
// but preserve unavailable if the mutation doesn't exist
const runKey = failedRunIds.join(",");
useEffect(() => {
setSummary(null);
setError(null);
setLoading(false);
// Only reset unavailable if the mutation actually exists
if (mutationExistsRef.current) {
setUnavailable(false);
}
}, [runKey]);

const requestTriage = useCallback(() => {
if (failedRunIds.length === 0 || unavailable) return;
if (!requestTriageMutation) {
Expand Down Expand Up @@ -94,5 +113,5 @@ export function useCommitTriage(
});
}, [failedRunIds, requestTriageMutation, unavailable]);

return { summary, loading, error, requestTriage };
return { summary, loading, error, unavailable, requestTriage };
}
Loading