fix(skills): refresh project folders in place (#11066)

## 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:<id>`
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 <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-08-11 09:57:19 -04:00 committed by GitHub
parent b58ce27a02
commit c4abecb2c4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 142 additions and 10 deletions

View File

@ -279,6 +279,59 @@ async function renderDiscoveryGrid(props: Partial<ComponentProps<typeof Discover
return container;
}
const projectFolderResult: FolderListResult = {
kind: "skill",
allCount: 1,
unfiledCount: 0,
folders: [
{
id: "projects-root",
companyId: "company-1",
kind: "skill",
parentId: null,
name: "Projects",
slug: "projects",
systemKey: "projects",
path: "projects",
depth: 1,
color: null,
position: 0,
createdAt: new Date("2026-08-01T00:00:00Z"),
updatedAt: new Date("2026-08-01T00:00:00Z"),
itemCount: 1,
},
{
id: "project-folder",
companyId: "company-1",
kind: "skill",
parentId: "projects-root",
name: "Acme",
slug: "acme",
systemKey: "project:project-1",
path: "projects/acme",
depth: 2,
color: null,
position: 0,
createdAt: new Date("2026-08-01T00:00:00Z"),
updatedAt: new Date("2026-08-01T00:00:00Z"),
itemCount: 1,
},
],
};
function projectFolderGridProps() {
return {
folderResult: projectFolderResult,
onFolderSelect: vi.fn(),
onCreateFolder: vi.fn(),
onCreateFolderIn: vi.fn(),
onRenameFolder: vi.fn(),
onEditFolder: vi.fn(),
onMoveFolder: vi.fn(),
onDeleteFolder: vi.fn(),
} satisfies Partial<ComponentProps<typeof DiscoveryGrid>>;
}
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<HTMLButtonElement>('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<HTMLButtonElement>(
'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 = {

View File

@ -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({
<Button
variant="ghost"
size="icon-sm"
onClick={onScan}
onClick={() => onScan()}
disabled={scanPending}
aria-label="Scan project workspaces for skills"
title="Scan project workspaces for skills"
>
<RefreshCw className={cn("h-4 w-4", scanPending && "animate-spin")} />
@ -1289,7 +1298,7 @@ export function DiscoveryGrid({
/>
</div>
) : null}
{onCreateFolder ? (
{onCreateFolder && !showFolderRail ? (
<Button variant="outline" size="sm" onClick={onCreateFolder}>
<Plus className="mr-1 h-3.5 w-3.5" />
New folder
@ -1357,8 +1366,21 @@ export function DiscoveryGrid({
<div className="min-h-0 flex-1 overflow-auto p-4">
{scanStatus ? <p className="mb-3 text-xs text-muted-foreground">{scanStatus}</p> : null}
{showFolderRail && onFolderSelect ? (
<div className="mb-4">
<div className="mb-4 flex items-center justify-between gap-2">
<FolderBreadcrumb result={folderResult} selection={folderSelection} onSelect={onFolderSelect} />
{activeProjectFolder && activeProjectId ? (
<Button
variant="ghost"
size="sm"
onClick={() => onScan(activeProjectId)}
disabled={scanPending}
aria-label={`Refresh ${activeProjectFolder.name} project skills`}
title={`Refresh skills from ${activeProjectFolder.name}`}
>
<RefreshCw className={cn("h-3.5 w-3.5", scanPending && "animate-spin")} />
Refresh
</Button>
) : null}
</div>
) : 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}