feat(site/src/pages/AgentsPage/components): show context sizes and full .mcp.json paths (#26614)

This commit is contained in:
Kyle Carberry
2026-06-23 07:36:05 -06:00
committed by GitHub
parent a30631198d
commit 44f2a77b34
2 changed files with 124 additions and 20 deletions
@@ -42,13 +42,18 @@ export const Clean: Story = {
// The list is driven by the pinned resources.
expect(body.getByText("AGENTS.md")).toBeVisible();
expect(body.getByText("deploy")).toBeVisible();
// MCP configs are listed by file basename and servers by name.
// MCP configs are listed by full path (so multiple .mcp.json files stay
// distinct) and servers by name.
expect(body.getByText("MCP")).toBeVisible();
expect(body.getByText(".mcp.json")).toBeVisible();
expect(body.getByText("/home/coder/.mcp.json")).toBeVisible();
expect(body.getByText("github")).toBeVisible();
// MCP server tools are listed under their server.
expect(body.getByText("search_issues")).toBeVisible();
expect(body.getByText("create_issue")).toBeVisible();
// Each populated category shows its total context size.
expect(body.getByText("(0.2 KiB)")).toBeVisible(); // context files
expect(body.getByText("(0.1 KiB)")).toBeVisible(); // skills
expect(body.getByText("(0.7 KiB)")).toBeVisible(); // MCP
// Invalid resources are surfaced as issues with their error, not
// silently dropped.
expect(body.getByText("Issues")).toBeVisible();
@@ -131,6 +136,44 @@ export const MultipleContextRoots: Story = {
},
};
// Multiple .mcp.json files: each config is listed by its full path so the two
// otherwise-identical .mcp.json files stay disambiguated.
export const MultipleMcpConfigs: Story = {
args: {
usage: {
usedTokens: 20_000,
contextLimitTokens: 200_000,
context: {
dirty: false,
resources: [
{
source: "/home/coder/.mcp.json",
kind: "mcp_config",
size_bytes: 184,
status: "ok",
},
{
source: "/home/coder/project/.mcp.json",
kind: "mcp_config",
size_bytes: 256,
status: "ok",
},
],
},
},
},
play: async ({ canvasElement }) => {
const button = within(canvasElement).getByRole("button");
await userEvent.hover(button);
const body = within(document.body);
await waitFor(() =>
expect(body.getByText("/home/coder/.mcp.json")).toBeVisible(),
);
// The two configs are distinguishable by their full path.
expect(body.getByText("/home/coder/project/.mcp.json")).toBeVisible();
},
};
// Drifted pin: the ring announces a change, and the popover surfaces a refresh
// affordance to re-pin the chat to the latest snapshot.
export const Dirty: Story = {
@@ -9,6 +9,7 @@ import {
import { type FC, useRef, useState } from "react";
import type {
ChatContext,
ChatContextResource,
ChatContextResourceKind,
ChatContextResourceStatus,
ChatContextTool,
@@ -27,6 +28,7 @@ import {
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { cn } from "#/utils/cn";
import { formatKiB } from "#/utils/fileSize";
import { isMobileViewport } from "#/utils/mobile";
import { getPathBasename, getPathDirname } from "../utils/path";
import { SvgRingProgress } from "./SvgRingProgress";
@@ -55,7 +57,10 @@ type ContextSkillItem = {
readonly description?: string;
readonly dir: string;
};
type ContextMcpItem = {
// MCP configs are file-backed (shown by full path), while MCP servers are
// keyed by name and carry their tools.
type ContextMcpConfigItem = { readonly source: string };
type ContextMcpServerItem = {
readonly name: string;
readonly source: string;
readonly tools: readonly ChatContextTool[];
@@ -99,6 +104,30 @@ const formatTokenCountCompact = (value: number | undefined): string => {
return String(value);
};
// Sum the byte size of the OK resources in the given kinds so each popover
// section can show how much context it costs. Non-OK resources are excluded
// because they are not injected into the prompt.
const sumResourceBytes = (
resources: readonly ChatContextResource[],
kinds: readonly ChatContextResourceKind[],
): number =>
resources.reduce(
(total, resource) =>
resource.status === "ok" && kinds.includes(resource.kind)
? total + (resource.size_bytes ?? 0)
: total,
0,
);
// Dimmed "(N.N KiB)" size suffix for a section header, omitted when the
// section has no measurable size.
const SectionSize: FC<{ bytes: number }> = ({ bytes }) =>
bytes > 0 ? (
<span className="ml-1 font-normal text-content-secondary">
{`(${formatKiB(bytes)})`}
</span>
) : null;
const getIndicatorToneClassName = (percentUsed: number | null): string => {
if (percentUsed === null) {
return "text-content-secondary/60";
@@ -237,25 +266,32 @@ export const ContextUsageIndicator: FC<{
// Drop entries with no usable name so an empty skill marker never renders
// as a blank row.
.filter((skill) => skill.name.trim().length > 0);
// An MCP server's source is its server name, while an MCP config's source is
// its file path.
const mcpItems: readonly ContextMcpItem[] = (pinnedResources ?? [])
// MCP configs are shown by their full path so multiple .mcp.json files
// (e.g. ~/.mcp.json and ~/project/.mcp.json) stay disambiguated; servers
// are keyed by name and carry their tools.
const mcpConfigItems: readonly ContextMcpConfigItem[] = (
pinnedResources ?? []
)
.filter(
(resource) =>
(resource.kind === "mcp_config" || resource.kind === "mcp_server") &&
resource.status === "ok",
(resource) => resource.kind === "mcp_config" && resource.status === "ok",
)
.map((resource) => ({ source: resource.source }))
.filter((config) => config.source.trim().length > 0);
const mcpServerItems: readonly ContextMcpServerItem[] = (
pinnedResources ?? []
)
.filter(
(resource) => resource.kind === "mcp_server" && resource.status === "ok",
)
.map((resource) => ({
name:
resource.kind === "mcp_server"
? resource.source
: getPathBasename(resource.source),
name: resource.source,
source: resource.source,
tools: resource.tools ?? [],
}))
// Drop entries with no usable name so an empty MCP marker never renders as
// a blank row.
.filter((mcp) => mcp.name.trim().length > 0);
.filter((server) => server.name.trim().length > 0);
const hasMcp = mcpConfigItems.length > 0 || mcpServerItems.length > 0;
// Pinned resources the agent could not use (invalid skill, unreadable or
// oversize file) are surfaced as issues with their error so the failure is
// visible rather than a silent omission.
@@ -275,8 +311,16 @@ export const ContextUsageIndicator: FC<{
const hasContextList =
fileItems.length > 0 ||
skillItems.length > 0 ||
mcpItems.length > 0 ||
hasMcp ||
issueItems.length > 0;
const fileBytes = sumResourceBytes(pinnedResources ?? [], [
"instruction_file",
]);
const skillBytes = sumResourceBytes(pinnedResources ?? [], ["skill"]);
const mcpBytes = sumResourceBytes(pinnedResources ?? [], [
"mcp_config",
"mcp_server",
]);
// Group files and skills by directory so every context root is labeled,
// keeping resources pulled from different directories distinguishable.
@@ -311,7 +355,8 @@ export const ContextUsageIndicator: FC<{
{fileItems.length > 0 && (
<div className="flex flex-col gap-1">
<span className="font-medium text-content-primary">
Context files
<span>Context files</span>
<SectionSize bytes={fileBytes} />
</span>
{fileGroups.map((group) => (
<div key={group.dir} className="flex flex-col gap-1">
@@ -340,7 +385,10 @@ export const ContextUsageIndicator: FC<{
)}
{skillItems.length > 0 && (
<div className="flex flex-col gap-1">
<span className="font-medium text-content-primary">Skills</span>
<span className="font-medium text-content-primary">
<span>Skills</span>
<SectionSize bytes={skillBytes} />
</span>
<TooltipProvider delayDuration={300}>
{skillGroups.map((group) => (
<div key={group.dir} className="flex flex-col gap-1">
@@ -382,11 +430,24 @@ export const ContextUsageIndicator: FC<{
</TooltipProvider>
</div>
)}
{mcpItems.length > 0 && (
{hasMcp && (
<div className="flex flex-col gap-1">
<span className="font-medium text-content-primary">MCP</span>
<span className="font-medium text-content-primary">
<span>MCP</span>
<SectionSize bytes={mcpBytes} />
</span>
<TooltipProvider delayDuration={300}>
{mcpItems.map((mcp) => (
{mcpConfigItems.map((config) => (
<div
key={config.source}
className="flex items-center gap-1.5"
title={config.source}
>
<FileIcon className="size-3 shrink-0" />
<span className="truncate">{config.source}</span>
</div>
))}
{mcpServerItems.map((mcp) => (
<div key={mcp.source} className="flex flex-col gap-0.5">
<div
className="flex items-center gap-1.5"