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 `<name>.md`.
- **Export all**: a header button zips every personal skill (each as
  `<name>/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).

<details>
<summary>Implementation plan</summary>

### 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(<name>.md)`;
export
  all fetches every skill, adds each as `<name>/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.

</details>

---

_Opened by Coder Agents on behalf of @Shelnutt2._
This commit is contained in:
Seth Shelnutt
2026-08-12 09:20:30 -04:00
committed by GitHub
parent 88e113554a
commit 6765731ea9
3 changed files with 143 additions and 1 deletions
@@ -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<string>,
): Promise<void> => {
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<string>,
): Promise<void> => {
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<DialogState>(null);
@@ -148,6 +179,35 @@ const AgentSettingsPersonalSkillsPage: FC = () => {
},
});
const fetchSkillContent = (name: string): Promise<string> =>
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}
/>
@@ -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<typeof AgentSettingsPersonalSkillsPageView>;
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: [],
@@ -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
</Button>
);
const headerActions = (
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={onExportAll}
disabled={isLoading || isExportingAll || skills.length === 0}
>
{isExportingAll && <Spinner className="size-4" loading />}
Export all
</Button>
{addSkillAction}
</div>
);
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}
action={headerActions}
/>
{isAtLimit && (
@@ -282,6 +304,17 @@ export const AgentSettingsPersonalSkillsPageView: FC<
<TableCell>{formatUpdatedAt(skill.updated_at)}</TableCell>
<TableCell>
<div className="flex justify-end gap-2">
<Button
size="xs"
variant="outline"
onClick={() => onDownload(skill)}
disabled={downloadingSkillName === skill.name}
>
{downloadingSkillName === skill.name && (
<Spinner className="size-4" loading />
)}
Download
</Button>
<Button
size="xs"
variant="outline"