From 356bccddc28ddce4d6890d379adfd04f14802801 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 22 May 2026 00:20:10 +0200 Subject: [PATCH] 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` --- coderd/x/chatd/chattool/skill.go | 5 +- codersdk/workspacesdk/frontmatter.go | 31 +- codersdk/workspacesdk/frontmatter_test.go | 18 + docs/ai-coder/agents/extending-agents.md | 39 +- site/src/api/api.ts | 50 ++ site/src/api/queries/userSkills.test.ts | 123 +++++ site/src/api/queries/userSkills.ts | 89 ++++ .../AgentSettingsPersonalSkillsPage.tsx | 283 ++++++++++++ ...SettingsPersonalSkillsPageView.stories.tsx | 430 ++++++++++++++++++ .../AgentSettingsPersonalSkillsPageView.tsx | 330 ++++++++++++++ .../ChatsSidebar/settings/SettingsPanel.tsx | 7 + .../components/ConfirmDeleteDialog.tsx | 3 + .../components/PersonalSkillEditor.tsx | 412 +++++++++++++++++ .../AgentsPage/utils/personalSkills.test.ts | 174 +++++++ .../pages/AgentsPage/utils/personalSkills.ts | 139 ++++++ site/src/router.tsx | 7 + site/src/utils/fileSize.test.ts | 10 + site/src/utils/fileSize.ts | 2 + 18 files changed, 2138 insertions(+), 14 deletions(-) create mode 100644 site/src/api/queries/userSkills.test.ts create mode 100644 site/src/api/queries/userSkills.ts create mode 100644 site/src/pages/AgentsPage/AgentSettingsPersonalSkillsPage.tsx create mode 100644 site/src/pages/AgentsPage/AgentSettingsPersonalSkillsPageView.stories.tsx create mode 100644 site/src/pages/AgentsPage/AgentSettingsPersonalSkillsPageView.tsx create mode 100644 site/src/pages/AgentsPage/components/PersonalSkillEditor.tsx create mode 100644 site/src/pages/AgentsPage/utils/personalSkills.test.ts create mode 100644 site/src/pages/AgentsPage/utils/personalSkills.ts create mode 100644 site/src/utils/fileSize.test.ts create mode 100644 site/src/utils/fileSize.ts diff --git a/coderd/x/chatd/chattool/skill.go b/coderd/x/chatd/chattool/skill.go index 93e6a943b1..a57d4b8a77 100644 --- a/coderd/x/chatd/chattool/skill.go +++ b/coderd/x/chatd/chattool/skill.go @@ -266,9 +266,8 @@ const DefaultSkillMetaFile = "SKILL.md" // ReadSkillOptions configures the read_skill and read_skill_file // tools. type ReadSkillOptions struct { - GetWorkspaceConn func(context.Context) (workspacesdk.AgentConn, error) - GetSkills func() []SkillMeta - + GetWorkspaceConn func(context.Context) (workspacesdk.AgentConn, error) + GetSkills func() []SkillMeta ResolveAlias func(string) (skillspkg.ResolvedSkill, error) LoadPersonalSkillBody func(context.Context, string) (skillspkg.ParsedSkill, error) } diff --git a/codersdk/workspacesdk/frontmatter.go b/codersdk/workspacesdk/frontmatter.go index 296b32159b..c70be57d74 100644 --- a/codersdk/workspacesdk/frontmatter.go +++ b/codersdk/workspacesdk/frontmatter.go @@ -24,6 +24,29 @@ var markdownCommentRe = regexp.MustCompile(``) // the frontmatter is missing a 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 // remaining body from a skill meta file. The expected format is // YAML-ish frontmatter delimited by "---" lines: @@ -62,13 +85,7 @@ func ParseSkillFrontmatter(content string) (name, description, body string, err } key = strings.TrimSpace(key) value = strings.TrimSpace(value) - // Strip surrounding quotes from YAML string values. - if len(value) >= 2 { - if (value[0] == '"' && value[len(value)-1] == '"') || - (value[0] == '\'' && value[len(value)-1] == '\'') { - value = value[1 : len(value)-1] - } - } + value = unquoteFrontmatterScalar(value) switch strings.ToLower(key) { case "name": name = value diff --git a/codersdk/workspacesdk/frontmatter_test.go b/codersdk/workspacesdk/frontmatter_test.go index d25077c4d2..c1155a641f 100644 --- a/codersdk/workspacesdk/frontmatter_test.go +++ b/codersdk/workspacesdk/frontmatter_test.go @@ -32,6 +32,24 @@ func TestParseSkillFrontmatter(t *testing.T) { 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.Parallel() name, desc, body, err := workspacesdk.ParseSkillFrontmatter( diff --git a/docs/ai-coder/agents/extending-agents.md b/docs/ai-coder/agents/extending-agents.md index 6ce6fc097c..04ce2eca4f 100644 --- a/docs/ai-coder/agents/extending-agents.md +++ b/docs/ai-coder/agents/extending-agents.md @@ -20,7 +20,7 @@ any supporting files the skill needs. On the first turn of a workspace-attached chat, the agent scans `.agents/skills/` and builds an `` block in its system 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. 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 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//`, or load them from a workspace. + ## Workspace MCP tools 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. -**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. ### How discovery works The agent reads `.mcp.json` via the workspace agent connection on each chat 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. ### Tool naming diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 282eb1abd0..c0107c0550 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -411,6 +411,10 @@ const chatProviderConfigsPath = "/api/experimental/chats/providers"; const chatModelConfigsPath = "/api/experimental/chats/model-configs"; const userChatProviderConfigsPath = "/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"; type ChatCostDateParams = { @@ -3592,6 +3596,52 @@ class ExperimentalApiMethods { return response.data; }; + createUserSkill = async ( + user: string, + req: TypesGen.CreateUserSkillRequest, + ): Promise => { + const response = await this.axios.post( + userSkillsPath(user), + req, + ); + return response.data; + }; + + getUserSkills = async ( + user: string, + ): Promise => { + const response = await this.axios.get( + userSkillsPath(user), + ); + return response.data; + }; + + getUserSkillByName = async ( + user: string, + name: string, + ): Promise => { + const response = await this.axios.get( + userSkillPath(user, name), + ); + return response.data; + }; + + updateUserSkill = async ( + user: string, + name: string, + req: TypesGen.UpdateUserSkillRequest, + ): Promise => { + const response = await this.axios.patch( + userSkillPath(user, name), + req, + ); + return response.data; + }; + + deleteUserSkill = async (user: string, name: string): Promise => { + await this.axios.delete(userSkillPath(user, name)); + }; + getUserChatCompactionThresholds = async (): Promise => { const response = diff --git a/site/src/api/queries/userSkills.test.ts b/site/src/api/queries/userSkills.test.ts new file mode 100644 index 0000000000..c8792aecbe --- /dev/null +++ b/site/src/api/queries/userSkills.test.ts @@ -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 => ({ + 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(); + }); +}); diff --git a/site/src/api/queries/userSkills.ts b/site/src/api/queries/userSkills.ts new file mode 100644 index 0000000000..2c85a7f3ad --- /dev/null +++ b/site/src/api/queries/userSkills.ts @@ -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 => + API.experimental.getUserSkills(user), +}); + +export const userSkill = (name: string, user = "me") => ({ + queryKey: userSkillKey(name, user), + queryFn: (): Promise => + 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( + 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( + 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( + userSkillsKey(user), + (skills) => skills?.filter((skill) => skill.name !== name), + ); + }, +}); diff --git a/site/src/pages/AgentsPage/AgentSettingsPersonalSkillsPage.tsx b/site/src/pages/AgentsPage/AgentSettingsPersonalSkillsPage.tsx new file mode 100644 index 0000000000..1de76fd1e1 --- /dev/null +++ b/site/src/pages/AgentsPage/AgentSettingsPersonalSkillsPage.tsx @@ -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(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 ( + { + 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; diff --git a/site/src/pages/AgentsPage/AgentSettingsPersonalSkillsPageView.stories.tsx b/site/src/pages/AgentsPage/AgentSettingsPersonalSkillsPageView.stories.tsx new file mode 100644 index 0000000000..f1464a612a --- /dev/null +++ b/site/src/pages/AgentsPage/AgentSettingsPersonalSkillsPageView.stories.tsx @@ -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 & Pick, +): 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; + +export default meta; +type Story = StoryObj; + +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', + ); + }); + }, +}; diff --git a/site/src/pages/AgentsPage/AgentSettingsPersonalSkillsPageView.tsx b/site/src/pages/AgentsPage/AgentSettingsPersonalSkillsPageView.tsx new file mode 100644 index 0000000000..fd83d035a4 --- /dev/null +++ b/site/src/pages/AgentsPage/AgentSettingsPersonalSkillsPageView.tsx @@ -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; +}> = ({ state }) => { + const handleOpenChange = (open: boolean) => { + if (!open) { + state.onClose(); + } + }; + + if (state.isLoading) { + return ( + + + + Loading personal skill + + Fetching the latest SKILL.md content. + + + + + + ); + } + + if (state.loadError || !state.initialValues) { + return ( + + + + Unable to load personal skill + + The skill could not be loaded for editing. + + + {state.loadError ? ( + + ) : ( + + + The saved content could not be parsed as SKILL.md. + + + )} + + + + + + + ); + } + + return ( + + ); +}; + +const DeleteSkillDialog: FC<{ state: PersonalSkillDeleteState }> = ({ + state, +}) => { + const handleOpenChange = (open: boolean) => { + if (!open) { + state.onClose(); + } + }; + + return ( + + 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 && ( + + + {state.error.message} + {state.error.detail ? ` ${state.error.detail}` : ""} + + + )} + + ); +}; + +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 = ( + + ); + + return ( +
+ + + {isAtLimit && ( + + + You have reached the limit of {PERSONAL_SKILLS_MAX_PER_USER}{" "} + personal skills. Delete a skill before creating another one. + + + )} + + {error ? ( +
+ + +
+ ) : isLoading ? ( + + ) : skills.length === 0 ? ( + + ) : ( + + + + Name + Description + Updated + Actions + + + + {skills.map((skill) => ( + + + {skill.name} + + + {skill.description || ( + + No description + + )} + + {formatUpdatedAt(skill.updated_at)} + +
+ + +
+
+
+ ))} +
+
+ )} + + {editorState?.mode === "create" && ( + { + if (!open) { + editorState.onClose(); + } + }} + onSubmit={editorState.onSubmit} + /> + )} + {editorState?.mode === "edit" && } + {deleteState && } +
+ ); +}; diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/settings/SettingsPanel.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/settings/SettingsPanel.tsx index 3c211b3e42..09a12d3818 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/settings/SettingsPanel.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/settings/SettingsPanel.tsx @@ -119,6 +119,13 @@ export const SettingsPanel: FC = ({ state={location.state} /> )} + void; isPending?: boolean; } @@ -29,6 +30,7 @@ export const ConfirmDeleteDialog: FC = ({ onOpenChange, entity, description, + children, onConfirm, isPending = false, }) => ( @@ -41,6 +43,7 @@ export const ConfirmDeleteDialog: FC = ({ `Are you sure you want to delete this ${entity}? This action is irreversible.`} + {children} + )} + + + +
+ + + {nameError ? ( +

+ {nameError} +

+ ) : ( +

+ Use lowercase letters, numbers, and hyphens. Names cannot be + changed after creation. +

+ )} +
+ +
+ + + {descriptionError && ( +

+ {descriptionError} +

+ )} +
+ +
+ + + {bodyError && ( +

+ {bodyError} +

+ )} +

+ {formatKiB(sizeBytes)} of{" "} + {formatKiB(PERSONAL_SKILL_MAX_SIZE_BYTES)} + used. +

+
+ + + + + + + + + + ); +}; diff --git a/site/src/pages/AgentsPage/utils/personalSkills.test.ts b/site/src/pages/AgentsPage/utils/personalSkills.test.ts new file mode 100644 index 0000000000..e0a66bd82c --- /dev/null +++ b/site/src/pages/AgentsPage/utils/personalSkills.test.ts @@ -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 notes.", + ), + ).toMatchObject({ + body: "Keep 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); + }); +}); diff --git a/site/src/pages/AgentsPage/utils/personalSkills.ts b/site/src/pages/AgentsPage/utils/personalSkills.ts new file mode 100644 index 0000000000..e3f67bb6f5 --- /dev/null +++ b/site/src/pages/AgentsPage/utils/personalSkills.ts @@ -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; diff --git a/site/src/router.tsx b/site/src/router.tsx index 4d3fa95368..e84bec537e 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -375,6 +375,9 @@ const AgentSettingsAgentsPage = lazy( const AgentSettingsUserAgentsPage = lazy( () => import("./pages/AgentsPage/AgentSettingsUserAgentsPage"), ); +const AgentSettingsPersonalSkillsPage = lazy( + () => import("./pages/AgentsPage/AgentSettingsPersonalSkillsPage"), +); const AgentSettingsProvidersPage = lazy( () => import("./pages/AgentsPage/AgentSettingsProvidersPage"), ); @@ -746,6 +749,10 @@ export const router = createBrowserRouter( path="user-agents" element={} /> + } + /> } /> } /> } /> diff --git a/site/src/utils/fileSize.test.ts b/site/src/utils/fileSize.test.ts new file mode 100644 index 0000000000..8321b1a767 --- /dev/null +++ b/site/src/utils/fileSize.test.ts @@ -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"); + }); +}); diff --git a/site/src/utils/fileSize.ts b/site/src/utils/fileSize.ts new file mode 100644 index 0000000000..4455c23110 --- /dev/null +++ b/site/src/utils/fileSize.ts @@ -0,0 +1,2 @@ +export const formatKiB = (bytes: number): string => + `${(bytes / 1024).toFixed(1)} KiB`;