feat(site/src): insert skills as chips in the composer

This commit is contained in:
Jaayden Halko
2026-10-15 20:44:48 +00:00
parent c2d7bc972a
commit 16d5d85ebd
25 changed files with 876 additions and 105 deletions
+1 -1
View File
@@ -2,7 +2,7 @@ import type { QueryClient } from "react-query";
import { API } from "#/api/api";
import type * as TypesGen from "#/api/typesGenerated";
const organizationSkillsKey = (organizationId: string) =>
export const organizationSkillsKey = (organizationId: string) =>
["organization-skills", organizationId] as const;
const organizationSkillKey = (organizationId: string, name: string) =>
+1 -1
View File
@@ -2,7 +2,7 @@ import type { QueryClient } from "react-query";
import { API } from "#/api/api";
import type * as TypesGen from "#/api/typesGenerated";
const userSkillsKey = (user = "me") => ["user-skills", user] as const;
export const userSkillsKey = (user = "me") => ["user-skills", user] as const;
const userSkillKey = (name: string, user = "me") =>
[...userSkillsKey(user), name] as const;
@@ -44,7 +44,10 @@ import { ChimeButton } from "./components/ChimeButton";
import { ProjectPage } from "./components/ProjectPage/ProjectPage";
import { WebPushButton } from "./components/WebPushButton";
import { isAbortError } from "./utils/chatAttachments";
import { toWorkspaceFileReferencePart } from "./utils/chatInputContent";
import {
buildChatInputContent,
toWorkspaceFileReferencePart,
} from "./utils/chatInputContent";
import { getChimeEnabled, setChimeEnabled } from "./utils/chime";
import { type DeepLinkState, readDeepLinkState } from "./utils/deepLinkState";
import { buildAgentChatPath } from "./utils/navigation";
@@ -260,6 +263,7 @@ const AgentCreatePageContent: React.FC<AgentCreatePageContentProps> = ({
const handleCreateChat = async ({
message,
composerParts,
fileIDs,
workspaceId,
model,
@@ -270,10 +274,7 @@ const AgentCreatePageContent: React.FC<AgentCreatePageContentProps> = ({
planMode,
uploadWorkspaceFiles,
}: CreateChatOptions) => {
const content: TypesGen.ChatInputPart[] = [];
if (message.trim()) {
content.push({ type: "text", text: message });
}
const content = buildChatInputContent({ message, composerParts }).content;
if (fileIDs) {
for (const fileID of fileIDs) {
content.push({ type: "file", file_id: fileID });
@@ -1,4 +1,5 @@
import {
act,
fireEvent,
render,
screen,
@@ -220,7 +221,25 @@ describe("AgentChatInput", () => {
await user.click(
await screen.findByRole("option", { name: /release-notes/ }),
);
expect(inputRef.current?.getValue()).toBe("/release-notes");
expect(inputRef.current?.getContentParts()).toContainEqual({
type: "skill-invocation",
source: "org",
name: "release-notes",
});
});
it("sends a message that holds only a skill chip", async () => {
const user = userEvent.setup();
const inputRef = createRef<ChatMessageInputRef>();
const onSend = vi.fn();
renderInput(
<AgentChatInput {...inputProps} onSend={onSend} inputRef={inputRef} />,
);
await waitFor(() => expect(inputRef.current).not.toBeNull());
act(() => inputRef.current?.insertSkill("project", "triage"));
await user.click(screen.getByRole("button", { name: "Send" }));
expect(onSend).toHaveBeenCalledTimes(1);
});
it("offers the chat project's skills in the slash menu", async () => {
@@ -248,7 +267,11 @@ describe("AgentChatInput", () => {
await user.click(
await screen.findByRole("option", { name: /project-conventions/ }),
);
expect(inputRef.current?.getValue()).toBe("/project-conventions");
expect(inputRef.current?.getContentParts()).toContainEqual({
type: "skill-invocation",
source: "project",
name: "project-conventions",
});
expect(getChatProjectSkills).toHaveBeenCalledWith("org-1", "project-1");
});
@@ -154,7 +154,7 @@ type AgentChatInputProps = {
onContentChange: (
content: string,
serializedEditorState: string,
hasFileReferences: boolean,
hasChips: boolean,
) => void;
// Model selector.
selectedModel: string;
@@ -627,7 +627,7 @@ export const AgentChatInput: React.FC<AgentChatInputProps> = ({
disconnectMCPServerOAuth2(queryClient),
);
const [hasFileReferences, setHasFileReferences] = useState(false);
const [hasChips, setHasChips] = useState(false);
const [cycleIndex, setCycleIndex] = useState<number | null>(null);
const [cycleSavedDraft, setCycleSavedDraft] = useState<string | null>(null);
const cycleHistorySnapshotRef = useRef<readonly string[] | null>(null);
@@ -691,6 +691,8 @@ export const AgentChatInput: React.FC<AgentChatInputProps> = ({
focus: () => internalRef.current?.focus(),
getValue: () => internalRef.current?.getValue() ?? "",
addFileReference: (ref) => internalRef.current?.addFileReference(ref),
insertSkill: (source, name) =>
internalRef.current?.insertSkill(source, name),
getContentParts: () => internalRef.current?.getContentParts() ?? [],
}),
[],
@@ -1083,7 +1085,7 @@ export const AgentChatInput: React.FC<AgentChatInputProps> = ({
const handleContentChange = (
content: string,
serializedEditorState: string,
hasRefs: boolean,
hasChipsInEditor: boolean,
) => {
// Lexical fires onChange synchronously from editor.setValue().
// While cycling, compare incoming content to currentCycleValueRef,
@@ -1096,9 +1098,9 @@ export const AgentChatInput: React.FC<AgentChatInputProps> = ({
resetPromptCycle();
}
setHasContent(Boolean(content.trim()));
setHasFileReferences(hasRefs);
setHasChips(hasChipsInEditor);
setInvisibleCharCount(countInvisibleCharacters(content));
onContentChange(content, serializedEditorState, hasRefs);
onContentChange(content, serializedEditorState, hasChipsInEditor);
};
// Re-focus the editor after a send completes (isLoading goes
@@ -1131,10 +1133,9 @@ export const AgentChatInput: React.FC<AgentChatInputProps> = ({
hasContent ||
attachments.length > 0 ||
workspaceUploadEntries.length > 0 ||
hasFileReferences;
hasChips;
const isComposerEffectivelyEmpty = !hasDraftContext;
const hasSendableContent =
hasContent || hasUploadedAttachments || hasFileReferences;
const hasSendableContent = hasContent || hasUploadedAttachments || hasChips;
const canSend =
!isDisabled &&
!isReadOnly &&
@@ -1150,7 +1151,7 @@ export const AgentChatInput: React.FC<AgentChatInputProps> = ({
if (
!text &&
!hasUploadedAttachments &&
!hasFileReferences &&
!hasChips &&
!isDisabled &&
!isReadOnly &&
!isLoading &&
@@ -1163,7 +1164,7 @@ export const AgentChatInput: React.FC<AgentChatInputProps> = ({
}
if (
(!text && !hasUploadedAttachments && !hasFileReferences) ||
(!text && !hasUploadedAttachments && !hasChips) ||
isDisabled ||
isReadOnly ||
isLoading ||
@@ -21,7 +21,9 @@ import {
organizationChatModelsKey,
userChatPersonalModelOverrides,
} from "#/api/queries/chats";
import { organizationSkillsKey } from "#/api/queries/organizationSkills";
import { permittedOrganizationsKey } from "#/api/queries/organizations";
import { userSkillsKey } from "#/api/queries/userSkills";
import { preferenceSettingsKey } from "#/api/queries/users";
import type * as TypesGen from "#/api/typesGenerated";
import { toast } from "#/components/Toaster/toast";
@@ -43,6 +45,7 @@ import {
} from "#/testHelpers/entities";
import { createTestQueryClient } from "#/testHelpers/renderHelpers";
import { server } from "#/testHelpers/server";
import { MockSkill } from "#/testHelpers/skills";
import { persistedAttachmentsStorageKey } from "../hooks/useFileAttachments";
import { readAgentAttachmentText } from "../utils/fileAttachmentLimits";
import { mcpSelectionStorageKey } from "../utils/mcpSelection";
@@ -298,6 +301,27 @@ describe("AgentCreateForm workspace file uploads", () => {
expect(submittedOptions(onCreateChat).uploadWorkspaceFiles).toBeUndefined();
});
it("submits a skill chosen from the slash menu as a composer part", async () => {
const queryClient = createQueryClient();
queryClient.setQueryData(userSkillsKey(), [MockSkill]);
queryClient.setQueryData(
organizationSkillsKey(MockDefaultOrganization.id),
[],
);
const { onCreateChat } = renderForm({}, { queryClient });
await typeMessage("/");
await user().click(await screen.findByRole("option", { name: /skill/ }));
await clickSend();
await waitFor(() => expect(onCreateChat).toHaveBeenCalledTimes(1));
expect(submittedOptions(onCreateChat).composerParts).toContainEqual({
type: "skill-invocation",
source: "personal",
name: MockSkill.name,
});
});
it("rejects workspace files when no workspace is selected", async () => {
const { onCreateChat } = renderForm();
@@ -28,6 +28,7 @@ import {
useWorkspaceFileUploads,
type WorkspaceFileUpload,
} from "../hooks/useWorkspaceFileUploads";
import type { ChatComposerContentPart } from "../utils/chatInputContent";
import { parseStoredDraft } from "../utils/draftStorage";
import {
getDefaultMCPSelection,
@@ -48,7 +49,7 @@ import {
pickReasoningEffort,
saveReasoningEffortForModel,
} from "../utils/reasoningEffort";
import { AgentChatInput } from "./AgentChatInput";
import { AgentChatInput, type ChatMessageInputRef } from "./AgentChatInput";
import { ChatAccessDeniedAlert } from "./ChatAccessDeniedAlert";
import {
isChatHookDeniedResponse,
@@ -77,6 +78,8 @@ const attachDuringSubmitMessage =
export type CreateChatOptions = {
message: string;
/** Composer text and chips in document order. */
composerParts?: readonly ChatComposerContentPart[];
fileIDs?: string[];
workspaceId?: string;
model?: string;
@@ -750,6 +753,7 @@ const AgentCreateFormContent: React.FC<AgentCreateFormProps> = ({
(agent) => !agent.parent_id && agent.status === "connected",
);
const chatInputRef = useRef<ChatMessageInputRef>(null);
const handleSend = async (
message: string,
fileIDs?: string[],
@@ -758,6 +762,7 @@ const AgentCreateFormContent: React.FC<AgentCreateFormProps> = ({
submitDraft();
await onCreateChat({
message,
composerParts: chatInputRef.current?.getContentParts(),
fileIDs,
workspaceId: effectiveWorkspaceId ?? undefined,
model: submittedModel,
@@ -1024,6 +1029,7 @@ const AgentCreateFormContent: React.FC<AgentCreateFormProps> = ({
<AgentChatInput
fillWidth={fillWidth}
onSend={handleSendWithAttachments}
inputRef={chatInputRef}
placeholder="Ask Coder to build, fix bugs, or explore your project..."
isDisabled={
isSubmitPending ||
@@ -21,10 +21,10 @@ const getInlineParts = (
blocks: readonly UserInlineRenderBlock[],
): InlinePart[] => {
return blocks.map((block) => {
if (block.type === "file-reference") {
return { type: "file-reference" };
if (block.type === "response") {
return { type: "text", text: block.text };
}
return { type: "text", text: block.text };
return { type: "chip" };
});
};
@@ -193,6 +193,13 @@ export const ClickSelectsSkill: Story = {
},
};
export const SkillChipBetweenText: Story = {
play: async ({ canvasElement }) => {
await typeInEditor(canvasElement, "Use /rev");
await userEvent.keyboard("{Enter}on the latest diff");
},
};
export const OpensWithPersonalAndWorkspaceSkills: Story = {
args: {
hasWorkspace: true,
@@ -1,4 +1,4 @@
import { render, screen, waitFor } from "@testing-library/react";
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { createRef, useLayoutEffect, useRef, useState } from "react";
import { type QueryClient, QueryClientProvider } from "react-query";
@@ -8,11 +8,14 @@ import { organizationSkills } from "#/api/queries/organizationSkills";
import { userSkills } from "#/api/queries/userSkills";
import type {
AgentChatSendShortcut,
ChatMessagePart,
ChatSkillSource,
SkillMetadata,
} from "#/api/typesGenerated";
import { createTestQueryClient } from "#/testHelpers/renderHelpers";
import { MockSkill } from "#/testHelpers/skills";
import { DEFAULT_AGENT_CHAT_SEND_SHORTCUT } from "../../utils/agentChatSendShortcut";
import { buildChatInputContent } from "../../utils/chatInputContent";
import { COMPACT_SLASH_COMMAND } from "../../utils/slashCommands";
import { ChatMessageInput, type ChatMessageInputRef } from "./ChatMessageInput";
@@ -26,6 +29,19 @@ const requiredProps = () => ({
hasWorkspace: false,
});
const mockSkillPart = (
skill_source: ChatSkillSource,
skill_name: string,
): ChatMessagePart => ({ type: "skill-invocation", skill_source, skill_name });
const getRequestContent = (
inputRef: React.RefObject<ChatMessageInputRef | null>,
) =>
buildChatInputContent({
message: inputRef.current?.getValue() ?? "",
composerParts: inputRef.current?.getContentParts() ?? [],
}).content;
const renderWithQueryClient = (
children: React.ReactNode,
queryClient: QueryClient = createTestQueryClient(),
@@ -301,17 +317,22 @@ describe("ChatMessageInput", () => {
return user;
};
it("inserts the bare trigger of an organization skill chosen with arrow keys", async () => {
it("sends an organization skill chosen with arrow keys as a chip between text", async () => {
const inputRef = renderWithSkills({
personal: [mockReviewerSkill],
organization: [
{ ...MockSkill, id: "skill-release-notes", name: "release-notes" },
],
});
const user = await pasteTrigger("/");
await screen.findByRole("option", { name: /release-notes/ });
const user = await pasteTrigger("Draft /");
await screen.findByRole("option", { name: /^\/release-notes/ });
await user.keyboard("{ArrowDown}{ArrowDown}{Enter}");
expect(inputRef.current?.getValue()).toBe("/release-notes");
await user.keyboard("now");
expect(getRequestContent(inputRef)).toEqual([
{ type: "text", text: "Draft " },
mockSkillPart("org", "release-notes"),
{ type: "text", text: " now" },
]);
});
it("ignores disabled skills when qualifying triggers", async () => {
@@ -320,27 +341,219 @@ describe("ChatMessageInput", () => {
organization: [{ ...mockReviewerSkill, enabled: false }],
});
const user = await pasteTrigger("/rev");
await user.click(await screen.findByRole("option", { name: /reviewer/ }));
expect(inputRef.current?.getValue()).toBe("/reviewer");
await user.click(
await screen.findByRole("option", { name: /^\/reviewer/ }),
);
expect(getRequestContent(inputRef)).toEqual([
mockSkillPart("personal", "reviewer"),
]);
});
it("inserts built-in commands as text", async () => {
const inputRef = renderWithSkills({ personal: [], organization: [] });
const user = await pasteTrigger("/comp");
await user.click(
await screen.findByRole("option", { name: /^\/compact/ }),
);
expect(getRequestContent(inputRef)).toEqual([
{ type: "text", text: "/compact" },
]);
});
it("hides built-in commands when the organization list fails", async () => {
// With /compact hidden, Enter picks the qualified personal match.
// With /compact hidden, Enter picks the personal match.
const inputRef = renderWithSkills({
personal: [{ ...MockSkill, id: "skill-compactor", name: "compactor" }],
});
const user = await pasteTrigger("/comp");
await screen.findByText(/Could not load organization skills/);
await user.keyboard("{Enter}");
expect(inputRef.current?.getValue()).toBe("/personal/compactor");
expect(getRequestContent(inputRef)).toEqual([
mockSkillPart("personal", "compactor"),
]);
});
it("keeps triggers qualified when the organization list fails", async () => {
const inputRef = renderWithSkills({ personal: [mockReviewerSkill] });
const user = await pasteTrigger("/rev");
await screen.findByText(/Could not load organization skills/);
await user.click(screen.getByRole("option", { name: /reviewer/ }));
expect(inputRef.current?.getValue()).toBe("/personal/reviewer");
await user.click(
screen.getByRole("option", { name: /^\/personal\/reviewer/ }),
);
expect(getRequestContent(inputRef)).toEqual([
mockSkillPart("personal", "reviewer"),
]);
});
});
describe("insertSkill", () => {
it("focuses the editor and inserts one chip per skill at the cursor", async () => {
const user = userEvent.setup();
const inputRef = createRef<ChatMessageInputRef>();
renderWithQueryClient(
<ChatMessageInput
{...requiredProps()}
ref={inputRef}
aria-label="Chat message input"
/>,
);
const textbox = screen.getByRole("textbox", {
name: "Chat message input",
});
await user.click(textbox);
await user.paste("Fix the flaky test");
textbox.blur();
inputRef.current?.insertSkill("project", "triage");
inputRef.current?.insertSkill("project", "triage");
inputRef.current?.insertSkill("personal", "triage");
await waitFor(() => {
expect(getRequestContent(inputRef)).toEqual([
{ type: "text", text: "Fix the flaky test" },
mockSkillPart("project", "triage"),
mockSkillPart("personal", "triage"),
]);
});
expect(textbox).toHaveFocus();
});
it("keeps typing at the caret after arrowing across a chip", async () => {
const user = userEvent.setup();
const inputRef = createRef<ChatMessageInputRef>();
renderWithQueryClient(
<ChatMessageInput
{...requiredProps()}
ref={inputRef}
aria-label="Chat message input"
/>,
);
await user.click(
screen.getByRole("textbox", { name: "Chat message input" }),
);
await user.paste("Lead ");
act(() => inputRef.current?.insertSkill("project", "triage"));
await user.keyboard("tail{ArrowLeft>6/}X{ArrowRight}{ArrowRight}Y");
await waitFor(() => {
expect(getRequestContent(inputRef)).toEqual([
{ type: "text", text: "Lead X" },
mockSkillPart("project", "triage"),
{ type: "text", text: " Ytail" },
]);
});
});
it("removes a chip with its remove button", async () => {
const user = userEvent.setup();
const inputRef = createRef<ChatMessageInputRef>();
renderWithQueryClient(
<ChatMessageInput
{...requiredProps()}
ref={inputRef}
aria-label="Chat message input"
/>,
);
await waitFor(() => expect(inputRef.current).not.toBeNull());
act(() => inputRef.current?.insertSkill("project", "triage"));
await user.click(
await screen.findByRole("button", {
name: "Remove skill /project/triage",
}),
);
expect(getRequestContent(inputRef)).toEqual([]);
});
it("copies skill chips as aliases and pastes each available skill once", async () => {
// Lexical's clipboard handlers check these jsdom-missing classes.
vi.stubGlobal("ClipboardEvent", class ClipboardEvent extends Event {});
vi.stubGlobal("DragEvent", class DragEvent extends MouseEvent {});
const user = userEvent.setup();
const inputRef = createRef<ChatMessageInputRef>();
renderWithQueryClient(
<ChatMessageInput
{...requiredProps()}
ref={inputRef}
aria-label="Chat message input"
/>,
);
await user.click(
screen.getByRole("textbox", { name: "Chat message input" }),
);
await user.paste("Lead");
// Without an organization, organization skills are unavailable.
act(() => {
inputRef.current?.insertSkill("personal", "triage");
inputRef.current?.insertSkill("org", "release-notes");
});
await user.keyboard("tail{Control>}a{/Control}");
const copied = await user.copy();
expect(copied?.getData("text/plain")).toBe(
"Lead/personal/triage /org/release-notes tail",
);
act(() => inputRef.current?.clear());
await waitFor(() => {
expect(getRequestContent(inputRef)).toEqual([]);
});
await user.paste(copied);
await user.paste(copied);
await waitFor(() => {
expect(getRequestContent(inputRef)).toEqual([
{ type: "text", text: "Lead" },
mockSkillPart("personal", "triage"),
{
type: "text",
text: " /org/release-notes tailLead /org/release-notes tail",
},
]);
});
});
it("restores chips from a saved draft without React errors", async () => {
const onChange = vi.fn();
const inputRef = createRef<ChatMessageInputRef>();
const { unmount } = renderWithQueryClient(
<ChatMessageInput
{...requiredProps()}
ref={inputRef}
onChange={onChange}
aria-label="Chat message input"
/>,
);
await waitFor(() => {
expect(inputRef.current).not.toBeNull();
});
inputRef.current?.insertSkill("org", "release-notes");
await waitFor(() => {
expect(onChange).toHaveBeenLastCalledWith(
expect.any(String),
expect.any(String),
true,
);
});
const [, savedEditorState] = onChange.mock.lastCall ?? [];
unmount();
const errorSpy = vi.spyOn(console, "error");
const restoredRef = createRef<ChatMessageInputRef>();
renderWithQueryClient(
<ChatMessageInput
{...requiredProps()}
ref={restoredRef}
initialEditorState={savedEditorState}
aria-label="Chat message input"
/>,
);
await waitFor(() => {
expect(getRequestContent(restoredRef)).toEqual([
mockSkillPart("org", "release-notes"),
]);
});
// Restoring chips must not make React log a flushSync error.
expect(errorSpy).not.toHaveBeenCalled();
});
});
});
@@ -17,6 +17,7 @@ import {
$isParagraphNode,
$isRangeSelection,
$isTextNode,
$parseSerializedNode,
COMMAND_PRIORITY_HIGH,
FORMAT_ELEMENT_COMMAND,
FORMAT_TEXT_COMMAND,
@@ -24,6 +25,7 @@ import {
KEY_ENTER_COMMAND,
type LexicalEditor,
PASTE_COMMAND,
type SerializedLexicalNode,
} from "lexical";
import {
useEffect,
@@ -64,6 +66,10 @@ import {
isLargePaste,
type PasteCommandEvent,
} from "./pasteHelpers";
import {
$insertSkillInvocation,
SkillInvocationNode,
} from "./SkillInvocationNode";
import {
createCommandMenuItem,
type SkillMenuItem,
@@ -74,6 +80,11 @@ import {
type ActiveSkillsTrigger,
SkillsTriggerPlugin,
} from "./SkillsTriggerPlugin";
import {
$insertClipboardNodes,
getClipboardNodes,
registerSkillAliasCopy,
} from "./skillClipboard";
// Blocks Cmd+B/I/U and element formatting shortcuts so the editor
// stays plain-text only.
@@ -150,9 +161,13 @@ function replacePlainTextInEditor(editor: LexicalEditor, text: string) {
});
}
const EDITOR_NAMESPACE = "ChatMessageInput";
// Intercepts paste events and inserts clipboard content as plain text,
// stripping any rich-text formatting. Image files and large pasted text
// are forwarded to the parent via the onFilePaste callback instead.
// Content copied from a chat composer keeps its chips, and copying puts
// skill chips into the plain-text form as their aliases.
//
// Cmd/Ctrl+Shift+V ("paste and match style") is treated as an explicit
// user intent to paste inline, so the large-paste-to-attachment
@@ -160,9 +175,11 @@ function replacePlainTextInEditor(editor: LexicalEditor, text: string) {
const PasteSanitizationPlugin: React.FC<{
onFilePaste?: (file: File) => boolean;
allowTextAttachmentPaste?: boolean;
isSkillAvailable: (source: SkillSource, name: string) => boolean;
}> = function PasteSanitizationPlugin({
onFilePaste,
allowTextAttachmentPaste = true,
isSkillAvailable,
}) {
const [editor] = useLexicalComposerContext();
const plainTextPasteRef = useRef(false);
@@ -170,6 +187,7 @@ const PasteSanitizationPlugin: React.FC<{
useEffect(() => {
const unregister = mergeRegister(
registerSkillAliasCopy(editor, EDITOR_NAMESPACE),
// Detect Cmd/Ctrl+Shift+V so the PASTE_COMMAND handler
// can bypass attachment conversion for that shortcut.
editor.registerCommand(
@@ -271,6 +289,24 @@ const PasteSanitizationPlugin: React.FC<{
return true;
}
const composerNodes = isPlainTextPaste
? null
: getClipboardNodes(dataTransfer, EDITOR_NAMESPACE);
if (composerNodes) {
event.preventDefault();
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
$insertClipboardNodes(
selection,
composerNodes,
isSkillAvailable,
);
}
});
return true;
}
// Small paste (or Cmd+Shift+V): insert as plain text.
event.preventDefault();
insertPlainTextIntoEditor(editor, text);
@@ -288,7 +324,7 @@ const PasteSanitizationPlugin: React.FC<{
}
unregister();
};
}, [allowTextAttachmentPaste, editor, onFilePaste]);
}, [allowTextAttachmentPaste, editor, isSkillAvailable, onFilePaste]);
return null;
};
@@ -375,7 +411,7 @@ const ContentChangePlugin: React.FC<{
onChange: (
content: string,
serializedEditorState: string,
hasFileReferences: boolean,
hasChips: boolean,
) => void;
}> = function ContentChangePlugin({ onChange }) {
const [editor] = useLexicalComposerContext();
@@ -385,22 +421,25 @@ const ContentChangePlugin: React.FC<{
editorState.read(() => {
const root = $getRoot();
const content = root.getTextContent();
let hasRefs = false;
let hasChips = false;
for (const child of root.getChildren()) {
if (!$isParagraphNode(child)) continue;
for (const node of child.getChildren()) {
if (node instanceof FileReferenceNode) {
hasRefs = true;
if (
node instanceof FileReferenceNode ||
node instanceof SkillInvocationNode
) {
hasChips = true;
break;
}
}
if (hasRefs) break;
if (hasChips) break;
}
const serialized = JSON.stringify(editorState.toJSON());
onChange(content, serialized, hasRefs);
onChange(content, serialized, hasChips);
});
});
}, [editor, onChange]);
@@ -408,6 +447,20 @@ const ContentChangePlugin: React.FC<{
return null;
};
// Returns the root children of a serialized editor state, or null when
// the state is malformed or uses node types the editor does not register.
const getRestorableRootChildren = (
editor: LexicalEditor,
serializedEditorState: string,
): SerializedLexicalNode[] | null => {
try {
return editor.parseEditorState(serializedEditorState).toJSON().root
.children;
} catch {
return null;
}
};
// Seeds the editor with an initial value on first mount. When
// initialEditorState is provided (a serialized Lexical JSON string),
// it restores the full editor state including file-reference chips.
@@ -425,25 +478,22 @@ const ValueSyncPlugin: React.FC<{
}
hasInitialized.current = true;
// Prefer restoring the full serialized editor state
// (preserves file-reference chips and node positions).
if (initialEditorState) {
try {
const parsed = editor.parseEditorState(initialEditorState);
editor.setEditorState(parsed);
return;
} catch {
// Malformed state, fall through to plain-text path.
}
}
if (initialValue === "") {
// Restore via editor.update, not setEditorState: a synchronous
// commit here makes chip decorators flushSync inside this effect.
const restoredChildren = initialEditorState
? getRestorableRootChildren(editor, initialEditorState)
: null;
if (!restoredChildren && initialValue === "") {
return;
}
editor.update(() => {
const root = $getRoot();
root.clear();
if (restoredChildren) {
root.append(...restoredChildren.map($parseSerializedNode));
return;
}
const paragraph = $createParagraphNode();
const textNode = $createTextNode(initialValue);
paragraph.append(textNode);
@@ -478,16 +528,21 @@ type FileReferenceData = {
readonly content: string;
};
type SkillInvocationData = {
readonly source: SkillSource;
readonly name: string;
};
/**
* A content part extracted from the Lexical editor in document order.
* Either a text segment or a file-reference chip.
*/
type EditorContentPart =
| { readonly type: "text"; readonly text: string }
| {
readonly type: "file-reference";
readonly reference: FileReferenceData;
};
}
| ({ readonly type: "skill-invocation" } & SkillInvocationData);
// Mutable variant used internally while building the parts
// array so we can append to the last text segment without
@@ -497,7 +552,10 @@ type MutableFileRefPart = {
type: "file-reference";
reference: FileReferenceData;
};
type MutableContentPart = MutableTextPart | MutableFileRefPart;
type MutableContentPart =
| MutableTextPart
| MutableFileRefPart
| ({ type: "skill-invocation" } & SkillInvocationData);
export type ChatMessageInputRef = {
setValue: (text: string) => void;
@@ -510,9 +568,15 @@ export type ChatMessageInputRef = {
* (atomic for undo/redo).
*/
addFileReference: (ref: FileReferenceData) => void;
/**
* Focus the editor and insert a skill chip at the cursor, or at the
* end when the editor has no selection. A skill already in the
* editor is not added again.
*/
insertSkill: (source: SkillSource, name: string) => void;
/**
* Walk the Lexical tree in document order and return interleaved
* text / file-reference parts. Adjacent text nodes within the same
* text and chip parts. Adjacent text nodes within the same
* paragraph are merged, and paragraphs are separated by newlines.
*/
getContentParts: () => EditorContentPart[];
@@ -533,7 +597,7 @@ type ChatMessageInputProps = Omit<
onChange: (
content: string,
serializedEditorState: string,
hasFileReferences: boolean,
hasChips: boolean,
) => void;
/** Monotonic counter to force editor remount. */
remountKey?: number;
@@ -701,12 +765,12 @@ const ChatMessageInput = ({
...props
}: ChatMessageInputProps & { ref?: React.Ref<ChatMessageInputRef> }) => {
const initialConfig = {
namespace: "ChatMessageInput",
namespace: EDITOR_NAMESPACE,
theme: {
paragraph: "m-0",
},
onError: (error: Error) => console.error("Lexical error:", error),
nodes: [FileReferenceNode],
nodes: [FileReferenceNode, SkillInvocationNode],
editable: !disabled,
};
const style = {
@@ -763,6 +827,11 @@ const ChatMessageInput = ({
{ source: "project", skills: projectSource.skills },
{ source: "workspace", skills: loadedWorkspaceSkills },
];
// A skill whose source list has not loaded stays a chip when pasted.
const isSkillAvailable = (source: SkillSource, name: string) => {
const skills = skillLists.find((list) => list.source === source)?.skills;
return !skills || skills.some((skill) => skill.name === name);
};
const availableSlashCommands = (slashCommands ?? []).filter(
(command) =>
resolveChatSlashCommandAvailability(command, skillLists) === "available",
@@ -870,7 +939,11 @@ const ChatMessageInput = ({
selection.anchor.set(trigger.nodeKey, trigger.slashOffset, "text");
selection.focus.set(trigger.nodeKey, caretOffset, "text");
selection.insertText(skill.triggerText);
if (skill.source === "command") {
selection.insertText(skill.triggerText);
} else {
$insertSkillInvocation(selection, skill.source, skill.name);
}
});
setSkillsTrigger(null);
setSkillsMenuSelectedIndex(0);
@@ -980,6 +1053,24 @@ const ChatMessageInput = ({
chipNode.selectNext();
});
},
insertSkill: (source: SkillSource, name: string) => {
const editor = editorRef.current;
if (!editor) return;
editor.update(() => {
const selection = $getSelection();
$insertSkillInvocation(
$isRangeSelection(selection) ? selection : $getRoot().selectEnd(),
source,
name,
);
});
// Lexical restores the selection but, unlike AutoFocusPlugin,
// does not move DOM focus when the editor was blurred.
editor.focus(() => {
editor.getRootElement()?.focus({ preventScroll: true });
});
},
getContentParts: () => {
const editor = editorRef.current;
if (!editor) return [];
@@ -1019,6 +1110,12 @@ const ChatMessageInput = ({
content: node.__content,
},
});
} else if (node instanceof SkillInvocationNode) {
parts.push({
type: "skill-invocation",
source: node.__source,
name: node.__name,
});
} else {
const t = node.getTextContent();
if (t) appendText(t);
@@ -1068,6 +1165,7 @@ const ChatMessageInput = ({
<PasteSanitizationPlugin
onFilePaste={onFilePaste}
allowTextAttachmentPaste={allowTextAttachmentPaste}
isSkillAvailable={isSkillAvailable}
/>
<EnterKeyPlugin
onEnter={disabled ? undefined : onEnter}
@@ -4,7 +4,7 @@ import { XIcon } from "lucide-react";
import { FileIcon } from "#/components/FileIcon/FileIcon";
import { getFileReferenceDisplay } from "./fileReferenceDisplay";
const fileReferenceChipVariants = cva(
export const fileReferenceChipVariants = cva(
"inline-flex min-h-5 max-w-[300px] select-none items-center gap-1 rounded-md border border-border bg-surface-primary py-0 pl-0.5 pr-1.5 align-middle font-sans text-[13px] font-normal leading-none text-inherit shadow-xs transition-colors",
{
variants: {
@@ -16,10 +16,22 @@ const fileReferenceChipVariants = cva(
true: "border-content-link bg-content-link/10 text-content-primary ring-1 ring-content-link/40",
false: "",
},
editable: {
true: "text-content-primary hover:bg-surface-quaternary",
false: "",
},
},
compoundVariants: [
{
editable: true,
selected: false,
className: "border-border-secondary bg-surface-tertiary",
},
],
defaultVariants: {
interactive: false,
selected: false,
editable: false,
},
},
);
@@ -119,6 +131,29 @@ export function FileReferenceChip({
);
}
/**
* Removes an editable chip; pointer only, since Backspace removes chips.
* The pseudo-element widens the tap target to 24px without moving the icon.
*/
export const ChipRemoveButton: React.FC<{
label: string;
onRemove: () => void;
}> = ({ label, onRemove }) => (
<button
type="button"
className="relative ml-0.5 inline-flex size-3.5 shrink-0 cursor-pointer items-center justify-center rounded border-0 bg-transparent p-0 text-content-secondary transition-colors after:absolute after:-inset-1.25 after:content-[''] hover:bg-surface-quaternary hover:text-content-primary"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onRemove();
}}
aria-label={label}
tabIndex={-1}
>
<XIcon className="size-2.5" />
</button>
);
export function EditableFileReferenceChip({
fileName,
startLine,
@@ -142,8 +177,11 @@ export function EditableFileReferenceChip({
<span
data-slot="file-reference-chip"
className={cn(
fileReferenceChipVariants({ interactive: true, selected }),
"border-border-secondary bg-surface-tertiary text-content-primary hover:bg-surface-quaternary",
fileReferenceChipVariants({
interactive: true,
selected,
editable: true,
}),
className,
)}
contentEditable={false}
@@ -158,20 +196,7 @@ export function EditableFileReferenceChip({
>
<FileReferenceChipContent fileName={shortFile} lineRange={lineRange} />
</button>
<button
data-slot="file-reference-chip-remove"
type="button"
className="ml-0.5 inline-flex size-3.5 shrink-0 cursor-pointer items-center justify-center rounded border-0 bg-transparent p-0 text-content-secondary transition-colors hover:bg-surface-quaternary hover:text-content-primary"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onRemove();
}}
aria-label="Remove reference"
tabIndex={-1}
>
<XIcon className="size-2.5" />
</button>
<ChipRemoveButton label="Remove reference" onRemove={onRemove} />
</span>
);
}
@@ -96,6 +96,13 @@ export class FileReferenceNode extends DecoratorNode<React.ReactNode> {
return true;
}
// A keyboard-selected decorator becomes a NodeSelection, which clears
// the DOM caret while the editor keeps focus, so the next keystroke
// lands at the start of the editor. Arrow keys step over the chip.
isKeyboardSelectable(): boolean {
return false;
}
decorate(_editor: LexicalEditor): React.ReactNode {
return (
<FileReferenceChipWrapper
@@ -122,7 +129,11 @@ const getFileReferenceSpacingSnapshot = (
);
};
const useFileReferenceSpacing = (editor: LexicalEditor, nodeKey: NodeKey) => {
/** Margins that keep an inline chip clear of adjacent text and chips. */
export const useFileReferenceSpacing = (
editor: LexicalEditor,
nodeKey: NodeKey,
) => {
const spacingSnapshot = useSyncExternalStore(
(notify) => editor.registerUpdateListener(notify),
() => getFileReferenceSpacingSnapshot(editor, nodeKey),
@@ -0,0 +1,38 @@
import { cn } from "cn";
import { ZapIcon } from "lucide-react";
import { getSkillAlias, type SkillSource } from "../../utils/skillAliases";
import {
ChipRemoveButton,
fileReferenceChipVariants,
} from "./FileReferenceChip";
type SkillChipProps = {
source: SkillSource;
name: string;
className?: string;
};
const SkillChipLabel: React.FC<{ alias: string }> = ({ alias }) => (
<>
<ZapIcon className="ml-0.5 size-3.5 shrink-0 text-content-secondary" />
<span className="min-w-0 truncate" title={alias}>
{alias}
</span>
</>
);
/** A skill attached to the message being composed. */
export const EditableSkillChip: React.FC<
SkillChipProps & { onRemove: () => void }
> = ({ source, name, onRemove, className }) => {
const alias = getSkillAlias(source, name);
return (
<span
className={cn(fileReferenceChipVariants({ editable: true }), className)}
contentEditable={false}
>
<SkillChipLabel alias={alias} />
<ChipRemoveButton label={`Remove skill ${alias}`} onRemove={onRemove} />
</span>
);
};
@@ -0,0 +1,156 @@
import { cn } from "cn";
import {
$getNodeByKey,
$getRoot,
$isParagraphNode,
$isTextNode,
DecoratorNode,
type LexicalEditor,
type NodeKey,
type RangeSelection,
type SerializedLexicalNode,
type Spread,
} from "lexical";
import type { SkillSource } from "../../utils/skillAliases";
import { useFileReferenceSpacing } from "./FileReferenceNode";
import { EditableSkillChip } from "./SkillChip";
type SerializedSkillInvocationNode = Spread<
{ source: SkillSource; name: string },
SerializedLexicalNode
>;
export const isSerializedSkillInvocationNode = (
node: SerializedLexicalNode,
): node is SerializedSkillInvocationNode => node.type === "skill-invocation";
/** A skill chip that sends as a skill-invocation part. */
export class SkillInvocationNode extends DecoratorNode<React.ReactNode> {
__source: SkillSource;
__name: string;
static getType(): string {
return "skill-invocation";
}
static clone(node: SkillInvocationNode): SkillInvocationNode {
return new SkillInvocationNode(node.__source, node.__name, node.__key);
}
constructor(source: SkillSource, name: string, key?: NodeKey) {
super(key);
this.__source = source;
this.__name = name;
}
createDOM(): HTMLElement {
const span = document.createElement("span");
span.style.display = "inline";
span.style.userSelect = "none";
return span;
}
updateDOM(): boolean {
return false;
}
exportJSON(): SerializedSkillInvocationNode {
return {
type: "skill-invocation",
version: 1,
source: this.__source,
name: this.__name,
};
}
static importJSON(json: SerializedSkillInvocationNode): SkillInvocationNode {
return new SkillInvocationNode(json.source, json.name);
}
getTextContent(): string {
return "";
}
isInline(): boolean {
return true;
}
// A keyboard-selected decorator becomes a NodeSelection, which clears
// the DOM caret while the editor keeps focus, so the next keystroke
// lands at the start of the editor. Arrow keys step over the chip.
isKeyboardSelectable(): boolean {
return false;
}
decorate(editor: LexicalEditor): React.ReactNode {
return (
<SkillChipWrapper
editor={editor}
nodeKey={this.__key}
source={this.__source}
name={this.__name}
/>
);
}
}
const SkillChipWrapper: React.FC<{
editor: LexicalEditor;
nodeKey: NodeKey;
source: SkillSource;
name: string;
}> = ({ editor, nodeKey, source, name }) => {
const spacing = useFileReferenceSpacing(editor, nodeKey);
const handleRemove = () => {
editor.update(() => {
$getNodeByKey(nodeKey)?.remove();
});
};
return (
<EditableSkillChip
source={source}
name={name}
onRemove={handleRemove}
className={cn(spacing.before && "ml-1", spacing.after && "mr-1")}
/>
);
};
export const $hasSkillInvocationNode = (source: SkillSource, name: string) =>
$getRoot()
.getChildren()
.some(
(paragraph) =>
$isParagraphNode(paragraph) &&
paragraph
.getChildren()
.some(
(node) =>
node instanceof SkillInvocationNode &&
node.__source === source &&
node.__name === name,
),
);
/**
* Replaces the selection with a skill chip followed by a space. A skill
* already in the editor is not added again; the selection is only removed.
*/
export function $insertSkillInvocation(
selection: RangeSelection,
source: SkillSource,
name: string,
) {
if ($hasSkillInvocationNode(source, name)) {
selection.removeText();
return;
}
const chip = new SkillInvocationNode(source, name);
selection.insertNodes([chip]);
const next = chip.getNextSibling();
if ($isTextNode(next) && /^\s/.test(next.getTextContent())) {
next.select(1, 1);
return;
}
chip.selectNext().insertText(" ");
}
@@ -38,7 +38,7 @@ describe("getFileReferenceDisplay", () => {
describe("inline spacing helpers", () => {
const parts = [
{ type: "text", text: "prefix" },
{ type: "file-reference" },
{ type: "chip" },
{ type: "text", text: "suffix" },
] as const;
@@ -51,7 +51,7 @@ describe("inline spacing helpers", () => {
const whitespaceParts = [
{ type: "text", text: "" },
{ type: "text", text: " " },
{ type: "file-reference" },
{ type: "chip" },
{ type: "text", text: "\n" },
] as const;
@@ -59,11 +59,11 @@ describe("inline spacing helpers", () => {
expect(hasInlineContentAfter(whitespaceParts, 2)).toBe(false);
});
it("treats adjacent file references as inline neighbors", () => {
it("treats adjacent chips as inline neighbors", () => {
const adjacentReferences = [
{ type: "file-reference" },
{ type: "file-reference" },
{ type: "file-reference" },
{ type: "chip" },
{ type: "chip" },
{ type: "chip" },
] as const;
expect(hasInlineContentBefore(adjacentReferences, 1)).toBe(true);
@@ -74,7 +74,7 @@ describe("inline spacing helpers", () => {
const sparseParts = [
{ type: "text", text: "" },
{ type: "text", text: "leading" },
{ type: "file-reference" },
{ type: "chip" },
{ type: "text", text: "" },
{ type: "text", text: "trailing" },
] as const;
@@ -3,11 +3,12 @@ import { $getNodeByKey } from "lexical";
import { getPathBasename } from "../../utils/path";
export type InlinePart =
| { readonly type: "file-reference" }
| { readonly type: "chip" }
| { readonly type: "text"; readonly text: string };
const isFileReferenceNode = (node: LexicalNode) => {
return node.getType() === "file-reference";
const isChipNode = (node: LexicalNode) => {
const type = node.getType();
return type === "file-reference" || type === "skill-invocation";
};
export const getFileReferenceDisplay = ({
@@ -33,7 +34,7 @@ export const hasInlineContentBefore = (
) => {
for (let i = index - 1; i >= 0; i--) {
const part = parts[i];
if (part.type === "file-reference") {
if (part.type === "chip") {
return true;
}
if (part.text.length > 0) {
@@ -49,7 +50,7 @@ export const hasInlineContentAfter = (
) => {
for (let i = index + 1; i < parts.length; i++) {
const part = parts[i];
if (part.type === "file-reference") {
if (part.type === "chip") {
return true;
}
if (part.text.length > 0) {
@@ -75,8 +76,8 @@ export const getFileReferenceSiblingSpacing = (
let sibling = node.getPreviousSibling();
const previousParts: InlinePart[] = [];
while (sibling) {
if (isFileReferenceNode(sibling)) {
previousParts.unshift({ type: "file-reference" });
if (isChipNode(sibling)) {
previousParts.unshift({ type: "chip" });
} else {
previousParts.unshift({ type: "text", text: sibling.getTextContent() });
}
@@ -85,12 +86,12 @@ export const getFileReferenceSiblingSpacing = (
parts.push(...previousParts);
referenceIndex = parts.length;
parts.push({ type: "file-reference" });
parts.push({ type: "chip" });
sibling = node.getNextSibling();
while (sibling) {
if (isFileReferenceNode(sibling)) {
parts.push({ type: "file-reference" });
if (isChipNode(sibling)) {
parts.push({ type: "chip" });
} else {
parts.push({ type: "text", text: sibling.getTextContent() });
}
@@ -0,0 +1,136 @@
import { mergeRegister } from "@lexical/utils";
import {
$createTextNode,
$isElementNode,
$parseSerializedNode,
type LexicalEditor,
type LexicalNode,
type RangeSelection,
type SerializedLexicalNode,
} from "lexical";
import { getSkillAlias, type SkillSource } from "../../utils/skillAliases";
import {
$hasSkillInvocationNode,
isSerializedSkillInvocationNode,
SkillInvocationNode,
} from "./SkillInvocationNode";
/**
* Returns the serialized nodes Lexical put on the clipboard when copying
* from an editor with the given namespace, or null for other content.
*/
export const getClipboardNodes = (
dataTransfer: DataTransfer | null,
namespace: string,
): SerializedLexicalNode[] | null => {
const payload = dataTransfer?.getData("application/x-lexical-editor");
if (!payload) {
return null;
}
try {
const { namespace: payloadNamespace, nodes } = JSON.parse(payload);
return payloadNamespace === namespace && Array.isArray(nodes)
? nodes
: null;
} catch {
return null;
}
};
/**
* Pastes copied composer nodes at the selection. A skill chip that is
* already in the editor is dropped, and one the composer cannot invoke
* becomes its plain-text alias.
*/
export const $insertClipboardNodes = (
selection: RangeSelection,
serializedNodes: readonly SerializedLexicalNode[],
isSkillAvailable: (source: SkillSource, name: string) => boolean,
) => {
// Chips the paste replaces must not count as already in the editor.
selection.removeText();
const pastedAliases = new Set<string>();
const $resolveNode = (node: LexicalNode): LexicalNode | null => {
if (node instanceof SkillInvocationNode) {
const { __source: source, __name: name } = node;
const alias = getSkillAlias(source, name);
if (!isSkillAvailable(source, name)) {
return $createTextNode(alias);
}
if (pastedAliases.has(alias) || $hasSkillInvocationNode(source, name)) {
return null;
}
pastedAliases.add(alias);
} else if ($isElementNode(node)) {
for (const child of node.getChildren()) {
const resolved = $resolveNode(child);
if (!resolved) {
child.remove();
} else if (resolved !== child) {
child.replace(resolved);
}
}
}
return node;
};
selection.insertNodes(
serializedNodes
.map((node) => $resolveNode($parseSerializedNode(node)))
.filter((node) => node !== null),
);
};
const getSerializedNodeText = (node: SerializedLexicalNode): string => {
if (isSerializedSkillInvocationNode(node)) {
return getSkillAlias(node.source, node.name);
}
if (node.type === "linebreak") {
return "\n";
}
if ("text" in node && typeof node.text === "string") {
return node.text;
}
if ("children" in node && Array.isArray(node.children)) {
return node.children.map(getSerializedNodeText).join("");
}
return "";
};
/**
* Rewrites the plain-text form of content copied or cut from the editor
* so skill chips, which have no text content, appear as their aliases.
* Lexical's own handlers fill the clipboard first because they listen on
* the root element before this listener is added.
*/
export const registerSkillAliasCopy = (
editor: LexicalEditor,
namespace: string,
) => {
const handleCopy = (event: ClipboardEvent) => {
const nodes = getClipboardNodes(event.clipboardData, namespace);
if (!nodes) {
return;
}
const isBlockList = nodes.every((node) => "children" in node);
event.clipboardData?.setData(
"text/plain",
nodes.map(getSerializedNodeText).join(isBlockList ? "\n" : ""),
);
};
const setListeners = (root: HTMLElement | null, add: boolean) => {
for (const type of ["copy", "cut"] as const) {
if (add) {
root?.addEventListener(type, handleCopy);
} else {
root?.removeEventListener(type, handleCopy);
}
}
};
return mergeRegister(
editor.registerRootListener((root, prevRoot) => {
setListeners(prevRoot, false);
setListeners(root, true);
}),
() => setListeners(editor.getRootElement(), false),
);
};
@@ -130,9 +130,13 @@ describe("ChatPageInput", () => {
await user.paste("/comp");
// An inherited project skill may shadow /compact, so the command
// stays hidden and Enter picks the qualified personal skill.
await screen.findByRole("option", { name: /compactor/ });
await screen.findByRole("option", { name: /^\/personal\/compactor/ });
await user.keyboard("{Enter}");
expect(inputRef.current?.getValue()).toBe("/personal/compactor");
expect(inputRef.current?.getContentParts()).toContainEqual({
type: "skill-invocation",
source: "personal",
name: "compactor",
});
expect(getChatProjectSkills).not.toHaveBeenCalled();
});
@@ -104,6 +104,7 @@ function mockComposer(value = ""): ChatMessageInputRef {
clear: vi.fn(),
focus: vi.fn(),
addFileReference: vi.fn(),
insertSkill: vi.fn(),
getContentParts: vi.fn(() => []),
};
}
@@ -34,6 +34,7 @@ const createMockChatInputHandle = (initialValue = ""): MockChatInputHandle => {
focus,
getValue,
addFileReference: vi.fn(),
insertSkill: vi.fn(),
getContentParts: vi.fn(() => []),
},
setValue,
@@ -396,6 +397,7 @@ describe("useConversationEditingState", () => {
insertText: vi.fn(),
getValue: vi.fn().mockReturnValue(""),
addFileReference: vi.fn(),
insertSkill: vi.fn(),
getContentParts: vi.fn().mockReturnValue([]),
}; // The hook exposes chatInputRef, so assign the mock to it.
result.current.chatInputRef.current = mockInputRef;
@@ -56,6 +56,7 @@ describe("buildChatInputContent", () => {
content: "code",
},
},
{ type: "skill-invocation", source: "project", name: "triage" },
{ type: "text", text: "after" },
],
}),
@@ -69,6 +70,11 @@ describe("buildChatInputContent", () => {
end_line: 4,
content: "code",
},
{
type: "skill-invocation",
skill_source: "project",
skill_name: "triage",
},
{ type: "text", text: "after" },
],
hasContent: true,
@@ -3,6 +3,7 @@ import type {
PendingAttachment,
PendingWorkspaceUpload,
} from "../components/ChatPageContent";
import type { SkillSource } from "./skillAliases";
export type ChatComposerContentPart =
| { readonly type: "text"; readonly text: string }
@@ -14,6 +15,11 @@ export type ChatComposerContentPart =
readonly endLine: number;
readonly content: string;
};
}
| {
readonly type: "skill-invocation";
readonly source: SkillSource;
readonly name: string;
};
export const buildAttachmentMediaTypes = (
@@ -40,8 +46,8 @@ export const toWorkspaceFileReferencePart = (
});
/**
* When `composerParts` is provided, file-reference chips stay in document
* order. Omit it to send `message` as text only.
* When `composerParts` is provided, file-reference and skill chips stay in
* document order. Omit it to send `message` as text only.
*/
export const buildChatInputContent = ({
message,
@@ -62,6 +68,12 @@ export const buildChatInputContent = ({
if (part.text.trim()) {
content.push({ type: "text", text: part.text });
}
} else if (part.type === "skill-invocation") {
content.push({
type: "skill-invocation",
skill_source: part.source,
skill_name: part.name,
});
} else {
const reference = part.reference;
content.push({
@@ -2,7 +2,7 @@
* Utilities for persisting and restoring chat input drafts.
*
* Drafts are stored in localStorage. The current format stores the
* serialized Lexical editor state JSON so that file-reference chips
* serialized Lexical editor state JSON so that file-reference and skill chips
* survive navigation. Legacy drafts (plain-text strings) are detected
* and handled transparently on read.
*/
@@ -1,5 +1,7 @@
/** Skill sources in chatd's merge order, named by their alias prefix. */
export type SkillSource = "personal" | "org" | "project" | "workspace";
import type { ChatSkillSource } from "#/api/typesGenerated";
/** Where a skill is stored, named by its alias prefix. */
export type SkillSource = ChatSkillSource;
export type SkillSourceList<T extends { name: string }> = {
source: SkillSource;
@@ -18,6 +20,10 @@ export type SkillTrigger = {
altTriggerText: string;
};
/** Returns the source-qualified alias of a skill, such as "/project/triage". */
export const getSkillAlias = (source: SkillSource, name: string): string =>
`/${source}/${name}`;
/**
* Builds every "/" trigger. Workspace skills and names in more than one
* source (chatd's MergeSkills) are qualified, as is every trigger while any
@@ -35,7 +41,7 @@ export const resolveSkillTriggers = (
}
return lists.flatMap(({ source, skills }) =>
(skills ?? []).map((skill) => {
const qualified = `/${source}/${skill.name}`;
const qualified = getSkillAlias(source, skill.name);
const isQualified =
source === "workspace" ||
hasUnknownList ||