mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
Merge branch 'chat-project-skills/skill-chips-transcript' into chat-project-skills/settings-api
This commit is contained in:
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
+36
@@ -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 ||
|
||||
|
||||
Reference in New Issue
Block a user