From 6765731ea9c732185fc2a894875add1070e51dbe Mon Sep 17 00:00:00 2001 From: Seth Shelnutt Date: Wed, 12 Aug 2026 08:20:30 -0500 Subject: [PATCH] feat(site/src/pages/AgentsPage): add download and export for personal skills (#28032) ## Summary Adds a way to get personal skills back out of Coder Agents as files, so sharing a skill no longer means pasting `SKILL.md` by hand. - **Per-skill Download**: a `Download` action on each row of the *Personal skills* settings page saves that skill's `SKILL.md` as `.md`. - **Export all**: a header button zips every personal skill (each as `/SKILL.md`) and downloads `personal-skills.zip`. Scope is **personal skills only** (workspace/filesystem skills are read-only in chat and out of scope). No backend changes: the single-skill content endpoint (`GET /api/experimental/users/{user}/skills/{skillName}`) already returns full content, so the view fetches on demand and downloads with `file-saver`, zipping with `jszip` (both existing deps, matching `DownloadLogsDialog`). ## Changes - `AgentSettingsPersonalSkillsPageView.tsx`: `Download` per-row button (with per-row spinner) and an `Export all` header button (disabled when empty or loading). - `AgentSettingsPersonalSkillsPage.tsx`: container handlers that fetch content via `queryClient.fetchQuery(userSkill(name))` and trigger the download/zip, with `toast` error handling. Download logic is extracted to module-level helpers to stay React Compiler friendly. - `AgentSettingsPersonalSkillsPageView.stories.tsx`: interaction stories asserting `onDownload`/`onExportAll` fire, plus loading-state stories. ## Testing - `pnpm check` (biome), `pnpm lint:types` (tsc), React Compiler check, and Storybook interaction tests (22 passed) all pass locally. - `make pre-commit` passed. Closes [CODAGT-918](https://linear.app/codercom/issue/CODAGT-918/add-ability-to-download-and-export-skills-from-coder-agents).
Implementation plan ### Problem Users can create, edit, and delete personal skills in Agent settings, but there is no way to get a skill back out as a file. The only workaround was to paste the `SKILL.md` content by hand. ### Decisions (confirmed with requester) 1. Surface: download in the settings UI (Personal skills page). 2. Build both single-skill download and export-all (zip). 3. Personal skills only (workspace/filesystem skills out of scope). ### Approach (frontend-only, additive) No backend changes: the single-skill content endpoint already exists. The view stays presentational and exposes new callbacks; the container fetches content and performs the download, mirroring how Edit/Delete already split between view and container. - Per-row Download button, placed before Edit in the actions cell; shows a spinner while its own row is downloading. - Export all button in the section header, disabled when there are no skills or while loading. - Container: single download fetches content and `saveAs(.md)`; export all fetches every skill, adds each as `/SKILL.md` to a `JSZip`, generates a blob, and `saveAs(personal-skills.zip)`. Failures surface via `toast.error`. - Stories cover the interactions and loading states (stories are the FE test surface). ### Out of scope - Workspace skills download (filesystem source): different data path, read-only in chat, not user-owned data. - In-chat download button on the `read_skill` tool output: possible follow-up, different surface and interaction model.
--- _Opened by Coder Agents on behalf of @Shelnutt2._ --- .../AgentSettingsPersonalSkillsPage.tsx | 68 +++++++++++++++++++ ...SettingsPersonalSkillsPageView.stories.tsx | 41 +++++++++++ .../AgentSettingsPersonalSkillsPageView.tsx | 35 +++++++++- 3 files changed, 143 insertions(+), 1 deletion(-) diff --git a/site/src/pages/AgentsPage/AgentSettingsPersonalSkillsPage.tsx b/site/src/pages/AgentsPage/AgentSettingsPersonalSkillsPage.tsx index 1de76fd1e1..3846047c4d 100644 --- a/site/src/pages/AgentsPage/AgentSettingsPersonalSkillsPage.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsPersonalSkillsPage.tsx @@ -1,4 +1,6 @@ import { isAxiosError } from "axios"; +import { saveAs } from "file-saver"; +import JSZip from "jszip"; import type { FC } from "react"; import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; @@ -62,6 +64,35 @@ const personalSkillError = ( }; }; +const downloadPersonalSkillFile = async ( + name: string, + fetchContent: (name: string) => Promise, +): Promise => { + const content = await fetchContent(name); + saveAs( + new Blob([content], { type: "text/markdown;charset=utf-8" }), + `${name}.md`, + ); +}; + +const exportPersonalSkillsArchive = async ( + skills: readonly UserSkillMetadata[], + fetchContent: (name: string) => Promise, +): Promise => { + const contents = await Promise.all( + skills.map(async (skill) => ({ + name: skill.name, + content: await fetchContent(skill.name), + })), + ); + const zip = new JSZip(); + for (const { name, content } of contents) { + zip.file(`${name}/SKILL.md`, content); + } + const archive = await zip.generateAsync({ type: "blob" }); + saveAs(archive, "personal-skills.zip"); +}; + const AgentSettingsPersonalSkillsPage: FC = () => { const queryClient = useQueryClient(); const [dialogState, setDialogState] = useState(null); @@ -148,6 +179,35 @@ const AgentSettingsPersonalSkillsPage: FC = () => { }, }); + const fetchSkillContent = (name: string): Promise => + queryClient.fetchQuery(userSkill(name)).then((skill) => skill.content); + + const downloadMutation = useMutation({ + mutationFn: (name: string) => + downloadPersonalSkillFile(name, fetchSkillContent), + onError: (error) => { + toast.error( + getErrorMessage(error, "Failed to download personal skill."), + { + description: getErrorDetail(error), + }, + ); + }, + }); + + const exportAllMutation = useMutation({ + mutationFn: () => exportPersonalSkillsArchive(skills, fetchSkillContent), + onError: (error) => { + toast.error(getErrorMessage(error, "Failed to export personal skills."), { + description: getErrorDetail(error), + }); + }, + }); + + const downloadingSkillName = downloadMutation.isPending + ? downloadMutation.variables + : undefined; + let editInitialValues: PersonalSkillFormValues | undefined; let editLoadError: unknown = editSkillQuery.error; if (editSkillQuery.data) { @@ -274,6 +334,14 @@ const AgentSettingsPersonalSkillsPage: FC = () => { deleteMutation.reset(); setDialogState({ type: "delete", skill }); }} + onDownload={(skill) => { + downloadMutation.mutate(skill.name); + }} + onExportAll={() => { + exportAllMutation.mutate(); + }} + downloadingSkillName={downloadingSkillName} + isExportingAll={exportAllMutation.isPending} editorState={editorState} deleteState={deleteState} /> diff --git a/site/src/pages/AgentsPage/AgentSettingsPersonalSkillsPageView.stories.tsx b/site/src/pages/AgentsPage/AgentSettingsPersonalSkillsPageView.stories.tsx index f1464a612a..de1eb4ad98 100644 --- a/site/src/pages/AgentsPage/AgentSettingsPersonalSkillsPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsPersonalSkillsPageView.stories.tsx @@ -39,6 +39,8 @@ const baseArgs: AgentSettingsPersonalSkillsPageViewProps = { onCreate: fn(), onEdit: fn(), onDelete: fn(), + onDownload: fn(), + onExportAll: fn(), }; const meta = { @@ -52,6 +54,45 @@ type Story = StoryObj; export const Populated: Story = {}; +export const DownloadingSkill: Story = { + args: { + downloadingSkillName: "review-sql", + }, +}; + +export const ExportingAll: Story = { + args: { + isExportingAll: true, + }, +}; + +export const DownloadsSkill: Story = { + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + const row = canvas.getByRole("row", { name: /review-sql/ }); + await userEvent.click( + within(row).getByRole("button", { name: "Download" }), + ); + + await waitFor(() => { + expect(args.onDownload).toHaveBeenCalledWith( + expect.objectContaining({ name: "review-sql" }), + ); + }); + }, +}; + +export const ExportsAllSkills: Story = { + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: "Export all" })); + + await waitFor(() => { + expect(args.onExportAll).toHaveBeenCalled(); + }); + }, +}; + export const Loading: Story = { args: { skills: [], diff --git a/site/src/pages/AgentsPage/AgentSettingsPersonalSkillsPageView.tsx b/site/src/pages/AgentsPage/AgentSettingsPersonalSkillsPageView.tsx index cb843191c8..62ebaf7158 100644 --- a/site/src/pages/AgentsPage/AgentSettingsPersonalSkillsPageView.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsPersonalSkillsPageView.tsx @@ -73,6 +73,10 @@ export interface AgentSettingsPersonalSkillsPageViewProps { onCreate: () => void; onEdit: (name: string) => void; onDelete: (skill: UserSkillMetadata) => void; + onDownload: (skill: UserSkillMetadata) => void; + onExportAll: () => void; + downloadingSkillName?: string; + isExportingAll?: boolean; editorState?: PersonalSkillEditorState; deleteState?: PersonalSkillDeleteState; } @@ -208,6 +212,10 @@ export const AgentSettingsPersonalSkillsPageView: FC< onCreate, onEdit, onDelete, + onDownload, + onExportAll, + downloadingSkillName, + isExportingAll = false, editorState, deleteState, }) => { @@ -217,13 +225,27 @@ export const AgentSettingsPersonalSkillsPageView: FC< Add skill ); + const headerActions = ( +
+ + {addSkillAction} +
+ ); return (
{isAtLimit && ( @@ -282,6 +304,17 @@ export const AgentSettingsPersonalSkillsPageView: FC< {formatUpdatedAt(skill.updated_at)}
+