mirror of
https://github.com/coder/coder.git
synced 2026-10-16 06:37:58 +08:00
feat(site/src/pages/AgentsPage): add action and automatic-use switches to project skills
This commit is contained in:
@@ -14,12 +14,20 @@ import { ProjectSkillsDialog } from "./ProjectSkillsDialog";
|
||||
const hoursAgo = (hours: number) =>
|
||||
new Date(Date.now() - hours * 60 * 60 * 1000).toISOString();
|
||||
|
||||
const mockSkills: SkillMetadata[] = [...MockSkills, MockDisabledSkill].map(
|
||||
(skill, index) => ({
|
||||
...skill,
|
||||
updated_at: hoursAgo(index * 5 + 2),
|
||||
}),
|
||||
);
|
||||
const mockActionSkill: SkillMetadata = {
|
||||
...MockSkills[0],
|
||||
action: true,
|
||||
model_invocable: false,
|
||||
};
|
||||
|
||||
const mockSkills: SkillMetadata[] = [
|
||||
mockActionSkill,
|
||||
...MockSkills.slice(1),
|
||||
MockDisabledSkill,
|
||||
].map((skill, index) => ({
|
||||
...skill,
|
||||
updated_at: hoursAgo(index * 5 + 2),
|
||||
}));
|
||||
|
||||
const mockReviewerSkill: Skill = {
|
||||
...mockSkills[0],
|
||||
|
||||
@@ -69,11 +69,18 @@ describe("ProjectSkillsDialog", () => {
|
||||
screen.getByRole("textbox", { name: "Body" }),
|
||||
"Plan the work.",
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("switch", {
|
||||
name: "Show as action on the project page",
|
||||
}),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(createSkill).toHaveBeenCalledWith(orgId, projectId, {
|
||||
content: "---\nname: planner\n---\nPlan the work.\n",
|
||||
action: true,
|
||||
model_invocable: true,
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(getSkills).toHaveBeenCalledTimes(2));
|
||||
@@ -123,7 +130,7 @@ describe("ProjectSkillsDialog", () => {
|
||||
await waitFor(() => expect(createSkill).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it("fills a new skill from pasted SKILL.md content", async () => {
|
||||
it("fills a new skill and its automatic use from pasted SKILL.md content", async () => {
|
||||
const createSkill = vi
|
||||
.spyOn(API.experimental, "createChatProjectSkill")
|
||||
.mockResolvedValue({ ...mockReviewerSkill, name: "planner" });
|
||||
@@ -134,7 +141,7 @@ describe("ProjectSkillsDialog", () => {
|
||||
screen.getByRole("textbox", { name: "Import from SKILL.md" }),
|
||||
);
|
||||
await user.paste(
|
||||
"---\nname: planner\ndescription: Plans work.\n---\nPlan the work.",
|
||||
"---\nname: planner\ndescription: Plans work.\ndisable-model-invocation: true\n---\nPlan the work.",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
@@ -142,6 +149,8 @@ describe("ProjectSkillsDialog", () => {
|
||||
expect(createSkill).toHaveBeenCalledWith(orgId, projectId, {
|
||||
content:
|
||||
'---\nname: planner\ndescription: "Plans work."\n---\nPlan the work.\n',
|
||||
action: false,
|
||||
model_invocable: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -163,6 +172,33 @@ describe("ProjectSkillsDialog", () => {
|
||||
expect(updateSkill).toHaveBeenCalledWith(orgId, projectId, "reviewer", {
|
||||
content:
|
||||
'---\nname: reviewer\ndescription: "Review changed files and suggest fixes."\n---\nReview carefully.\n',
|
||||
action: false,
|
||||
model_invocable: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("saves a change to only a skill's automatic use", async () => {
|
||||
const updateSkill = vi
|
||||
.spyOn(API.experimental, "updateChatProjectSkill")
|
||||
.mockResolvedValue(mockReviewerSkill);
|
||||
const { user } = renderDialog();
|
||||
|
||||
await openSkillMenu(user);
|
||||
await user.click(screen.getByRole("menuitem", { name: "Edit" }));
|
||||
await user.click(
|
||||
await screen.findByRole("switch", {
|
||||
name: "Agents can use it automatically",
|
||||
}),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(updateSkill).toHaveBeenCalledWith(orgId, projectId, "reviewer", {
|
||||
content:
|
||||
'---\nname: reviewer\ndescription: "Review changed files and suggest fixes."\n---\nReview the diff.\n',
|
||||
action: false,
|
||||
model_invocable: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -202,9 +238,14 @@ describe("ProjectSkillsDialog", () => {
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(updateSkill).toHaveBeenCalledWith(orgId, projectId, "reviewer", {
|
||||
content: expect.stringContaining("Review carefully."),
|
||||
}),
|
||||
expect(updateSkill).toHaveBeenCalledWith(
|
||||
orgId,
|
||||
projectId,
|
||||
"reviewer",
|
||||
expect.objectContaining({
|
||||
content: expect.stringContaining("Review carefully."),
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -253,10 +294,14 @@ describe("ProjectSkillsDialog", () => {
|
||||
|
||||
expect(onOpenChange).not.toHaveBeenCalled();
|
||||
await waitFor(() =>
|
||||
expect(updateSkill).toHaveBeenCalledWith(orgId, projectId, "reviewer", {
|
||||
content:
|
||||
'---\nname: reviewer\ndescription: "Review changed files and suggest fixes."\n---\nReview carefully.\n',
|
||||
}),
|
||||
expect(updateSkill).toHaveBeenCalledWith(
|
||||
orgId,
|
||||
projectId,
|
||||
"reviewer",
|
||||
expect.objectContaining({
|
||||
content: expect.stringContaining("Review carefully."),
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||
import { getErrorDetail, getErrorMessage } from "#/api/errors";
|
||||
import type { ChatProject, SkillMetadata } from "#/api/typesGenerated";
|
||||
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
|
||||
import { Badge } from "#/components/Badge/Badge";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import { ConfirmDialog } from "#/components/Dialog/ConfirmDialog/ConfirmDialog";
|
||||
import {
|
||||
@@ -35,7 +36,7 @@ import {
|
||||
import { toast } from "#/components/Toaster/toast";
|
||||
import { DATE_FORMAT, formatDateTime, relativeTime } from "#/utils/time";
|
||||
import {
|
||||
emptySkillFormValues,
|
||||
emptyProjectSkillFormValues,
|
||||
projectSkillsScope,
|
||||
SKILLS_MAX_PER_OWNER,
|
||||
type SkillFormValues,
|
||||
@@ -43,7 +44,11 @@ import {
|
||||
skillFormValuesFromSkill,
|
||||
} from "../../utils/skills";
|
||||
import { RetryButton } from "../RetryButton";
|
||||
import { SkillEditorForm, type SkillErrorDisplay } from "../SkillEditor";
|
||||
import {
|
||||
SkillEditorForm,
|
||||
type SkillErrorDisplay,
|
||||
type SkillSubmitHandler,
|
||||
} from "../SkillEditor";
|
||||
import { SkillEnabledSwitch } from "../SkillEnabledSwitch";
|
||||
import { downloadSkillFile, skillError } from "../SkillsTable";
|
||||
import { DeleteSkillDialog } from "../SkillsTableView";
|
||||
@@ -173,7 +178,12 @@ export const ProjectSkillsDialog: React.FC<ProjectSkillsDialogProps> = ({
|
||||
isSubmitting={isSubmitting}
|
||||
backButtonRef={backButtonRef}
|
||||
onBack={closeEditor}
|
||||
onSubmit={(content, onSettled) => {
|
||||
onSubmit={(values, content, onSettled) => {
|
||||
const req = {
|
||||
content,
|
||||
action: values.action,
|
||||
model_invocable: values.modelInvocable,
|
||||
};
|
||||
const onSuccess = () => {
|
||||
closeEditor();
|
||||
toast.success(
|
||||
@@ -181,10 +191,10 @@ export const ProjectSkillsDialog: React.FC<ProjectSkillsDialogProps> = ({
|
||||
);
|
||||
};
|
||||
if (editor.type === "create") {
|
||||
createMutation.mutate({ content }, { onSuccess, onSettled });
|
||||
createMutation.mutate(req, { onSuccess, onSettled });
|
||||
} else {
|
||||
updateMutation.mutate(
|
||||
{ name: editor.name, req: { content } },
|
||||
{ name: editor.name, req },
|
||||
{ onSuccess, onSettled },
|
||||
);
|
||||
}
|
||||
@@ -334,7 +344,7 @@ type ProjectSkillEditorViewProps = {
|
||||
readonly isSubmitting: boolean;
|
||||
readonly backButtonRef: React.RefObject<HTMLButtonElement | null>;
|
||||
readonly onBack: () => void;
|
||||
readonly onSubmit: (content: string, onSettled: () => void) => void;
|
||||
readonly onSubmit: SkillSubmitHandler;
|
||||
};
|
||||
|
||||
const ProjectSkillEditorView: React.FC<ProjectSkillEditorViewProps> = ({
|
||||
@@ -360,7 +370,7 @@ const ProjectSkillEditorView: React.FC<ProjectSkillEditorViewProps> = ({
|
||||
skillQuery.error.response?.status === 404;
|
||||
let loaded: ReturnType<typeof skillFormValuesFromSkill> | undefined;
|
||||
if (view.type === "create") {
|
||||
loaded = { ok: true, values: emptySkillFormValues };
|
||||
loaded = { ok: true, values: emptyProjectSkillFormValues };
|
||||
} else if (skillQuery.data) {
|
||||
loaded = skillFormValuesFromSkill(skillQuery.data);
|
||||
}
|
||||
@@ -430,6 +440,7 @@ const ProjectSkillEditorView: React.FC<ProjectSkillEditorViewProps> = ({
|
||||
isSubmitting={isSubmitting}
|
||||
nameHint="Lowercase letters, numbers, and hyphens."
|
||||
bodyPlaceholder="Describe when and how the agent should use this skill."
|
||||
showProjectSettings
|
||||
header={({ isDirty }) => (
|
||||
<ProjectSkillEditorHeader
|
||||
title={title}
|
||||
@@ -448,7 +459,7 @@ const ProjectSkillEditorView: React.FC<ProjectSkillEditorViewProps> = ({
|
||||
</DialogFooter>
|
||||
)
|
||||
}
|
||||
onSubmit={(_values, content, onSettled) => onSubmit(content, onSettled)}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={isDiscardOpen}
|
||||
@@ -530,8 +541,12 @@ const ProjectSkillRow: React.FC<ProjectSkillRowProps> = ({
|
||||
}) => (
|
||||
<TableRow>
|
||||
<TableCell className="w-full max-w-0">
|
||||
<div className="truncate font-medium text-content-primary">
|
||||
{skill.name}
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="truncate font-medium text-content-primary">
|
||||
{skill.name}
|
||||
</span>
|
||||
{skill.action && <Badge size="xs">Action</Badge>}
|
||||
{!skill.model_invocable && <Badge size="xs">Manual only</Badge>}
|
||||
</div>
|
||||
<div
|
||||
className="truncate text-xs text-content-secondary"
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { Input } from "#/components/Input/Input";
|
||||
import { Label } from "#/components/Label/Label";
|
||||
import { Spinner } from "#/components/Spinner/Spinner";
|
||||
import { Switch } from "#/components/Switch/Switch";
|
||||
import { formatKiB } from "#/utils/fileSize";
|
||||
import { readAgentAttachmentText } from "../utils/fileAttachmentLimits";
|
||||
import {
|
||||
@@ -59,6 +60,8 @@ type SkillEditorFormProps = {
|
||||
isSubmitting: boolean;
|
||||
nameHint?: string;
|
||||
bodyPlaceholder?: string;
|
||||
/** Shows the action and automatic-use switches of a project skill. */
|
||||
showProjectSettings?: boolean;
|
||||
/** Rendered inside the form, above the scrollable fields. */
|
||||
header: (state: SkillEditorFormState) => React.ReactNode;
|
||||
/** Rendered inside the form, below the fields, so it can hold Submit. */
|
||||
@@ -68,7 +71,7 @@ type SkillEditorFormProps = {
|
||||
|
||||
type SkillEditorProps = Omit<
|
||||
SkillEditorFormProps,
|
||||
"header" | "footer" | "nameHint" | "bodyPlaceholder"
|
||||
"header" | "footer" | "nameHint" | "bodyPlaceholder" | "showProjectSettings"
|
||||
> & {
|
||||
open: boolean;
|
||||
noun: string;
|
||||
@@ -99,6 +102,7 @@ export const SkillEditorForm: React.FC<SkillEditorFormProps> = ({
|
||||
isSubmitting,
|
||||
nameHint = "Use lowercase letters, numbers, and hyphens. Names cannot be changed after creation.",
|
||||
bodyPlaceholder = "Describe when and how agents should use this skill.",
|
||||
showProjectSettings = false,
|
||||
header,
|
||||
footer,
|
||||
onSubmit,
|
||||
@@ -161,6 +165,7 @@ export const SkillEditorForm: React.FC<SkillEditorFormProps> = ({
|
||||
// before the caller's isSubmitting turns on.
|
||||
onSubmit: (values, { setSubmitting }) => {
|
||||
const normalizedValues = {
|
||||
...values,
|
||||
name: values.name.trim(),
|
||||
description: values.description.trim(),
|
||||
body: values.body.trim(),
|
||||
@@ -193,7 +198,7 @@ export const SkillEditorForm: React.FC<SkillEditorFormProps> = ({
|
||||
}
|
||||
|
||||
if (isCreate) {
|
||||
await form.setValues(result.values);
|
||||
await form.setValues({ ...form.values, ...result.values });
|
||||
await form.setTouched(
|
||||
{ name: true, description: true, body: true },
|
||||
false,
|
||||
@@ -483,6 +488,29 @@ export const SkillEditorForm: React.FC<SkillEditorFormProps> = ({
|
||||
{`${formatKiB(sizeBytes)} of ${formatKiB(SKILL_MAX_SIZE_BYTES)} used.`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{showProjectSettings && (
|
||||
<>
|
||||
<SkillSettingSwitch
|
||||
label="Show as action on the project page"
|
||||
description="Adds a button above the composer in this project's chats that inserts the skill."
|
||||
checked={form.values.action ?? false}
|
||||
disabled={readOnly || isBusy}
|
||||
onCheckedChange={(checked) =>
|
||||
void form.setFieldValue("action", checked)
|
||||
}
|
||||
/>
|
||||
<SkillSettingSwitch
|
||||
label="Agents can use it automatically"
|
||||
description="When off, agents use the skill only when someone invokes it."
|
||||
checked={form.values.modelInvocable ?? true}
|
||||
disabled={readOnly || isBusy}
|
||||
onCheckedChange={(checked) =>
|
||||
void form.setFieldValue("modelInvocable", checked)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{footer(formState)}
|
||||
@@ -490,6 +518,42 @@ export const SkillEditorForm: React.FC<SkillEditorFormProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
type SkillSettingSwitchProps = {
|
||||
label: string;
|
||||
description: string;
|
||||
checked: boolean;
|
||||
disabled: boolean;
|
||||
onCheckedChange: (checked: boolean) => void;
|
||||
};
|
||||
|
||||
const SkillSettingSwitch: React.FC<SkillSettingSwitchProps> = ({
|
||||
label,
|
||||
description,
|
||||
checked,
|
||||
disabled,
|
||||
onCheckedChange,
|
||||
}) => {
|
||||
const switchId = useId();
|
||||
const descriptionId = useId();
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label htmlFor={switchId}>{label}</Label>
|
||||
<p id={descriptionId} className="m-0 text-xs text-content-secondary">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id={switchId}
|
||||
aria-describedby={descriptionId}
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onCheckedChange={onCheckedChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** Creates, edits, or views a skill in a dialog. */
|
||||
export const SkillEditor: React.FC<SkillEditorProps> = ({
|
||||
open,
|
||||
|
||||
@@ -40,6 +40,10 @@ export type SkillFormValues = {
|
||||
name: string;
|
||||
description: string;
|
||||
body: string;
|
||||
/** Project skills only: offers the skill above project composers. */
|
||||
action?: boolean;
|
||||
/** Project skills only: lets agents find and load the skill. */
|
||||
modelInvocable?: boolean;
|
||||
};
|
||||
|
||||
/** The fields of a skill that has not been written yet. */
|
||||
@@ -49,6 +53,13 @@ export const emptySkillFormValues: SkillFormValues = {
|
||||
body: "",
|
||||
};
|
||||
|
||||
/** The fields and API defaults of a project skill not written yet. */
|
||||
export const emptyProjectSkillFormValues: SkillFormValues = {
|
||||
...emptySkillFormValues,
|
||||
action: false,
|
||||
modelInvocable: true,
|
||||
};
|
||||
|
||||
type SkillSearchMetadata = {
|
||||
name: string;
|
||||
description: string;
|
||||
@@ -199,7 +210,10 @@ export const parseSkillMarkdown = (content: string): SkillFormValues => {
|
||||
throw new SkillMarkdownError("Skill body is required.");
|
||||
}
|
||||
|
||||
return { name, description, body };
|
||||
// Claude Code marks skills that only users may invoke this way.
|
||||
return parsed.attributes["disable-model-invocation"] === true
|
||||
? { name, description, body, modelInvocable: false }
|
||||
: { name, description, body };
|
||||
};
|
||||
|
||||
export const tryParseSkillMarkdown = (
|
||||
@@ -233,6 +247,8 @@ export const skillFormValuesFromSkill = (
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
body: parsed.values.body,
|
||||
action: skill.action,
|
||||
modelInvocable: skill.model_invocable,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user