feat(site/src/pages/AgentsPage): group context indicator resources by directory (#26598)

## 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.

<details>
<summary>Design decision log</summary>

- **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.

</details>

---

🤖 This PR was created by Coder Agents on behalf of @kylecarbs.
This commit is contained in:
Kyle Carberry
2026-06-22 21:39:34 -06:00
committed by GitHub
parent 0b856ef637
commit 688ee63f9d
4 changed files with 212 additions and 34 deletions
@@ -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 = {
@@ -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<T> = {
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 = <T extends { readonly dir: string }>(
items: readonly T[],
): readonly DirectoryGroup<T>[] => {
const order: string[] = [];
const byDir = new Map<string, T[]>();
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 }) => (
<span
className="flex items-center gap-1 text-[11px] text-content-secondary"
title={dir}
>
<FolderIcon className="size-3 shrink-0" />
<span className="truncate">{dir}</span>
</span>
);
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<{
<span className="font-medium text-content-primary">
Context files
</span>
{fileItems.map((file) => (
<div key={file.path} className="flex items-center gap-1.5">
<FileIcon className="size-3 shrink-0" />
<span className="truncate" title={file.path}>
{getPathBasename(file.path)}
</span>
{fileGroups.map((group) => (
<div key={group.dir} className="flex flex-col gap-1">
{group.dir !== "" && <ContextDirLabel dir={group.dir} />}
<div
className={cn(
"flex flex-col",
group.dir !== "" ? "ml-3.5 gap-0.5" : "gap-1",
)}
>
{group.items.map((file) => (
<div
key={file.path}
className="flex items-center gap-1.5"
>
<FileIcon className="size-3 shrink-0" />
<span className="truncate" title={file.path}>
{getPathBasename(file.path)}
</span>
</div>
))}
</div>
</div>
))}
</div>
@@ -274,31 +342,43 @@ export const ContextUsageIndicator: FC<{
<div className="flex flex-col gap-1">
<span className="font-medium text-content-primary">Skills</span>
<TooltipProvider delayDuration={300}>
{skillItems.map((skill) => {
const row = (
<div className="flex items-center gap-1.5 rounded px-0.5 py-px transition-colors hover:bg-surface-tertiary">
<ZapIcon className="size-3 shrink-0" />
<span className="truncate">{skill.name}</span>
{skillGroups.map((group) => (
<div key={group.dir} className="flex flex-col gap-1">
{group.dir !== "" && <ContextDirLabel dir={group.dir} />}
<div
className={cn(
"flex flex-col",
group.dir !== "" ? "ml-3.5 gap-0.5" : "gap-1",
)}
>
{group.items.map((skill) => {
const row = (
<div className="flex items-center gap-1.5 rounded px-0.5 py-px transition-colors hover:bg-surface-tertiary">
<ZapIcon className="size-3 shrink-0" />
<span className="truncate">{skill.name}</span>
</div>
);
if (!skill.description) {
return <div key={skill.source}>{row}</div>;
}
return (
<Tooltip key={skill.source}>
<TooltipTrigger asChild>
<div className="cursor-default">{row}</div>
</TooltipTrigger>
<TooltipContent
side="right"
sideOffset={4}
className="max-w-48 text-xs"
>
{skill.description}
</TooltipContent>
</Tooltip>
);
})}
</div>
);
if (!skill.description) {
return <div key={skill.name}>{row}</div>;
}
return (
<Tooltip key={skill.name}>
<TooltipTrigger asChild>
<div className="cursor-default">{row}</div>
</TooltipTrigger>
<TooltipContent
side="right"
sideOffset={4}
className="max-w-48 text-xs"
>
{skill.description}
</TooltipContent>
</Tooltip>
);
})}
</div>
))}
</TooltipProvider>
</div>
)}
+14 -1
View File
@@ -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);
});
});
+13
View File
@@ -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);
};