Skip to content
Open
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
13 changes: 13 additions & 0 deletions .changeset/evals-run-detail-funnel-visibility.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@mcpjam/inspector": patch
---

The user-value chain is visible on `/evals` run detail when it has data

The chain funnel was mounted on run detail but could not be seen on the runs where it was the only thing to show. Two gates decide whether the insight rail exists at all — `run-insight-rail.tsx`'s emptiness check and `run-detail-view.tsx`'s `hasInsightContent` — and both counted only the triage, goal-completion and groundedness cards. A run with a derived chain and no judge or triage output rendered no rail, so the funnel it contained was never drawn.

Adding the chain card to those checks would have traded one bug for another, which is why the exclusion was deliberate and documented: the card is a truthy fragment whose two halves each suppress themselves from the inside, so counting the NODE would keep an otherwise-empty rail alive as a full-height column of dead space on every run with no insight content at all.

So the gates now read a fact about the DATA instead. A probe mounted above every layout branch asks the same rollup query the funnel itself uses — `undefined` while loading, `null` for a run with no rollup, which is exactly the panel's own render condition — and reports one boolean that both gates consume. Convex de-duplicates identical subscriptions, so asking twice costs one query, and the probe carries the same `ErrorBoundary` the panels do: `useQuery` throws when the query is not deployed or when there is no `ConvexProvider`, and a probe that took the page down with it would be worse than the empty rail it exists to prevent. Undeployed reads as "no funnel", which is correct.

The state starts `false`, so a run without a funnel never flashes an empty rail on the way to finding out.
8 changes: 7 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,16 @@ on:
# `branches` filters on the PR's BASE. A stacked PR is based on the branch
# below it rather than on main, so with `main` alone every PR above the
# root of a stack merges without these tests having run against it once.
# Drop the second pattern once the Connector Bench stack has landed.
# Drop each stack pattern once that stack has landed.
#
# The UVH pattern was added after this bit us: a test in the stack asserted
# a label's wording verbatim, a later PR in the same stack changed that
# wording, and the break sat unnoticed because every PR above the root was
# green on previews and review bots alone.
branches:
- main
- "claude/mcp-benchmarks-v2-b0tw4b-**"
- "claude/uvc-mcp-eval-reporting-gyycwl**"
push:
branches: [main]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,10 +129,14 @@ describe("RunAccuracyHeroBand", () => {
/>,
);

expect(screen.getByRole("heading", { name: /Run run-2/i })).toBeInTheDocument();
expect(
screen.getByRole("heading", { name: /Run run-2/i }),
).toBeInTheDocument();
expect(screen.getByText("Failed")).toBeInTheDocument();
expect(screen.getByText(/7 passed · 3 failed ·/)).toBeInTheDocument();
const band = screen.getByRole("heading", { name: /Run run-2/i }).closest("section");
const band = screen
.getByRole("heading", { name: /Run run-2/i })
.closest("section");
expect(band).toHaveTextContent("Accuracy");
expect(band).toHaveTextContent("70%");
});
Expand Down Expand Up @@ -174,6 +178,50 @@ describe("RunInsightRail", () => {
screen.queryByRole("heading", { name: "Latency by test (p50 / p95)" }),
).not.toBeInTheDocument();
});

it("stays closed when the chain card is the ONLY thing passed and it has no data", () => {
// The reason the card was excluded from the emptiness check to begin
// with: the node is truthy even when both of its halves render nothing,
// so counting the node would leave a full-height column of dead space.
const { container } = render(
<RunInsightRail
triageCard={null}
userValueChainCard={<div data-testid="chain-slot" />}
/>,
);

expect(container).toBeEmptyDOMElement();
expect(screen.queryByTestId("chain-slot")).not.toBeInTheDocument();
});

it("opens for a run whose ONLY insight is its user-value chain", () => {
// UVH-IN5, and the bug this fixes: a run with a derived chain but no
// judge or triage output rendered no rail at all, so the chain was
// invisible on exactly the runs where it was the whole story.
render(
<RunInsightRail
triageCard={null}
userValueChainCard={<div data-testid="chain-slot">Funnel</div>}
userValueChainHasContent
/>,
);

expect(screen.getByTestId("chain-slot")).toBeInTheDocument();
});

it("still opens for other insight content when the chain has none", () => {
render(
<RunInsightRail
triageCard={<div data-testid="triage-slot">Insights</div>}
userValueChainCard={<div data-testid="chain-slot" />}
userValueChainHasContent={false}
/>,
);

expect(screen.getByTestId("triage-slot")).toBeInTheDocument();
// The card is still mounted — it suppresses itself from the inside.
expect(screen.getByTestId("chain-slot")).toBeInTheDocument();
});
});

describe("RunDetailMetricsCharts", () => {
Expand Down
61 changes: 59 additions & 2 deletions mcpjam-inspector/client/src/components/evals/run-detail-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,10 @@ import {
type JudgeCase,
} from "./goal-completion-presentation";
import { RunInsightBand, type InsightSeverity } from "./run-insight-band";
import { SuiteRunStageFunnelPanel } from "@/components/shared/user-value-chain/StageFunnelPanels";
import {
SuiteRunStageFunnelAvailability,
SuiteRunStageFunnelPanel,
} from "@/components/shared/user-value-chain/StageFunnelPanels";
import { ExplanatoryFlowOptIn } from "@/components/shared/usage-insights/ExplanatoryFlowOptIn";
import type { InsightsScope } from "@/hooks/useUsageInsights";
import { useAvailableModels } from "@/hooks/use-available-models";
Expand Down Expand Up @@ -576,6 +579,30 @@ export function RunDetailView({

const embeddedInResultsSplit = hideKpiStrip;

/**
* Whether this run has a stage funnel to draw, reported by the probe below.
*
* Stored WITH the run it describes, and read only when that run is the one
* on screen. This component is reused across runs by the run selector, so a
* bare boolean would survive a switch and a stale `true` would open an empty
* rail on the run you moved to. Starting empty also means a run without a
* funnel never flashes one on the way to finding out.
*/
const [stageFunnelFor, setStageFunnelFor] = useState<{
suiteRunId: string | undefined;
hasFunnel: boolean;
}>({ suiteRunId: undefined, hasFunnel: false });

const handleStageFunnelAvailability = useCallback(
(suiteRunId: string | undefined, hasFunnel: boolean) =>
setStageFunnelFor({ suiteRunId, hasFunnel }),
[],
);

const hasStageFunnel =
stageFunnelFor.suiteRunId === selectedRunDetails._id &&
stageFunnelFor.hasFunnel;

const serverQualityTriage =
selectedRunDetails.status === "completed" && !serverQualityUnavailable ? (
<AiTriageCard
Expand Down Expand Up @@ -780,6 +807,19 @@ export function RunDetailView({
</>
);

/**
* Mounted unconditionally, and deliberately NOT inside the rail or the band
* it informs — it answers whether those should open, so it has to exist
* before they do. Renders nothing; costs one query, which Convex shares with
* the panel's own subscription.
*/
const stageFunnelProbe = (
<SuiteRunStageFunnelAvailability
suiteRunId={selectedRunDetails._id}
onChange={handleStageFunnelAvailability}
/>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
);

const insightRail = (
<RunInsightRail
triageCard={
Expand All @@ -795,15 +835,29 @@ export function RunDetailView({
goalCompletionCard={goalCompletionPanel}
groundednessCard={groundednessPanel}
userValueChainCard={userValueChainPanel}
userValueChainHasContent={hasStageFunnel}
embedded={embeddedInResultsSplit}
/>
);

/**
* The chain counts toward "is there anything to show" — but only when it
* actually has a funnel to draw.
*
* Both gates below read this ONE boolean, and it comes from a probe rather
* than from the panel, because the panel cannot answer: neither gate mounts
* it until they have already decided to open. Counting the card itself
* instead would keep a rail alive on every run with no insight content at
* all, since the node is truthy whether or not it renders anything — which
* is why it was excluded from these checks in the first place, and why the
* fix has to be data-driven rather than a matter of adding the node.
*/
const hasInsightContent = Boolean(
serverQualityTriage ||
goalCompletionPanel ||
groundednessPanel ||
actionableFindingsPanel,
actionableFindingsPanel ||
hasStageFunnel,
);

const triageFixCount = useMemo(
Expand Down Expand Up @@ -1041,6 +1095,9 @@ export function RunDetailView({
omitIterationList && "px-3 py-3",
)}
>
{/* Renders nothing. Sits above every layout branch below because all of
them gate on the answer it reports. */}
{stageFunnelProbe}
{onExportTraces || pluginSubmissionVersions.length > 0 || onShare ? (
// Always-on run-level actions — placed here (not the accuracy hero) so
// they survive the folded run-detail layout that hides the hero.
Expand Down
95 changes: 57 additions & 38 deletions mcpjam-inspector/client/src/components/evals/run-insight-rail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -180,8 +180,8 @@ export function RunAccuracyHeroBand({
stats.total > 0
? normalizeRunPassRatePercent(stats.passRate)
: run.summary
? normalizeRunPassRatePercent(run.summary.passRate)
: null;
? normalizeRunPassRatePercent(run.summary.passRate)
: null;

const trendChips = useMemo(() => {
if (runTrendData.length < 2) return { points: [], hiddenCount: 0 };
Expand Down Expand Up @@ -251,10 +251,7 @@ export function RunAccuracyHeroBand({
{runClient || runServers.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5">
{runClient ? (
<HostChip
name={runClient.displayName}
hostId={runClient.hostId}
/>
<HostChip name={runClient.displayName} hostId={runClient.hostId} />
) : null}
{visibleServers.map((name) => (
<Badge
Expand Down Expand Up @@ -306,31 +303,32 @@ export function RunAccuracyHeroBand({
</div>
);

const recentRunsBlock = hasRecentRuns && !hideRecentRuns ? (
<div className="flex min-w-0 flex-1 flex-col gap-2">
<div className="flex min-w-0 items-baseline gap-2">
<p className={runDetailSectionLabelClass}>Recent runs</p>
{trendChips.hiddenCount > 0 ? (
<p className={runDetailSupportingClass}>
Last {RUN_TREND_CHIP_LIMIT} of {runTrendData.length}
</p>
) : null}
</div>
<div
className="flex w-full min-w-0 gap-3 overflow-x-auto pb-0.5 [scrollbar-width:thin]"
aria-label={`${metricLabel} across recent suite runs`}
>
{trendChips.points.map((point) => (
<RunAccuracyRunCard
key={point.runId}
point={point}
isCurrent={point.isCurrent}
onSelectRun={onSelectRun}
/>
))}
const recentRunsBlock =
hasRecentRuns && !hideRecentRuns ? (
<div className="flex min-w-0 flex-1 flex-col gap-2">
<div className="flex min-w-0 items-baseline gap-2">
<p className={runDetailSectionLabelClass}>Recent runs</p>
{trendChips.hiddenCount > 0 ? (
<p className={runDetailSupportingClass}>
Last {RUN_TREND_CHIP_LIMIT} of {runTrendData.length}
</p>
) : null}
</div>
<div
className="flex w-full min-w-0 gap-3 overflow-x-auto pb-0.5 [scrollbar-width:thin]"
aria-label={`${metricLabel} across recent suite runs`}
>
{trendChips.points.map((point) => (
<RunAccuracyRunCard
key={point.runId}
point={point}
isCurrent={point.isCurrent}
onSelectRun={onSelectRun}
/>
))}
</div>
</div>
</div>
) : null;
) : null;

// With run identity: title/stats and recent runs share one row; accuracy on the right.
if (includeRunIdentity) {
Expand Down Expand Up @@ -380,8 +378,8 @@ export function shouldShowRunAccuracyHero({
stats.total > 0
? normalizeRunPassRatePercent(stats.passRate)
: run.summary
? normalizeRunPassRatePercent(run.summary.passRate)
: null;
? normalizeRunPassRatePercent(run.summary.passRate)
: null;
return passRatePercent !== null;
}

Expand Down Expand Up @@ -417,6 +415,7 @@ export function RunInsightRail({
goalCompletionCard,
groundednessCard,
userValueChainCard,
userValueChainHasContent = false,
className,
embedded = false,
}: {
Expand All @@ -433,18 +432,38 @@ export function RunInsightRail({
* diagram beside it is bought per pass and waits for a click. Same traces,
* different price, so different affordance.
*
* Deliberately NOT part of the emptiness check below. Both halves render
* nothing of their own when there is no derived chain and no analyzable
* cohort, and the node itself is truthy either way — counting it would keep
* an otherwise-empty rail alive as a full-height column of dead space on
* every run with no insight content at all.
* The NODE is deliberately still not part of the emptiness check below.
* Both halves render nothing of their own when there is no derived chain and
* no analyzable cohort, yet the node itself is truthy either way — counting
* it would keep an otherwise-empty rail alive as a full-height column of
* dead space on every run with no insight content at all.
*
* What the check reads instead is `userValueChainHasContent`, a fact about
* the DATA rather than about the node. That is what lets a run whose only
* insight is its chain open the rail — the case this card was previously
* invisible on — without reintroducing the empty column.
*/
userValueChainCard?: ReactNode;
/**
* Whether the chain card will actually draw something.
*
* Supplied by the caller, which asks a probe mounted outside this rail: the
* rail cannot ask the card, because it does not mount the card until it has
* already decided to exist.
*/
userValueChainHasContent?: boolean;
className?: string;
/** Flush layout inside the run-detail split (shared dividers, no card gaps). */
embedded?: boolean;
}) {
if (!triageCard && !goalCompletionCard && !groundednessCard) return null;
if (
!triageCard &&
!goalCompletionCard &&
!groundednessCard &&
!userValueChainHasContent
) {
return null;
}

return (
<aside
Expand Down
Loading
Loading