From 9b2dab602ec944caca34f6b383dff7985b8d6c11 Mon Sep 17 00:00:00 2001 From: Seth Shelnutt Date: Tue, 11 Aug 2026 17:25:27 +0000 Subject: [PATCH 1/2] feat(site/src/pages/AgentsPage): add download and export for personal skills --- .../AgentSettingsPersonalSkillsPage.tsx | 79 +++++++++++++++++++ ...SettingsPersonalSkillsPageView.stories.tsx | 41 ++++++++++ .../AgentSettingsPersonalSkillsPageView.tsx | 35 +++++++- 3 files changed, 154 insertions(+), 1 deletion(-) diff --git a/site/src/pages/AgentsPage/AgentSettingsPersonalSkillsPage.tsx b/site/src/pages/AgentsPage/AgentSettingsPersonalSkillsPage.tsx index 1de76fd1e16..5ced2c47d0f 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,9 +64,42 @@ 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); + const [downloadingSkillName, setDownloadingSkillName] = useState< + string | undefined + >(undefined); + const [isExportingAll, setIsExportingAll] = useState(false); const skillsQuery = useQuery(userSkills()); const skills = skillsQuery.data ?? []; const existingNames = skills.map((skill) => @@ -148,6 +183,42 @@ const AgentSettingsPersonalSkillsPage: FC = () => { }, }); + const fetchSkillContent = (name: string): Promise => + queryClient.fetchQuery(userSkill(name)).then((skill) => skill.content); + + const handleDownload = async (skill: UserSkillMetadata) => { + if (downloadingSkillName) { + return; + } + setDownloadingSkillName(skill.name); + try { + await downloadPersonalSkillFile(skill.name, fetchSkillContent); + } catch (error) { + toast.error( + getErrorMessage(error, "Failed to download personal skill."), + { + description: getErrorDetail(error), + }, + ); + } + setDownloadingSkillName(undefined); + }; + + const handleExportAll = async () => { + if (isExportingAll || skills.length === 0) { + return; + } + setIsExportingAll(true); + try { + await exportPersonalSkillsArchive(skills, fetchSkillContent); + } catch (error) { + toast.error(getErrorMessage(error, "Failed to export personal skills."), { + description: getErrorDetail(error), + }); + } + setIsExportingAll(false); + }; + let editInitialValues: PersonalSkillFormValues | undefined; let editLoadError: unknown = editSkillQuery.error; if (editSkillQuery.data) { @@ -274,6 +345,14 @@ const AgentSettingsPersonalSkillsPage: FC = () => { deleteMutation.reset(); setDialogState({ type: "delete", skill }); }} + onDownload={(skill) => { + void handleDownload(skill); + }} + onExportAll={() => { + void handleExportAll(); + }} + downloadingSkillName={downloadingSkillName} + isExportingAll={isExportingAll} editorState={editorState} deleteState={deleteState} /> diff --git a/site/src/pages/AgentsPage/AgentSettingsPersonalSkillsPageView.stories.tsx b/site/src/pages/AgentsPage/AgentSettingsPersonalSkillsPageView.stories.tsx index f1464a612a2..de1eb4ad987 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 cb843191c85..62ebaf71585 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)}
+