diff --git a/site/src/pages/AgentsPage/components/ContextUsageIndicator.stories.tsx b/site/src/pages/AgentsPage/components/ContextUsageIndicator.stories.tsx index fd293243cb..22b8c3ed58 100644 --- a/site/src/pages/AgentsPage/components/ContextUsageIndicator.stories.tsx +++ b/site/src/pages/AgentsPage/components/ContextUsageIndicator.stories.tsx @@ -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 = { diff --git a/site/src/pages/AgentsPage/components/ContextUsageIndicator.tsx b/site/src/pages/AgentsPage/components/ContextUsageIndicator.tsx index 6285fd7e85..d69c671ba0 100644 --- a/site/src/pages/AgentsPage/components/ContextUsageIndicator.tsx +++ b/site/src/pages/AgentsPage/components/ContextUsageIndicator.tsx @@ -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 ? ( + + {`(${formatKiB(bytes)})`} + + ) : 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 && (