Compare commits

...

5 Commits

Author SHA1 Message Date
Kyle Carberry
290c3997c5 fix(coderd/x/chatd): make skill part text an explicit instruction to the LLM
Replace the opaque "[skill] name: description" format with a clear
directive: Use the "name" skill (description). Read it with read_skill
before following its instructions.

This tells the LLM exactly what the user wants — use the named skill
from <available-skills> — rather than injecting cryptic metadata tags.
2026-04-04 18:11:53 +00:00
Kyle Carberry
6aa4ae5d16 feat(site/AgentsPage): use styled tooltip for SkillChip description on hover
Replace the native `title` attribute on SkillChip with the project's
Tooltip/TooltipContent/TooltipTrigger components from Radix, matching
the pattern used in ContextUsageIndicator. When a skill description is
present, hovering the chip shows a styled tooltip; without a description,
no tooltip is rendered.
2026-04-04 17:58:43 +00:00
Kyle Carberry
5fac9798b7 test(coderd/x/chatd): add TestSkillPartPreservation for chatprompt
Verifies skill parts survive the storage round-trip (MarshalParts →
ParseContent) and convert to TextPart for LLM dispatch, matching
the pattern of TestFileReferencePreservation.
2026-04-04 17:58:43 +00:00
Kyle Carberry
7c47583853 fix(coderd/x/chatd): convert skill input parts to text for LLM dispatch
Without this, skill parts from user messages were silently dropped
in partsToMessageParts() — stored in the DB but never included in
the LLM prompt. Now they're converted to a readable text format
like file-reference parts are.
2026-04-04 17:58:43 +00:00
Kyle Carberry
1650c02128 feat: add skill chips to chat input
Allows users to click a skill in the context usage indicator and insert
it as an inline chip in the chat editor. The chip is sent to the backend
as a skill input part and rendered in the conversation timeline.

Backend:
- Add ChatInputPartTypeSkill to accepted input part types.
- Add SkillName/SkillDescription fields to ChatInputPart.
- Handle skill parts in convertInputParts() with validation.

Frontend:
- New SkillNode Lexical DecoratorNode with SkillChip component.
- Register in ChatMessageInput, expose addSkill() on imperative ref.
- Serialize skill parts in AgentChatPage handleSend().
- Make skills clickable in ContextUsageIndicator popover.
- Wire clicks through AgentChatInput to editor addSkill + focus.
- Render skill chips inline in ConversationTimeline user messages.
- Push skill parts as render blocks in messageParsing.
2026-04-04 17:58:43 +00:00
15 changed files with 510 additions and 26 deletions

View File

@@ -3767,6 +3767,19 @@ func createChatInputFromParts(
_, _ = fmt.Fprintf(&sb, "\n```%s\n%s\n```", part.FileName, strings.TrimSpace(part.Content))
}
textParts = append(textParts, sb.String())
case string(codersdk.ChatInputPartTypeSkill):
if part.SkillName == "" {
return nil, "", &codersdk.Response{
Message: "Invalid input part.",
Detail: fmt.Sprintf("%s[%d].skill_name cannot be empty for skill parts.", fieldName, i),
}
}
content = append(content, codersdk.ChatMessagePart{
Type: codersdk.ChatMessagePartTypeSkill,
SkillName: part.SkillName,
SkillDescription: part.SkillDescription,
})
textParts = append(textParts, fmt.Sprintf("Use the %q skill", part.SkillName))
default:
return nil, "", &codersdk.Response{
Message: "Invalid input part.",

View File

@@ -4135,6 +4135,104 @@ func TestChatMessageWithFileReferences(t *testing.T) {
})
}
func TestChatMessageWithSkillParts(t *testing.T) {
t.Parallel()
createChatForTest := func(t *testing.T, client *codersdk.ExperimentalClient) codersdk.Chat {
t.Helper()
ctx := testutil.Context(t, testutil.WaitLong)
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
Content: []codersdk.ChatInputPart{{
Type: codersdk.ChatInputPartTypeText,
Text: "initial message",
}},
})
require.NoError(t, err)
return chat
}
t.Run("SkillPartRoundTrip", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModelConfig(t, client)
chat := createChatForTest(t, client)
created, err := client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
Content: []codersdk.ChatInputPart{
{
Type: codersdk.ChatInputPartTypeText,
Text: "please run this skill",
},
{
Type: codersdk.ChatInputPartTypeSkill,
SkillName: "deep-review",
SkillDescription: "Multi-reviewer code review",
},
},
})
require.NoError(t, err)
checkSkill := func(part codersdk.ChatMessagePart) bool {
return part.Type == codersdk.ChatMessagePartTypeSkill &&
part.SkillName == "deep-review" &&
part.SkillDescription == "Multi-reviewer code review"
}
var found bool
require.Eventually(t, func() bool {
messagesResult, getErr := client.GetChatMessages(ctx, chat.ID, nil)
if getErr != nil {
return false
}
for _, message := range messagesResult.Messages {
if message.Role != codersdk.ChatMessageRoleUser {
continue
}
for _, part := range message.Content {
if checkSkill(part) {
found = true
return true
}
}
}
if created.Queued && created.QueuedMessage != nil {
for _, queued := range messagesResult.QueuedMessages {
for _, part := range queued.Content {
if checkSkill(part) {
found = true
return true
}
}
}
}
return false
}, testutil.WaitLong, testutil.IntervalFast)
require.True(t, found, "expected to find skill part in stored message")
})
t.Run("SkillPartEmptyNameRejected", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModelConfig(t, client)
chat := createChatForTest(t, client)
_, err := client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
Content: []codersdk.ChatInputPart{{
Type: codersdk.ChatInputPartTypeSkill,
SkillName: "",
}},
})
require.Error(t, err)
require.Contains(t, err.Error(), "skill_name cannot be empty")
})
}
func TestChatMessageWithFiles(t *testing.T) {
t.Parallel()

View File

@@ -1192,6 +1192,23 @@ func fileReferencePartToText(part codersdk.ChatMessagePart) string {
return sb.String()
}
// skillPartToText formats a skill SDK part as plain text for
// LLM consumption. The user explicitly attached this skill chip
// to their message, so the text makes clear they want the agent
// to use the named skill (listed in <available-skills>).
func skillPartToText(part codersdk.ChatMessagePart) string {
if part.SkillDescription != "" {
return fmt.Sprintf(
"Use the %q skill (%s). Read it with read_skill before following its instructions.",
part.SkillName, part.SkillDescription,
)
}
return fmt.Sprintf(
"Use the %q skill. Read it with read_skill before following its instructions.",
part.SkillName,
)
}
// toolResultPartToMessagePart converts an SDK tool-result part
// into a fantasy ToolResultPart for LLM dispatch.
func toolResultPartToMessagePart(logger slog.Logger, part codersdk.ChatMessagePart) fantasy.ToolResultPart {
@@ -1360,6 +1377,12 @@ func partsToMessageParts(
result = append(result, fantasy.TextPart{
Text: fileReferencePartToText(part),
})
case codersdk.ChatMessagePartTypeSkill:
// Skill parts from user input are converted to text
// so the LLM knows which skill the user requested.
result = append(result, fantasy.TextPart{
Text: skillPartToText(part),
})
case codersdk.ChatMessagePartTypeContextFile:
if part.ContextFileContent == "" {
continue

View File

@@ -1088,6 +1088,49 @@ func TestFileReferencePreservation(t *testing.T) {
assert.Contains(t, textPart.Text, "func main() {}")
}
// TestSkillPartPreservation verifies skill parts survive the
// storage round-trip and convert to text for LLMs.
func TestSkillPartPreservation(t *testing.T) {
t.Parallel()
raw, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{{
Type: codersdk.ChatMessagePartTypeSkill,
SkillName: "deep-review",
SkillDescription: "Multi-reviewer code review",
}})
require.NoError(t, err)
// Storage round-trip: all fields intact.
parts, err := chatprompt.ParseContent(testMsg(codersdk.ChatMessageRoleUser, raw))
require.NoError(t, err)
require.Len(t, parts, 1)
assert.Equal(t, codersdk.ChatMessagePartTypeSkill, parts[0].Type)
assert.Equal(t, "deep-review", parts[0].SkillName)
assert.Equal(t, "Multi-reviewer code review", parts[0].SkillDescription)
// LLM dispatch: skill becomes a TextPart.
prompt, err := chatprompt.ConvertMessagesWithFiles(
context.Background(),
[]database.ChatMessage{{
Role: database.ChatMessageRoleUser,
Visibility: database.ChatMessageVisibilityBoth,
Content: raw,
}},
nil,
slogtest.Make(t, nil),
)
require.NoError(t, err)
require.Len(t, prompt, 1)
require.Len(t, prompt[0].Content, 1)
textPart, ok := fantasy.AsMessagePart[fantasy.TextPart](prompt[0].Content[0])
require.True(t, ok, "skill should become TextPart for LLM")
assert.Contains(t, textPart.Text, "deep-review")
assert.Contains(t, textPart.Text, "read_skill")
assert.Contains(t, textPart.Text, "Multi-reviewer code review")
assert.Contains(t, textPart.Text, "Multi-reviewer code review")
}
// TestAssistantWriteRoundTrip verifies the Stage 4 write path:
// fantasy.Content (with ProviderMetadata) → PartFromContent →
// MarshalParts → DB → ParseContent (SDK path) →

View File

@@ -326,6 +326,7 @@ const (
ChatInputPartTypeText ChatInputPartType = "text"
ChatInputPartTypeFile ChatInputPartType = "file"
ChatInputPartTypeFileReference ChatInputPartType = "file-reference"
ChatInputPartTypeSkill ChatInputPartType = "skill"
)
// ChatInputPart is a single user input part for creating a chat.
@@ -340,6 +341,10 @@ type ChatInputPart struct {
EndLine int `json:"end_line,omitempty"`
// The code content from the diff that was commented on.
Content string `json:"content,omitempty"`
// The following fields are only set when Type is
// ChatInputPartTypeSkill.
SkillName string `json:"skill_name,omitempty"`
SkillDescription string `json:"skill_description,omitempty"`
}
// CreateChatRequest is the request to create a new chat.

View File

@@ -1451,14 +1451,21 @@ export interface ChatInputPart {
* The code content from the diff that was commented on.
*/
readonly content?: string;
/**
* The following fields are only set when Type is
* ChatInputPartTypeSkill.
*/
readonly skill_name?: string;
readonly skill_description?: string;
}
// From codersdk/chats.go
export type ChatInputPartType = "file" | "file-reference" | "text";
export type ChatInputPartType = "file" | "file-reference" | "skill" | "text";
export const ChatInputPartTypes: ChatInputPartType[] = [
"file",
"file-reference",
"skill",
"text",
];

View File

@@ -36,6 +36,7 @@ const createMockChatInputHandle = (initialValue = ""): MockChatInputHandle => {
focus,
getValue,
addFileReference: vi.fn(),
addSkill: vi.fn(),
getContentParts: vi.fn(() => []),
},
setValue,
@@ -373,6 +374,7 @@ describe("useConversationEditingState", () => {
insertText: vi.fn(),
getValue: vi.fn().mockReturnValue(""),
addFileReference: vi.fn(),
addSkill: vi.fn(),
getContentParts: vi.fn().mockReturnValue([]),
}; // The hook exposes chatInputRef assign the mock to it.
result.current.chatInputRef.current = mockInputRef;

View File

@@ -792,7 +792,7 @@ const AgentChatPage: FC = () => {
// surrounding text the user typed.
const editorParts = chatInputHandle?.getContentParts() ?? [];
const hasFileReferences = editorParts.some(
(p) => p.type === "file-reference",
(p) => p.type === "file-reference" || p.type === "skill",
);
const hasContent =
message.trim() || (fileIds && fileIds.length > 0) || hasFileReferences;
@@ -802,16 +802,16 @@ const AgentChatPage: FC = () => {
const content: TypesGen.ChatInputPart[] = [];
// Emit parts in document order — text segments and
// file-reference chips are interleaved as they appear in
// the editor.
// Emit parts in document order — text segments,
// file-reference chips, and skill chips are interleaved
// as they appear in the editor.
for (const part of editorParts) {
if (part.type === "text") {
const trimmed = part.text.trim();
if (trimmed) {
content.push({ type: "text", text: part.text });
}
} else {
} else if (part.type === "file-reference") {
const r = part.reference;
content.push({
type: "file-reference",
@@ -820,9 +820,15 @@ const AgentChatPage: FC = () => {
end_line: r.endLine,
content: r.content,
});
} else if (part.type === "skill") {
const s = part.skill;
content.push({
type: "skill",
skill_name: s.skillName,
skill_description: s.skillDescription,
});
}
}
// Add pre-uploaded file references.
if (fileIds && fileIds.length > 0) {
for (const fileId of fileIds) {

View File

@@ -1007,8 +1007,17 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
</>
)}
{contextUsage !== undefined && (
<ContextUsageIndicator usage={contextUsage} />
)}
<ContextUsageIndicator
usage={contextUsage}
onSkillClick={(skillName, skillDescription) => {
internalRef.current?.addSkill({
skillName,
skillDescription: skillDescription ?? "",
});
internalRef.current?.focus();
}}
/>
)}{" "}
{isStreaming && onInterrupt && (
<Button
size="icon"

View File

@@ -37,6 +37,7 @@ import {
} from "../ChatElements";
import { WebSearchSources } from "../ChatElements/tools";
import { FileReferenceChip } from "../ChatMessageInput/FileReferenceNode";
import { SkillChip } from "../ChatMessageInput/SkillNode";
import { ImageLightbox } from "../ImageLightbox";
import { TextPreviewDialog } from "../TextPreviewDialog";
import { getEditableUserMessagePayload } from "./messageParsing";
@@ -515,8 +516,11 @@ const ChatMessageItem = memo<{
b,
): b is
| Extract<RenderBlock, { type: "response" }>
| Extract<RenderBlock, { type: "file-reference" }> =>
b.type === "response" || b.type === "file-reference",
| Extract<RenderBlock, { type: "file-reference" }>
| Extract<RenderBlock, { type: "skill" }> =>
b.type === "response" ||
b.type === "file-reference" ||
b.type === "skill",
)
: [];
const userFileBlocks = isUser
@@ -566,7 +570,7 @@ const ChatMessageItem = memo<{
? userInlineContent.map((block, i) =>
block.type === "response" ? (
<Fragment key={i}>{block.text}</Fragment>
) : (
) : block.type === "file-reference" ? (
<FileReferenceChip
key={i}
fileName={block.file_name}
@@ -574,6 +578,13 @@ const ChatMessageItem = memo<{
endLine={block.end_line}
className="mx-1"
/>
) : (
<SkillChip
key={i}
skillName={block.skill_name}
skillDescription={block.skill_description}
className="mx-1"
/>
),
)
: parsed.markdown || ""}

View File

@@ -223,8 +223,11 @@ export const parseMessageContent = (
break;
}
case "skill": {
// Skill parts are metadata for the context indicator;
// they are not rendered in the conversation timeline.
// User-sent skill parts render as inline chips;
// backend-injected ones are metadata only but we
// push them as blocks either way — the timeline
// hides metadata-only messages separately.
parsed.blocks.push(part);
break;
}
default: {

View File

@@ -45,6 +45,7 @@ export type RenderBlock =
}
| TypesGen.ChatFilePart
| TypesGen.ChatFileReferencePart
| TypesGen.ChatSkillPart
| {
type: "sources";
sources: Array<{ url: string; title: string }>;

View File

@@ -41,6 +41,7 @@ import {
isLargePaste,
type PasteCommandEvent,
} from "./pasteHelpers";
import { $createSkillNode, type SkillData, SkillNode } from "./SkillNode";
// Blocks Cmd+B/I/U and element formatting shortcuts so the editor
// stays plain-text only.
@@ -288,7 +289,7 @@ const ContentChangePlugin: FC<{
onChange?: (
content: string,
serializedEditorState: string,
hasFileReferences: boolean,
hasChipContent: boolean,
) => void;
}> = function ContentChangePlugin({ onChange }) {
const [editor] = useLexicalComposerContext();
@@ -306,7 +307,10 @@ const ContentChangePlugin: FC<{
if (!$isParagraphNode(child)) continue;
for (const node of child.getChildren()) {
if (node instanceof FileReferenceNode) {
if (
node instanceof FileReferenceNode ||
node instanceof SkillNode
) {
hasRefs = true;
break;
}
@@ -395,13 +399,17 @@ interface FileReferenceData {
/**
* A content part extracted from the Lexical editor in document order.
* Either a text segment or a file-reference chip.
* A text segment, file-reference chip, or skill chip.
*/
type EditorContentPart =
| { readonly type: "text"; readonly text: string }
| {
readonly type: "file-reference";
readonly reference: FileReferenceData;
}
| {
readonly type: "skill";
readonly skill: SkillData;
};
// Mutable variant used internally while building the parts
@@ -412,7 +420,14 @@ type MutableFileRefPart = {
type: "file-reference";
reference: FileReferenceData;
};
type MutableContentPart = MutableTextPart | MutableFileRefPart;
type MutableSkillPart = {
type: "skill";
skill: SkillData;
};
type MutableContentPart =
| MutableTextPart
| MutableFileRefPart
| MutableSkillPart;
export interface ChatMessageInputRef {
setValue: (text: string) => void;
@@ -425,10 +440,16 @@ export interface ChatMessageInputRef {
* (atomic for undo/redo).
*/
addFileReference: (ref: FileReferenceData) => void;
/**
* Insert a skill chip in a single Lexical update
* (atomic for undo/redo).
*/
addSkill: (data: SkillData) => void;
/**
* Walk the Lexical tree in document order and return interleaved
* text / file-reference parts. Adjacent text nodes within the same
* paragraph are merged, and paragraphs are separated by newlines.
* text / file-reference / skill parts. Adjacent text nodes within
* the same paragraph are merged, and paragraphs are separated by
* newlines.
*/
getContentParts: () => EditorContentPart[];
}
@@ -446,7 +467,7 @@ interface ChatMessageInputProps
onChange?: (
content: string,
serializedEditorState: string,
hasFileReferences: boolean,
hasChipContent: boolean,
) => void;
/** Monotonic counter to force editor remount. */
remountKey?: number;
@@ -497,7 +518,7 @@ const ChatMessageInput = ({
inlineDecorator: "mx-1",
},
onError: (error: Error) => console.error("Lexical error:", error),
nodes: [FileReferenceNode],
nodes: [FileReferenceNode, SkillNode],
editable: !disabled,
};
const style = {
@@ -615,6 +636,29 @@ const ChatMessageInput = ({
chipNode.selectNext();
});
},
addSkill: (data: SkillData) => {
const editor = editorRef.current;
if (!editor) return;
editor.update(() => {
const root = $getRoot();
const firstChild = root.getFirstChild();
const paragraph = $isParagraphNode(firstChild)
? firstChild
: $createParagraphNode();
if (!$isParagraphNode(firstChild)) {
root.append(paragraph);
}
const chipNode = $createSkillNode(
data.skillName,
data.skillDescription,
);
paragraph.append(chipNode);
chipNode.selectNext();
});
},
getContentParts: () => {
const editor = editorRef.current;
if (!editor) return [];
@@ -654,6 +698,14 @@ const ChatMessageInput = ({
content: node.__content,
},
});
} else if (node instanceof SkillNode) {
parts.push({
type: "skill",
skill: {
skillName: node.__skillName,
skillDescription: node.__skillDescription,
},
});
} else {
const t = node.getTextContent();
if (t) appendText(t);

View File

@@ -0,0 +1,188 @@
import { useLexicalNodeSelection } from "@lexical/react/useLexicalNodeSelection";
import {
$getNodeByKey,
DecoratorNode,
type EditorConfig,
type LexicalEditor,
type NodeKey,
type SerializedLexicalNode,
type Spread,
} from "lexical";
import { XIcon, ZapIcon } from "lucide-react";
import { type FC, memo, type ReactNode } from "react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { cn } from "#/utils/cn";
export type SkillData = {
skillName: string;
skillDescription: string;
};
type SerializedSkillNode = Spread<
{ skillName: string; skillDescription: string },
SerializedLexicalNode
>;
export function SkillChip({
skillName,
skillDescription,
isSelected,
onRemove,
className: extraClassName,
}: {
skillName: string;
skillDescription?: string;
isSelected?: boolean;
onRemove?: () => void;
className?: string;
}) {
const chip = (
<span
className={cn(
"inline-flex h-6 max-w-[300px] cursor-default select-none items-center gap-1.5 rounded-md border border-border-default bg-surface-primary px-1.5 align-middle text-xs text-content-primary shadow-sm transition-colors",
isSelected &&
"border-content-link bg-content-link/10 ring-1 ring-content-link/40",
extraClassName,
)}
contentEditable={false}
>
<ZapIcon className="size-3 shrink-0" />
<span className="min-w-0 truncate text-content-secondary">
{skillName}
</span>
{onRemove && (
<button
type="button"
className="ml-auto inline-flex size-4 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-content-secondary transition-colors hover:text-content-primary cursor-pointer"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onRemove();
}}
aria-label="Remove skill"
tabIndex={-1}
>
<XIcon className="size-2" />
</button>
)}
</span>
);
if (!skillDescription) {
return chip;
}
return (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>{chip}</TooltipTrigger>
<TooltipContent side="top" sideOffset={4} className="max-w-64 text-xs">
{skillDescription}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
export class SkillNode extends DecoratorNode<ReactNode> {
__skillName: string;
__skillDescription: string;
static getType(): string {
return "skill-reference";
}
static clone(node: SkillNode): SkillNode {
return new SkillNode(node.__skillName, node.__skillDescription, node.__key);
}
constructor(skillName: string, skillDescription: string, key?: NodeKey) {
super(key);
this.__skillName = skillName;
this.__skillDescription = skillDescription;
}
createDOM(config: EditorConfig): HTMLElement {
const span = document.createElement("span");
span.className = config.theme.inlineDecorator ?? "";
span.style.display = "inline";
span.style.userSelect = "none";
return span;
}
updateDOM(): boolean {
return false;
}
exportJSON(): SerializedSkillNode {
return {
type: "skill-reference",
version: 1,
skillName: this.__skillName,
skillDescription: this.__skillDescription,
};
}
static importJSON(json: SerializedSkillNode): SkillNode {
return new SkillNode(json.skillName, json.skillDescription);
}
getTextContent(): string {
return "";
}
isInline(): boolean {
return true;
}
decorate(_editor: LexicalEditor): ReactNode {
return (
<SkillChipWrapper
editor={_editor}
nodeKey={this.__key}
skillName={this.__skillName}
skillDescription={this.__skillDescription}
/>
);
}
}
const SkillChipWrapper: FC<{
editor: LexicalEditor;
nodeKey: NodeKey;
skillName: string;
skillDescription: string;
}> = memo(({ editor, nodeKey, skillName, skillDescription }) => {
const [isSelected] = useLexicalNodeSelection(nodeKey);
const handleRemove = () => {
editor.update(() => {
const node = $getNodeByKey(nodeKey);
if (node instanceof SkillNode) {
node.remove();
}
});
};
return (
<SkillChip
skillName={skillName}
skillDescription={skillDescription}
isSelected={isSelected}
onRemove={handleRemove}
/>
);
});
SkillChipWrapper.displayName = "SkillChipWrapper";
export function $createSkillNode(
skillName: string,
skillDescription: string,
): SkillNode {
return new SkillNode(skillName, skillDescription);
}

View File

@@ -78,9 +78,10 @@ const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS;
// 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;
onSkillClick?: (skillName: string, skillDescription?: string) => void;
}> = ({ usage, onSkillClick }) => {
const [open, setOpen] = useState(false);
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -187,8 +188,30 @@ export const ContextUsageIndicator: FC<{ usage: AgentContextUsage | null }> = ({
<TooltipProvider delayDuration={300}>
{skills.map((part) => {
if (part.type !== "skill") return null;
const handleClick = onSkillClick
? () =>
onSkillClick(part.skill_name, part.skill_description)
: undefined;
const row = (
<div className="flex items-center gap-1.5 rounded px-0.5 py-px transition-colors hover:bg-surface-tertiary">
<div
className={cn(
"flex items-center gap-1.5 rounded px-0.5 py-px transition-colors hover:bg-surface-tertiary",
onSkillClick && "cursor-pointer",
)}
onClick={handleClick}
onKeyDown={(e) => {
if (
(e.key === "Enter" || e.key === " ") &&
handleClick
) {
e.preventDefault();
handleClick();
}
}}
role={onSkillClick ? "button" : undefined}
tabIndex={onSkillClick ? 0 : undefined}
>
{" "}
<ZapIcon className="size-3 shrink-0" />
<span className="truncate">{part.skill_name}</span>
</div>