mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
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:
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 (0–100) 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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user