feat: add personal skills settings UI and docs (#25066)

> Mux updated this PR on behalf of Mike.

## Summary

- Add experimental personal skills API helpers and an Agents settings UI
for listing, creating, editing, deleting, and importing SKILL.md
content.
- Add docs, Storybook coverage, and unit tests for backend-compatible
SKILL.md parsing.
- Address review feedback by simplifying frontmatter scalar parsing,
clarifying the UI parser scope, defaulting personal skill queries to
`me`, and patching React Query caches after create, update, and delete.
- Merge latest `main` and resolve the Agents sidebar refactor conflicts.

## Validation

- pre-commit hook
- `go test ./codersdk/workspacesdk -run TestParseSkillFrontmatter
-count=1`
- `go test ./coderd/x/chatd/chattool -run 'Test' -count=1`
- `cd site && pnpm test --
src/pages/AgentsPage/utils/personalSkills.test.ts
src/api/queries/userSkills.test.ts src/utils/fileSize.test.ts
--runInBand`
- `cd site && pnpm lint:types`
- `cd site && pnpm lint:check`
This commit is contained in:
Michael Suchacz
2026-05-22 00:20:10 +02:00
committed by GitHub
parent 35a624bebd
commit 356bccddc2
18 changed files with 2138 additions and 14 deletions
+2 -3
View File
@@ -266,9 +266,8 @@ const DefaultSkillMetaFile = "SKILL.md"
// ReadSkillOptions configures the read_skill and read_skill_file // ReadSkillOptions configures the read_skill and read_skill_file
// tools. // tools.
type ReadSkillOptions struct { type ReadSkillOptions struct {
GetWorkspaceConn func(context.Context) (workspacesdk.AgentConn, error) GetWorkspaceConn func(context.Context) (workspacesdk.AgentConn, error)
GetSkills func() []SkillMeta GetSkills func() []SkillMeta
ResolveAlias func(string) (skillspkg.ResolvedSkill, error) ResolveAlias func(string) (skillspkg.ResolvedSkill, error)
LoadPersonalSkillBody func(context.Context, string) (skillspkg.ParsedSkill, error) LoadPersonalSkillBody func(context.Context, string) (skillspkg.ParsedSkill, error)
} }
+24 -7
View File
@@ -24,6 +24,29 @@ var markdownCommentRe = regexp.MustCompile(`<!--[\s\S]*?-->`)
// the frontmatter is missing a required name field. // the frontmatter is missing a required name field.
var ErrFrontmatterNameRequired = xerrors.New("frontmatter missing required 'name' field") var ErrFrontmatterNameRequired = xerrors.New("frontmatter missing required 'name' field")
func unquoteFrontmatterScalar(value string) string {
if len(value) < 2 {
return value
}
quote := value[0]
if quote != value[len(value)-1] {
return value
}
inner := value[1 : len(value)-1]
switch quote {
case '"':
// This parser supports a small SKILL.md scalar subset, not full
// YAML. Double quotes only unescape quoted text and Windows paths.
return strings.NewReplacer(`\"`, `"`, `\\`, `\`).Replace(inner)
case '\'':
return inner
default:
return value
}
}
// ParseSkillFrontmatter extracts name, description, and the // ParseSkillFrontmatter extracts name, description, and the
// remaining body from a skill meta file. The expected format is // remaining body from a skill meta file. The expected format is
// YAML-ish frontmatter delimited by "---" lines: // YAML-ish frontmatter delimited by "---" lines:
@@ -62,13 +85,7 @@ func ParseSkillFrontmatter(content string) (name, description, body string, err
} }
key = strings.TrimSpace(key) key = strings.TrimSpace(key)
value = strings.TrimSpace(value) value = strings.TrimSpace(value)
// Strip surrounding quotes from YAML string values. value = unquoteFrontmatterScalar(value)
if len(value) >= 2 {
if (value[0] == '"' && value[len(value)-1] == '"') ||
(value[0] == '\'' && value[len(value)-1] == '\'') {
value = value[1 : len(value)-1]
}
}
switch strings.ToLower(key) { switch strings.ToLower(key) {
case "name": case "name":
name = value name = value
+18
View File
@@ -32,6 +32,24 @@ func TestParseSkillFrontmatter(t *testing.T) {
require.Equal(t, "single-quoted", desc) require.Equal(t, "single-quoted", desc)
}) })
t.Run("EscapedDoubleQuotedValue", func(t *testing.T) {
t.Parallel()
_, desc, _, err := workspacesdk.ParseSkillFrontmatter(
"---\nname: escaped\ndescription: \"Review \\\"critical\\\" C:\\\\paths.\"\n---\nBody\n",
)
require.NoError(t, err)
require.Equal(t, "Review \"critical\" C:\\paths.", desc)
})
t.Run("PlainHashValue", func(t *testing.T) {
t.Parallel()
_, desc, _, err := workspacesdk.ParseSkillFrontmatter(
"---\nname: plain-hash\ndescription: Build # test\n---\nBody\n",
)
require.NoError(t, err)
require.Equal(t, "Build # test", desc)
})
t.Run("NoDescription", func(t *testing.T) { t.Run("NoDescription", func(t *testing.T) {
t.Parallel() t.Parallel()
name, desc, body, err := workspacesdk.ParseSkillFrontmatter( name, desc, body, err := workspacesdk.ParseSkillFrontmatter(
+35 -4
View File
@@ -20,7 +20,7 @@ any supporting files the skill needs.
On the first turn of a workspace-attached chat, the agent scans On the first turn of a workspace-attached chat, the agent scans
`.agents/skills/` and builds an `<available-skills>` block in its system `.agents/skills/` and builds an `<available-skills>` block in its system
prompt listing each skill's name and description. Only frontmatter is read prompt listing each skill's name and description. Only frontmatter is read
during discovery — the full skill content is loaded lazily when the agent during discovery. The full skill content is loaded lazily when the agent
calls a tool. calls a tool.
Two tools are registered when skills are present: Two tools are registered when skills are present:
@@ -75,6 +75,37 @@ Instructions for the skill go here...
references to hidden files. All paths are resolved relative to the skill references to hidden files. All paths are resolved relative to the skill
directory. directory.
## Personal skills
Personal skills are user-owned skills that are available to all of your
chats. They are not tied to a specific workspace. Manage them from the
**Agents** page, under **Settings** > **Personal Skills**.
Personal skills use the same `SKILL.md` format as workspace skills: YAML
frontmatter with a kebab-case `name`, an optional `description`, and a
markdown body. This keeps content portable between personal skills and
workspace skills.
```markdown
---
name: personal-reviewer
description: "Personal review guidance"
---
# Personal Reviewer
Instructions for the skill go here...
```
Each personal skill is stored as a single `SKILL.md` file containing
frontmatter and body content. Supporting files are not supported. Each
`SKILL.md` file can be up to 64 KB, and each user can create up to 100
personal skills.
If you need richer skills with supporting files or multiple files, use
workspace skills instead. Store them in the repo under
`.agents/skills/<name>/`, or load them from a workspace.
## Workspace MCP tools ## Workspace MCP tools
Workspace templates can expose custom Workspace templates can expose custom
@@ -106,17 +137,17 @@ whether `command` or `url` is present, or you can set it explicitly with
} }
``` ```
**Stdio transport** set `command`, and optionally `args` and `env`. The **Stdio transport**: set `command`, and optionally `args` and `env`. The
agent spawns the process in the workspace. agent spawns the process in the workspace.
**HTTP transport** set `url`, and optionally `headers`. The agent connects **HTTP transport**: set `url`, and optionally `headers`. The agent connects
to the HTTP endpoint from the workspace. to the HTTP endpoint from the workspace.
### How discovery works ### How discovery works
The agent reads `.mcp.json` via the workspace agent connection on each chat The agent reads `.mcp.json` via the workspace agent connection on each chat
turn. Discovery uses a 5-second timeout. Servers that fail to turn. Discovery uses a 5-second timeout. Servers that fail to
respond are skipped — partial success is acceptable. Empty results are not respond are skipped. Partial success is acceptable. Empty results are not
cached because the MCP servers may still be starting. cached because the MCP servers may still be starting.
### Tool naming ### Tool naming
+50
View File
@@ -411,6 +411,10 @@ const chatProviderConfigsPath = "/api/experimental/chats/providers";
const chatModelConfigsPath = "/api/experimental/chats/model-configs"; const chatModelConfigsPath = "/api/experimental/chats/model-configs";
const userChatProviderConfigsPath = const userChatProviderConfigsPath =
"/api/experimental/chats/user-provider-configs"; "/api/experimental/chats/user-provider-configs";
const userSkillsPath = (user: string) =>
`/api/experimental/users/${encodeURIComponent(user)}/skills`;
const userSkillPath = (user: string, name: string) =>
`${userSkillsPath(user)}/${encodeURIComponent(name)}`;
const mcpServerConfigsPath = "/api/experimental/mcp/servers"; const mcpServerConfigsPath = "/api/experimental/mcp/servers";
type ChatCostDateParams = { type ChatCostDateParams = {
@@ -3592,6 +3596,52 @@ class ExperimentalApiMethods {
return response.data; return response.data;
}; };
createUserSkill = async (
user: string,
req: TypesGen.CreateUserSkillRequest,
): Promise<TypesGen.UserSkill> => {
const response = await this.axios.post<TypesGen.UserSkill>(
userSkillsPath(user),
req,
);
return response.data;
};
getUserSkills = async (
user: string,
): Promise<TypesGen.UserSkillMetadata[]> => {
const response = await this.axios.get<TypesGen.UserSkillMetadata[]>(
userSkillsPath(user),
);
return response.data;
};
getUserSkillByName = async (
user: string,
name: string,
): Promise<TypesGen.UserSkill> => {
const response = await this.axios.get<TypesGen.UserSkill>(
userSkillPath(user, name),
);
return response.data;
};
updateUserSkill = async (
user: string,
name: string,
req: TypesGen.UpdateUserSkillRequest,
): Promise<TypesGen.UserSkill> => {
const response = await this.axios.patch<TypesGen.UserSkill>(
userSkillPath(user, name),
req,
);
return response.data;
};
deleteUserSkill = async (user: string, name: string): Promise<void> => {
await this.axios.delete(userSkillPath(user, name));
};
getUserChatCompactionThresholds = getUserChatCompactionThresholds =
async (): Promise<TypesGen.UserChatCompactionThresholds> => { async (): Promise<TypesGen.UserChatCompactionThresholds> => {
const response = const response =
+123
View File
@@ -0,0 +1,123 @@
import { QueryClient } from "react-query";
import { describe, expect, it } from "vitest";
import type { UserSkill, UserSkillMetadata } from "#/api/typesGenerated";
import {
createUserSkill,
deleteUserSkill,
updateUserSkill,
userSkill,
userSkills,
} from "./userSkills";
const createTestQueryClient = (): QueryClient =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: Number.POSITIVE_INFINITY,
refetchOnWindowFocus: false,
networkMode: "offlineFirst",
},
},
});
const makeSkill = (
name: string,
overrides: Partial<UserSkill> = {},
): UserSkill => ({
id: `${name}-id`,
name,
description: `${name} description`,
content: `---\nname: ${name}\n---\nBody\n`,
created_at: "2026-05-21T00:00:00Z",
updated_at: "2026-05-21T00:00:00Z",
...overrides,
});
const toMetadata = (skill: UserSkill): UserSkillMetadata => ({
id: skill.id,
name: skill.name,
description: skill.description,
created_at: skill.created_at,
updated_at: skill.updated_at,
});
describe("user skill queries", () => {
it("defaults query keys to the current user alias", () => {
expect(userSkills().queryKey).toEqual(["user-skills", "me"]);
expect(userSkill("alpha").queryKey).toEqual(["user-skills", "me", "alpha"]);
expect(userSkills("user-id").queryKey).toEqual(["user-skills", "user-id"]);
expect(userSkill("alpha", "user-id").queryKey).toEqual([
"user-skills",
"user-id",
"alpha",
]);
});
it("adds a created skill to the sorted list cache", () => {
const queryClient = createTestQueryClient();
const alpha = makeSkill("alpha");
const zeta = makeSkill("zeta");
queryClient.setQueryData(userSkills().queryKey, [toMetadata(zeta)]);
createUserSkill(queryClient).onSuccess(alpha);
expect(queryClient.getQueryData(userSkills().queryKey)).toEqual([
toMetadata(alpha),
toMetadata(zeta),
]);
expect(queryClient.getQueryData(userSkill("alpha").queryKey)).toEqual(
alpha,
);
});
it("updates list and detail caches for an updated skill", () => {
const queryClient = createTestQueryClient();
const alpha = makeSkill("alpha");
const beta = makeSkill("beta");
const updatedAlpha = makeSkill("alpha", {
description: "updated description",
content:
"---\nname: alpha\ndescription: updated description\n---\nUpdated\n",
updated_at: "2026-05-21T01:00:00Z",
});
queryClient.setQueryData(userSkills().queryKey, [
toMetadata(alpha),
toMetadata(beta),
]);
queryClient.setQueryData(userSkill("alpha").queryKey, alpha);
updateUserSkill(queryClient).onSuccess(updatedAlpha, {
name: "alpha",
req: { content: updatedAlpha.content },
});
expect(queryClient.getQueryData(userSkills().queryKey)).toEqual([
toMetadata(updatedAlpha),
toMetadata(beta),
]);
expect(queryClient.getQueryData(userSkill("alpha").queryKey)).toEqual(
updatedAlpha,
);
});
it("removes a deleted skill from list and detail caches", () => {
const queryClient = createTestQueryClient();
const alpha = makeSkill("alpha");
const beta = makeSkill("beta");
queryClient.setQueryData(userSkills().queryKey, [
toMetadata(alpha),
toMetadata(beta),
]);
queryClient.setQueryData(userSkill("alpha").queryKey, alpha);
deleteUserSkill(queryClient).onSuccess(undefined, "alpha");
expect(queryClient.getQueryData(userSkills().queryKey)).toEqual([
toMetadata(beta),
]);
expect(
queryClient.getQueryData(userSkill("alpha").queryKey),
).toBeUndefined();
});
});
+89
View File
@@ -0,0 +1,89 @@
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;
const userSkillKey = (name: string, user = "me") =>
[...userSkillsKey(user), name] as const;
const toUserSkillMetadata = (
skill: TypesGen.UserSkill,
): TypesGen.UserSkillMetadata => ({
id: skill.id,
name: skill.name,
description: skill.description,
created_at: skill.created_at,
updated_at: skill.updated_at,
});
const sortUserSkillMetadata = (
skills: TypesGen.UserSkillMetadata[],
): TypesGen.UserSkillMetadata[] =>
skills.toSorted((a, b) => a.name.localeCompare(b.name, "en-US"));
const upsertUserSkillMetadata = (
skills: TypesGen.UserSkillMetadata[] | undefined,
skill: TypesGen.UserSkillMetadata,
): TypesGen.UserSkillMetadata[] => {
const withoutSkill = skills?.filter(({ name }) => name !== skill.name) ?? [];
return sortUserSkillMetadata([...withoutSkill, skill]);
};
export const userSkills = (user = "me") => ({
queryKey: userSkillsKey(user),
queryFn: (): Promise<TypesGen.UserSkillMetadata[]> =>
API.experimental.getUserSkills(user),
});
export const userSkill = (name: string, user = "me") => ({
queryKey: userSkillKey(name, user),
queryFn: (): Promise<TypesGen.UserSkill> =>
API.experimental.getUserSkillByName(user, name),
});
export const createUserSkill = (queryClient: QueryClient, user = "me") => ({
mutationFn: (req: TypesGen.CreateUserSkillRequest) =>
API.experimental.createUserSkill(user, req),
onSuccess: (skill: TypesGen.UserSkill) => {
queryClient.setQueryData<TypesGen.UserSkillMetadata[]>(
userSkillsKey(user),
(skills) => upsertUserSkillMetadata(skills, toUserSkillMetadata(skill)),
);
queryClient.setQueryData(userSkillKey(skill.name, user), skill);
},
});
type UpdateUserSkillArgs = {
name: string;
req: TypesGen.UpdateUserSkillRequest;
};
export const updateUserSkill = (queryClient: QueryClient, user = "me") => ({
mutationFn: ({ name, req }: UpdateUserSkillArgs) =>
API.experimental.updateUserSkill(user, name, req),
onSuccess: (skill: TypesGen.UserSkill, { name }: UpdateUserSkillArgs) => {
queryClient.setQueryData(userSkillKey(name, user), skill);
queryClient.setQueryData<TypesGen.UserSkillMetadata[]>(
userSkillsKey(user),
(skills) =>
skills
? upsertUserSkillMetadata(skills, toUserSkillMetadata(skill))
: skills,
);
},
});
export const deleteUserSkill = (queryClient: QueryClient, user = "me") => ({
mutationFn: (name: string) => API.experimental.deleteUserSkill(user, name),
onSuccess: (_data: unknown, name: string) => {
queryClient.removeQueries({
queryKey: userSkillKey(name, user),
exact: true,
});
queryClient.setQueryData<TypesGen.UserSkillMetadata[]>(
userSkillsKey(user),
(skills) => skills?.filter((skill) => skill.name !== name),
);
},
});
@@ -0,0 +1,283 @@
import { isAxiosError } from "axios";
import type { FC } from "react";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { toast } from "sonner";
import { getErrorDetail, getErrorMessage } from "#/api/errors";
import {
createUserSkill,
deleteUserSkill,
updateUserSkill,
userSkill,
userSkills,
} from "#/api/queries/userSkills";
import type { UserSkillMetadata } from "#/api/typesGenerated";
import {
AgentSettingsPersonalSkillsPageView,
type PersonalSkillDeleteState,
type PersonalSkillEditorState,
} from "./AgentSettingsPersonalSkillsPageView";
import type { PersonalSkillErrorDisplay } from "./components/PersonalSkillEditor";
import {
PERSONAL_SKILLS_MAX_PER_USER,
type PersonalSkillFormValues,
parsePersonalSkillMarkdown,
} from "./utils/personalSkills";
const emptySkillFormValues: PersonalSkillFormValues = {
name: "",
description: "",
body: "",
};
type DialogState =
| { type: "create"; submittedContent?: string }
| { type: "edit"; name: string; submittedContent?: string }
| { type: "delete"; skill: UserSkillMetadata; submittedName?: string }
| null;
const personalSkillError = (
error: unknown,
fallback: string,
): PersonalSkillErrorDisplay | undefined => {
if (!error) {
return undefined;
}
const status = isAxiosError(error) ? error.response?.status : undefined;
let statusFallback = fallback;
if (status === 400) {
statusFallback = "Skill content is invalid.";
} else if (status === 403) {
statusFallback = "You do not have permission to manage personal skills.";
} else if (status === 404) {
statusFallback = "That personal skill was not found.";
} else if (status === 409) {
statusFallback = "A skill with that name already exists.";
}
return {
message: getErrorMessage(error, statusFallback),
detail: getErrorDetail(error),
};
};
const AgentSettingsPersonalSkillsPage: FC = () => {
const queryClient = useQueryClient();
const [dialogState, setDialogState] = useState<DialogState>(null);
const skillsQuery = useQuery(userSkills());
const skills = skillsQuery.data ?? [];
const existingNames = skills.map((skill) =>
skill.name.toLocaleLowerCase("en-US"),
);
const editName = dialogState?.type === "edit" ? dialogState.name : "";
const editSkillQuery = useQuery({
...userSkill(editName),
enabled: Boolean(editName),
});
const createMutationOptions = createUserSkill(queryClient);
const createMutation = useMutation({
...createMutationOptions,
onSuccess: async (_skill, variables) => {
await createMutationOptions.onSuccess?.(_skill);
setDialogState((current) =>
current?.type === "create" &&
current.submittedContent === variables.content
? null
: current,
);
toast.success("Personal skill created.");
},
});
const updateMutationOptions = updateUserSkill(queryClient);
const updateMutation = useMutation({
...updateMutationOptions,
onSuccess: async (skill, variables) => {
await updateMutationOptions.onSuccess?.(skill, variables);
setDialogState((current) =>
current?.type === "edit" &&
current.name === variables.name &&
current.submittedContent === variables.req.content
? null
: current,
);
toast.success("Personal skill saved.");
},
onError: (error, variables) => {
if (isAxiosError(error) && error.response?.status === 404) {
toast.info("That skill was deleted while you were editing it.");
setDialogState((current) =>
current?.type === "edit" &&
current.name === variables.name &&
current.submittedContent === variables.req.content
? null
: current,
);
void skillsQuery.refetch();
}
},
});
const deleteMutationOptions = deleteUserSkill(queryClient);
const deleteMutation = useMutation({
...deleteMutationOptions,
onSuccess: async (data, variables) => {
await deleteMutationOptions.onSuccess?.(data, variables);
setDialogState((current) =>
current?.type === "delete" &&
current.skill.name === variables &&
current.submittedName === variables
? null
: current,
);
toast.success("Personal skill deleted.");
},
onError: (error, variables) => {
if (isAxiosError(error) && error.response?.status === 404) {
setDialogState((current) =>
current?.type === "delete" &&
current.skill.name === variables &&
current.submittedName === variables
? null
: current,
);
void skillsQuery.refetch();
}
},
});
let editInitialValues: PersonalSkillFormValues | undefined;
let editLoadError: unknown = editSkillQuery.error;
if (editSkillQuery.data) {
try {
const parsed = parsePersonalSkillMarkdown(editSkillQuery.data.content);
editInitialValues = {
name: editSkillQuery.data.name,
description: editSkillQuery.data.description,
body: parsed.body,
};
} catch (error) {
editLoadError = error;
}
}
let editorState: PersonalSkillEditorState | undefined;
if (dialogState?.type === "create") {
editorState = {
mode: "create",
initialValues: emptySkillFormValues,
existingNames,
submitError:
createMutation.variables?.content === dialogState.submittedContent
? personalSkillError(
createMutation.error,
"Failed to create personal skill.",
)
: undefined,
isSubmitting: createMutation.isPending,
onSubmit: (_values, content) => {
setDialogState((current) =>
current?.type === "create"
? { ...current, submittedContent: content }
: current,
);
createMutation.mutate({ content });
},
onClose: () => setDialogState(null),
};
} else if (dialogState?.type === "edit") {
editorState = {
mode: "edit",
initialValues: editInitialValues,
existingNames,
loadError: editLoadError,
isLoading: editSkillQuery.isLoading,
isRetrying: editSkillQuery.isFetching,
submitError:
updateMutation.variables?.name === dialogState.name &&
updateMutation.variables.req.content === dialogState.submittedContent
? personalSkillError(
updateMutation.error,
"Failed to save personal skill.",
)
: undefined,
isSubmitting: updateMutation.isPending,
onRetry: () => {
void editSkillQuery.refetch();
},
onSubmit: (_values, content) => {
setDialogState((current) =>
current?.type === "edit" && current.name === dialogState.name
? { ...current, submittedContent: content }
: current,
);
updateMutation.mutate({
name: dialogState.name,
req: { content },
});
},
onClose: () => setDialogState(null),
};
}
let deleteState: PersonalSkillDeleteState | undefined;
if (dialogState?.type === "delete") {
deleteState = {
skill: dialogState.skill,
error:
deleteMutation.variables === dialogState.skill.name &&
dialogState.submittedName === dialogState.skill.name
? personalSkillError(
deleteMutation.error,
"Failed to delete personal skill.",
)
: undefined,
isDeleting:
deleteMutation.isPending &&
deleteMutation.variables === dialogState.skill.name,
onConfirm: () => {
setDialogState((current) =>
current?.type === "delete" &&
current.skill.name === dialogState.skill.name
? { ...current, submittedName: dialogState.skill.name }
: current,
);
deleteMutation.mutate(dialogState.skill.name);
},
onClose: () => setDialogState(null),
};
}
return (
<AgentSettingsPersonalSkillsPageView
skills={skills}
error={skillsQuery.error}
isLoading={skillsQuery.isLoading}
isRetrying={skillsQuery.isFetching}
onRetry={() => {
void skillsQuery.refetch();
}}
onCreate={() => {
if (skills.length >= PERSONAL_SKILLS_MAX_PER_USER) {
return;
}
createMutation.reset();
setDialogState({ type: "create" });
}}
onEdit={(name) => {
updateMutation.reset();
setDialogState({ type: "edit", name });
}}
onDelete={(skill) => {
deleteMutation.reset();
setDialogState({ type: "delete", skill });
}}
editorState={editorState}
deleteState={deleteState}
/>
);
};
export default AgentSettingsPersonalSkillsPage;
@@ -0,0 +1,430 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
import type { UserSkillMetadata } from "#/api/typesGenerated";
import {
AgentSettingsPersonalSkillsPageView,
type AgentSettingsPersonalSkillsPageViewProps,
} from "./AgentSettingsPersonalSkillsPageView";
const buildSkill = (
overrides: Partial<UserSkillMetadata> & Pick<UserSkillMetadata, "name">,
): UserSkillMetadata => ({
id: overrides.id ?? `skill-${overrides.name}`,
name: overrides.name,
description: overrides.description ?? "Reusable guidance for agents.",
created_at: overrides.created_at ?? "2026-05-01T12:00:00.000Z",
updated_at: overrides.updated_at ?? "2026-05-03T15:30:00.000Z",
});
const skills = [
buildSkill({
name: "review-sql",
description: "Review SQL changes for query and index risks.",
}),
buildSkill({
name: "write-release-notes",
description: "Draft concise release notes from a change list.",
updated_at: "2026-05-04T09:15:00.000Z",
}),
];
const firstSkill = skills[0] ?? buildSkill({ name: "review-sql" });
const baseArgs: AgentSettingsPersonalSkillsPageViewProps = {
skills,
error: undefined,
isLoading: false,
isRetrying: false,
onRetry: fn(),
onCreate: fn(),
onEdit: fn(),
onDelete: fn(),
};
const meta = {
title: "pages/AgentsPage/AgentSettingsPersonalSkillsPageView",
component: AgentSettingsPersonalSkillsPageView,
args: baseArgs,
} satisfies Meta<typeof AgentSettingsPersonalSkillsPageView>;
export default meta;
type Story = StoryObj<typeof AgentSettingsPersonalSkillsPageView>;
export const Populated: Story = {};
export const Loading: Story = {
args: {
skills: [],
isLoading: true,
},
};
export const Empty: Story = {
args: {
skills: [],
},
};
export const ListError: Story = {
args: {
skills: [],
error: new Error("Failed to load personal skills."),
},
};
export const CreateDialogOpen: Story = {
args: {
editorState: {
mode: "create",
initialValues: { name: "", description: "", body: "" },
existingNames: skills.map((skill) => skill.name),
isSubmitting: false,
onSubmit: fn(),
onClose: fn(),
},
},
};
export const EditDialogOpen: Story = {
args: {
editorState: {
mode: "edit",
initialValues: {
name: "review-sql",
description: "Review SQL changes for query and index risks.",
body: "Check query plans, missing indexes, and transaction boundaries.",
},
existingNames: skills.map((skill) => skill.name),
isLoading: false,
isRetrying: false,
isSubmitting: false,
onRetry: fn(),
onSubmit: fn(),
onClose: fn(),
},
},
};
export const EditDialogLoading: Story = {
args: {
editorState: {
mode: "edit",
existingNames: skills.map((skill) => skill.name),
isLoading: true,
isRetrying: false,
isSubmitting: false,
onRetry: fn(),
onSubmit: fn(),
onClose: fn(),
},
},
};
export const EditDialogLoadError: Story = {
args: {
editorState: {
mode: "edit",
existingNames: skills.map((skill) => skill.name),
loadError: new Error("Failed to load personal skill."),
isLoading: false,
isRetrying: true,
isSubmitting: false,
onRetry: fn(),
onSubmit: fn(),
onClose: fn(),
},
},
};
export const ImportSkillMarkdownPopulatesCreateFields: Story = {
args: {
editorState: {
mode: "create",
initialValues: { name: "", description: "", body: "" },
existingNames: skills.map((skill) => skill.name),
isSubmitting: false,
onSubmit: fn(),
onClose: fn(),
},
},
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
const dialog = await body.findByRole("dialog");
const dialogCanvas = within(dialog);
const importInput = dialogCanvas.getByLabelText("Import from SKILL.md");
await userEvent.click(importInput);
await userEvent.paste(
"---\nname: imported-skill\ndescription: Imported guidance.\n---\n\nUse imported instructions.",
);
await waitFor(() => {
expect(dialogCanvas.getByLabelText("Name")).toHaveValue("imported-skill");
expect(dialogCanvas.getByLabelText("Description")).toHaveValue(
"Imported guidance.",
);
expect(dialogCanvas.getByLabelText("Body")).toHaveValue(
"Use imported instructions.",
);
expect(dialogCanvas.getByText("Imported SKILL.md")).toBeVisible();
});
},
};
export const ImportSkillMarkdownShowsParseError: Story = {
args: {
editorState: {
mode: "create",
initialValues: { name: "", description: "", body: "" },
existingNames: skills.map((skill) => skill.name),
isSubmitting: false,
onSubmit: fn(),
onClose: fn(),
},
},
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
const dialog = await body.findByRole("dialog");
const dialogCanvas = within(dialog);
const importInput = dialogCanvas.getByLabelText("Import from SKILL.md");
await userEvent.click(importInput);
await userEvent.paste("---\ndescription: Missing name\n---\nBody");
await waitFor(() => {
expect(dialogCanvas.getByText("Could not parse SKILL.md")).toBeVisible();
expect(dialogCanvas.getByText("Skill name is required.")).toBeVisible();
expect(dialogCanvas.getByLabelText("Name")).toHaveValue("");
expect(dialogCanvas.getByLabelText("Description")).toHaveValue("");
expect(dialogCanvas.getByLabelText("Body")).toHaveValue("");
});
},
};
export const ImportSkillMarkdownKeepsEditName: Story = {
args: {
editorState: {
mode: "edit",
initialValues: {
name: "review-sql",
description: "Review SQL changes for query and index risks.",
body: "Check query plans, missing indexes, and transaction boundaries.",
},
existingNames: skills.map((skill) => skill.name),
isLoading: false,
isRetrying: false,
isSubmitting: false,
onRetry: fn(),
onSubmit: fn(),
onClose: fn(),
},
},
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
const dialog = await body.findByRole("dialog");
const dialogCanvas = within(dialog);
const importInput = dialogCanvas.getByLabelText("Import from SKILL.md");
await userEvent.click(importInput);
await userEvent.paste(
"---\nname: pasted-name\ndescription: New description.\n---\n\nNew body.",
);
await waitFor(() => {
expect(dialogCanvas.getByLabelText("Name")).toHaveValue("review-sql");
expect(dialogCanvas.getByLabelText("Description")).toHaveValue(
"New description.",
);
expect(dialogCanvas.getByLabelText("Body")).toHaveValue("New body.");
expect(
dialogCanvas.getByText(
"Updated description and body fields. Kept the existing name.",
),
).toBeVisible();
});
},
};
export const DeleteConfirmationOpen: Story = {
args: {
deleteState: {
skill: firstSkill,
isDeleting: false,
onConfirm: fn(),
onClose: fn(),
},
},
play: async ({ canvasElement, args }) => {
const body = within(canvasElement.ownerDocument.body);
const dialog = await body.findByRole("dialog");
const dialogCanvas = within(dialog);
await userEvent.click(
dialogCanvas.getByRole("button", { name: "Delete skill" }),
);
await waitFor(() => {
expect(args.deleteState?.onConfirm).toHaveBeenCalled();
});
},
};
export const CreateDialogSubmitError: Story = {
args: {
editorState: {
mode: "create",
initialValues: { name: "", description: "", body: "" },
existingNames: skills.map((skill) => skill.name),
submitError: {
message: "Failed to create personal skill.",
detail: "Skill content is invalid.",
},
isSubmitting: false,
onSubmit: fn(),
onClose: fn(),
},
},
};
export const EditDialogSubmitError: Story = {
args: {
editorState: {
mode: "edit",
initialValues: {
name: "review-sql",
description: "Review SQL changes for query and index risks.",
body: "Check query plans, missing indexes, and transaction boundaries.",
},
existingNames: skills.map((skill) => skill.name),
isLoading: false,
isRetrying: false,
submitError: {
message: "Failed to save personal skill.",
detail: "That personal skill was not found.",
},
isSubmitting: false,
onRetry: fn(),
onSubmit: fn(),
onClose: fn(),
},
},
};
export const DeleteConfirmationError: Story = {
args: {
deleteState: {
skill: firstSkill,
error: {
message: "Failed to delete personal skill.",
detail: "That personal skill was not found.",
},
isDeleting: false,
onConfirm: fn(),
onClose: fn(),
},
},
};
export const InvalidNameIsRejected: Story = {
args: {
editorState: {
mode: "create",
initialValues: { name: "", description: "", body: "" },
existingNames: skills.map((skill) => skill.name),
isSubmitting: false,
onSubmit: fn(),
onClose: fn(),
},
},
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
const dialog = await body.findByRole("dialog");
const dialogCanvas = within(dialog);
const nameInput = dialogCanvas.getByLabelText("Name");
const bodyInput = dialogCanvas.getByLabelText("Body");
await userEvent.type(nameInput, "Bad Name");
await userEvent.click(bodyInput);
await waitFor(() => {
expect(nameInput).toHaveAttribute("aria-invalid", "true");
expect(
dialogCanvas.getByRole("button", { name: "Create skill" }),
).toBeDisabled();
});
},
};
export const DuplicateNameIsRejected: Story = {
args: {
editorState: {
mode: "create",
initialValues: { name: "", description: "", body: "" },
existingNames: skills.map((skill) => skill.name),
isSubmitting: false,
onSubmit: fn(),
onClose: fn(),
},
},
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
const dialog = await body.findByRole("dialog");
const dialogCanvas = within(dialog);
const nameInput = dialogCanvas.getByLabelText("Name");
const bodyInput = dialogCanvas.getByLabelText("Body");
await userEvent.type(nameInput, "review-sql");
await userEvent.click(bodyInput);
await waitFor(() => {
expect(
dialogCanvas.getByText("A skill with this name already exists."),
).toBeVisible();
expect(
dialogCanvas.getByRole("button", { name: "Create skill" }),
).toBeDisabled();
});
},
};
export const SubmitsCreateDialog: Story = {
args: {
editorState: {
mode: "create",
initialValues: { name: "", description: "", body: "" },
existingNames: skills.map((skill) => skill.name),
isSubmitting: false,
onSubmit: fn(),
onClose: fn(),
},
},
play: async ({ canvasElement, args }) => {
const body = within(canvasElement.ownerDocument.body);
const dialog = await body.findByRole("dialog");
const dialogCanvas = within(dialog);
await userEvent.type(dialogCanvas.getByLabelText("Name"), "debug-http");
await userEvent.type(
dialogCanvas.getByLabelText("Description"),
"Debug HTTP handlers.",
);
await userEvent.type(
dialogCanvas.getByLabelText("Body"),
"Inspect request flow and response codes.",
);
await userEvent.click(
dialogCanvas.getByRole("button", { name: "Create skill" }),
);
await waitFor(() => {
expect(args.editorState?.onSubmit).toHaveBeenCalledWith(
{
name: "debug-http",
description: "Debug HTTP handlers.",
body: "Inspect request flow and response codes.",
},
'---\nname: debug-http\ndescription: "Debug HTTP handlers."\n---\nInspect request flow and response codes.\n',
);
});
},
};
@@ -0,0 +1,330 @@
import type { FC } from "react";
import type { UserSkillMetadata } from "#/api/typesGenerated";
import { Alert, AlertDescription } from "#/components/Alert/Alert";
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
import { Button } from "#/components/Button/Button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "#/components/Dialog/Dialog";
import { EmptyState } from "#/components/EmptyState/EmptyState";
import { Loader } from "#/components/Loader/Loader";
import { Spinner } from "#/components/Spinner/Spinner";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "#/components/Table/Table";
import { formatDate } from "#/utils/time";
import { ConfirmDeleteDialog } from "./components/ConfirmDeleteDialog";
import type { PersonalSkillErrorDisplay } from "./components/PersonalSkillEditor";
import { PersonalSkillEditor } from "./components/PersonalSkillEditor";
import { SectionHeader } from "./components/SectionHeader";
import {
PERSONAL_SKILLS_MAX_PER_USER,
type PersonalSkillFormValues,
} from "./utils/personalSkills";
export type PersonalSkillEditorState =
| {
mode: "create";
initialValues: PersonalSkillFormValues;
existingNames: readonly string[];
submitError?: PersonalSkillErrorDisplay;
isSubmitting: boolean;
onSubmit: (values: PersonalSkillFormValues, content: string) => void;
onClose: () => void;
}
| {
mode: "edit";
initialValues?: PersonalSkillFormValues;
existingNames: readonly string[];
loadError?: unknown;
isLoading: boolean;
isRetrying: boolean;
submitError?: PersonalSkillErrorDisplay;
isSubmitting: boolean;
onRetry: () => void;
onSubmit: (values: PersonalSkillFormValues, content: string) => void;
onClose: () => void;
};
export type PersonalSkillDeleteState = {
skill: UserSkillMetadata;
error?: PersonalSkillErrorDisplay;
isDeleting: boolean;
onConfirm: () => void;
onClose: () => void;
};
export interface AgentSettingsPersonalSkillsPageViewProps {
skills: readonly UserSkillMetadata[];
error: unknown;
isLoading: boolean;
isRetrying: boolean;
onRetry: () => void;
onCreate: () => void;
onEdit: (name: string) => void;
onDelete: (skill: UserSkillMetadata) => void;
editorState?: PersonalSkillEditorState;
deleteState?: PersonalSkillDeleteState;
}
const formatUpdatedAt = (value: string) => {
const date = new Date(value);
if (!Number.isFinite(date.getTime())) {
return "Unknown";
}
return formatDate(date, {
locale: "en-US",
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
second: undefined,
minute: "2-digit",
});
};
const EditSkillDialog: FC<{
state: Extract<PersonalSkillEditorState, { mode: "edit" }>;
}> = ({ state }) => {
const handleOpenChange = (open: boolean) => {
if (!open) {
state.onClose();
}
};
if (state.isLoading) {
return (
<Dialog open onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Loading personal skill</DialogTitle>
<DialogDescription>
Fetching the latest SKILL.md content.
</DialogDescription>
</DialogHeader>
<Loader />
</DialogContent>
</Dialog>
);
}
if (state.loadError || !state.initialValues) {
return (
<Dialog open onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Unable to load personal skill</DialogTitle>
<DialogDescription>
The skill could not be loaded for editing.
</DialogDescription>
</DialogHeader>
{state.loadError ? (
<ErrorAlert error={state.loadError} showDebugDetail={false} />
) : (
<Alert severity="error">
<AlertDescription>
The saved content could not be parsed as SKILL.md.
</AlertDescription>
</Alert>
)}
<DialogFooter>
<Button variant="outline" onClick={state.onClose}>
Close
</Button>
<Button onClick={state.onRetry} disabled={state.isRetrying}>
{state.isRetrying && <Spinner className="h-4 w-4" loading />}
Retry
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
return (
<PersonalSkillEditor
open
mode="edit"
initialValues={state.initialValues}
existingNames={state.existingNames}
submitError={state.submitError}
isSubmitting={state.isSubmitting}
onOpenChange={handleOpenChange}
onSubmit={state.onSubmit}
/>
);
};
const DeleteSkillDialog: FC<{ state: PersonalSkillDeleteState }> = ({
state,
}) => {
const handleOpenChange = (open: boolean) => {
if (!open) {
state.onClose();
}
};
return (
<ConfirmDeleteDialog
open
onOpenChange={handleOpenChange}
entity="skill"
description={
<>
Delete {state.skill.name}? Agents will no longer be able to use this
skill. This action cannot be undone.
</>
}
onConfirm={state.onConfirm}
isPending={state.isDeleting}
>
{state.error && (
<Alert severity="error">
<AlertDescription>
{state.error.message}
{state.error.detail ? ` ${state.error.detail}` : ""}
</AlertDescription>
</Alert>
)}
</ConfirmDeleteDialog>
);
};
export const AgentSettingsPersonalSkillsPageView: FC<
AgentSettingsPersonalSkillsPageViewProps
> = ({
skills,
error,
isLoading,
isRetrying,
onRetry,
onCreate,
onEdit,
onDelete,
editorState,
deleteState,
}) => {
const isAtLimit = skills.length >= PERSONAL_SKILLS_MAX_PER_USER;
const addSkillAction = (
<Button size="sm" onClick={onCreate} disabled={isLoading || isAtLimit}>
Add skill
</Button>
);
return (
<div className="flex flex-col gap-8">
<SectionHeader
label="Personal Skills"
description="Reusable instructions your agents can pick when they need specialized guidance. Personal skills hold a single SKILL.md file. For richer skills with supporting files, add them to your repo under `.agents/skills/` or load them from a workspace."
action={addSkillAction}
/>
{isAtLimit && (
<Alert severity="warning">
<AlertDescription>
You have reached the limit of {PERSONAL_SKILLS_MAX_PER_USER}{" "}
personal skills. Delete a skill before creating another one.
</AlertDescription>
</Alert>
)}
{error ? (
<div className="flex flex-col items-start gap-3">
<ErrorAlert error={error} />
<Button
variant="outline"
size="sm"
onClick={onRetry}
disabled={isRetrying}
>
{isRetrying && <Spinner className="h-4 w-4" loading />}
Retry
</Button>
</div>
) : isLoading ? (
<Loader />
) : skills.length === 0 ? (
<EmptyState
message="No personal skills yet"
description="Create a personal skill to save reusable agent guidance for your workflows."
cta={addSkillAction}
/>
) : (
<Table aria-label="Personal skills">
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Description</TableHead>
<TableHead>Updated</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{skills.map((skill) => (
<TableRow key={skill.id}>
<TableCell className="font-mono text-content-primary">
{skill.name}
</TableCell>
<TableCell>
{skill.description || (
<span className="text-content-secondary">
No description
</span>
)}
</TableCell>
<TableCell>{formatUpdatedAt(skill.updated_at)}</TableCell>
<TableCell>
<div className="flex justify-end gap-2">
<Button
size="xs"
variant="outline"
onClick={() => onEdit(skill.name)}
>
Edit
</Button>
<Button
size="xs"
variant="destructive"
onClick={() => onDelete(skill)}
>
Delete
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
{editorState?.mode === "create" && (
<PersonalSkillEditor
open
mode="create"
initialValues={editorState.initialValues}
existingNames={editorState.existingNames}
submitError={editorState.submitError}
isSubmitting={editorState.isSubmitting}
onOpenChange={(open) => {
if (!open) {
editorState.onClose();
}
}}
onSubmit={editorState.onSubmit}
/>
)}
{editorState?.mode === "edit" && <EditSkillDialog state={editorState} />}
{deleteState && <DeleteSkillDialog state={deleteState} />}
</div>
);
};
@@ -119,6 +119,13 @@ export const SettingsPanel: FC<SettingsPanelProps> = ({
state={location.state} state={location.state}
/> />
)} )}
<SettingsNavItem
icon={ReceiptTextIcon}
label="Personal Skills"
active={settingsSection === "personal-skills"}
to="/agents/settings/personal-skills"
state={location.state}
/>
<SettingsNavItem <SettingsNavItem
icon={ShrinkIcon} icon={ShrinkIcon}
label="Compaction" label="Compaction"
@@ -20,6 +20,7 @@ interface ConfirmDeleteDialogProps {
* delete this {entity}? This action is irreversible." * delete this {entity}? This action is irreversible."
*/ */
description?: ReactNode; description?: ReactNode;
children?: ReactNode;
onConfirm: () => void; onConfirm: () => void;
isPending?: boolean; isPending?: boolean;
} }
@@ -29,6 +30,7 @@ export const ConfirmDeleteDialog: FC<ConfirmDeleteDialogProps> = ({
onOpenChange, onOpenChange,
entity, entity,
description, description,
children,
onConfirm, onConfirm,
isPending = false, isPending = false,
}) => ( }) => (
@@ -41,6 +43,7 @@ export const ConfirmDeleteDialog: FC<ConfirmDeleteDialogProps> = ({
`Are you sure you want to delete this ${entity}? This action is irreversible.`} `Are you sure you want to delete this ${entity}? This action is irreversible.`}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{children}
<DialogFooter> <DialogFooter>
<Button <Button
variant="outline" variant="outline"
@@ -0,0 +1,412 @@
import { type FormikErrors, useFormik } from "formik";
import {
type ChangeEvent,
type ClipboardEvent,
type FC,
useId,
useState,
} from "react";
import TextareaAutosize from "react-textarea-autosize";
import * as Yup from "yup";
import { Alert, AlertDescription, AlertTitle } from "#/components/Alert/Alert";
import { Button } from "#/components/Button/Button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "#/components/Dialog/Dialog";
import { Input } from "#/components/Input/Input";
import { Label } from "#/components/Label/Label";
import { Spinner } from "#/components/Spinner/Spinner";
import { cn } from "#/utils/cn";
import { formatKiB } from "#/utils/fileSize";
import {
buildPersonalSkillMarkdown,
getPersonalSkillContentSizeBytes,
isValidPersonalSkillDescription,
isValidPersonalSkillName,
PERSONAL_SKILL_MAX_SIZE_BYTES,
type PersonalSkillFormValues,
tryParsePersonalSkillMarkdown,
} from "../utils/personalSkills";
export type PersonalSkillErrorDisplay = {
message: string;
detail?: string;
};
interface PersonalSkillEditorProps {
open: boolean;
mode: "create" | "edit";
initialValues: PersonalSkillFormValues;
existingNames: readonly string[];
submitError?: PersonalSkillErrorDisplay;
isSubmitting: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (values: PersonalSkillFormValues, content: string) => void;
}
type ImportStatus = {
kind: "success" | "error";
title: string;
detail?: string;
};
const beginsWithFrontmatterDelimiter = (content: string): boolean =>
content
.replace(/^\uFEFF/, "")
.split(/\r?\n/, 1)[0]
?.trim() === "---";
export const PersonalSkillEditor: FC<PersonalSkillEditorProps> = ({
open,
mode,
initialValues,
existingNames,
submitError,
isSubmitting,
onOpenChange,
onSubmit,
}) => {
const isCreate = mode === "create";
const importId = useId();
const nameId = useId();
const nameErrorId = useId();
const descriptionId = useId();
const descriptionErrorId = useId();
const bodyId = useId();
const bodyErrorId = useId();
const validationSchema = Yup.object({
name: Yup.string()
.trim()
.required("Name is required.")
.test(
"skill-name",
"Use kebab-case with lowercase letters, numbers, and single hyphens, up to 256 bytes.",
(value) => Boolean(value && isValidPersonalSkillName(value.trim())),
)
.test(
"unique-name",
"A skill with this name already exists.",
(value) =>
!isCreate ||
!existingNames.includes(
value?.trim().toLocaleLowerCase("en-US") ?? "",
),
),
description: Yup.string().test(
"description-size",
"Description must be 4096 bytes or smaller.",
(value) => isValidPersonalSkillDescription(value ?? ""),
),
body: Yup.string().test("body-required", "Body is required.", (value) =>
Boolean(value?.trim()),
),
});
const validate = (
values: PersonalSkillFormValues,
): FormikErrors<PersonalSkillFormValues> => {
if (
getPersonalSkillContentSizeBytes(buildPersonalSkillMarkdown(values)) <=
PERSONAL_SKILL_MAX_SIZE_BYTES
) {
return {};
}
return {
body: `Skill content must be ${formatKiB(PERSONAL_SKILL_MAX_SIZE_BYTES)} or smaller.`,
};
};
const form = useFormik<PersonalSkillFormValues>({
initialValues,
enableReinitialize: true,
validationSchema,
validate,
onSubmit: (values) => {
const normalizedValues = {
name: values.name.trim(),
description: values.description.trim(),
body: values.body.trim(),
};
onSubmit(normalizedValues, buildPersonalSkillMarkdown(normalizedValues));
},
});
const [importContent, setImportContent] = useState("");
const [importStatus, setImportStatus] = useState<ImportStatus | null>(null);
const importSkillMarkdown = async (contentToImport: string) => {
if (!contentToImport.trim()) {
return;
}
const result = tryParsePersonalSkillMarkdown(contentToImport);
if (!result.ok) {
setImportStatus({
kind: "error",
title: "Could not parse SKILL.md",
detail: result.error,
});
return;
}
if (isCreate) {
await form.setValues(result.values);
await form.setTouched(
{ name: true, description: true, body: true },
false,
);
} else {
await form.setValues({
...form.values,
description: result.values.description,
body: result.values.body,
});
await form.setTouched(
{ name: false, description: true, body: true },
false,
);
}
setImportContent("");
setImportStatus({
kind: "success",
title: "Imported SKILL.md",
detail: isCreate
? "Updated name, description, and body fields."
: "Updated description and body fields. Kept the existing name.",
});
};
const handleImportContentChange = (
event: ChangeEvent<HTMLTextAreaElement>,
) => {
setImportContent(event.target.value);
setImportStatus(null);
};
const handleImportContentPaste = (
event: ClipboardEvent<HTMLTextAreaElement>,
) => {
const pastedContent = event.clipboardData.getData("text");
if (!beginsWithFrontmatterDelimiter(pastedContent)) {
return;
}
event.preventDefault();
setImportContent(pastedContent);
setImportStatus(null);
void importSkillMarkdown(pastedContent);
};
const content = buildPersonalSkillMarkdown(form.values);
const sizeBytes = getPersonalSkillContentSizeBytes(content);
const nameError = form.touched.name ? form.errors.name : undefined;
const descriptionError = form.touched.description
? form.errors.description
: undefined;
const bodyError = form.touched.body ? form.errors.body : undefined;
const isTooLarge = sizeBytes > PERSONAL_SKILL_MAX_SIZE_BYTES;
const isNearLimit = sizeBytes > PERSONAL_SKILL_MAX_SIZE_BYTES * 0.9;
const title = isCreate ? "Create personal skill" : "Edit personal skill";
const submitLabel = isCreate ? "Create skill" : "Save skill";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[90vh] max-w-2xl flex-col gap-0 overflow-hidden p-0">
<form
className="flex min-h-0 flex-1 flex-col"
onSubmit={form.handleSubmit}
>
<DialogHeader className="px-6 pt-6">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>
Personal skills are available to your agents and stored as a
single SKILL.md file with frontmatter. For richer skills with
supporting files, add them to your repo under `.agents/skills/` or
load them from a workspace.
</DialogDescription>
</DialogHeader>
<div className="flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto px-6 py-4">
{submitError && (
<Alert severity="error">
<AlertTitle>{submitError.message}</AlertTitle>
{submitError.detail && (
<AlertDescription>{submitError.detail}</AlertDescription>
)}
</Alert>
)}
<div className="flex flex-col gap-3 rounded-md border border-border-default p-4">
<div className="flex flex-col gap-1">
<Label htmlFor={importId}>Import from SKILL.md</Label>
<p className="m-0 text-xs text-content-secondary">
Paste a full SKILL.md file with frontmatter to auto-fill the
fields below.
</p>
</div>
<TextareaAutosize
id={importId}
value={importContent}
onChange={handleImportContentChange}
onPaste={handleImportContentPaste}
placeholder="---\nname: my-skill\ndescription: ...\n---\n\nBody..."
disabled={isSubmitting}
minRows={4}
maxRows={10}
className="w-full resize-y rounded-md border border-border bg-transparent px-3 py-2 font-mono text-sm leading-relaxed text-content-primary placeholder:text-content-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link disabled:cursor-not-allowed disabled:opacity-50"
/>
{importStatus && (
<Alert severity={importStatus.kind}>
<AlertTitle>{importStatus.title}</AlertTitle>
{importStatus.detail && (
<AlertDescription>{importStatus.detail}</AlertDescription>
)}
</Alert>
)}
<div className="flex justify-end gap-2">
{importContent && (
<Button
variant="outline"
size="sm"
disabled={isSubmitting}
onClick={() => {
setImportContent("");
setImportStatus(null);
}}
>
Clear
</Button>
)}
<Button
size="sm"
disabled={isSubmitting || !importContent.trim()}
onClick={() => {
void importSkillMarkdown(importContent);
}}
>
Import
</Button>
</div>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={nameId}>Name</Label>
<Input
id={nameId}
name="name"
value={form.values.name}
onChange={form.handleChange}
onBlur={form.handleBlur}
placeholder="review-database-query"
readOnly={!isCreate}
disabled={isSubmitting}
aria-invalid={Boolean(nameError)}
aria-describedby={nameError ? nameErrorId : undefined}
className={cn(!isCreate && "bg-surface-secondary")}
/>
{nameError ? (
<p
id={nameErrorId}
className="m-0 text-xs text-content-destructive"
>
{nameError}
</p>
) : (
<p className="m-0 text-xs text-content-secondary">
Use lowercase letters, numbers, and hyphens. Names cannot be
changed after creation.
</p>
)}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={descriptionId}>Description</Label>
<Input
id={descriptionId}
name="description"
value={form.values.description}
onChange={form.handleChange}
onBlur={form.handleBlur}
placeholder="When to use this skill"
disabled={isSubmitting}
aria-invalid={Boolean(descriptionError)}
aria-describedby={
descriptionError ? descriptionErrorId : undefined
}
/>
{descriptionError && (
<p
id={descriptionErrorId}
className="m-0 text-xs text-content-destructive"
>
{descriptionError}
</p>
)}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={bodyId}>Body</Label>
<TextareaAutosize
id={bodyId}
name="body"
value={form.values.body}
onChange={form.handleChange}
onBlur={form.handleBlur}
placeholder="Describe when and how agents should use this skill."
disabled={isSubmitting}
minRows={8}
aria-invalid={Boolean(bodyError)}
aria-describedby={bodyError ? bodyErrorId : undefined}
className={cn(
"w-full resize-y rounded-md border border-border bg-transparent px-3 py-2 font-mono text-sm leading-relaxed text-content-primary placeholder:text-content-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link disabled:cursor-not-allowed disabled:opacity-50",
bodyError && "border-border-destructive",
)}
/>
{bodyError && (
<p
id={bodyErrorId}
className="m-0 text-xs text-content-destructive"
>
{bodyError}
</p>
)}
<p
className={cn(
"m-0 text-xs text-content-secondary",
isNearLimit && "text-content-warning",
isTooLarge && "text-content-destructive",
)}
>
{formatKiB(sizeBytes)} of{" "}
{formatKiB(PERSONAL_SKILL_MAX_SIZE_BYTES)}
used.
</p>
</div>
</div>
<DialogFooter className="border-t border-border-default px-6 py-4">
<Button
variant="outline"
disabled={isSubmitting}
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button
type="submit"
disabled={isSubmitting || !form.isValid || !form.dirty}
>
{isSubmitting && <Spinner className="h-4 w-4" loading />}
{submitLabel}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,174 @@
import { describe, expect, it } from "vitest";
import {
buildPersonalSkillMarkdown,
getPersonalSkillContentSizeBytes,
isValidPersonalSkillDescription,
isValidPersonalSkillName,
PERSONAL_SKILL_MAX_SIZE_BYTES,
parsePersonalSkillMarkdown,
tryParsePersonalSkillMarkdown,
} from "./personalSkills";
describe("parsePersonalSkillMarkdown", () => {
it("parses SKILL.md frontmatter and body", () => {
expect(
parsePersonalSkillMarkdown(
'---\nname: test-skill\ndescription: "Does a thing"\n---\n\nUse this skill.',
),
).toEqual({
name: "test-skill",
description: "Does a thing",
body: "Use this skill.",
});
});
it("uses backend-compatible parsing for YAML-comment-sensitive values", () => {
expect(
parsePersonalSkillMarkdown(
"---\nname: test-skill\ndescription: Build # test\n---\nBody",
),
).toEqual({
name: "test-skill",
description: "Build # test",
body: "Body",
});
});
it("requires a non-empty body", () => {
expect(() =>
parsePersonalSkillMarkdown("---\nname: test-skill\n---\n\n"),
).toThrow("Skill body is required.");
});
it("preserves HTML comments in the body", () => {
expect(
parsePersonalSkillMarkdown(
"---\nname: test-skill\n---\n\nKeep <!-- TODO --> notes.",
),
).toMatchObject({
body: "Keep <!-- TODO --> notes.",
});
});
});
describe("tryParsePersonalSkillMarkdown", () => {
it("returns parsed values for valid SKILL.md content", () => {
expect(
tryParsePersonalSkillMarkdown(
"---\nname: test-skill\ndescription: Does a thing\n---\nBody",
),
).toEqual({
ok: true,
values: {
name: "test-skill",
description: "Does a thing",
body: "Body",
},
});
});
it("returns an error message for invalid SKILL.md content", () => {
expect(
tryParsePersonalSkillMarkdown(
"---\ndescription: Missing name\n---\nBody",
),
).toEqual({
ok: false,
error: "Skill name is required.",
});
});
it("keeps delimiter errors distinct", () => {
expect(
tryParsePersonalSkillMarkdown("name: test-skill\n---\nBody"),
).toEqual({
ok: false,
error: "Missing opening frontmatter delimiter.",
});
expect(
tryParsePersonalSkillMarkdown("---\nname: test-skill\nBody"),
).toEqual({
ok: false,
error: "Missing closing frontmatter delimiter.",
});
});
});
describe("buildPersonalSkillMarkdown", () => {
it("builds backend-compatible skill markdown", () => {
const content = buildPersonalSkillMarkdown({
name: "test-skill",
description: "Does a thing",
body: "Use this skill.",
});
expect(content).toBe(
'---\nname: test-skill\ndescription: "Does a thing"\n---\nUse this skill.\n',
);
expect(parsePersonalSkillMarkdown(content)).toEqual({
name: "test-skill",
description: "Does a thing",
body: "Use this skill.",
});
});
it("omits the description line when description is empty", () => {
expect(
buildPersonalSkillMarkdown({
name: "test-skill",
description: "",
body: "Use this skill.",
}),
).toBe("---\nname: test-skill\n---\nUse this skill.\n");
});
it("escapes quoted description values", () => {
const content = buildPersonalSkillMarkdown({
name: "test-skill",
description: 'Review "critical" C:\\paths.',
body: "Use this skill.",
});
expect(content).toContain(
'description: "Review \\"critical\\" C:\\\\paths."',
);
expect(parsePersonalSkillMarkdown(content)).toMatchObject({
description: 'Review "critical" C:\\paths.',
});
});
});
describe("isValidPersonalSkillName", () => {
it("accepts kebab-case skill names", () => {
expect(isValidPersonalSkillName("test-skill-1")).toBe(true);
});
it("rejects names outside the backend pattern", () => {
expect(isValidPersonalSkillName("Test Skill")).toBe(false);
expect(isValidPersonalSkillName("test--skill")).toBe(false);
expect(isValidPersonalSkillName("-test-skill")).toBe(false);
});
it("rejects names over the backend byte limit", () => {
expect(isValidPersonalSkillName("a".repeat(256))).toBe(true);
expect(isValidPersonalSkillName("a".repeat(257))).toBe(false);
});
});
describe("isValidPersonalSkillDescription", () => {
it("rejects descriptions over the backend byte limit", () => {
expect(isValidPersonalSkillDescription("a".repeat(4096))).toBe(true);
expect(isValidPersonalSkillDescription("a".repeat(4097))).toBe(false);
});
});
describe("getPersonalSkillContentSizeBytes", () => {
it("counts UTF-8 bytes", () => {
expect(getPersonalSkillContentSizeBytes("a")).toBe(1);
expect(getPersonalSkillContentSizeBytes("🧪")).toBe(4);
});
it("exposes the backend size limit", () => {
expect(PERSONAL_SKILL_MAX_SIZE_BYTES).toBe(65_536);
});
});
@@ -0,0 +1,139 @@
export const PERSONAL_SKILL_MAX_SIZE_BYTES = 64 * 1024;
const PERSONAL_SKILL_MAX_NAME_BYTES = 256;
const PERSONAL_SKILL_MAX_DESCRIPTION_BYTES = 4096;
export const PERSONAL_SKILLS_MAX_PER_USER = 100;
const personalSkillNamePattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
const textEncoder = new TextEncoder();
export type PersonalSkillFormValues = {
name: string;
description: string;
body: string;
};
class PersonalSkillMarkdownError extends Error {}
const unquoteFrontmatterScalar = (value: string): string => {
if (value.length < 2) {
return value;
}
const first = value[0];
const last = value[value.length - 1];
if (first !== last) {
return value;
}
const inner = value.slice(1, -1);
if (first === '"') {
return inner.replaceAll('\\"', '"').replaceAll("\\\\", "\\");
}
if (first === "'") {
return inner;
}
return value;
};
// This parser is only for projecting SKILL.md content into form fields.
// The API reparses and validates saved content on submit, so this mirrors the
// backend scalar subset instead of accepting full YAML semantics.
export const parsePersonalSkillMarkdown = (
content: string,
): PersonalSkillFormValues => {
const lines = content.replace(/^\uFEFF/, "").split("\n");
if (lines[0]?.trim() !== "---") {
throw new PersonalSkillMarkdownError(
"Missing opening frontmatter delimiter.",
);
}
const closingIndex = lines.findIndex(
(line, index) => index > 0 && line.trim() === "---",
);
if (closingIndex < 0) {
throw new PersonalSkillMarkdownError(
"Missing closing frontmatter delimiter.",
);
}
let name = "";
let description = "";
for (const line of lines.slice(1, closingIndex)) {
const separatorIndex = line.indexOf(":");
if (separatorIndex < 0) {
continue;
}
const key = line.slice(0, separatorIndex).trim().toLowerCase();
const value = unquoteFrontmatterScalar(
line.slice(separatorIndex + 1).trim(),
);
if (key === "name") {
name = value;
} else if (key === "description") {
description = value;
}
}
const body = lines
.slice(closingIndex + 1)
.join("\n")
.trim();
if (!name) {
throw new PersonalSkillMarkdownError("Skill name is required.");
}
if (!body) {
throw new PersonalSkillMarkdownError("Skill body is required.");
}
return { name, description, body };
};
export const tryParsePersonalSkillMarkdown = (
content: string,
):
| { ok: true; values: PersonalSkillFormValues }
| { ok: false; error: string } => {
try {
return { ok: true, values: parsePersonalSkillMarkdown(content) };
} catch (error) {
return {
ok: false,
error:
error instanceof Error ? error.message : "Unable to parse SKILL.md.",
};
}
};
const frontmatterLineValue = (value: string): string =>
value.replace(/\r?\n/g, " ").trim();
const frontmatterStringValue = (value: string): string =>
`"${frontmatterLineValue(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
export const isValidPersonalSkillDescription = (description: string): boolean =>
getPersonalSkillContentSizeBytes(description) <=
PERSONAL_SKILL_MAX_DESCRIPTION_BYTES;
export const buildPersonalSkillMarkdown = (
values: PersonalSkillFormValues,
): string => {
const name = frontmatterLineValue(values.name);
const description = frontmatterLineValue(values.description);
const body = values.body.trim();
const frontmatter = ["---", `name: ${name}`];
if (description) {
frontmatter.push(`description: ${frontmatterStringValue(description)}`);
}
frontmatter.push("---");
return `${frontmatter.join("\n")}\n${body}\n`;
};
export const getPersonalSkillContentSizeBytes = (content: string): number =>
textEncoder.encode(content).length;
export const isValidPersonalSkillName = (name: string): boolean =>
personalSkillNamePattern.test(name) &&
getPersonalSkillContentSizeBytes(name) <= PERSONAL_SKILL_MAX_NAME_BYTES;
+7
View File
@@ -375,6 +375,9 @@ const AgentSettingsAgentsPage = lazy(
const AgentSettingsUserAgentsPage = lazy( const AgentSettingsUserAgentsPage = lazy(
() => import("./pages/AgentsPage/AgentSettingsUserAgentsPage"), () => import("./pages/AgentsPage/AgentSettingsUserAgentsPage"),
); );
const AgentSettingsPersonalSkillsPage = lazy(
() => import("./pages/AgentsPage/AgentSettingsPersonalSkillsPage"),
);
const AgentSettingsProvidersPage = lazy( const AgentSettingsProvidersPage = lazy(
() => import("./pages/AgentsPage/AgentSettingsProvidersPage"), () => import("./pages/AgentsPage/AgentSettingsProvidersPage"),
); );
@@ -746,6 +749,10 @@ export const router = createBrowserRouter(
path="user-agents" path="user-agents"
element={<AgentSettingsUserAgentsPage />} element={<AgentSettingsUserAgentsPage />}
/> />
<Route
path="personal-skills"
element={<AgentSettingsPersonalSkillsPage />}
/>
<Route path="admin" element={<AgentSettingsAgentsPage />} /> <Route path="admin" element={<AgentSettingsAgentsPage />} />
<Route path="agents" element={<AgentSettingsAgentsPage />} /> <Route path="agents" element={<AgentSettingsAgentsPage />} />
<Route path="api-keys" element={<AgentSettingsAPIKeysPage />} /> <Route path="api-keys" element={<AgentSettingsAPIKeysPage />} />
+10
View File
@@ -0,0 +1,10 @@
import { describe, expect, it } from "vitest";
import { formatKiB } from "./fileSize";
describe("formatKiB", () => {
it("formats bytes as kibibytes with one decimal place", () => {
expect(formatKiB(0)).toBe("0.0 KiB");
expect(formatKiB(1536)).toBe("1.5 KiB");
expect(formatKiB(64 * 1024)).toBe("64.0 KiB");
});
});
+2
View File
@@ -0,0 +1,2 @@
export const formatKiB = (bytes: number): string =>
`${(bytes / 1024).toFixed(1)} KiB`;