Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions site/src/pages/AgentsPage/AgentSettingsPersonalSkillsPage.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ const baseArgs: AgentSettingsPersonalSkillsPageViewProps = {
onCreate: fn(),
onEdit: fn(),
onDelete: fn(),
onDownload: fn(),
onExportAll: fn(),
};

const meta = {
Expand All @@ -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: [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -208,6 +212,10 @@ export const AgentSettingsPersonalSkillsPageView: FC<
onCreate,
onEdit,
onDelete,
onDownload,
onExportAll,
downloadingSkillName,
isExportingAll = false,
editorState,
deleteState,
}) => {
Expand All @@ -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 && (
Expand Down Expand Up @@ -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"
Expand Down
Loading