From c4abecb2c4c0dfd865b9546424a83991d1c35a64 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Tue, 11 Aug 2026 09:57:19 -0400 Subject: [PATCH] fix(skills): refresh project folders in place (#11066) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip helps operators manage agent skills across a company. > - The installed skills view groups project-backed skills into folders. > - The view showed two folder creation controls and only offered a global project scan. > - Operators need one clear folder action and a refresh action for the selected project. > - This pull request keeps folder creation in the folder rail and adds a scoped project refresh. > - The benefit is a calmer skills view and faster, more precise project skill updates. ## Linked Issues or Issue Description No public GitHub issue exists for this focused UI bug. **What happened?** The installed skills view repeated the folder creation action in the toolbar. A selected project folder also had no way to refresh only its own project skills. **Expected behavior** The folder rail must own folder creation. A selected project-backed folder must offer a refresh action that scans only that project and refreshes the skill and folder queries. **Steps to reproduce** 1. Open the installed skills view for a company with project-backed skill folders. 2. Select a project folder. 3. Observe the duplicate folder action and the absence of a project-scoped refresh action. **Paperclip version or commit** Reproduced before this two-commit fix on `master`. **Deployment mode** Local development with `pnpm dev`. ## What Changed - Removed the duplicate toolbar folder creation button when the folder rail exists. - Preserved the toolbar folder action when no folder rail exists. - Added a refresh action beside the breadcrumb for a selected project-backed folder. - Passed the selected project ID to the project scan API. - Refreshed both the installed skill list and skill folder data after scans. - Added component tests for compact folder creation, the empty-folder fallback, and scoped project refresh. ## Verification - `pnpm exec vitest run ui/src/pages/CompanySkills.test.tsx` — 20 tests passed. - `pnpm check:token-gates` — passed with all three gates clean. - `pnpm -r typecheck` — passed. - `pnpm build` — passed. - `pnpm test:run` — the server and UI stages passed 7,475 tests. The CLI stage then found one environment-sensitive AWS doctor assertion because this agent runtime injects static AWS credentials. - `env -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY pnpm exec vitest run cli/src/__tests__/secrets.test.ts --project paperclipai` — all 8 tests passed. - GitHub CI — all latest-head checks passed. ## Risks - Low risk. The scoped refresh depends on the existing `project:` folder system key. - The global scan path is unchanged. - There are no schema, migration, API contract, dependency, workflow, or documentation changes. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex, GPT-5 family. The runtime did not expose a more specific model ID or context-window size. The agent used high-reasoning mode, repository tools, GitHub tools, and code execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- ui/src/pages/CompanySkills.test.tsx | 102 ++++++++++++++++++++++++++++ ui/src/pages/CompanySkills.tsx | 50 +++++++++++--- 2 files changed, 142 insertions(+), 10 deletions(-) diff --git a/ui/src/pages/CompanySkills.test.tsx b/ui/src/pages/CompanySkills.test.tsx index 180fd9d45a..ef381ae1bc 100644 --- a/ui/src/pages/CompanySkills.test.tsx +++ b/ui/src/pages/CompanySkills.test.tsx @@ -279,6 +279,59 @@ async function renderDiscoveryGrid(props: Partial>; +} + function buttonsNamed(node: ParentNode, name: string) { return Array.from(node.querySelectorAll("button")).filter((button) => button.textContent?.trim() === name); } @@ -341,6 +394,55 @@ describe("DiscoveryGrid Studio entry points", () => { expect(onCreate).toHaveBeenCalledTimes(2); }); + it("keeps folder creation in the compact rail control", async () => { + const props = projectFolderGridProps(); + const node = await renderDiscoveryGrid(props); + const compactCreateButton = node.querySelector('button[title="New folder"]'); + + expect(buttonsNamed(node, "New folder")).toHaveLength(0); + expect(compactCreateButton).not.toBeNull(); + + await click(compactCreateButton!); + + expect(props.onCreateFolderIn).toHaveBeenCalledWith(null); + expect(props.onCreateFolder).not.toHaveBeenCalled(); + }); + + it("keeps folder creation available when no folder rail exists", async () => { + const onCreateFolder = vi.fn(); + const node = await renderDiscoveryGrid({ + ...projectFolderGridProps(), + folderResult: { ...projectFolderResult, folders: [] }, + onCreateFolder, + }); + const createButton = buttonsNamed(node, "New folder")[0] as HTMLButtonElement; + + expect(createButton).toBeDefined(); + + await click(createButton); + + expect(onCreateFolder).toHaveBeenCalledOnce(); + }); + + it("refreshes only the project represented by the active project folder", async () => { + const onScan = vi.fn(); + const node = await renderDiscoveryGrid({ + ...projectFolderGridProps(), + folderSelection: "project-folder", + onScan, + }); + const refreshButton = node.querySelector( + 'button[aria-label="Refresh Acme project skills"]', + ); + + expect(refreshButton).not.toBeNull(); + + await click(refreshButton!); + + expect(onScan).toHaveBeenCalledOnce(); + expect(onScan).toHaveBeenCalledWith("project-1"); + }); + it("does not open a skill when keyboard-activating its actions button", async () => { const onOpenCard = vi.fn(); const card = { diff --git a/ui/src/pages/CompanySkills.tsx b/ui/src/pages/CompanySkills.tsx index 2bb0859f07..42a9325d46 100644 --- a/ui/src/pages/CompanySkills.tsx +++ b/ui/src/pages/CompanySkills.tsx @@ -1084,7 +1084,7 @@ export function DiscoveryGrid({ onImport: () => void; onImportFromProject: () => void; onBrowseCatalog: () => void; - onScan: () => void; + onScan: (projectId?: string) => void; scanPending: boolean; scanStatus: string | null; folderResult?: FolderListResult | null; @@ -1138,7 +1138,15 @@ export function DiscoveryGrid({ ); // The nested folder tree owns the left rail whenever folders (reserved roots // or user folders) exist for the installed view. - const showFolderRail = Boolean(folderResult && folderResult.folders.length > 0 && onFolderSelect && folderActionsReady); + const showFolderRail = Boolean( + folderResult && folderResult.folders.length > 0 && onFolderSelect && folderActionsReady, + ); + const activeProjectFolder = useMemo(() => { + if (!folderResult || folderSelection === "all" || folderSelection === "unfiled") return null; + const folder = folderResult.folders.find((candidate) => candidate.id === folderSelection); + return folder?.systemKey?.startsWith("project:") ? folder : null; + }, [folderResult, folderSelection]); + const activeProjectId = activeProjectFolder?.systemKey?.slice("project:".length) || null; return ( // On desktop the store is bounded to the viewport so the category sidebar @@ -1240,8 +1248,9 @@ export function DiscoveryGrid({ + ) : null} ) : null} {folderNudgeStorageKey && onCreateFolder && folderResult && folderResult.folders.length === 0 && !loading && cards.length > 0 ? ( @@ -4278,13 +4300,21 @@ export function CompanySkills() { }); const scanProjects = useMutation({ - mutationFn: () => companySkillsApi.scanProjects(selectedCompanyId!), - onMutate: () => { - setScanStatusMessage("Scanning project workspaces for skills..."); + mutationFn: (projectId?: string) => companySkillsApi.scanProjects( + selectedCompanyId!, + projectId ? { projectIds: [projectId] } : {}, + ), + onMutate: (projectId) => { + setScanStatusMessage( + projectId ? "Refreshing project skills..." : "Scanning project workspaces for skills...", + ); }, onSuccess: async (result) => { setScanStatusMessage("Refreshing skills list..."); - await queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.list(selectedCompanyId!) }); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.list(selectedCompanyId!) }), + queryClient.invalidateQueries({ queryKey: queryKeys.folders.list(selectedCompanyId!, "skill") }), + ]); const summary = formatProjectScanSummary(result); setScanStatusMessage(summary); pushToast({ @@ -5308,7 +5338,7 @@ export function CompanySkills() { onImport={() => setImportDialogOpen(true)} onImportFromProject={() => setImportFromProjectOpen(true)} onBrowseCatalog={() => setDiscoveryTab("catalog")} - onScan={() => scanProjects.mutate()} + onScan={(projectId) => scanProjects.mutate(projectId)} scanPending={scanProjects.isPending} scanStatus={scanStatusMessage} folderResult={showInstalledFolders ? railSkillFolderResult : null}