From 688ee63f9d6cf31dde9a5a7779d59b2372c04c1e Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 22 Jun 2026 21:39:34 -0600 Subject: [PATCH] feat(site/src/pages/AgentsPage): group context indicator resources by directory (#26598) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Groups the **Skills** and **Context files** lists in the context-usage popover (`ContextUsageIndicator`) by their parent directory. Resources pulled from different roots, for example a repo-root `AGENTS.md` and a nested `site/AGENTS.md`, or same-named skills from `.coder/skills` vs `.agents/skills`, previously collapsed to identical basenames and lost their provenance. Each group now renders a dimmed directory header with a folder icon, and its items are indented beneath it. ## Why `ChatContextResource.source` carries the real path for instruction files and skill directories, but the popover only rendered the basename, so multiple context roots were indistinguishable. ## Notes - The directory header shows even for a single root, so provenance is always visible. - MCP is intentionally left flat for now: an `mcp_server`'s `source` is a server name (not a path) and the row carries no reference to the `mcp_config` that declared it, so it does not fit the directory model without a backend change. - Adds a `getPathDirname` helper (with tests) and switches the skill list `key` from `skill.name` to `skill.source` to avoid collisions across roots. ## Test plan - `ContextUsageIndicator.stories.tsx`: new `MultipleContextRoots` story (files + skills across several directories); `Clean` story updated to assert the single-root header. Storybook play tests pass (4/4). - `path.test.ts`: added `getPathDirname` cases (unit pass). - `biome check`, `tsc -p .`, and `make pre-commit` all pass.
Design decision log - **Header gating:** started by only showing the directory header when a section spanned more than one directory; changed to **always show** because the provenance is useful even for a single root. - **Presentation:** chose a grouped layout (dimmed directory header + folder icon, items indented beneath) over an inline dimmed path prefix, since there is typically a lot of shared structure to convey. - **Directory labels** currently render the full path (dimmed, truncated with a `title` tooltip). Open follow-ups: collapse `$HOME` to `~` or show fewer path segments if the labels feel long. - **MCP:** evaluated applying the same grouping. An `mcp_server` is keyed by name and has no link to its `mcp_config` (confirmed in `codersdk.ChatContextResource` and the `pinnedContextResources` builder), so grouping servers under a config would require a backend data change. Left flat for this PR.
--- 🤖 This PR was created by Coder Agents on behalf of @kylecarbs. --- .../ContextUsageIndicator.stories.tsx | 72 +++++++++ .../components/ContextUsageIndicator.tsx | 146 ++++++++++++++---- site/src/pages/AgentsPage/utils/path.test.ts | 15 +- site/src/pages/AgentsPage/utils/path.ts | 13 ++ 4 files changed, 212 insertions(+), 34 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ContextUsageIndicator.stories.tsx b/site/src/pages/AgentsPage/components/ContextUsageIndicator.stories.tsx index 0b8b4e36f6..fd293243cb 100644 --- a/site/src/pages/AgentsPage/components/ContextUsageIndicator.stories.tsx +++ b/site/src/pages/AgentsPage/components/ContextUsageIndicator.stories.tsx @@ -36,6 +36,9 @@ export const Clean: Story = { await userEvent.hover(button); const body = within(document.body); await waitFor(() => expect(body.getByText("Context files")).toBeVisible()); + // A single context root still shows its directory header. + expect(body.getByText("/home/coder")).toBeVisible(); + expect(body.getByText("/home/coder/.coder/skills")).toBeVisible(); // The list is driven by the pinned resources. expect(body.getByText("AGENTS.md")).toBeVisible(); expect(body.getByText("deploy")).toBeVisible(); @@ -59,6 +62,75 @@ export const Clean: Story = { }, }; +// Multiple context roots: files and skills are pulled from several +// directories, so each list groups by its parent directory. Without grouping +// the two AGENTS.md files would render as identical, ambiguous rows. +export const MultipleContextRoots: Story = { + args: { + usage: { + usedTokens: 48_000, + contextLimitTokens: 200_000, + context: { + dirty: false, + resources: [ + { + source: "/home/coder/AGENTS.md", + kind: "instruction_file", + size_bytes: 248, + status: "ok", + }, + { + source: "/home/coder/site/AGENTS.md", + kind: "instruction_file", + size_bytes: 512, + status: "ok", + }, + { + source: "/home/coder/.coder/skills/deploy", + kind: "skill", + size_bytes: 96, + status: "ok", + skill_name: "deploy", + skill_description: "Deploy the app to staging.", + }, + { + source: "/home/coder/.coder/skills/migrate", + kind: "skill", + size_bytes: 120, + status: "ok", + skill_name: "migrate", + skill_description: "Run database migrations.", + }, + { + source: "/home/coder/.agents/skills/review", + kind: "skill", + size_bytes: 140, + status: "ok", + skill_name: "review", + skill_description: "Review a pull request.", + }, + ], + }, + }, + }, + play: async ({ canvasElement }) => { + const button = within(canvasElement).getByRole("button"); + await userEvent.hover(button); + const body = within(document.body); + // Both directories that contribute instruction files are listed, so the + // two AGENTS.md files are no longer ambiguous. + await waitFor(() => expect(body.getByText("/home/coder")).toBeVisible()); + expect(body.getByText("/home/coder/site")).toBeVisible(); + expect(body.getAllByText("AGENTS.md")).toHaveLength(2); + // Skills are grouped under each skill root. + expect(body.getByText("/home/coder/.coder/skills")).toBeVisible(); + expect(body.getByText("/home/coder/.agents/skills")).toBeVisible(); + expect(body.getByText("deploy")).toBeVisible(); + expect(body.getByText("migrate")).toBeVisible(); + expect(body.getByText("review")).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 179b03f68d..6285fd7e85 100644 --- a/site/src/pages/AgentsPage/components/ContextUsageIndicator.tsx +++ b/site/src/pages/AgentsPage/components/ContextUsageIndicator.tsx @@ -1,5 +1,6 @@ import { FileIcon, + FolderIcon, PlugIcon, TriangleAlertIcon, WrenchIcon, @@ -27,7 +28,7 @@ import { } from "#/components/Tooltip/Tooltip"; import { cn } from "#/utils/cn"; import { isMobileViewport } from "#/utils/mobile"; -import { getPathBasename } from "../utils/path"; +import { getPathBasename, getPathDirname } from "../utils/path"; import { SvgRingProgress } from "./SvgRingProgress"; export interface AgentContextUsage { @@ -47,10 +48,12 @@ export interface AgentContextUsage { // Normalized popover entries, sourced from the chat's pinned context // resources. -type ContextFileItem = { readonly path: string }; +type ContextFileItem = { readonly path: string; readonly dir: string }; type ContextSkillItem = { + readonly source: string; readonly name: string; readonly description?: string; + readonly dir: string; }; type ContextMcpItem = { readonly name: string; @@ -109,6 +112,34 @@ const getIndicatorToneClassName = (percentUsed: number | null): string => { return "text-content-secondary/60"; }; +// A set of context resources that share a parent directory. Lists are grouped +// by directory so resources pulled from different roots (for example a +// repo-root AGENTS.md and a nested one) stay distinguishable instead of +// collapsing to identical basenames. +type DirectoryGroup = { + readonly dir: string; + readonly items: readonly T[]; +}; + +// Group items by their precomputed dir, preserving first-seen order so the +// popover layout stays stable across renders. +const groupByDirectory = ( + items: readonly T[], +): readonly DirectoryGroup[] => { + const order: string[] = []; + const byDir = new Map(); + for (const item of items) { + const existing = byDir.get(item.dir); + if (existing) { + existing.push(item); + } else { + byDir.set(item.dir, [item]); + order.push(item.dir); + } + } + return order.map((dir) => ({ dir, items: byDir.get(dir) ?? [] })); +}; + const RING_SIZE = 18; const RING_STROKE = 2.5; @@ -116,6 +147,18 @@ const RING_STROKE = 2.5; // the user time to move into the popover content. const HOVER_CLOSE_DELAY_MS = 150; +// Dimmed directory header shown above a group of context resources when a +// section spans more than one directory. +const ContextDirLabel: FC<{ dir: string }> = ({ dir }) => ( + + + {dir} + +); + export const ContextUsageIndicator: FC<{ usage: AgentContextUsage | null; onRefreshContext?: () => void; @@ -176,15 +219,20 @@ export const ContextUsageIndicator: FC<{ (resource) => resource.kind === "instruction_file" && resource.status === "ok", ) - .map((resource) => ({ path: resource.source })) + .map((resource) => ({ + path: resource.source, + dir: getPathDirname(resource.source), + })) // Drop entries with no usable path so an empty marker never renders as a // nameless "Context files" row. .filter((file) => file.path.trim().length > 0); const skillItems: readonly ContextSkillItem[] = (pinnedResources ?? []) .filter((resource) => resource.kind === "skill" && resource.status === "ok") .map((resource) => ({ + source: resource.source, name: resource.skill_name || getPathBasename(resource.source), description: resource.skill_description, + dir: getPathDirname(resource.source), })) // Drop entries with no usable name so an empty skill marker never renders // as a blank row. @@ -230,6 +278,11 @@ export const ContextUsageIndicator: FC<{ mcpItems.length > 0 || issueItems.length > 0; + // Group files and skills by directory so every context root is labeled, + // keeping resources pulled from different directories distinguishable. + const fileGroups = groupByDirectory(fileItems); + const skillGroups = groupByDirectory(skillItems); + const ariaLabel = hasPercent ? `Context usage ${percentLabel}. ${formatTokenCount(usedTokens)} of ${formatTokenCount(contextLimitTokens)} tokens used.${isDirty ? " Context changed." : ""}` : isDirty @@ -260,12 +313,27 @@ export const ContextUsageIndicator: FC<{ Context files - {fileItems.map((file) => ( -
- - - {getPathBasename(file.path)} - + {fileGroups.map((group) => ( +
+ {group.dir !== "" && } +
+ {group.items.map((file) => ( +
+ + + {getPathBasename(file.path)} + +
+ ))} +
))}
@@ -274,31 +342,43 @@ export const ContextUsageIndicator: FC<{
Skills - {skillItems.map((skill) => { - const row = ( -
- - {skill.name} + {skillGroups.map((group) => ( +
+ {group.dir !== "" && } +
+ {group.items.map((skill) => { + const row = ( +
+ + {skill.name} +
+ ); + if (!skill.description) { + return
{row}
; + } + return ( + + +
{row}
+
+ + {skill.description} + +
+ ); + })}
- ); - if (!skill.description) { - return
{row}
; - } - return ( - - -
{row}
-
- - {skill.description} - -
- ); - })} +
+ ))}
)} diff --git a/site/src/pages/AgentsPage/utils/path.test.ts b/site/src/pages/AgentsPage/utils/path.test.ts index a29b4a8f1b..d564bf557b 100644 --- a/site/src/pages/AgentsPage/utils/path.test.ts +++ b/site/src/pages/AgentsPage/utils/path.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { getPathBasename } from "./path"; +import { getPathBasename, getPathDirname } from "./path"; describe("getPathBasename", () => { it.each([ @@ -12,3 +12,16 @@ describe("getPathBasename", () => { expect(getPathBasename(path)).toBe(expected); }); }); + +describe("getPathDirname", () => { + it.each([ + ["/home/coder/AGENTS.md", "/home/coder"], + ["/home/coder/.coder/skills/deploy", "/home/coder/.coder/skills"], + ["foo/bar.ts", "foo"], + ["main.go", ""], + ["", ""], + ["/AGENTS.md", "/"], + ])("returns the dirname for %s", (path, expected) => { + expect(getPathDirname(path)).toBe(expected); + }); +}); diff --git a/site/src/pages/AgentsPage/utils/path.ts b/site/src/pages/AgentsPage/utils/path.ts index 51f44d2c9e..fa3a7b8bea 100644 --- a/site/src/pages/AgentsPage/utils/path.ts +++ b/site/src/pages/AgentsPage/utils/path.ts @@ -3,3 +3,16 @@ export const getPathBasename = (path: string): string => { const basename = slash >= 0 ? path.substring(slash + 1) : path; return basename || path; }; + +// Returns the parent directory of a path, or "" when the path has no directory +// component. Root-level paths (e.g. "/AGENTS.md") return "/". +export const getPathDirname = (path: string): string => { + const slash = path.lastIndexOf("/"); + if (slash < 0) { + return ""; + } + if (slash === 0) { + return "/"; + } + return path.substring(0, slash); +};