mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
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:
@@ -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,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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -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 />} />
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export const formatKiB = (bytes: number): string =>
|
||||||
|
`${(bytes / 1024).toFixed(1)} KiB`;
|
||||||
Reference in New Issue
Block a user