mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat(site): display loaded context files and skills in context indicator tooltip (#23853)
Renders the `last_injected_context` data (AGENTS.md files and skills) from the Chat API in the `ContextUsageIndicator` hover tooltip. On hover, users now see: - **Context files**: basename with full path on title hover, truncation indicator - **Skills**: name and optional description Separated from the existing token usage info by a border divider when both sections are present. Added `max-w-72` to prevent the tooltip from getting too wide. <img width="970" height="598" alt="image" src="https://github.com/user-attachments/assets/5bc25cb2-1d92-41d2-ab1a-63e5e49f667a" /> <details> <summary>Data flow</summary> ``` chatQuery.data.last_injected_context → AgentChatPage (AgentChatPageView prop) → AgentChatPageView (ChatPageInput prop) → ChatPageInput (spread into latestContextUsage) → AgentChatInput (contextUsage prop) → ContextUsageIndicator (usage.lastInjectedContext) ``` </details> <details> <summary>Files changed</summary> | File | Change | |---|---| | `ContextUsageIndicator.tsx` | Add `lastInjectedContext` to interface, render context files and skills sections in tooltip | | `ChatPageContent.tsx` | Thread `lastInjectedContext` prop, spread into context usage object | | `AgentChatPageView.tsx` | Thread `lastInjectedContext` prop to `ChatPageInput` | | `AgentChatPage.tsx` | Pass `chatQuery.data?.last_injected_context` down | </details>
This commit is contained in:
@@ -1069,6 +1069,7 @@ const AgentChatPage: FC = () => {
|
||||
selectedMCPServerIds={effectiveMCPServerIds}
|
||||
onMCPSelectionChange={handleMCPSelectionChange}
|
||||
onMCPAuthComplete={handleMCPAuthComplete}
|
||||
lastInjectedContext={chatQuery.data?.last_injected_context}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -143,6 +143,8 @@ interface AgentChatPageViewProps {
|
||||
|
||||
// Desktop chat ID (optional).
|
||||
desktopChatId?: string;
|
||||
|
||||
lastInjectedContext?: readonly TypesGen.ChatMessagePart[];
|
||||
}
|
||||
|
||||
export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
|
||||
@@ -199,6 +201,7 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
|
||||
onMCPSelectionChange,
|
||||
onMCPAuthComplete,
|
||||
desktopChatId,
|
||||
lastInjectedContext,
|
||||
}) => {
|
||||
const [isRightPanelExpanded, setIsRightPanelExpanded] = useState(false);
|
||||
const [dragVisualExpanded, setDragVisualExpanded] = useState<boolean | null>(
|
||||
@@ -350,6 +353,7 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
|
||||
selectedMCPServerIds={selectedMCPServerIds}
|
||||
onMCPSelectionChange={onMCPSelectionChange}
|
||||
onMCPAuthComplete={onMCPAuthComplete}
|
||||
lastInjectedContext={lastInjectedContext}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,11 @@ import { useEffect, useRef } from "react";
|
||||
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import type { ChatMessageInputRef } from "#/components/ChatMessageInput/ChatMessageInput";
|
||||
import { AgentChatInput, type UploadState } from "./AgentChatInput";
|
||||
import {
|
||||
AgentChatInput,
|
||||
type AgentContextUsage,
|
||||
type UploadState,
|
||||
} from "./AgentChatInput";
|
||||
|
||||
const defaultModelConfigID = "model-config-1";
|
||||
|
||||
@@ -653,3 +657,74 @@ export const OverflowBadges: Story = {
|
||||
expect(within(popover).getByText("Confluence Cloud")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context-usage indicator stories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const baseContextUsage: AgentContextUsage = {
|
||||
usedTokens: 45_000,
|
||||
contextLimitTokens: 128_000,
|
||||
inputTokens: 30_000,
|
||||
outputTokens: 10_000,
|
||||
cacheReadTokens: 3_000,
|
||||
cacheCreationTokens: 2_000,
|
||||
compressionThreshold: 90,
|
||||
};
|
||||
|
||||
/** Shows the context-usage ring and token summary tooltip. */
|
||||
export const WithContextUsage: Story = {
|
||||
args: {
|
||||
contextUsage: baseContextUsage,
|
||||
},
|
||||
};
|
||||
|
||||
/** Tooltip includes loaded AGENTS.md files and discovered skills. */
|
||||
export const WithContextFiles: Story = {
|
||||
args: {
|
||||
contextUsage: {
|
||||
...baseContextUsage,
|
||||
lastInjectedContext: [
|
||||
{
|
||||
type: "context-file" as const,
|
||||
context_file_path: "/home/coder/project/AGENTS.md",
|
||||
},
|
||||
{
|
||||
type: "context-file" as const,
|
||||
context_file_path: "/home/coder/project/.claude/docs/WORKFLOWS.md",
|
||||
context_file_truncated: true,
|
||||
},
|
||||
{
|
||||
type: "skill" as const,
|
||||
skill_name: "pull-requests",
|
||||
skill_description: "Guide for creating and updating pull requests",
|
||||
},
|
||||
{
|
||||
type: "skill" as const,
|
||||
skill_name: "deep-review",
|
||||
skill_description: "Multi-reviewer code review",
|
||||
},
|
||||
] as TypesGen.ChatMessagePart[],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Context at 95%+ shows the ring in destructive (red) tone. */
|
||||
export const ContextNearLimit: Story = {
|
||||
args: {
|
||||
contextUsage: {
|
||||
usedTokens: 124_000,
|
||||
contextLimitTokens: 128_000,
|
||||
inputTokens: 100_000,
|
||||
outputTokens: 20_000,
|
||||
cacheReadTokens: 4_000,
|
||||
compressionThreshold: 90,
|
||||
lastInjectedContext: [
|
||||
{
|
||||
type: "context-file" as const,
|
||||
context_file_path: "/home/coder/project/AGENTS.md",
|
||||
},
|
||||
] as TypesGen.ChatMessagePart[],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -150,6 +150,7 @@ interface ChatPageInputProps {
|
||||
selectedMCPServerIds?: readonly string[];
|
||||
onMCPSelectionChange?: (ids: string[]) => void;
|
||||
onMCPAuthComplete?: (serverId: string) => void;
|
||||
lastInjectedContext?: readonly TypesGen.ChatMessagePart[];
|
||||
}
|
||||
|
||||
export const ChatPageInput: FC<ChatPageInputProps> = ({
|
||||
@@ -182,6 +183,7 @@ export const ChatPageInput: FC<ChatPageInputProps> = ({
|
||||
selectedMCPServerIds,
|
||||
onMCPSelectionChange,
|
||||
onMCPAuthComplete,
|
||||
lastInjectedContext,
|
||||
}) => {
|
||||
const messagesByID = useChatSelector(store, selectMessagesByID);
|
||||
const orderedMessageIDs = useChatSelector(store, selectOrderedMessageIDs);
|
||||
@@ -212,7 +214,7 @@ export const ChatPageInput: FC<ChatPageInputProps> = ({
|
||||
|
||||
const rawUsage = getLatestContextUsage(messages);
|
||||
const latestContextUsage = rawUsage
|
||||
? { ...rawUsage, compressionThreshold }
|
||||
? { ...rawUsage, compressionThreshold, lastInjectedContext }
|
||||
: rawUsage;
|
||||
const { organizations } = useDashboard();
|
||||
const organizationId = organizations[0]?.id;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { FileIcon, ZapIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import type { ChatMessagePart } from "#/api/typesGenerated";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -22,6 +24,8 @@ export interface AgentContextUsage {
|
||||
readonly reasoningTokens?: number;
|
||||
// Percentage (0–100) at which the context will be compacted.
|
||||
readonly compressionThreshold?: number;
|
||||
// Last injected context parts (AGENTS.md files and skills).
|
||||
readonly lastInjectedContext?: readonly ChatMessagePart[];
|
||||
}
|
||||
|
||||
const hasFiniteTokenValue = (value: number | undefined): value is number =>
|
||||
@@ -58,6 +62,12 @@ const getIndicatorToneClassName = (percentUsed: number | null): string => {
|
||||
return "text-content-secondary/60";
|
||||
};
|
||||
|
||||
/** Extract the trailing filename from an absolute path. */
|
||||
const basename = (path: string): string => {
|
||||
const slash = path.lastIndexOf("/");
|
||||
return slash >= 0 ? path.substring(slash + 1) : path;
|
||||
};
|
||||
|
||||
const RING_SIZE = 18;
|
||||
const RING_STROKE = 2.5;
|
||||
const RING_RADIUS = (RING_SIZE - RING_STROKE) / 2;
|
||||
@@ -91,6 +101,13 @@ export const ContextUsageIndicator: FC<{ usage: AgentContextUsage | null }> = ({
|
||||
? `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 triggerButton = (
|
||||
<button
|
||||
type="button"
|
||||
@@ -136,9 +153,67 @@ export const ContextUsageIndicator: FC<{ usage: AgentContextUsage | null }> = ({
|
||||
usage?.compressionThreshold !== undefined &&
|
||||
usage.compressionThreshold > 0 && (
|
||||
<div className="mt-1 text-content-secondary">
|
||||
Compacts at {usage.compressionThreshold}%
|
||||
{`Compacts at ${usage.compressionThreshold}%`}{" "}
|
||||
</div>
|
||||
)}
|
||||
{hasInjectedContext && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-2 text-content-secondary",
|
||||
hasPercent && "mt-2",
|
||||
)}
|
||||
>
|
||||
{" "}
|
||||
{contextFiles.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}>
|
||||
{basename(part.context_file_path)}
|
||||
</span>
|
||||
{part.context_file_truncated && (
|
||||
<span className="shrink-0 text-content-warning">
|
||||
(truncated)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{skills.length > 0 && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium text-content-primary">Skills</span>{" "}
|
||||
{skills.map((part) => {
|
||||
if (part.type !== "skill") return null;
|
||||
return (
|
||||
<div
|
||||
key={part.skill_name}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
<ZapIcon className="size-3 shrink-0" />
|
||||
<span className="truncate">{part.skill_name}</span>
|
||||
{part.skill_description && (
|
||||
<span className="ml-0.5 truncate text-content-secondary/60">
|
||||
– {part.skill_description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -149,7 +224,7 @@ export const ContextUsageIndicator: FC<{ usage: AgentContextUsage | null }> = ({
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>{triggerButton}</PopoverTrigger>
|
||||
<PopoverContent side="top" className="w-auto px-3 py-2">
|
||||
<PopoverContent side="top" className="w-auto max-w-72 px-3 py-2">
|
||||
{tooltipContent}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
@@ -159,7 +234,9 @@ export const ContextUsageIndicator: FC<{ usage: AgentContextUsage | null }> = ({
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{triggerButton}</TooltipTrigger>
|
||||
<TooltipContent side="top">{tooltipContent}</TooltipContent>
|
||||
<TooltipContent side="top" className="max-w-72">
|
||||
{tooltipContent}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user