Merge branch 'chat-project-skills/skill-chips-transcript' into chat-project-skills/settings-api

This commit is contained in:
Jaayden Halko
2026-10-15 20:59:21 +00:00
43 changed files with 1278 additions and 153 deletions
+8
View File
@@ -277,10 +277,18 @@ Because stored skills are read again at each step, a change takes effect on the
For example, when an administrator turns off an organization skill or removes someone's access, the chat stops listing that skill on its next step.
The `/` menu in the chat composer lists skills in four groups: **Personal skills**, **Organization skills**, **Project skills**, and **Workspace skills**.
When you write a new message in an existing chat, a **Commands** group above them also lists the built-in `/compact` and `/clear` commands.
The **Project skills** group appears only in chats that belong to a project.
The **Organization skills** group shows the skills of the chat's organization that are turned on and that you can use.
For site owners, organization admins, and auditors, the group lists every organization skill that is turned on.
Choosing a skill from the `/` menu attaches it to your message as a chip, so the agent always loads that skill for the message instead of deciding whether to read it.
The chip shows the skill's qualified alias, such as `/project/triage`, even when the menu lists the skill by its bare name.
Coder reads the skill when you send the message, so a queued message keeps the version of the skill it was sent with.
If the skill is turned off, deleted, or no longer available to you at that point, Coder rejects the message with an error such as `Skill project/triage is not available.`
Remove the chip, or turn the skill back on, and send the message again.
Text such as `/triage` that you type without choosing it from the menu is sent as plain text, and the agent decides whether to read the skill.
### Turn a skill on or off
Personal, organization, and project skills each have an **Enabled** column in their skills table.
@@ -45,6 +45,20 @@ describe("buildOptimisticEditedMessage", () => {
expect(message.content).toEqual([existingFilePart]);
});
it("keeps skill invocations as skill parts", () => {
const skillPart: TypesGen.ChatSkillInvocationPart = {
type: "skill-invocation",
skill_source: "project",
skill_name: "triage",
};
const message = buildOptimisticEditedMessage({
requestContent: [skillPart],
originalMessage: makeUserMessage(),
});
expect(message.content).toEqual([skillPart]);
});
});
describe("reconcileEditedMessageInCache", () => {
+7
View File
@@ -30,6 +30,13 @@ const buildOptimisticEditedContent = ({
content: part.content ?? "",
};
}
if (part.type === "skill-invocation") {
return {
type: "skill-invocation",
skill_source: part.skill_source ?? "personal",
skill_name: part.skill_name ?? "",
};
}
if (part.type === "workspace-file-reference") {
return {
type: "workspace-file-reference",
+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;
@@ -85,6 +85,7 @@ type EditingState = {
messageId: number,
text: string,
fileBlocks?: readonly ChatMessagePart[],
editorState?: string,
) => void;
handleCancelHistoryEdit: () => void;
handleSendFromInput: (options: SendChatMessageOptions) => void;
@@ -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 ||
@@ -1476,6 +1476,41 @@ export const UserMessageWithInlineFileRef: Story = {
},
};
/** Skill chips render inline with text, and a skill-only message still renders. */
export const UserMessageWithSkillChips: Story = {
args: {
...defaultArgs,
parsedMessages: buildMessages([
{
...baseMessage,
id: 1,
role: "user",
content: [
{ type: "text", text: "Use " },
{
type: "skill-invocation",
skill_source: "project",
skill_name: "triage",
},
{ type: "text", text: " on the flaky test." },
],
},
{
...baseMessage,
id: 2,
role: "user",
content: [
{
type: "skill-invocation",
skill_source: "personal",
skill_name: "release-notes",
},
],
},
]),
},
};
export const MetadataOnlyUserMessageRendersNoRow: Story = {
args: {
...defaultArgs,
@@ -1676,6 +1711,7 @@ export const UserMessageCopyButton: Story = {
1,
"Can you fix this bug?",
undefined,
undefined,
);
// Behavioral: clicking copy writes the raw markdown to the
@@ -120,6 +120,7 @@ const ChatMessageItem = memo<{
messageId: number,
text: string,
fileBlocks?: readonly TypesGen.ChatMessagePart[],
editorState?: string,
) => void;
editingMessageId?: number | null;
isAfterEditingMessage?: boolean;
@@ -343,9 +344,14 @@ const ChatMessageItem = memo<{
className="size-6"
aria-label="Edit message"
onClick={() => {
const { text, fileBlocks } =
const { text, fileBlocks, editorState } =
getEditableUserMessagePayload(message);
onEditUserMessage?.(messageId, text, fileBlocks);
onEditUserMessage?.(
messageId,
text,
fileBlocks,
editorState,
);
}}
>
<PencilIcon />
@@ -451,6 +457,7 @@ type ConversationTimelineProps = {
messageId: number,
text: string,
fileBlocks?: readonly TypesGen.ChatMessagePart[],
editorState?: string,
) => void;
editingMessageId?: number | null;
onImplementPlan?: () => Promise<void> | void;
@@ -396,9 +396,10 @@ export const BlockList: React.FC<BlockListProps> = ({
sources={block.sources}
/>
);
// Workspace file references render through the user
// message display state, not as timeline blocks.
// Workspace file references and skill invocations render
// through the user message display state, not as timeline blocks.
case "workspace-file-reference":
case "skill-invocation":
return null;
default: {
const _exhaustive: never = block;
@@ -7,6 +7,7 @@ import {
hasInlineContentBefore,
type InlinePart,
} from "../ChatMessageInput/fileReferenceDisplay";
import { SkillChip } from "../ChatMessageInput/SkillChip";
import {
AttachmentBlock,
type PreviewTextAttachment,
@@ -21,10 +22,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" };
});
};
@@ -36,6 +37,20 @@ const renderUserInlineBlock = (
if (block.type === "response") {
return <Fragment key={index}>{block.text}</Fragment>;
}
const spacing = cn(
hasInlineContentBefore(inlineParts, index) && "ml-1",
hasInlineContentAfter(inlineParts, index) && "mr-1",
);
if (block.type === "skill-invocation") {
return (
<SkillChip
key={index}
source={block.skill_source}
name={block.skill_name}
className={spacing}
/>
);
}
return (
<FileReferenceChip
@@ -43,10 +58,7 @@ const renderUserInlineBlock = (
fileName={block.file_name}
startLine={block.start_line}
endLine={block.end_line}
className={cn(
hasInlineContentBefore(inlineParts, index) && "ml-1",
hasInlineContentAfter(inlineParts, index) && "mr-1",
)}
className={spacing}
/>
);
};
@@ -371,6 +371,20 @@ describe("deriveMessageDisplayState", () => {
expect(state.hasCopyableContent).toBe(false);
});
it("renders a user message that only invokes a skill", () => {
const message = buildMessage([
{
type: "skill-invocation",
skill_source: "project",
skill_name: "triage",
},
]);
const state = getDisplayState(message);
expect(state.shouldHide).toBe(false);
expect(state.hasUserMessageBody).toBe(true);
});
it("hides assistant messages with no renderable content", () => {
const message = buildMessage([], "assistant");
@@ -10,7 +10,8 @@ import type {
export type UserInlineRenderBlock =
| Extract<RenderBlock, { type: "response" }>
| Extract<RenderBlock, { type: "file-reference" }>;
| Extract<RenderBlock, { type: "file-reference" }>
| Extract<RenderBlock, { type: "skill-invocation" }>;
type FileRenderBlock = Extract<RenderBlock, { type: "file" }>;
type WorkspaceFileReferenceBlock = Extract<
@@ -44,7 +45,9 @@ type HiddenTimelineEntryReason =
const isUserInlineRenderBlock = (
block: RenderBlock,
): block is UserInlineRenderBlock =>
block.type === "response" || block.type === "file-reference";
block.type === "response" ||
block.type === "file-reference" ||
block.type === "skill-invocation";
const isFileRenderBlock = (block: RenderBlock): block is FileRenderBlock =>
block.type === "file";
@@ -220,6 +220,24 @@ describe("parseMessageContent", () => {
expect(result.blocks).toEqual([{ type: "response", text: "Hello" }]);
});
it("keeps skill invocations between the text around them", () => {
const skillPart = {
type: "skill-invocation",
skill_source: "project",
skill_name: "triage",
} as const;
const result = parseMessageContent([
{ type: "text", text: "Use " },
skillPart,
{ type: "text", text: " now" },
]);
expect(result.blocks).toEqual([
{ type: "response", text: "Use " },
skillPart,
{ type: "response", text: " now" },
]);
});
it("merges multiple text blocks into a single response block", () => {
const result = parseMessageContent([
{ type: "text", text: "Line one" },
@@ -8,6 +8,7 @@ import {
isSubagentToolName,
type SubagentVariant,
} from "../ChatElements/tools/subagentDescriptor";
import { serializeMessageDraft } from "../ChatMessageInput/SkillInvocationNode";
import { appendTextBlock } from "./blockUtils";
import type {
MergedTool,
@@ -354,7 +355,7 @@ export const parseMessageContent = (
break;
}
case "skill-invocation": {
// Not rendered yet; the skill chip UI renders these.
parsed.blocks.push(part);
break;
}
default: {
@@ -382,6 +383,8 @@ export const getEditableUserMessagePayload = (
): {
text: string;
fileBlocks: readonly TypesGen.ChatMessagePart[] | undefined;
/** Restores skill chips in place; undefined when there are none. */
editorState: string | undefined;
} => {
// Concatenate text parts verbatim to match the server-side string_agg in
// GetChatUserPromptsByChatID; parseMessageContent/appendText drops leading
@@ -395,6 +398,11 @@ export const getEditableUserMessagePayload = (
return {
text,
fileBlocks: fileBlocks.length > 0 ? fileBlocks : undefined,
editorState: parsed.blocks.some(
(block) => block.type === "skill-invocation",
)
? serializeMessageDraft(message.content ?? [])
: undefined,
};
};
@@ -63,6 +63,7 @@ export type RenderBlock =
| TypesGen.ChatFilePart
| TypesGen.ChatFileReferencePart
| TypesGen.ChatWorkspaceFileReferencePart
| TypesGen.ChatSkillInvocationPart
| {
type: "sources";
sources: Array<{ url: string; title: string }>;
@@ -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,12 +8,17 @@ import { organizationSkills } from "#/api/queries/organizationSkills";
import { userSkills } from "#/api/queries/userSkills";
import type {
AgentChatSendShortcut,
ChatMessagePart,
ChatSkillSource,
SkillMetadata,
} from "#/api/typesGenerated";
import { MockChatMessage } from "#/testHelpers/chatEntities";
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 { getEditableUserMessagePayload } from "../ChatConversation/messageParsing";
import { ChatMessageInput, type ChatMessageInputRef } from "./ChatMessageInput";
const requiredProps = () => ({
@@ -26,6 +31,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 +319,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 +343,244 @@ 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();
});
it("reopens an edited message with its skill chips in place", async () => {
const content: ChatMessagePart[] = [
{ type: "text", text: "Use " },
mockSkillPart("project", "triage"),
{ type: "text", text: " on\nthe diff" },
];
const { text, editorState } = getEditableUserMessagePayload({
...MockChatMessage,
content,
});
const inputRef = createRef<ChatMessageInputRef>();
renderWithQueryClient(
<ChatMessageInput
{...requiredProps()}
ref={inputRef}
initialValue={text}
initialEditorState={editorState}
aria-label="Chat message input"
/>,
);
await waitFor(() => {
expect(getRequestContent(inputRef)).toEqual(content);
});
});
});
});
@@ -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,35 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { SkillChip } from "./SkillChip";
const meta: Meta<typeof SkillChip> = {
title: "components/ChatMessageInput/SkillChip",
component: SkillChip,
args: {
source: "project",
name: "triage",
},
decorators: [
(Story) => (
<div className="p-6">
<Story />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof SkillChip>;
export const InlineWithText: Story = {
render: (args) => (
<p className="m-0 font-sans text-sm leading-6 text-content-primary">
Use <SkillChip {...args} /> on the flaky test.
</p>
),
};
export const LongNameTruncates: Story = {
args: {
name: "summarize-every-failing-integration-test-in-the-workspace",
},
};
@@ -0,0 +1,49 @@
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 invoked by a sent message. */
export const SkillChip: React.FC<SkillChipProps> = ({
source,
name,
className,
}) => (
<span className={cn(fileReferenceChipVariants(), className)}>
<SkillChipLabel alias={getSkillAlias(source, name)} />
</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,200 @@
import { cn } from "cn";
import {
$createLineBreakNode,
$createParagraphNode,
$createTextNode,
$getNodeByKey,
$getRoot,
$isParagraphNode,
$isTextNode,
createEditor,
DecoratorNode,
type LexicalEditor,
type NodeKey,
type RangeSelection,
type SerializedLexicalNode,
type Spread,
} from "lexical";
import type { ChatMessagePart } from "#/api/typesGenerated";
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(" ");
}
/**
* Serializes the text and skill-invocation parts of a sent message as a
* composer draft, so editing the message keeps its skill chips in place.
*/
export const serializeMessageDraft = (
content: readonly ChatMessagePart[],
): string => {
const editor = createEditor({
nodes: [SkillInvocationNode],
onError: (error) => {
throw error;
},
});
editor.update(
() => {
const paragraph = $createParagraphNode();
for (const part of content) {
if (part.type === "skill-invocation") {
paragraph.append(
new SkillInvocationNode(part.skill_source, part.skill_name),
);
} else if (part.type === "text") {
for (const [index, line] of part.text.split("\n").entries()) {
if (index > 0) {
paragraph.append($createLineBreakNode());
}
if (line) {
paragraph.append($createTextNode(line));
}
}
}
}
$getRoot().append(paragraph);
},
{ discrete: true },
);
return JSON.stringify(editor.getEditorState().toJSON());
};
@@ -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();
});
@@ -127,6 +127,7 @@ type ChatPageTimelineProps = {
messageId: number,
text: string,
fileBlocks?: readonly TypesGen.ChatMessagePart[],
editorState?: string,
) => void;
editingMessageId?: number | null;
onImplementPlan?: () => Promise<void> | void;
@@ -104,6 +104,7 @@ function mockComposer(value = ""): ChatMessageInputRef {
clear: vi.fn(),
focus: vi.fn(),
addFileReference: vi.fn(),
insertSkill: vi.fn(),
getContentParts: vi.fn(() => []),
};
}
@@ -116,6 +116,44 @@ export const MultiLineTextTruncation: Story = {
},
};
// Invoked skills render as chips in document order.
export const WithSkillChips: Story = {
args: {
messages: [
buildMessage(1, [
{ type: "text", text: "Draft start" },
{
type: "skill-invocation",
skill_source: "personal",
skill_name: "zebra-notes",
},
{ type: "text", text: "draft end" },
]),
buildMessage(2, [
{
type: "skill-invocation",
skill_source: "project",
skill_name: "triage",
},
]),
// Only the first line shows, so the chip after the break is hidden.
buildMessage(3, [
{
type: "skill-invocation",
skill_source: "org",
skill_name: "release-notes",
},
{ type: "text", text: "First line\nSecond line" },
{
type: "skill-invocation",
skill_source: "project",
skill_name: "triage",
},
]),
],
},
};
// A message with both text and a file attachment shows the ImageIcon badge.
export const WithAttachments: Story = {
args: {
@@ -13,12 +13,33 @@ describe("getQueuedMessageInfo", () => {
buildMessage([{ type: "text", text: "hello" }]),
);
expect(result).toEqual({
displayText: "hello",
previewParts: [{ type: "text", text: "hello" }],
attachmentCount: 0,
hookNotices: [],
});
});
it("keeps invoked skills in document order", () => {
const result = getQueuedMessageInfo(
buildMessage([
{
type: "skill-invocation",
skill_source: "project",
skill_name: "triage",
},
{ type: "text", text: "the flaky test" },
]),
);
expect(result.previewParts).toEqual([
{
type: "skill-invocation",
skill_source: "project",
skill_name: "triage",
},
{ type: "text", text: "the flaky test" },
]);
});
it("collects hook notices without polluting the preview text", () => {
const result = getQueuedMessageInfo(
buildMessage([
@@ -27,7 +48,7 @@ describe("getQueuedMessageInfo", () => {
]),
);
expect(result).toEqual({
displayText: "hello",
previewParts: [{ type: "text", text: "hello" }],
attachmentCount: 0,
hookNotices: ["policy notice"],
});
@@ -38,24 +59,24 @@ describe("getQueuedMessageInfo", () => {
buildMessage([{ type: "text", text: "line1\nline2" }]),
);
expect(result).toEqual({
displayText: "line1\nline2",
previewParts: [{ type: "text", text: "line1\nline2" }],
attachmentCount: 0,
hookNotices: [],
});
});
it("returns attachment label for a single file", () => {
it("returns no preview for a single file", () => {
const result = getQueuedMessageInfo(
buildMessage([{ type: "file", file_id: "a", media_type: "image/png" }]),
);
expect(result).toEqual({
displayText: "[Queued message]",
previewParts: [],
attachmentCount: 1,
hookNotices: [],
});
});
it("returns attachment label for multiple files", () => {
it("returns no preview for multiple files", () => {
const result = getQueuedMessageInfo(
buildMessage([
{ type: "file", file_id: "a", media_type: "image/png" },
@@ -63,7 +84,7 @@ describe("getQueuedMessageInfo", () => {
]),
);
expect(result).toEqual({
displayText: "[Queued message]",
previewParts: [],
attachmentCount: 2,
hookNotices: [],
});
@@ -77,33 +98,33 @@ describe("getQueuedMessageInfo", () => {
]),
);
expect(result).toEqual({
displayText: "look",
previewParts: [{ type: "text", text: "look" }],
attachmentCount: 1,
hookNotices: [],
});
});
it("returns fallback for empty content", () => {
it("returns no preview for empty content", () => {
const result = getQueuedMessageInfo(buildMessage([]));
expect(result).toEqual({
displayText: "[Queued message]",
previewParts: [],
attachmentCount: 0,
hookNotices: [],
});
});
it("returns fallback for whitespace-only text", () => {
it("returns no preview for whitespace-only text", () => {
const result = getQueuedMessageInfo(
buildMessage([{ type: "text", text: " " }]),
);
expect(result).toEqual({
displayText: "[Queued message]",
previewParts: [],
attachmentCount: 0,
hookNotices: [],
});
});
it("returns attachment label for whitespace text + file", () => {
it("returns no preview for whitespace text + file", () => {
const result = getQueuedMessageInfo(
buildMessage([
{ type: "text", text: " " },
@@ -111,13 +132,13 @@ describe("getQueuedMessageInfo", () => {
]),
);
expect(result).toEqual({
displayText: "[Queued message]",
previewParts: [],
attachmentCount: 1,
hookNotices: [],
});
});
it("joins multiple text parts with a space", () => {
it("keeps each text part", () => {
const result = getQueuedMessageInfo(
buildMessage([
{ type: "text", text: "a" },
@@ -125,7 +146,10 @@ describe("getQueuedMessageInfo", () => {
]),
);
expect(result).toEqual({
displayText: "a b",
previewParts: [
{ type: "text", text: "a" },
{ type: "text", text: "b" },
],
attachmentCount: 0,
hookNotices: [],
});
@@ -140,7 +164,7 @@ describe("getQueuedMessageInfo", () => {
]),
);
expect(result).toEqual({
displayText: "check this",
previewParts: [{ type: "text", text: "check this" }],
attachmentCount: 2,
hookNotices: [],
});
@@ -6,8 +6,12 @@ import {
PaperclipIcon,
Trash2Icon,
} from "lucide-react";
import { useEffect, useState } from "react";
import type { ChatQueuedMessage } from "#/api/typesGenerated";
import { Fragment, useEffect, useState } from "react";
import type {
ChatQueuedMessage,
ChatSkillInvocationPart,
ChatTextPart,
} from "#/api/typesGenerated";
import { Button } from "#/components/Button/Button";
import { Spinner } from "#/components/Spinner/Spinner";
import {
@@ -15,6 +19,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { SkillChip } from "./ChatMessageInput/SkillChip";
type QueuedMessagesListProps = {
messages: readonly ChatQueuedMessage[];
@@ -23,8 +28,10 @@ type QueuedMessagesListProps = {
className?: string;
};
type QueuedMessagePreviewPart = ChatTextPart | ChatSkillInvocationPart;
type QueuedMessageInfo = {
displayText: string;
previewParts: QueuedMessagePreviewPart[];
attachmentCount: number;
hookNotices: string[];
};
@@ -33,24 +40,50 @@ export const getQueuedMessageInfo = (
message: ChatQueuedMessage,
): QueuedMessageInfo => {
let attachmentCount = 0;
const textParts: string[] = [];
const previewParts: QueuedMessagePreviewPart[] = [];
const hookNotices: string[] = [];
for (const part of message.content) {
if (part.type === "file" || part.type === "workspace-file-reference") {
attachmentCount++;
} else if (part.type === "text" && part.text?.trim()) {
textParts.push(part.text);
previewParts.push({ type: "text", text: part.text.trim() });
} else if (part.type === "skill-invocation") {
previewParts.push(part);
} else if (part.type === "hook-notice" && part.text?.trim()) {
hookNotices.push(part.text);
}
}
const rawText = textParts.join(" ").trim();
return {
displayText: rawText || "[Queued message]",
attachmentCount,
hookNotices,
};
return { previewParts, attachmentCount, hookNotices };
};
// Shows the message's first line, with skills as chips in document order.
const QueuedMessagePreview: React.FC<{
parts: readonly QueuedMessagePreviewPart[];
}> = ({ parts }) => {
if (parts.length === 0) {
return "[Queued message]";
}
const lineBreakIndex = parts.findIndex(
(part) => part.type === "text" && part.text.includes("\n"),
);
const firstLineParts =
lineBreakIndex === -1 ? parts : parts.slice(0, lineBreakIndex + 1);
return (
<>
{firstLineParts.map((part, index) => (
<Fragment key={index}>
{index > 0 && " "}
{part.type === "skill-invocation" ? (
<SkillChip source={part.skill_source} name={part.skill_name} />
) : (
part.text.split("\n")[0]
)}
</Fragment>
))}
{lineBreakIndex !== -1 && "…"}
</>
);
};
export const QueuedMessagesList: React.FC<QueuedMessagesListProps> = ({
@@ -59,11 +92,10 @@ export const QueuedMessagesList: React.FC<QueuedMessagesListProps> = ({
onPromote,
className,
}) => {
const items = messages.map((message) => {
const { displayText, attachmentCount, hookNotices } =
getQueuedMessageInfo(message);
return { id: message.id, displayText, attachmentCount, hookNotices };
});
const items = messages.map((message) => ({
id: message.id,
...getQueuedMessageInfo(message),
}));
const [hoveredID, setHoveredID] = useState<number | null>(null);
// Tracks which item has an async action in flight and what kind.
@@ -174,8 +206,7 @@ export const QueuedMessagesList: React.FC<QueuedMessagesListProps> = ({
>
<div className="flex items-center gap-2 rounded-lg border border-solid border-border bg-surface-secondary px-3 py-2 font-sans text-sm leading-relaxed text-content-primary shadow-xs">
<span className="min-w-0 flex-1 truncate">
{item.displayText.split("\n")[0]}
{item.displayText.includes("\n") ? "…" : ""}
<QueuedMessagePreview parts={item.previewParts} />
</span>
{item.attachmentCount > 0 && (
<span
@@ -34,6 +34,7 @@ const createMockChatInputHandle = (initialValue = ""): MockChatInputHandle => {
focus,
getValue,
addFileReference: vi.fn(),
insertSkill: vi.fn(),
getContentParts: vi.fn(() => []),
},
setValue,
@@ -174,6 +175,18 @@ describe("useConversationEditingState", () => {
unmount();
});
it("seeds the composer with the edited message's editor state", () => {
const { result, unmount } = renderEditing();
const editorState = JSON.stringify({ root: { type: "root" } });
act(() => {
result.current.handleEditUserMessage(7, "", undefined, editorState);
});
expect(result.current.initialEditorState).toBe(editorState);
unmount();
});
it("does not force focus when replacing input values on mobile", () => {
setMobileViewport(true);
const { result, unmount } = renderEditing();
@@ -396,6 +409,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;
@@ -64,6 +64,7 @@ export function useConversationEditingState(deps: {
messageId: number,
text: string,
fileBlocks?: readonly ChatMessagePart[],
editorState?: string,
) => {
if (editingMessageId === null) {
// Read the current serialized editor state from localStorage
@@ -80,9 +81,9 @@ export function useConversationEditingState(deps: {
setEditingMessageId(messageId);
setDraftState({
editorInitialValue: text,
initialEditorState: undefined,
initialEditorState: editorState,
});
serializedEditorStateRef.current = undefined;
serializedEditorStateRef.current = editorState;
setRemountKey((k) => k + 1);
inputValueRef.current = text;
setEditingFileBlocks(fileBlocks ?? []);
@@ -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 ||