mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user