mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user