feat(site/src/pages/AgentsPage): add action and automatic-use switches to project skills

This commit is contained in:
Jaayden Halko
2026-10-15 22:09:17 +00:00
parent 21d290386d
commit a514af37ca
5 changed files with 176 additions and 28 deletions
@@ -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,
+17 -1
View File
@@ -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,
},
};
};