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:
parent
b58ce27a02
commit
c4abecb2c4
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Reference in New Issue