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:
Kyle Carberry
2026-03-31 14:43:32 +00:00
committed by GitHub
parent 5d07014f9f
commit 2953245862
5 changed files with 164 additions and 5 deletions
@@ -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>
);
};