Compare commits
5 Commits
main
...
skill-chip
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
290c3997c5 | ||
|
|
6aa4ae5d16 | ||
|
|
5fac9798b7 | ||
|
|
7c47583853 | ||
|
|
1650c02128 |
@@ -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.",
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) →
|
||||
|
||||
@@ -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.
|
||||
|
||||
9
site/src/api/typesGenerated.ts
generated
9
site/src/api/typesGenerated.ts
generated
@@ -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",
|
||||
];
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 || ""}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -45,6 +45,7 @@ export type RenderBlock =
|
||||
}
|
||||
| TypesGen.ChatFilePart
|
||||
| TypesGen.ChatFileReferencePart
|
||||
| TypesGen.ChatSkillPart
|
||||
| {
|
||||
type: "sources";
|
||||
sources: Array<{ url: string; title: string }>;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user