feat(site/src): surface pinned chat workspace context in the UI (#26573)

Adds the workspace-context indicator UI for agent chats, part of
breaking the "Workspace Context Sources for Coder Agents" RFC (#26466)
into small, reviewable PRs.

## What this adds

- **Pinned context popover**: the context-usage indicator now lists the
chat's pinned resources, instruction files, skills, and MCP servers with
their tools. Unusable resources (invalid skill, unreadable/oversize
file) are surfaced in an "Issues" section with their error rather than
dropped silently.
- **Drift and error states**: when the pinned context differs from the
agent's latest snapshot, the ring shows a warning marker and the popover
explains the drift. A snapshot-level error gets a distinct treatment.
- **Refresh context**: a button re-pins the chat to the agent's latest
snapshot via `PUT /api/experimental/chats/{id}/context`.
- **Live updates**: `context_dirty` watch events apply the lightweight
dirty flags across the cached chat lists and refetch the open chat so
the full pinned detail loads.

## Not included

The context **changes/diff** view (the "View changes" affordance and its
dialog) is intentionally deferred to a later split, so this PR contains
no diff rendering.

## Testing

- `pnpm lint` (types, biome, circular deps, React Compiler, knip)
- `pnpm test src/api/queries/chats.test.ts` (cache-merge unit tests,
including the new `context_dirty` cases)
- `pnpm test:storybook
src/pages/AgentsPage/components/ContextUsageIndicator.stories.tsx` (4
stories)
- `pnpm format`

<details>
<summary>Design notes</summary>

This is **Split 3** of #26466. Split sequence:

1. #26558 - prompt pin consumption (merged)
2. #26570 - `codersdk` context resource types (merged). During review
the type was renamed `ChatContextMCPTool` -> `ChatContextTool` and the
field `mcp_tools` -> `tools`; this PR consumes those merged names.
3. **This PR** - the UI.
4. CLI `coder exp chat context` source CRUD + `refresh` (next).
5. The context diff (`changes`, `ChatContextResourceChange`, the changes
dialog, and `buildContentPatch`) - last.

Key decisions:

- The backend already publishes `ChatWatchEventKindContextDirty` and
exposes the refresh endpoint (#26389). Watch/pubsub payloads stay
lightweight: they carry only the `dirty`/`dirty_since`/`error` flags and
omit `resources`. So `mergeWatchedChatSummary` merges (not replaces) the
cached context to preserve the pinned `resources` a single-chat GET
populated, and the `AgentsPage` watch handler refetches only the open
chat to pull the full pinned detail.
- The indicator prefers the chat's pinned `resources`; while they have
not loaded it falls back to the agent's `last_injected_context`,
skipping the empty context-file placeholder so it never renders a
nameless row.
- All diff/changes rendering is excluded here and lands in the final
split to keep this PR focused on the read-only pinned view and the
refresh action.

</details>

*This PR was created by Coder Agents on behalf of @kylecarbs.*
This commit is contained in:
Kyle Carberry
2026-06-22 11:09:39 -06:00
committed by GitHub
parent c0f854c289
commit e6a9b59abe
11 changed files with 689 additions and 52 deletions
+11
View File
@@ -3420,6 +3420,17 @@ class ExperimentalApiMethods {
return response.data;
};
/**
* Re-pins the chat to its agent's latest context snapshot and clears
* the dirty marker. Returns the updated chat.
*/
refreshChatContext = async (chatId: string): Promise<TypesGen.Chat> => {
const response = await this.axios.put<TypesGen.Chat>(
`/api/experimental/chats/${chatId}/context`,
);
return response.data;
};
deleteChatQueuedMessage = async (
chatId: string,
queuedMessageId: number,
+62
View File
@@ -2062,6 +2062,68 @@ describe("updateChildInParentCache", () => {
});
describe("mergeWatchedChatSummary", () => {
it("applies context_dirty flags while preserving the pinned resource list", () => {
const cachedChat = makeChat("chat-1", {
updated_at: "2025-01-01T00:00:00.000Z",
context: {
dirty: false,
resources: [
{
source: "/AGENTS.md",
kind: "instruction_file",
size_bytes: 10,
status: "ok",
},
],
},
});
const watchedChat = makeChat("chat-1", {
// Drift is tracked outside updated_at, so an older event timestamp
// still applies the dirty flags.
updated_at: "2024-12-31T00:00:00.000Z",
context: { dirty: true, dirty_since: "2025-01-02T00:00:00.000Z" },
});
expect(
mergeWatchedChatSummary(cachedChat, watchedChat, {
eventKind: "context_dirty",
}).context,
).toEqual({
dirty: true,
dirty_since: "2025-01-02T00:00:00.000Z",
// The lightweight watch payload omits resources; the merge keeps the
// pinned list a prior single-chat GET populated.
resources: [
{
source: "/AGENTS.md",
kind: "instruction_file",
size_bytes: 10,
status: "ok",
},
],
});
});
it("leaves context untouched for non-context events", () => {
const context = { dirty: true, dirty_since: "2025-01-02T00:00:00.000Z" };
const cachedChat = makeChat("chat-1", {
status: "pending",
updated_at: "2025-01-01T00:00:00.000Z",
context,
});
const watchedChat = makeChat("chat-1", {
status: "running",
updated_at: "2025-01-01T00:05:00.000Z",
context: { dirty: false },
});
expect(
mergeWatchedChatSummary(cachedChat, watchedChat, {
eventKind: "status_change",
}).context,
).toBe(context);
});
it("merges fresh status updates without clobbering a newer title snapshot", () => {
const cachedChat = makeChat("chat-1", {
status: "pending",
+46 -1
View File
@@ -310,6 +310,7 @@ export const mergeWatchedChatSummary = (
const isStatusEvent = eventKind === "status_change";
const isSummaryEvent = eventKind === "summary_change";
const isDiffStatusEvent = eventKind === "diff_status_change";
const isContextDirtyEvent = eventKind === "context_dirty";
const updatedAtComparison = compareUpdatedAtInstants(
cachedChat.updated_at,
watchedChat.updated_at,
@@ -325,6 +326,15 @@ export const mergeWatchedChatSummary = (
const nextDiffStatus = isDiffStatusEvent
? watchedChat.diff_status
: cachedChat.diff_status;
// Context drift is tracked outside chats.updated_at (it is driven by
// agent context pushes), so apply context_dirty payloads regardless of
// the summary timestamp. Merge rather than replace so the pinned
// resources a single-chat GET populated are preserved while the dirty
// flags update; the open chat refetches the full detail.
const nextContext =
isContextDirtyEvent && watchedChat.context
? { ...cachedChat.context, ...watchedChat.context }
: cachedChat.context;
const nextWorkspaceId = isFreshEnough
? (watchedChat.workspace_id ?? cachedChat.workspace_id)
: cachedChat.workspace_id;
@@ -358,7 +368,8 @@ export const mergeWatchedChatSummary = (
nextLastModelConfigId === cachedChat.last_model_config_id &&
nextLastTurnSummary === cachedChat.last_turn_summary &&
nextHasUnread === cachedChat.has_unread &&
nextUpdatedAt === cachedChat.updated_at
nextUpdatedAt === cachedChat.updated_at &&
nextContext === cachedChat.context
) {
return cachedChat;
}
@@ -374,6 +385,7 @@ export const mergeWatchedChatSummary = (
last_turn_summary: nextLastTurnSummary,
has_unread: nextHasUnread,
updated_at: nextUpdatedAt,
context: nextContext,
};
};
@@ -1344,6 +1356,39 @@ export const interruptChat = (queryClient: QueryClient, chatId: string) => ({
},
});
/**
* Re-pins the chat to its agent's latest context snapshot, clearing the
* dirty marker. On success the returned chat (carrying the freshly pinned
* resources) is written into the open-chat cache, and the lightweight
* context flags are propagated across the list caches so the dirty
* indicator clears in the sidebar too.
*/
export const refreshChatContext = (
queryClient: QueryClient,
chatId: string,
) => ({
mutationFn: () => API.experimental.refreshChatContext(chatId),
onSuccess: (updatedChat: TypesGen.Chat) => {
queryClient.setQueryData<TypesGen.Chat>(chatKey(chatId), (cached) =>
cached ? { ...cached, context: updatedChat.context } : updatedChat,
);
const applyContext = (chat: TypesGen.Chat): TypesGen.Chat =>
chat.id === chatId ? { ...chat, context: updatedChat.context } : chat;
updateInfiniteChatsCache(queryClient, (chats) => {
let changed = false;
const next = chats.map((chat) => {
const updated = applyContext(chat);
if (updated !== chat) {
changed = true;
}
return updated;
});
return changed ? next : chats;
});
updateChildInParentCache(queryClient, applyContext, chatId);
},
});
export const deleteChatQueuedMessage = (
queryClient: QueryClient,
chatId: string,
@@ -1674,6 +1674,7 @@ const AgentChatPage: FC = () => {
onMCPSelectionChange={handleMCPSelectionChange}
onMCPAuthComplete={handleMCPAuthComplete}
lastInjectedContext={chatQuery.data?.last_injected_context}
chatContext={chatQuery.data?.context}
/>
);
};
@@ -216,6 +216,7 @@ interface AgentChatPageViewProps {
desktopChatId?: string;
lastInjectedContext?: readonly TypesGen.ChatMessagePart[];
chatContext?: TypesGen.ChatContext;
}
const UnavailableTabMessage: FC<{ message: string }> = ({ message }) => (
@@ -373,6 +374,7 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
onMCPAuthComplete,
desktopChatId,
lastInjectedContext,
chatContext,
}) => {
const queryClient = useQueryClient();
const { proxy } = useProxy();
@@ -964,6 +966,7 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
onMCPSelectionChange={onMCPSelectionChange}
onMCPAuthComplete={onMCPAuthComplete}
lastInjectedContext={lastInjectedContext}
chatContext={chatContext}
workspace={workspace}
workspaceAgent={workspaceAgent}
chatId={agentId}
+12
View File
@@ -645,6 +645,18 @@ const AgentsPage: FC = () => {
if (shouldInvalidateFilteredChatList(updatedChat, chatEvent.kind)) {
void invalidateChatListQueries(queryClient);
}
if (chatEvent.kind === "context_dirty") {
// The watch payload carries only the lightweight
// context flags (the merge above applies them);
// refetch the open chat to pull the pinned
// resources the single-chat GET computes. Only the
// active chat has an observer, so other chats are
// merely marked stale.
void queryClient.invalidateQueries({
queryKey: chatKey(updatedChat.id),
exact: true,
});
}
}
});
return ws;
@@ -162,6 +162,11 @@ interface AgentChatInputProps {
// Pass `null` to render fallback values (e.g. when limit is unknown).
// Omit entirely to hide the indicator.
contextUsage?: AgentContextUsage | null;
// Re-pins the chat to the workspace's latest context snapshot,
// surfaced by the context indicator when the pinned context has
// drifted.
onRefreshContext?: () => void;
isRefreshingContext?: boolean;
attachments?: readonly File[];
onAttach?: (files: File[]) => void;
onRemoveAttachment?: (attachment: number | File) => void;
@@ -367,6 +372,8 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
onCancelHistoryEdit,
userPromptHistory = [],
contextUsage,
onRefreshContext,
isRefreshingContext,
attachments = [],
onAttach,
onRemoveAttachment,
@@ -1537,7 +1544,11 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
</>
)}
{contextUsage !== undefined && (
<ContextUsageIndicator usage={contextUsage} />
<ContextUsageIndicator
usage={contextUsage}
onRefreshContext={onRefreshContext}
isRefreshingContext={isRefreshingContext}
/>
)}
{isStreaming && onInterrupt && (
<Button
@@ -1,8 +1,8 @@
import { type FC, Profiler, type ReactNode, useEffect, useRef } from "react";
import { useQuery } from "react-query";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { toast } from "sonner";
import type { UrlTransform } from "streamdown";
import { chatPromptsQuery } from "#/api/queries/chats";
import { chatPromptsQuery, refreshChatContext } from "#/api/queries/chats";
import type * as TypesGen from "#/api/typesGenerated";
import type { AgentChatSendShortcut } from "#/api/typesGenerated";
import { cn } from "#/utils/cn";
@@ -209,6 +209,9 @@ interface ChatPageInputProps {
onMCPSelectionChange?: (ids: string[]) => void;
onMCPAuthComplete?: (serverId: string) => void;
lastInjectedContext?: readonly TypesGen.ChatMessagePart[];
// Pinned workspace-context state for the chat, surfaced by the
// context indicator (dirty marker and pinned resources).
chatContext?: TypesGen.ChatContext;
workspaceOptions: readonly TypesGen.Workspace[];
chatOrganizationId?: string;
selectedWorkspaceId: string | null;
@@ -263,6 +266,7 @@ export const ChatPageInput: FC<ChatPageInputProps> = ({
onMCPSelectionChange,
onMCPAuthComplete,
lastInjectedContext,
chatContext,
workspaceOptions,
chatOrganizationId,
selectedWorkspaceId,
@@ -300,9 +304,26 @@ export const ChatPageInput: FC<ChatPageInputProps> = ({
promptsData?.prompts.map((prompt) => prompt.text) ?? [];
const rawUsage = getLatestContextUsage(messages);
const latestContextUsage = rawUsage
? { ...rawUsage, compressionThreshold, lastInjectedContext }
: rawUsage;
const latestContextUsage =
rawUsage || lastInjectedContext || chatContext
? {
...(rawUsage ?? {}),
compressionThreshold,
lastInjectedContext,
context: chatContext,
}
: rawUsage;
const queryClient = useQueryClient();
const refreshContextMutation = useMutation(
refreshChatContext(queryClient, chatId ?? ""),
);
const handleRefreshContext = chatId
? () =>
refreshContextMutation.mutate(undefined, {
onSuccess: () => toast.success("Context refreshed."),
onError: () => toast.error("Failed to refresh context."),
})
: undefined;
const composeAttachments = useChatDraftAttachments(organizationId, chatId, {
provider: getProviderForModelOption(modelOptions, selectedModel),
});
@@ -475,6 +496,8 @@ export const ChatPageInput: FC<ChatPageInputProps> = ({
onInterrupt={onInterrupt}
isInterruptPending={isInterruptPending}
contextUsage={latestContextUsage}
onRefreshContext={handleRefreshContext}
isRefreshingContext={refreshContextMutation.isPending}
hasModelOptions={hasModelOptions}
selectedModel={selectedModel}
onModelChange={onModelChange}
@@ -0,0 +1,145 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
import {
MockChatContextClean,
MockChatContextDirty,
MockLastInjectedContextEmptyFile,
} from "#/testHelpers/chatEntities";
import { ContextUsageIndicator } from "./ContextUsageIndicator";
const meta: Meta<typeof ContextUsageIndicator> = {
title: "pages/AgentsPage/ContextUsageIndicator",
component: ContextUsageIndicator,
args: {
onRefreshContext: fn(),
},
};
export default meta;
type Story = StoryObj<typeof ContextUsageIndicator>;
// Clean pin: the ring carries no change marker and the popover lists the
// pinned resources.
export const Clean: Story = {
args: {
usage: {
usedTokens: 12_000,
contextLimitTokens: 200_000,
context: MockChatContextClean,
},
},
play: async ({ canvasElement }) => {
const button = within(canvasElement).getByRole("button");
expect(button.getAttribute("aria-label") ?? "").not.toContain(
"Context changed",
);
await userEvent.hover(button);
const body = within(document.body);
await waitFor(() => expect(body.getByText("Context files")).toBeVisible());
// 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.
expect(body.getByText("MCP")).toBeVisible();
expect(body.getByText(".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();
// Invalid resources are surfaced as issues with their error, not
// silently dropped.
expect(body.getByText("Issues")).toBeVisible();
expect(
body.getByText(
'front-matter name "coder-review" does not match directory "moo"',
),
).toBeVisible();
// A clean pin offers no refresh affordance.
expect(body.queryByRole("button", { name: "Refresh context" })).toBeNull();
},
};
// 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 = {
args: {
usage: {
usedTokens: 12_000,
contextLimitTokens: 200_000,
context: MockChatContextDirty,
},
},
play: async ({ canvasElement, args }) => {
const button = within(canvasElement).getByRole("button");
expect(button.getAttribute("aria-label") ?? "").toContain(
"Context changed",
);
await userEvent.hover(button);
const body = within(document.body);
await waitFor(() =>
expect(body.getByText("Context changed")).toBeVisible(),
);
// Refresh from the popover invokes the handler.
await userEvent.click(
body.getByRole("button", { name: "Refresh context" }),
);
expect(args.onRefreshContext).toHaveBeenCalledTimes(1);
},
};
// Regression: a dirty pin whose pinned resources have not loaded falls back to
// the agent's injected context, which can carry an empty context-file marker.
// The popover must skip it rather than render a nameless "Context files" row,
// while still surfacing the drift affordances.
export const DirtyEmptyInjectedContext: Story = {
args: {
usage: {
usedTokens: 12_000,
contextLimitTokens: 200_000,
lastInjectedContext: MockLastInjectedContextEmptyFile,
context: {
dirty: true,
},
},
},
play: async ({ canvasElement }) => {
const button = within(canvasElement).getByRole("button");
await userEvent.hover(button);
const body = within(document.body);
// The drift affordances still render.
await waitFor(() =>
expect(body.getByText("Context changed")).toBeVisible(),
);
expect(body.getByRole("button", { name: "Refresh context" })).toBeVisible();
// The empty injected marker must not produce a nameless file list.
expect(body.queryByText("Context files")).toBeNull();
},
};
// Snapshot-level error: the ring shows a distinct error treatment and the
// popover surfaces the error message.
export const SnapshotError: Story = {
args: {
usage: {
usedTokens: 12_000,
contextLimitTokens: 200_000,
context: {
dirty: false,
error: "failed to read AGENTS.md: permission denied",
resources: MockChatContextClean.resources,
},
},
},
play: async ({ canvasElement }) => {
const button = within(canvasElement).getByRole("button");
await userEvent.hover(button);
const body = within(document.body);
await waitFor(() => expect(body.getByText("Context error")).toBeVisible());
expect(
body.getByText("failed to read AGENTS.md: permission denied"),
).toBeVisible();
},
};
@@ -1,11 +1,25 @@
import { FileIcon, ZapIcon } from "lucide-react";
import {
FileIcon,
PlugIcon,
TriangleAlertIcon,
WrenchIcon,
ZapIcon,
} from "lucide-react";
import { type FC, useRef, useState } from "react";
import type { ChatMessagePart } from "#/api/typesGenerated";
import type {
ChatContext,
ChatContextResourceKind,
ChatContextResourceStatus,
ChatContextTool,
ChatMessagePart,
} from "#/api/typesGenerated";
import { Button } from "#/components/Button/Button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "#/components/Popover/Popover";
import { Spinner } from "#/components/Spinner/Spinner";
import {
Tooltip,
TooltipContent,
@@ -25,12 +39,47 @@ export interface AgentContextUsage {
readonly cacheReadTokens?: number;
readonly cacheCreationTokens?: number;
readonly reasoningTokens?: number;
// Percentage (0100) at which the context will be compacted.
// Percentage (0-100) at which the context will be compacted.
readonly compressionThreshold?: number;
// Last injected context parts (AGENTS.md files and skills).
// Last injected context parts (AGENTS.md files and skills). Used as a
// fallback to list the context when the chat's pinned resources have not
// loaded yet.
readonly lastInjectedContext?: readonly ChatMessagePart[];
// Pinned workspace-context state: the resources the chat is built from and
// whether they have drifted from the agent's latest snapshot.
readonly context?: ChatContext;
}
// Normalized popover entries, sourced from either the chat's pinned context
// resources or, as a fallback, the last injected context parts.
type ContextFileItem = { readonly path: string; readonly truncated?: boolean };
type ContextSkillItem = {
readonly name: string;
readonly description?: string;
};
type ContextMcpItem = {
readonly name: string;
readonly source: string;
readonly tools: readonly ChatContextTool[];
};
// A pinned resource the agent could not use, surfaced with its error so the
// failure is visible instead of silent.
type ContextIssueItem = {
readonly name: string;
readonly kind: ChatContextResourceKind;
readonly status: ChatContextResourceStatus;
readonly error: string;
readonly source: string;
};
// Human-readable label per resource kind, used in the issues list.
const RESOURCE_KIND_LABELS: Record<ChatContextResourceKind, string> = {
instruction_file: "file",
skill: "skill",
mcp_config: "MCP config",
mcp_server: "MCP server",
};
const hasFiniteTokenValue = (value: number | undefined): value is number =>
typeof value === "number" && Number.isFinite(value) && value >= 0;
@@ -72,9 +121,11 @@ const RING_STROKE = 2.5;
// the user time to move into the popover content.
const HOVER_CLOSE_DELAY_MS = 150;
export const ContextUsageIndicator: FC<{ usage: AgentContextUsage | null }> = ({
usage,
}) => {
export const ContextUsageIndicator: FC<{
usage: AgentContextUsage | null;
onRefreshContext?: () => void;
isRefreshingContext?: boolean;
}> = ({ usage, onRefreshContext, isRefreshingContext }) => {
const [open, setOpen] = useState(false);
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -117,21 +168,115 @@ export const ContextUsageIndicator: FC<{ usage: AgentContextUsage | null }> = ({
? Math.min(Math.max(percentUsed, 0), 100)
: 100;
const toneClassName = getIndicatorToneClassName(percentUsed);
const ariaLabel = hasPercent
? `Context usage ${percentLabel}. ${formatTokenCount(usedTokens)} of ${formatTokenCount(contextLimitTokens)} tokens used.`
: "Context usage";
// Extract context files and skills from lastInjectedContext.
const contextFiles =
usage?.lastInjectedContext?.filter((p) => p.type === "context-file") ?? [];
const skills =
usage?.lastInjectedContext?.filter((p) => p.type === "skill") ?? [];
const hasInjectedContext = contextFiles.length > 0 || skills.length > 0;
const context = usage?.context;
const isDirty = context?.dirty ?? false;
const contextError = context?.error ?? "";
const hasContextError = contextError !== "";
const pinnedResources = context?.resources;
// Drive the listed context from the chat's pinned resources, falling back
// to the last injected context parts while the pin has not loaded.
const usePinned = (pinnedResources?.length ?? 0) > 0;
const fileItems: readonly ContextFileItem[] = (
usePinned
? (pinnedResources ?? [])
.filter(
(resource) =>
resource.kind === "instruction_file" && resource.status === "ok",
)
.map((resource) => ({ path: resource.source }))
: (usage?.lastInjectedContext ?? [])
.filter((part) => part.type === "context-file")
.map((part) => ({
path: part.context_file_path,
truncated: part.context_file_truncated,
}))
)
// Drop entries with no usable path. The injected-context fallback can
// carry an empty context-file marker, which would otherwise render as a
// nameless "Context files" row.
.filter((file) => file.path.trim().length > 0);
const skillItems: readonly ContextSkillItem[] = (
usePinned
? (pinnedResources ?? [])
.filter(
(resource) => resource.kind === "skill" && resource.status === "ok",
)
.map((resource) => ({
name: resource.skill_name || getPathBasename(resource.source),
description: resource.skill_description,
}))
: (usage?.lastInjectedContext ?? [])
.filter((part) => part.type === "skill")
.map((part) => ({
name: part.skill_name,
description: part.skill_description,
}))
)
// Drop entries with no usable name so an empty skill marker never renders
// as a blank row.
.filter((skill) => skill.name.trim().length > 0);
// MCP configs/servers are only ever surfaced from the chat's pinned
// resources; there is no injected-context fallback for them. An MCP server's
// source is its server name, while an MCP config's source is its file path.
const mcpItems: readonly ContextMcpItem[] = (
usePinned
? (pinnedResources ?? [])
.filter(
(resource) =>
(resource.kind === "mcp_config" ||
resource.kind === "mcp_server") &&
resource.status === "ok",
)
.map((resource) => ({
name:
resource.kind === "mcp_server"
? resource.source
: getPathBasename(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);
// 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. Pinned-only; the injected-context
// fallback has no status.
const issueItems: readonly ContextIssueItem[] = (
usePinned ? (pinnedResources ?? []) : []
)
.filter((resource) => resource.status !== "ok")
.map((resource) => ({
name:
resource.skill_name ||
getPathBasename(resource.source) ||
resource.source,
kind: resource.kind,
status: resource.status,
error: resource.error ?? "",
source: resource.source,
}))
.filter((issue) => issue.name.trim().length > 0);
const hasContextList =
fileItems.length > 0 ||
skillItems.length > 0 ||
mcpItems.length > 0 ||
issueItems.length > 0;
const ariaLabel = hasPercent
? `Context usage ${percentLabel}. ${formatTokenCount(usedTokens)} of ${formatTokenCount(contextLimitTokens)} tokens used.${isDirty ? " Context changed." : ""}`
: isDirty
? "Context usage. Context changed."
: "Context usage";
const panelContent = (
<div className="text-xs text-content-primary">
{hasPercent
? `${percentLabel} ${formatTokenCountCompact(usedTokens)} / ${formatTokenCountCompact(contextLimitTokens)} context used`
? `${percentLabel} - ${formatTokenCountCompact(usedTokens)} / ${formatTokenCountCompact(contextLimitTokens)} context used`
: "Context usage unavailable"}
{hasPercent &&
usage?.compressionThreshold !== undefined &&
@@ -140,56 +285,49 @@ export const ContextUsageIndicator: FC<{ usage: AgentContextUsage | null }> = ({
{`Compacts at ${usage.compressionThreshold}%`}
</div>
)}
{hasInjectedContext && (
{hasContextList && (
<div
className={cn(
"flex flex-col gap-2 text-content-secondary",
hasPercent && "mt-2",
)}
>
{contextFiles.length > 0 && (
{fileItems.length > 0 && (
<div className="flex flex-col gap-1">
<span className="font-medium text-content-primary">
Context files
</span>
{contextFiles.map((part) => {
if (part.type !== "context-file") return null;
return (
<div
key={part.context_file_path}
className="flex items-center gap-1.5"
>
<FileIcon className="size-3 shrink-0" />
<span className="truncate" title={part.context_file_path}>
{getPathBasename(part.context_file_path)}
{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>
{file.truncated && (
<span className="shrink-0 text-content-warning">
(truncated)
</span>
{part.context_file_truncated && (
<span className="shrink-0 text-content-warning">
(truncated)
</span>
)}
</div>
);
})}
)}
</div>
))}
</div>
)}
{skills.length > 0 && (
{skillItems.length > 0 && (
<div className="flex flex-col gap-1">
<span className="font-medium text-content-primary">Skills</span>
<TooltipProvider delayDuration={300}>
{skills.map((part) => {
if (part.type !== "skill") return null;
{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">{part.skill_name}</span>
<span className="truncate">{skill.name}</span>
</div>
);
if (!part.skill_description) {
return <div key={part.skill_name}>{row}</div>;
if (!skill.description) {
return <div key={skill.name}>{row}</div>;
}
return (
<Tooltip key={part.skill_name}>
<Tooltip key={skill.name}>
<TooltipTrigger asChild>
<div className="cursor-default">{row}</div>
</TooltipTrigger>
@@ -198,7 +336,7 @@ export const ContextUsageIndicator: FC<{ usage: AgentContextUsage | null }> = ({
sideOffset={4}
className="max-w-48 text-xs"
>
{part.skill_description}
{skill.description}
</TooltipContent>
</Tooltip>
);
@@ -206,6 +344,114 @@ export const ContextUsageIndicator: FC<{ usage: AgentContextUsage | null }> = ({
</TooltipProvider>
</div>
)}
{mcpItems.length > 0 && (
<div className="flex flex-col gap-1">
<span className="font-medium text-content-primary">MCP</span>
<TooltipProvider delayDuration={300}>
{mcpItems.map((mcp) => (
<div key={mcp.source} className="flex flex-col gap-0.5">
<div
className="flex items-center gap-1.5"
title={mcp.source}
>
<PlugIcon className="size-3 shrink-0" />
<span className="truncate">{mcp.name}</span>
</div>
{mcp.tools.length > 0 && (
<div className="ml-4 flex flex-col gap-0.5">
{mcp.tools.map((tool) => {
const row = (
<div className="flex items-center gap-1.5 rounded px-0.5 py-px text-content-secondary transition-colors hover:bg-surface-tertiary">
<WrenchIcon className="size-3 shrink-0" />
<span className="truncate">{tool.name}</span>
</div>
);
if (!tool.description) {
return <div key={tool.name}>{row}</div>;
}
return (
<Tooltip key={tool.name}>
<TooltipTrigger asChild>
<div className="cursor-default">{row}</div>
</TooltipTrigger>
<TooltipContent
side="right"
sideOffset={4}
className="max-w-48 text-xs"
>
{tool.description}
</TooltipContent>
</Tooltip>
);
})}
</div>
)}
</div>
))}
</TooltipProvider>
</div>
)}
{issueItems.length > 0 && (
<div className="flex flex-col gap-1">
<span className="flex items-center gap-1.5 font-medium text-content-warning">
<TriangleAlertIcon className="size-3 shrink-0" />
Issues
</span>
{issueItems.map((issue) => (
<div
key={issue.source}
className="flex flex-col"
title={issue.source}
>
<span className="truncate">
{issue.name}{" "}
<span className="text-content-secondary">
({RESOURCE_KIND_LABELS[issue.kind]}: {issue.status})
</span>
</span>
{issue.error && (
<span className="text-content-secondary">
{issue.error}
</span>
)}
</div>
))}
</div>
)}
</div>
)}
{(isDirty || hasContextError) && (
<div className="mt-2 flex flex-col gap-1.5 border-0 border-t border-solid border-border-default pt-2">
{hasContextError ? (
<span className="flex items-center gap-1.5 font-medium text-content-destructive">
<TriangleAlertIcon className="size-3 shrink-0" />
Context error
</span>
) : (
<span className="flex items-center gap-1.5 font-medium text-content-warning">
<TriangleAlertIcon className="size-3 shrink-0" />
Context changed
</span>
)}
{hasContextError ? (
<span className="text-content-secondary">{contextError}</span>
) : (
<span className="text-content-secondary">
The workspace context changed since this chat was pinned.
</span>
)}
{onRefreshContext && (
<div className="flex flex-wrap gap-2">
<Button
size="sm"
disabled={isRefreshingContext}
onClick={() => onRefreshContext()}
>
<Spinner loading={isRefreshingContext} />
Refresh context
</Button>
</div>
)}
</div>
)}
</div>
@@ -225,6 +471,17 @@ export const ContextUsageIndicator: FC<{ usage: AgentContextUsage | null }> = ({
progressClassName="stroke-current"
className={cn("size-icon-sm", toneClassName)}
/>
{(isDirty || hasContextError) && (
<TriangleAlertIcon
aria-hidden
className={cn(
"absolute -right-0.5 -top-0.5 size-3",
hasContextError
? "text-content-destructive"
: "text-content-warning",
)}
/>
)}
</button>
);
+67
View File
@@ -1,6 +1,9 @@
import type {
Chat,
ChatContext,
ChatContextResource,
ChatMessage,
ChatMessagePart,
ChatQueuedMessage,
MCPServerConfig,
} from "#/api/typesGenerated";
@@ -30,6 +33,70 @@ export const MockChat: Chat = {
children: [],
};
// Pinned workspace-context resources the prompt is built from.
const MockChatContextResources: ChatContextResource[] = [
{
source: "/home/coder/AGENTS.md",
kind: "instruction_file",
size_bytes: 248,
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/.mcp.json",
kind: "mcp_config",
size_bytes: 184,
status: "ok",
},
{
source: "github",
kind: "mcp_server",
size_bytes: 512,
status: "ok",
tools: [
{
name: "search_issues",
description: "Search issues and pull requests.",
},
{ name: "create_issue", description: "Open a new issue." },
],
},
{
// An invalid skill the agent rejected: surfaced as an issue with its
// error rather than silently dropped.
source: "/home/coder/test/.agents/skills/moo",
kind: "skill",
size_bytes: 356,
status: "invalid",
error: 'front-matter name "coder-review" does not match directory "moo"',
},
];
export const MockChatContextClean: ChatContext = {
dirty: false,
resources: MockChatContextResources,
};
export const MockChatContextDirty: ChatContext = {
dirty: true,
dirty_since: "2024-01-02T00:00:00Z",
resources: MockChatContextResources,
};
// Injected-context fallback whose only context-file marker has no path. The
// agent emits this empty placeholder for skill-only additions; the context
// indicator must skip it rather than render a nameless "Context files" row.
export const MockLastInjectedContextEmptyFile: readonly ChatMessagePart[] = [
{ type: "context-file", context_file_path: "" },
];
export const MockMCPServerConfig: MCPServerConfig = {
id: "mcp-1",
display_name: "MCP Server",