feat: organize skills with nested folders and My Skills (#9633)

## Thinking Path

> - Paperclip is the open source control plane people use to organize
and govern AI-agent companies
> - Company skills are durable resources that users browse, import,
assign, and maintain over time
> - A flat skill list plus tags does not provide a stable location or
hierarchy for personal, company, project-imported, and bundled skills
> - Folder paths need to be canonical, company-scoped, safe to move, and
preserved across re-imports without changing skill IDs
> - The `/skills` UI also needs traversal, breadcrumbs, move/create
flows, and a dedicated My Skills namespace that work on desktop and
mobile
> - This pull request adds the folder data model and APIs, reserved-root
lifecycle, project import behavior, and the folder-first skills
experience
> - The benefit is a predictable filesystem-like organization model
while tags remain available for cross-cutting classification

## Linked Issues or Issue Description

Refs #9619 — the reviewed folder foundation was intentionally closed and
folded into this combined feature PR.
Refs #9026 — earlier flat-folder attempt superseded by this integrated
implementation.
Refs #3281 — related skill organization proposal; this PR uses canonical
persisted folders rather than deriving groups from skill keys, and does
not add hidden-skill behavior.

**Feature request**

- **Problem:** Skills currently lack a canonical hierarchical location,
making personal skills, project imports, bundled skills, and
company-authored skills difficult to traverse and manage at scale.
- **Proposed behavior:** Add nested company-scoped folders with stable
paths, reserved My/Projects/Bundled roots, subtree queries, safe
move/create operations, and a folder-first `/skills` library UI.
- **Import behavior:** New project scans file skills under
`projects/<project-slug>`; later imports update content without
overriding a user-selected folder.
- **Alternatives considered:** Tags alone remain useful for
cross-cutting classification, but they do not provide canonical
location, nesting, reserved namespaces, or stable import placement.
- **Roadmap alignment:** Extends the completed Skills Manager and
Scheduled Routines capabilities without duplicating an active roadmap
item.

## What Changed

- Adds `folders` persistence for routine and skill folders, nested
canonical paths, parent/slug/system-key fields, migration backfills, and
reapply-safe migrations `0174`–`0175` after current master migrations.
- Adds company-scoped folder CRUD, cycle/depth/namespace validation,
reserved My/Projects/Bundled lifecycle, item moves, subtree filtering,
and folder paths on skill results.
- Preserves project-import placement: first import files into the
project folder, while re-import keeps user-owned placement and stable
skill IDs.
- Adds the `/skills` folder tree rail, tags facet, breadcrumbs,
subfolder browser, move/new-folder dialog, canonical detail location,
inline tag editing, and folder-aware Studio creation.
- Keeps bundled skills read-only even when their source metadata is
incomplete by detecting the reserved Bundled folder and hiding
selection/move actions.
- Extends routine folder UI and OpenAPI coverage, and adds regression
tests across migrations, services, routes, tree helpers, pages, and
Studio creation.

## Verification

- `pnpm exec vitest run
packages/db/src/nested-skill-folders-migration.test.ts
server/src/__tests__/folders-routes.test.ts
server/src/__tests__/folders-service.test.ts
server/src/__tests__/company-skills-service.test.ts
server/src/__tests__/routines-service.test.ts
ui/src/components/folders/FolderControls.test.tsx
ui/src/components/folders/SkillFolderTree.test.tsx
ui/src/components/folders/skill-folder-tree.test.ts
ui/src/pages/CompanySkills.test.tsx ui/src/pages/Routines.test.tsx
ui/src/pages/SkillStudio.test.tsx
ui/src/lib/company-skill-routes.test.ts ui/src/lib/skill-create.test.ts`
— 13 files, 192 tests passed.
- `pnpm exec vitest run ui/src/pages/CompanySkills.test.tsx
ui/src/components/folders/SkillFolderTree.test.tsx` — 2 files, 20 tests
passed after preserving the existing PR's bundled-skill fixes.
- `pnpm -r typecheck` — passed for all workspace packages.
- `pnpm test:run` — passed in an isolated CI-like environment with
inherited Paperclip runtime identity and static AWS credential variables
removed.
- `pnpm build` — production build passed for all workspace packages.
- Greptile iteration 2 — 5/5 confidence with zero unresolved threads on
commit `ff2d67aa71`.
- Latest-head GitHub checks — all success, neutral, or skipped; PR is
mergeable with a clean merge state.
- `pnpm check:token-gates` — reports nine existing `#9627` comment false
positives already present on `master`; this PR introduces no new token
violation.

## Risks

- **Migration/backfill:** `0174` creates the foundation and `0175` adds
nested/reserved semantics. Both are ordered after current master
migration `0173`, are covered by numbering/safety checks, and are
designed to be reapply-safe.
- **Reserved namespaces:** My, Projects, and Bundled roots are
service-managed. Regression coverage prevents namespace squatting,
cross-company folder use, bundled writes, cycles, and excessive depth.
- **Behavioral change:** Project scans choose a project folder only on
initial creation; existing skills deliberately retain their current
folder during refresh.
- **UI scope:** The folder rail applies to the Installed library;
Catalog retains the discovery-oriented category sidebar.

> 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 `gpt-5.5` in Codex CLI, medium reasoning mode; runtime did not
expose a context-window value. Used repository/file tools, terminal
execution, Git/GitHub operations, test execution, and code editing.

## 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-07-16 15:50:45 -05:00 committed by GitHub
parent 1d2b6af5ac
commit 52aea90263
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
53 changed files with 7010 additions and 61 deletions

View File

@ -0,0 +1,46 @@
CREATE TABLE IF NOT EXISTS "folders" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"kind" text NOT NULL,
"name" text NOT NULL,
"color" text,
"position" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "company_skills" ADD COLUMN IF NOT EXISTS "folder_id" uuid;
--> statement-breakpoint
ALTER TABLE "routines" ADD COLUMN IF NOT EXISTS "folder_id" uuid;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM "pg_constraint" WHERE "conname" = 'folders_company_id_companies_id_fk'
) THEN
ALTER TABLE "folders" ADD CONSTRAINT "folders_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM "pg_constraint" WHERE "conname" = 'company_skills_folder_id_folders_id_fk'
) THEN
ALTER TABLE "company_skills" ADD CONSTRAINT "company_skills_folder_id_folders_id_fk" FOREIGN KEY ("folder_id") REFERENCES "public"."folders"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM "pg_constraint" WHERE "conname" = 'routines_folder_id_folders_id_fk'
) THEN
ALTER TABLE "routines" ADD CONSTRAINT "routines_folder_id_folders_id_fk" FOREIGN KEY ("folder_id") REFERENCES "public"."folders"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "folders_company_kind_position_idx" ON "folders" USING btree ("company_id","kind","position","name");
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "folders_company_kind_name_uq" ON "folders" USING btree ("company_id","kind","name");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "company_skills_company_folder_idx" ON "company_skills" USING btree ("company_id","folder_id");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "routines_company_folder_idx" ON "routines" USING btree ("company_id","folder_id");

View File

@ -0,0 +1,232 @@
ALTER TABLE "folders" ADD COLUMN IF NOT EXISTS "parent_id" uuid;
--> statement-breakpoint
ALTER TABLE "folders" ADD COLUMN IF NOT EXISTS "slug" text;
--> statement-breakpoint
ALTER TABLE "folders" ADD COLUMN IF NOT EXISTS "system_key" text;
--> statement-breakpoint
WITH normalized AS (
SELECT
"id",
COALESCE(
NULLIF(TRIM(BOTH '-' FROM REGEXP_REPLACE(LOWER("name"), '[^a-z0-9]+', '-', 'g')), ''),
'folder'
) AS base_slug,
ROW_NUMBER() OVER (
PARTITION BY "company_id", "kind", COALESCE(
NULLIF(TRIM(BOTH '-' FROM REGEXP_REPLACE(LOWER("name"), '[^a-z0-9]+', '-', 'g')), ''),
'folder'
)
ORDER BY "id"
) AS duplicate_number
FROM "folders"
WHERE "slug" IS NULL
)
UPDATE "folders" AS folder
SET "slug" = CASE
WHEN normalized.duplicate_number = 1 THEN normalized.base_slug
ELSE normalized.base_slug || '-' || LEFT(folder."id"::text, 8)
END
FROM normalized
WHERE folder."id" = normalized."id";
--> statement-breakpoint
ALTER TABLE "folders" ALTER COLUMN "slug" SET NOT NULL;
--> statement-breakpoint
DROP INDEX IF EXISTS "folders_company_kind_name_uq";
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM "pg_constraint" WHERE "conname" = 'folders_parent_id_folders_id_fk'
) THEN
ALTER TABLE "folders" ADD CONSTRAINT "folders_parent_id_folders_id_fk"
FOREIGN KEY ("parent_id") REFERENCES "public"."folders"("id") ON DELETE restrict ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
ALTER TABLE "folders" DROP CONSTRAINT IF EXISTS "folders_company_kind_parent_slug_uq";
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "folders_company_kind_root_slug_uq"
ON "folders" USING btree ("company_id", "kind", "slug")
WHERE "parent_id" IS NULL;
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "folders_company_kind_parent_slug_uq"
ON "folders" USING btree ("company_id", "kind", "parent_id", "slug")
WHERE "parent_id" IS NOT NULL;
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "folders_company_kind_system_key_uq"
ON "folders" USING btree ("company_id", "kind", "system_key")
WHERE "system_key" IS NOT NULL;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "folders_company_kind_parent_position_idx"
ON "folders" USING btree ("company_id", "kind", "parent_id", "position", "name");
--> statement-breakpoint
UPDATE "folders" AS folder
SET "slug" = 'bundled-' || LEFT(REPLACE(folder."id"::text, '-', ''), 8), "updated_at" = now()
WHERE folder."kind" = 'skill'
AND folder."parent_id" IS NULL
AND folder."slug" = 'bundled'
AND folder."system_key" IS NULL
AND EXISTS (
SELECT 1
FROM "company_skills" AS skill
WHERE skill."company_id" = folder."company_id"
AND skill."folder_id" IS NULL
AND (skill."key" LIKE 'paperclipai/bundled/%' OR skill."metadata"->>'sourceKind' = 'paperclip_bundled')
);
--> statement-breakpoint
INSERT INTO "folders" ("company_id", "kind", "parent_id", "name", "slug", "system_key", "position")
SELECT DISTINCT skill."company_id", 'skill', NULL::uuid, 'Bundled', 'bundled', 'bundled', 0
FROM "company_skills" AS skill
WHERE skill."folder_id" IS NULL
AND (skill."key" LIKE 'paperclipai/bundled/%' OR skill."metadata"->>'sourceKind' = 'paperclip_bundled')
AND NOT EXISTS (
SELECT 1 FROM "folders" AS folder
WHERE folder."company_id" = skill."company_id"
AND folder."kind" = 'skill'
AND folder."system_key" = 'bundled'
)
ON CONFLICT DO NOTHING;
--> statement-breakpoint
WITH bundled_categories AS (
SELECT DISTINCT ON (skill."company_id", category.slug)
skill."company_id",
category.name,
category.slug
FROM "company_skills" AS skill
CROSS JOIN LATERAL (
SELECT
COALESCE(NULLIF(SPLIT_PART(skill."key", '/', 3), ''), 'other') AS name,
COALESCE(
NULLIF(TRIM(BOTH '-' FROM REGEXP_REPLACE(LOWER(COALESCE(NULLIF(SPLIT_PART(skill."key", '/', 3), ''), 'other')), '[^a-z0-9]+', '-', 'g')), ''),
'other'
) AS slug
) AS category
WHERE skill."folder_id" IS NULL
AND (skill."key" LIKE 'paperclipai/bundled/%' OR skill."metadata"->>'sourceKind' = 'paperclip_bundled')
ORDER BY skill."company_id", category.slug, category.name
)
INSERT INTO "folders" ("company_id", "kind", "parent_id", "name", "slug", "system_key", "position")
SELECT category."company_id", 'skill', root."id", category."name", category."slug", 'bundled:' || category."slug", 0
FROM bundled_categories AS category
JOIN "folders" AS root
ON root."company_id" = category."company_id"
AND root."kind" = 'skill'
AND root."system_key" = 'bundled'
ON CONFLICT DO NOTHING;
--> statement-breakpoint
UPDATE "company_skills" AS skill
SET "folder_id" = category_folder."id", "updated_at" = now()
FROM "folders" AS root
JOIN "folders" AS category_folder
ON category_folder."company_id" = root."company_id"
AND category_folder."kind" = 'skill'
AND category_folder."parent_id" = root."id"
WHERE skill."company_id" = root."company_id"
AND root."kind" = 'skill'
AND root."system_key" = 'bundled'
AND skill."folder_id" IS NULL
AND (skill."key" LIKE 'paperclipai/bundled/%' OR skill."metadata"->>'sourceKind' = 'paperclip_bundled')
AND category_folder."system_key" = 'bundled:' || COALESCE(
NULLIF(TRIM(BOTH '-' FROM REGEXP_REPLACE(LOWER(COALESCE(NULLIF(SPLIT_PART(skill."key", '/', 3), ''), 'other')), '[^a-z0-9]+', '-', 'g')), ''),
'other'
);
--> statement-breakpoint
UPDATE "folders" AS folder
SET "slug" = 'projects-' || LEFT(REPLACE(folder."id"::text, '-', ''), 8), "updated_at" = now()
WHERE folder."kind" = 'skill'
AND folder."parent_id" IS NULL
AND folder."slug" = 'projects'
AND folder."system_key" IS NULL
AND EXISTS (
SELECT 1
FROM "company_skills" AS skill
WHERE skill."company_id" = folder."company_id"
AND skill."folder_id" IS NULL
AND skill."metadata"->>'sourceKind' = 'project_scan'
AND skill."metadata"->>'projectId' IS NOT NULL
);
--> statement-breakpoint
INSERT INTO "folders" ("company_id", "kind", "parent_id", "name", "slug", "system_key", "position")
SELECT DISTINCT skill."company_id", 'skill', NULL::uuid, 'Projects', 'projects', 'projects', 1
FROM "company_skills" AS skill
WHERE skill."folder_id" IS NULL
AND skill."metadata"->>'sourceKind' = 'project_scan'
AND skill."metadata"->>'projectId' IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM "folders" AS folder
WHERE folder."company_id" = skill."company_id"
AND folder."kind" = 'skill'
AND folder."system_key" = 'projects'
)
ON CONFLICT DO NOTHING;
--> statement-breakpoint
WITH project_sources AS (
SELECT DISTINCT ON (skill."company_id", skill."metadata"->>'projectId')
skill."company_id",
skill."metadata"->>'projectId' AS project_id,
COALESCE(project."name", NULLIF(skill."metadata"->>'projectName', ''), 'Project') AS project_name
FROM "company_skills" AS skill
LEFT JOIN "projects" AS project
ON project."id"::text = skill."metadata"->>'projectId'
AND project."company_id" = skill."company_id"
WHERE skill."folder_id" IS NULL
AND skill."metadata"->>'sourceKind' = 'project_scan'
AND skill."metadata"->>'projectId' IS NOT NULL
ORDER BY skill."company_id", skill."metadata"->>'projectId', skill."id"
), project_folders AS (
SELECT
source."company_id",
source.project_id,
source.project_name,
container."id" AS parent_id,
COALESCE(
NULLIF(TRIM(BOTH '-' FROM REGEXP_REPLACE(LOWER(source.project_name), '[^a-z0-9]+', '-', 'g')), ''),
'project'
) AS base_slug
FROM project_sources AS source
JOIN "folders" AS container
ON container."company_id" = source."company_id"
AND container."kind" = 'skill'
AND container."system_key" = 'projects'
), ranked_project_folders AS (
SELECT
source.*,
ROW_NUMBER() OVER (
PARTITION BY source."company_id", source.parent_id, source.base_slug
ORDER BY source.project_id
) AS duplicate_number
FROM project_folders AS source
)
INSERT INTO "folders" ("company_id", "kind", "parent_id", "name", "slug", "system_key", "position")
SELECT
source."company_id",
'skill',
source.parent_id,
source.project_name,
CASE
WHEN sibling."id" IS NULL AND source.duplicate_number = 1 THEN source.base_slug
ELSE source.base_slug || '-' || LEFT(source.project_id, 8)
END,
'project:' || source.project_id,
0
FROM ranked_project_folders AS source
LEFT JOIN "folders" AS sibling
ON sibling."company_id" = source."company_id"
AND sibling."kind" = 'skill'
AND sibling."parent_id" = source.parent_id
AND sibling."slug" = source.base_slug
WHERE NOT EXISTS (
SELECT 1 FROM "folders" AS existing
WHERE existing."company_id" = source."company_id"
AND existing."kind" = 'skill'
AND existing."system_key" = 'project:' || source.project_id
)
ON CONFLICT DO NOTHING;
--> statement-breakpoint
UPDATE "company_skills" AS skill
SET "folder_id" = project_folder."id", "updated_at" = now()
FROM "folders" AS project_folder
WHERE project_folder."company_id" = skill."company_id"
AND project_folder."kind" = 'skill'
AND project_folder."system_key" = 'project:' || (skill."metadata"->>'projectId')
AND skill."folder_id" IS NULL
AND skill."metadata"->>'sourceKind' = 'project_scan';

View File

@ -1205,6 +1205,20 @@
"when": 1784210753027,
"tag": "0173_inbox_policy_agent_cleanup",
"breakpoints": true
},
{
"idx": 174,
"version": "7",
"when": 1784210754027,
"tag": "0174_folders",
"breakpoints": true
},
{
"idx": 175,
"version": "7",
"when": 1784210755027,
"tag": "0175_nested_skill_folders",
"breakpoints": true
}
]
}

View File

@ -0,0 +1,123 @@
import { createHash, randomUUID } from "node:crypto";
import fs from "node:fs";
import { afterEach, describe, expect, it } from "vitest";
import postgres from "postgres";
import { applyPendingMigrations } from "./client.js";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./test-embedded-postgres.js";
const MIGRATION_FILE = "0175_nested_skill_folders.sql";
const cleanups: Array<() => Promise<void>> = [];
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
async function migrationHash() {
const content = await fs.promises.readFile(new URL(`./migrations/${MIGRATION_FILE}`, import.meta.url), "utf8");
return createHash("sha256").update(content).digest("hex");
}
describeEmbeddedPostgres("nested skill folders migration", () => {
afterEach(async () => {
await Promise.all(cleanups.splice(0).map((cleanup) => cleanup()));
});
it("backfills existing folders, bundled skills, and project scan skills", async () => {
const database = await startEmbeddedPostgresTestDatabase("paperclip-nested-folders-migration-");
cleanups.push(database.cleanup);
const sql = postgres(database.connectionString, { max: 1 });
cleanups.push(async () => sql.end());
await sql`DELETE FROM "drizzle"."__drizzle_migrations" WHERE "hash" = ${await migrationHash()}`;
await sql`DROP INDEX IF EXISTS "folders_company_kind_parent_position_idx"`;
await sql`DROP INDEX IF EXISTS "folders_company_kind_system_key_uq"`;
await sql`DROP INDEX IF EXISTS "folders_company_kind_root_slug_uq"`;
await sql`DROP INDEX IF EXISTS "folders_company_kind_parent_slug_uq"`;
await sql`ALTER TABLE "folders" DROP CONSTRAINT IF EXISTS "folders_company_kind_parent_slug_uq"`;
await sql`ALTER TABLE "folders" DROP CONSTRAINT IF EXISTS "folders_parent_id_folders_id_fk"`;
await sql`ALTER TABLE "folders" DROP COLUMN IF EXISTS "system_key"`;
await sql`ALTER TABLE "folders" DROP COLUMN IF EXISTS "slug"`;
await sql`ALTER TABLE "folders" DROP COLUMN IF EXISTS "parent_id"`;
await sql`CREATE UNIQUE INDEX IF NOT EXISTS "folders_company_kind_name_uq" ON "folders" ("company_id", "kind", "name")`;
const companyId = randomUUID();
const projectId = randomUUID();
const existingFolderId = randomUUID();
const squattedBundledId = randomUUID();
const squattedProjectsId = randomUUID();
const bundledSkillId = randomUUID();
const projectSkillId = randomUUID();
const unfiledSkillId = randomUUID();
await sql`
INSERT INTO "companies" ("id", "name", "issue_prefix")
VALUES (${companyId}, 'Paperclip', 'PAP')
`;
await sql`
INSERT INTO "projects" ("id", "company_id", "name")
VALUES (${projectId}, ${companyId}, 'Agent Platform')
`;
await sql`
INSERT INTO "folders" ("id", "company_id", "kind", "name", "position")
VALUES
(${existingFolderId}, ${companyId}, 'skill', 'Team Notes', 0),
(${squattedBundledId}, ${companyId}, 'skill', 'Bundled', 1),
(${squattedProjectsId}, ${companyId}, 'skill', 'Projects', 2)
`;
await sql`
INSERT INTO "company_skills" ("id", "company_id", "key", "slug", "name", "markdown", "metadata")
VALUES
(${bundledSkillId}, ${companyId}, 'paperclipai/bundled/software-development/review', 'review', 'Review', '# Review', '{"sourceKind":"paperclip_bundled"}'::jsonb),
(${projectSkillId}, ${companyId}, 'company/project-skill', 'project-skill', 'Project Skill', '# Project', ${sql.json({ sourceKind: "project_scan", projectId, projectName: "Agent Platform" })}),
(${unfiledSkillId}, ${companyId}, 'company/unfiled', 'unfiled', 'Unfiled', '# Unfiled', '{}'::jsonb)
`;
await applyPendingMigrations(database.connectionString);
const folderRows = await sql<{
id: string;
parent_id: string | null;
slug: string;
system_key: string | null;
}[]>`
SELECT "id", "parent_id", "slug", "system_key"
FROM "folders"
WHERE "company_id" = ${companyId}
ORDER BY "slug"
`;
expect(folderRows).toEqual(expect.arrayContaining([
expect.objectContaining({ id: existingFolderId, slug: "team-notes", parent_id: null }),
expect.objectContaining({ id: squattedBundledId, slug: `bundled-${squattedBundledId.replace(/-/g, "").slice(0, 8)}`, system_key: null }),
expect.objectContaining({ id: squattedProjectsId, slug: `projects-${squattedProjectsId.replace(/-/g, "").slice(0, 8)}`, system_key: null }),
expect.objectContaining({ slug: "bundled", system_key: "bundled", parent_id: null }),
expect.objectContaining({ slug: "software-development", system_key: "bundled:software-development" }),
expect.objectContaining({ slug: "projects", system_key: "projects", parent_id: null }),
expect.objectContaining({ slug: "agent-platform", system_key: `project:${projectId}` }),
]));
const skills = await sql<{ id: string; folder_id: string | null; folder_slug: string | null }[]>`
SELECT skill."id", skill."folder_id", folder."slug" AS folder_slug
FROM "company_skills" AS skill
LEFT JOIN "folders" AS folder ON folder."id" = skill."folder_id"
WHERE skill."company_id" = ${companyId}
ORDER BY skill."id"
`;
expect(skills.find((skill) => skill.id === bundledSkillId)).toMatchObject({ folder_slug: "software-development" });
expect(skills.find((skill) => skill.id === projectSkillId)).toMatchObject({ folder_slug: "agent-platform" });
expect(skills.find((skill) => skill.id === unfiledSkillId)).toMatchObject({ folder_id: null, folder_slug: null });
const indexes = await sql<{ indexname: string; indexdef: string }[]>`
SELECT "indexname", "indexdef"
FROM "pg_indexes"
WHERE "tablename" = 'folders'
AND "indexname" IN ('folders_company_kind_root_slug_uq', 'folders_company_kind_parent_slug_uq')
ORDER BY "indexname"
`;
expect(indexes).toHaveLength(2);
expect(indexes.map((index) => index.indexdef).join("\n")).not.toContain("NULLS NOT DISTINCT");
expect(indexes.find((index) => index.indexname === "folders_company_kind_root_slug_uq")?.indexdef)
.toContain("WHERE (parent_id IS NULL)");
expect(indexes.find((index) => index.indexname === "folders_company_kind_parent_slug_uq")?.indexdef)
.toContain("WHERE (parent_id IS NOT NULL)");
}, 30_000);
});

View File

@ -13,12 +13,14 @@ import type { CompanySkillFileInventoryEntry, CompanySkillSharingScope } from "@
import { agents } from "./agents.js";
import { companies } from "./companies.js";
import { issues } from "./issues.js";
import { folders } from "./folders.js";
export const companySkills = pgTable(
"company_skills",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id),
folderId: uuid("folder_id").references(() => folders.id, { onDelete: "set null" }),
key: text("key").notNull(),
slug: text("slug").notNull(),
name: text("name").notNull(),
@ -51,6 +53,7 @@ export const companySkills = pgTable(
(table) => ({
companyKeyUniqueIdx: uniqueIndex("company_skills_company_key_idx").on(table.companyId, table.key),
companyNameIdx: index("company_skills_company_name_idx").on(table.companyId, table.name),
companyFolderIdx: index("company_skills_company_folder_idx").on(table.companyId, table.folderId),
companyCategoriesIdx: index("company_skills_company_categories_idx").using("gin", table.categories),
companySharingScopeIdx: index("company_skills_company_sharing_scope_idx").on(table.companyId, table.sharingScope),
companyCurrentVersionIdx: index("company_skills_company_current_version_idx").on(table.companyId, table.currentVersionId),

View File

@ -0,0 +1,54 @@
import {
type AnyPgColumn,
index,
integer,
pgTable,
text,
timestamp,
uniqueIndex,
uuid,
} from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
import { companies } from "./companies.js";
import type { FolderKind } from "@paperclipai/shared";
export const folders = pgTable(
"folders",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
kind: text("kind").$type<FolderKind>().notNull(),
parentId: uuid("parent_id").references((): AnyPgColumn => folders.id, { onDelete: "restrict" }),
name: text("name").notNull(),
slug: text("slug").notNull(),
systemKey: text("system_key"),
color: text("color"),
position: integer("position").notNull().default(0),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyKindPositionIdx: index("folders_company_kind_position_idx").on(
table.companyId,
table.kind,
table.position,
table.name,
),
companyKindRootSlugUniqueIdx: uniqueIndex("folders_company_kind_root_slug_uq")
.on(table.companyId, table.kind, table.slug)
.where(sql`${table.parentId} is null`),
companyKindParentSlugUniqueIdx: uniqueIndex("folders_company_kind_parent_slug_uq")
.on(table.companyId, table.kind, table.parentId, table.slug)
.where(sql`${table.parentId} is not null`),
companyKindSystemKeyUniqueIdx: uniqueIndex("folders_company_kind_system_key_uq")
.on(table.companyId, table.kind, table.systemKey)
.where(sql`${table.systemKey} is not null`),
companyKindParentPositionIdx: index("folders_company_kind_parent_position_idx").on(
table.companyId,
table.kind,
table.parentId,
table.position,
table.name,
),
}),
);

View File

@ -35,6 +35,7 @@ export { workspaceOperations } from "./workspace_operations.js";
export { workspaceRuntimeServices } from "./workspace_runtime_services.js";
export { projectGoals } from "./project_goals.js";
export { goals } from "./goals.js";
export { folders } from "./folders.js";
export { issues } from "./issues.js";
export { issueWatchdogs } from "./issue_watchdogs.js";
export { issuePlanDecompositions } from "./issue_plan_decompositions.js";

View File

@ -17,6 +17,7 @@ import { issues } from "./issues.js";
import { projects } from "./projects.js";
import { goals } from "./goals.js";
import { heartbeatRuns } from "./heartbeat_runs.js";
import { folders } from "./folders.js";
import type { RoutineEnvConfig, RoutineRevisionSnapshotV1, RoutineVariable } from "@paperclipai/shared";
export const routines = pgTable(
@ -25,6 +26,7 @@ export const routines = pgTable(
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
projectId: uuid("project_id").references(() => projects.id, { onDelete: "cascade" }),
folderId: uuid("folder_id").references(() => folders.id, { onDelete: "set null" }),
goalId: uuid("goal_id").references(() => goals.id, { onDelete: "set null" }),
parentIssueId: uuid("parent_issue_id").references(() => issues.id, { onDelete: "set null" }),
title: text("title").notNull(),
@ -56,6 +58,7 @@ export const routines = pgTable(
companyStatusIdx: index("routines_company_status_idx").on(table.companyId, table.status),
companyAssigneeIdx: index("routines_company_assignee_idx").on(table.companyId, table.assigneeAgentId),
companyProjectIdx: index("routines_company_project_idx").on(table.companyId, table.projectId),
companyFolderIdx: index("routines_company_folder_idx").on(table.companyId, table.folderId),
companyResponsibleUserIdx: index("routines_company_responsible_user_idx").on(table.companyId, table.responsibleUserId),
companyOriginIdx: index("routines_company_origin_idx").on(table.companyId, table.originKind, table.originId),
}),

View File

@ -3,6 +3,10 @@ export const API_PREFIX = "/api";
export const API = {
health: `${API_PREFIX}/health`,
companies: `${API_PREFIX}/companies`,
companyFolders: `${API_PREFIX}/companies/:companyId/folders`,
companyFolder: `${API_PREFIX}/companies/:companyId/folders/:folderId`,
companyFolderMove: `${API_PREFIX}/companies/:companyId/folders/:folderId/move`,
companyFolderItemMove: `${API_PREFIX}/companies/:companyId/folders/items/move`,
agents: `${API_PREFIX}/agents`,
projects: `${API_PREFIX}/projects`,
environments: `${API_PREFIX}/environments`,

View File

@ -2101,12 +2101,40 @@ export type {
} from "./environment-support.js";
export type { AdapterRegistryEntry } from "./types/adapter-registry.js";
export type {
FolderKind,
Folder,
FolderListItem,
FolderListResult,
CreateFolderRequest,
UpdateFolderRequest,
MoveFolderRequest,
MoveFolderItemRequest,
EnsureMySkillFolderRequest,
} from "./types/folder.js";
export {
adapterRegistryEntrySchema,
adapterRegistrySchema,
type AdapterRegistryEntryParsed,
} from "./validators/adapter-registry.js";
export {
folderKindSchema,
folderSlugSchema,
folderSchema,
folderListItemSchema,
folderListResultSchema,
createFolderSchema,
updateFolderSchema,
moveFolderSchema,
moveFolderItemSchema,
ensureMySkillFolderSchema,
type CreateFolder,
type UpdateFolder,
type MoveFolder,
type MoveFolderItem,
type EnsureMySkillFolder,
} from "./validators/folder.js";
export {
environmentCustomImageTemplateKindSchema,

View File

@ -34,6 +34,8 @@ export interface CompanySkillVersionFileInventoryEntry extends CompanySkillFileI
export interface CompanySkill {
id: string;
companyId: string;
folderId?: string | null;
folderPath?: string | null;
key: string;
slug: string;
name: string;
@ -67,6 +69,8 @@ export interface CompanySkill {
export interface CompanySkillListItem {
id: string;
companyId: string;
folderId?: string | null;
folderPath?: string | null;
key: string;
slug: string;
name: string;
@ -140,6 +144,8 @@ export interface CompanySkillListQuery {
categories?: string[];
scope?: CompanySkillSharingScope;
include?: CompanySkillListInclude[];
folderId?: string;
includeSubtree?: boolean;
}
export interface CompanySkillCategoryCount {
@ -374,6 +380,7 @@ export interface CompanySkillProjectScanResult {
}
export interface CompanySkillCreateRequest {
folderId?: string | null;
name: string;
slug?: string | null;
description?: string | null;

View File

@ -0,0 +1,59 @@
export type FolderKind = "routine" | "skill";
export interface Folder {
id: string;
companyId: string;
kind: FolderKind;
parentId: string | null;
name: string;
slug: string;
systemKey: string | null;
path: string;
depth: number;
color: string | null;
position: number;
createdAt: Date;
updatedAt: Date;
}
export interface FolderListItem extends Folder {
itemCount: number;
}
export interface FolderListResult {
kind: FolderKind;
folders: FolderListItem[];
allCount: number;
unfiledCount: number;
}
export interface CreateFolderRequest {
kind: FolderKind;
parentId?: string | null;
name: string;
slug?: string | null;
color?: string | null;
position?: number | null;
}
export interface UpdateFolderRequest {
name?: string;
slug?: string;
color?: string | null;
position?: number;
}
export interface MoveFolderRequest {
parentId?: string | null;
position: number;
}
export interface EnsureMySkillFolderRequest {
slug?: string | null;
}
export interface MoveFolderItemRequest {
kind: FolderKind;
itemId: string;
folderId?: string | null;
}

View File

@ -163,6 +163,17 @@ export type {
CompanySkillInstallCatalogRequest,
CompanySkillInstallCatalogResult,
} from "./company-skill.js";
export type {
FolderKind,
Folder,
FolderListItem,
FolderListResult,
CreateFolderRequest,
UpdateFolderRequest,
MoveFolderRequest,
MoveFolderItemRequest,
EnsureMySkillFolderRequest,
} from "./folder.js";
export type {
CatalogTeamKind,
CatalogTeamTrustLevel,

View File

@ -71,6 +71,7 @@ export interface Routine {
id: string;
companyId: string;
projectId: string | null;
folderId?: string | null;
goalId: string | null;
parentIssueId: string | null;
title: string;

View File

@ -20,6 +20,8 @@ export const companySkillVersionFileInventoryEntrySchema = companySkillFileInven
export const companySkillSchema = z.object({
id: z.string().uuid(),
companyId: z.string().uuid(),
folderId: z.string().uuid().nullable().optional(),
folderPath: z.string().nullable().optional(),
key: z.string().min(1),
slug: z.string().min(1),
name: z.string().min(1),
@ -106,6 +108,8 @@ export const companySkillListQuerySchema = z.object({
categories: z.array(z.string().min(1)).optional(),
scope: companySkillSharingScopeSchema.optional(),
include: z.array(companySkillListIncludeSchema).optional(),
folderId: z.string().uuid().optional(),
includeSubtree: z.boolean().optional(),
});
export const companySkillCategoryCountSchema = z.object({
@ -320,6 +324,7 @@ export const companySkillProjectScanResultSchema = z.object({
});
export const companySkillCreateSchema = z.object({
folderId: z.string().uuid().nullable().optional(),
name: z.string().min(1),
slug: z.string().min(1).nullable().optional(),
description: z.string().nullable().optional(),

View File

@ -0,0 +1,73 @@
import { z } from "zod";
export const folderKindSchema = z.enum(["routine", "skill"]);
export const folderSlugSchema = z.string().trim().min(1).max(120).regex(
/^[a-z0-9]+(?:-[a-z0-9]+)*$/,
"Folder slug must contain only lowercase letters, numbers, and single hyphens",
);
export const folderSchema = z.object({
id: z.string().uuid(),
companyId: z.string().uuid(),
kind: folderKindSchema,
parentId: z.string().uuid().nullable(),
name: z.string().min(1),
slug: folderSlugSchema,
systemKey: z.string().nullable(),
path: z.string().min(1),
depth: z.number().int().min(1),
color: z.string().nullable(),
position: z.number().int(),
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
});
export const folderListItemSchema = folderSchema.extend({
itemCount: z.number().int().nonnegative(),
});
export const folderListResultSchema = z.object({
kind: folderKindSchema,
folders: z.array(folderListItemSchema),
allCount: z.number().int().nonnegative(),
unfiledCount: z.number().int().nonnegative(),
});
export const createFolderSchema = z.object({
kind: folderKindSchema,
parentId: z.string().uuid().optional().nullable(),
name: z.string().trim().min(1).max(120),
slug: folderSlugSchema.optional().nullable(),
color: z.string().trim().min(1).max(80).optional().nullable(),
position: z.number().int().min(0).optional().nullable(),
});
export const updateFolderSchema = z.object({
name: z.string().trim().min(1).max(120).optional(),
slug: folderSlugSchema.optional(),
color: z.string().trim().min(1).max(80).optional().nullable(),
position: z.number().int().min(0).optional(),
}).refine((value) => Object.keys(value).length > 0, {
message: "At least one folder field is required",
});
export const moveFolderSchema = z.object({
parentId: z.string().uuid().optional().nullable(),
position: z.number().int().min(0),
});
export const ensureMySkillFolderSchema = z.object({
slug: folderSlugSchema.optional().nullable(),
}).default({});
export const moveFolderItemSchema = z.object({
kind: folderKindSchema,
itemId: z.string().uuid(),
folderId: z.string().uuid().optional().nullable(),
});
export type CreateFolder = z.infer<typeof createFolderSchema>;
export type UpdateFolder = z.infer<typeof updateFolderSchema>;
export type MoveFolder = z.infer<typeof moveFolderSchema>;
export type MoveFolderItem = z.infer<typeof moveFolderItemSchema>;
export type EnsureMySkillFolder = z.infer<typeof ensureMySkillFolderSchema>;

View File

@ -194,6 +194,23 @@ export {
type CompanySkillInstallUpdate,
type CompanySkillReset,
} from "./company-skill.js";
export {
folderKindSchema,
folderSlugSchema,
folderSchema,
folderListItemSchema,
folderListResultSchema,
createFolderSchema,
updateFolderSchema,
moveFolderSchema,
moveFolderItemSchema,
ensureMySkillFolderSchema,
type CreateFolder,
type UpdateFolder,
type MoveFolder,
type MoveFolderItem,
type EnsureMySkillFolder,
} from "./folder.js";
export {
catalogTeamKindSchema,
catalogTeamTrustLevelSchema,

View File

@ -61,6 +61,7 @@ export const routineVariableSchema = z.object({
export const createRoutineSchema = z.object({
projectId: z.string().uuid().optional().nullable(),
folderId: z.string().uuid().optional().nullable(),
goalId: z.string().uuid().optional().nullable(),
parentIssueId: z.string().uuid().optional().nullable(),
title: z.string().trim().min(1).max(200),
@ -85,6 +86,7 @@ export const routineRevisionSnapshotRoutineV1Schema = z.object({
id: z.string().uuid(),
companyId: z.string().uuid(),
projectId: z.string().uuid().nullable(),
folderId: z.string().uuid().nullable().optional(),
goalId: z.string().uuid().nullable(),
parentIssueId: z.string().uuid().nullable(),
title: z.string().trim().min(1).max(200),

View File

@ -4,7 +4,7 @@ import path from "node:path";
import { promises as fs } from "node:fs";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { and, eq } from "drizzle-orm";
import { companies, companySkills, createDb } from "@paperclipai/db";
import { companies, companySkills, createDb, folders } from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
@ -132,6 +132,7 @@ describeEmbeddedPostgres("companySkillService.installFromCatalog", () => {
afterEach(async () => {
await db.delete(companySkills);
await db.delete(folders);
await db.delete(companies);
await Promise.all(Array.from(cleanupDirs, (dir) => fs.rm(dir, { recursive: true, force: true })));
cleanupDirs.clear();
@ -154,6 +155,7 @@ describeEmbeddedPostgres("companySkillService.installFromCatalog", () => {
expect(result.action).toBe("created");
expect(result.skill).toMatchObject({
companyId,
folderId: expect.any(String),
key: sampleCatalogSkill.key,
slug: sampleCatalogSkill.slug,
sourceType: "catalog",
@ -182,6 +184,37 @@ describeEmbeddedPostgres("companySkillService.installFromCatalog", () => {
packageName: "@paperclipai/skills-catalog",
packageVersion: "0.3.1",
});
const folder = await db
.select()
.from(folders)
.where(eq(folders.id, result.skill.folderId!))
.then((rows) => rows[0]);
expect(folder).toMatchObject({
name: "Software Development",
slug: "software-development",
systemKey: "bundled:software-development",
});
});
it("repairs an existing unfiled Paperclip catalog skill during inventory refresh", async () => {
const companyId = await createCompany();
const installed = await svc.installFromCatalog(companyId, { catalogSkillId: sampleCatalogSkill.id });
await db
.update(companySkills)
.set({ folderId: null })
.where(eq(companySkills.id, installed.skill.id));
const listed = await svc.list(companyId);
const repaired = listed.find((skill) => skill.id === installed.skill.id);
const folder = repaired?.folderId
? await db.select().from(folders).where(eq(folders.id, repaired.folderId)).then((rows) => rows[0])
: null;
expect(repaired?.folderId).toEqual(expect.any(String));
expect(folder).toMatchObject({
name: "Software Development",
systemKey: "bundled:software-development",
});
});
it("materializes catalog asset files without UTF-8 rewriting", async () => {

View File

@ -11,6 +11,7 @@ import {
companySkillVersions,
companySkills,
createDb,
folders,
projects,
projectWorkspaces,
} from "@paperclipai/db";
@ -19,6 +20,7 @@ import {
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { companySkillService } from "../services/company-skills.ts";
import { folderService } from "../services/folders.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
@ -63,6 +65,7 @@ describeEmbeddedPostgres("companySkillService.list", () => {
await db.delete(companySkills);
await db.delete(projectWorkspaces);
await db.delete(projects);
await db.delete(folders);
await db.delete(companies);
await db.delete(authUsers);
await Promise.all(Array.from(cleanupDirs, (dir) => fs.rm(dir, { recursive: true, force: true })));
@ -310,6 +313,13 @@ describeEmbeddedPostgres("companySkillService.list", () => {
const bundledSkill = initialList.find((skill) => skill.key.startsWith("paperclipai/paperclip/"));
expect(bundledSkill).toBeDefined();
if (!bundledSkill) throw new Error("Expected bundled Paperclip skills fixture");
const bundledFolder = bundledSkill.folderId
? await db.select().from(folders).where(eq(folders.id, bundledSkill.folderId)).then((rows) => rows[0])
: null;
expect(bundledFolder).toMatchObject({
name: "Paperclip Core",
systemKey: "bundled:paperclip-core",
});
const preservedUpdatedAt = new Date("2026-01-01T00:00:00.000Z");
await db
@ -323,6 +333,34 @@ describeEmbeddedPostgres("companySkillService.list", () => {
expect(refreshedSkill?.updatedAt.toISOString()).toBe(preservedUpdatedAt.toISOString());
});
it("repairs a squatted bundled root during bundled-skill list refresh", async () => {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
const [squatted] = await db.insert(folders).values({
companyId,
kind: "skill",
parentId: null,
name: "User Bundled",
slug: "bundled",
position: 0,
}).returning();
const listed = await svc.list(companyId);
const folderRows = await db.select().from(folders).where(eq(folders.companyId, companyId));
const bundledRoot = folderRows.find((folder) => folder.systemKey === "bundled");
const repairedSquat = folderRows.find((folder) => folder.id === squatted!.id);
expect(listed.some((skill) => skill.key.startsWith("paperclipai/paperclip/"))).toBe(true);
expect(bundledRoot).toMatchObject({ slug: "bundled", parentId: null, systemKey: "bundled" });
expect(repairedSquat).toMatchObject({ name: "User Bundled", systemKey: null });
expect(repairedSquat?.slug).toMatch(/^bundled-[a-f0-9]{8}$/);
});
it("does not retouch bundled skills with stale missing-source metadata during list refresh", async () => {
const companyId = randomUUID();
await db.insert(companies).values({
@ -686,6 +724,97 @@ describeEmbeddedPostgres("companySkillService.list", () => {
await expect(svc.categoryCounts(companyId)).resolves.toEqual([]);
});
it("filters by folder subtree, keeps search global, and returns canonical folder paths", async () => {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
const folderSvc = folderService(db);
const engineering = await folderSvc.create(companyId, { kind: "skill", name: "Engineering" });
const reviews = await folderSvc.create(companyId, { kind: "skill", parentId: engineering.id, name: "Reviews" });
const operations = await folderSvc.create(companyId, { kind: "skill", name: "Operations" });
const reviewDir = await createManagedSkillDir(companyId, "review-");
const deployDir = await createManagedSkillDir(companyId, "deploy-");
await fs.writeFile(path.join(reviewDir, "SKILL.md"), "# Review\n", "utf8");
await fs.writeFile(path.join(deployDir, "SKILL.md"), "# Deploy\n", "utf8");
await db.insert(companySkills).values([
{
companyId,
folderId: reviews.id,
key: `company/${companyId}/review`,
slug: "review",
name: "Review",
markdown: "# Review",
sourceType: "local_path",
sourceLocator: reviewDir,
categories: ["engineering"],
},
{
companyId,
folderId: operations.id,
key: `company/${companyId}/deploy`,
slug: "deploy",
name: "Deploy",
markdown: "# Deploy",
sourceType: "local_path",
sourceLocator: deployDir,
categories: ["operations"],
},
]);
await expect(svc.list(companyId, {
folderId: engineering.id,
includeSubtree: true,
categories: ["engineering"],
})).resolves.toEqual([
expect.objectContaining({ name: "Review", folderPath: "engineering/reviews" }),
]);
await expect(svc.list(companyId, { folderId: engineering.id })).resolves.toEqual([]);
await expect(svc.list(companyId, { folderId: engineering.id, q: "deploy" })).resolves.toEqual([
expect.objectContaining({ name: "Deploy", folderPath: "operations" }),
]);
const review = (await svc.list(companyId)).find((skill) => skill.name === "Review");
await expect(svc.getById(companyId, review!.id)).resolves.toMatchObject({
name: "Review",
folderPath: "engineering/reviews",
});
});
it("creates skills in same-company folders and rejects cross-company folder references", async () => {
const companyId = randomUUID();
const otherCompanyId = randomUUID();
await db.insert(companies).values([
{
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
},
{
id: otherCompanyId,
name: "Other",
issuePrefix: `T${otherCompanyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
},
]);
const folderSvc = folderService(db);
const folder = await folderSvc.create(companyId, { kind: "skill", name: "Personal" });
const otherFolder = await folderSvc.create(otherCompanyId, { kind: "skill", name: "Private" });
await expect(svc.createLocalSkill(companyId, {
name: "Filed Skill",
folderId: folder.id,
})).resolves.toMatchObject({ folderId: folder.id });
await expect(svc.createLocalSkill(companyId, {
name: "Cross Company Skill",
folderId: otherFolder.id,
})).rejects.toMatchObject({ status: 404, message: "Skill folder not found" });
});
it("resolves detail by unique skill slug for Studio deep links", async () => {
const companyId = randomUUID();
await db.insert(companies).values({
@ -2327,4 +2456,60 @@ describeEmbeddedPostgres("companySkillService.list", () => {
const persisted = await db.select().from(companySkills).where(eq(companySkills.companyId, companyId));
expect(persisted.filter((skill) => skill.metadata?.sourceKind === "project_scan")).toEqual([]);
});
it("files new project imports without moving them back on re-import", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
const workspaceId = randomUUID();
const folderSvc = folderService(db);
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skill-project-folder-"));
cleanupDirs.add(workspaceDir);
const skillDir = path.join(workspaceDir, "skills", "project-skill");
const skillFile = path.join(skillDir, "SKILL.md");
await fs.mkdir(skillDir, { recursive: true });
await fs.writeFile(skillFile, "---\nname: Project Skill\n---\n\nInitial content.\n", "utf8");
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(projects).values({ id: projectId, companyId, name: "Skills Project" });
await db.insert(projectWorkspaces).values({
id: workspaceId,
companyId,
projectId,
name: "Primary",
cwd: workspaceDir,
isPrimary: true,
});
const firstImport = await svc.scanProjectWorkspaces(companyId, { projectIds: [projectId] });
expect(firstImport.imported).toHaveLength(1);
const importedSkill = firstImport.imported[0]!;
const projectFolder = await folderSvc.getFolder(companyId, importedSkill.folderId!);
expect(projectFolder).toMatchObject({
path: "projects/skills-project",
systemKey: `project:${projectId}`,
});
const personalFolder = await folderSvc.create(companyId, { kind: "skill", name: "Personal" });
await folderSvc.moveItem(companyId, {
kind: "skill",
itemId: importedSkill.id,
folderId: personalFolder.id,
});
await fs.writeFile(skillFile, "---\nname: Project Skill\n---\n\nUpdated content.\n", "utf8");
const reimport = await svc.scanProjectWorkspaces(companyId, { projectIds: [projectId] });
expect(reimport.updated).toHaveLength(1);
expect(reimport.updated[0]).toMatchObject({
id: importedSkill.id,
folderId: personalFolder.id,
markdown: expect.stringContaining("Updated content."),
});
});
});

View File

@ -0,0 +1,74 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockFolderService = vi.hoisted(() => ({
list: vi.fn(),
create: vi.fn(),
update: vi.fn(),
moveFolder: vi.fn(),
moveItem: vi.fn(),
deleteFolder: vi.fn(),
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
vi.mock("../services/index.js", () => ({
folderService: () => mockFolderService,
logActivity: mockLogActivity,
}));
async function createApp() {
vi.resetModules();
const [{ errorHandler }, { folderRoutes }] = await Promise.all([
import("../middleware/index.js") as Promise<typeof import("../middleware/index.js")>,
import("../routes/folders.js") as Promise<typeof import("../routes/folders.js")>,
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = {
type: "board",
userId: "user-1",
companyIds: ["company-1"],
source: "session",
isInstanceAdmin: false,
};
next();
});
app.use("/api", folderRoutes({} as any));
app.use(errorHandler);
return app;
}
describe("folder routes", () => {
beforeEach(() => {
for (const mock of Object.values(mockFolderService)) mock.mockReset();
mockLogActivity.mockReset();
});
it("routes item moves to the item move handler before the folder reorder route", async () => {
mockFolderService.moveItem.mockResolvedValue({
kind: "routine",
itemId: "11111111-1111-4111-8111-111111111111",
folderId: "22222222-2222-4222-8222-222222222222",
});
const app = await createApp();
const res = await request(app)
.post("/api/companies/company-1/folders/items/move")
.send({
kind: "routine",
itemId: "11111111-1111-4111-8111-111111111111",
folderId: "22222222-2222-4222-8222-222222222222",
});
expect(res.status).toBe(200);
expect(mockFolderService.moveItem).toHaveBeenCalledWith("company-1", {
kind: "routine",
itemId: "11111111-1111-4111-8111-111111111111",
folderId: "22222222-2222-4222-8222-222222222222",
});
expect(mockFolderService.moveFolder).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,447 @@
import { randomUUID } from "node:crypto";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { eq, sql } from "drizzle-orm";
import {
folderSlugSchema,
} from "@paperclipai/shared";
import {
companies,
companySkills,
createDb,
folders,
routines,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { folderService } from "../services/folders.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
describeEmbeddedPostgres("folder service", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-folders-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
await db.delete(companySkills);
await db.delete(routines);
await db.delete(folders);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
async function seedCompany() {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
defaultResponsibleUserId: "responsible-user",
});
return companyId;
}
async function seedRoutine(companyId: string, title: string, folderId?: string | null) {
const [routine] = await db
.insert(routines)
.values({
companyId,
title,
folderId: folderId ?? null,
responsibleUserId: "responsible-user",
})
.returning();
return routine!;
}
async function seedSkill(companyId: string, slug: string, folderId?: string | null) {
const [skill] = await db
.insert(companySkills)
.values({
companyId,
folderId: folderId ?? null,
key: `company/${companyId}/${slug}`,
slug,
name: slug,
markdown: `# ${slug}`,
})
.returning();
return skill!;
}
it("creates, updates, reorders, and lists routine folders with counts", async () => {
const companyId = await seedCompany();
const svc = folderService(db);
const reporting = await svc.create(companyId, {
kind: "routine",
name: "Reporting",
color: "green",
});
const cleanup = await svc.create(companyId, {
kind: "routine",
name: "Cleanup",
color: null,
});
await seedRoutine(companyId, "Filed", reporting.id);
await seedRoutine(companyId, "Unfiled");
const renamed = await svc.update(companyId, cleanup.id, { name: "Ops", color: "cyan" });
expect(renamed).toMatchObject({ id: cleanup.id, name: "Ops", color: "cyan" });
const cleared = await svc.update(companyId, cleanup.id, { color: null });
expect(cleared).toMatchObject({ id: cleanup.id, color: null });
const movedFolder = await svc.moveFolder(companyId, reporting.id, { position: 10 });
expect(movedFolder).toMatchObject({ id: reporting.id, position: 10 });
const listed = await svc.list(companyId, "routine");
expect(listed.allCount).toBe(2);
expect(listed.unfiledCount).toBe(1);
expect(listed.folders).toEqual([
expect.objectContaining({ id: cleanup.id, name: "Ops", itemCount: 0 }),
expect.objectContaining({ id: reporting.id, name: "Reporting", itemCount: 1 }),
]);
});
it("moves routines and skills to folders and back to virtual Unfiled", async () => {
const companyId = await seedCompany();
const svc = folderService(db);
const routineFolder = await svc.create(companyId, { kind: "routine", name: "Reports" });
const skillFolder = await svc.create(companyId, { kind: "skill", name: "Runtime" });
const routine = await seedRoutine(companyId, "Daily report");
const skill = await seedSkill(companyId, "review");
await expect(svc.moveItem(companyId, {
kind: "routine",
itemId: routine.id,
folderId: routineFolder.id,
})).resolves.toEqual({ kind: "routine", itemId: routine.id, folderId: routineFolder.id });
await expect(svc.moveItem(companyId, {
kind: "skill",
itemId: skill.id,
folderId: skillFolder.id,
})).resolves.toEqual({ kind: "skill", itemId: skill.id, folderId: skillFolder.id });
await expect(svc.moveItem(companyId, {
kind: "routine",
itemId: routine.id,
folderId: null,
})).resolves.toEqual({ kind: "routine", itemId: routine.id, folderId: null });
const [updatedRoutine] = await db.select().from(routines).where(eq(routines.id, routine.id));
const [updatedSkill] = await db.select().from(companySkills).where(eq(companySkills.id, skill.id));
expect(updatedRoutine?.folderId).toBeNull();
expect(updatedSkill?.folderId).toBe(skillFolder.id);
});
it("rejects moving an item into a folder of the wrong kind", async () => {
const companyId = await seedCompany();
const svc = folderService(db);
const skillFolder = await svc.create(companyId, { kind: "skill", name: "Runtime" });
const routine = await seedRoutine(companyId, "Daily report");
await expect(svc.moveItem(companyId, {
kind: "routine",
itemId: routine.id,
folderId: skillFolder.id,
})).rejects.toMatchObject({
status: 422,
message: "Folder kind must match item kind",
});
});
it("deletes folders without deleting contents by moving items to Unfiled", async () => {
const companyId = await seedCompany();
const svc = folderService(db);
const folder = await svc.create(companyId, { kind: "routine", name: "Reports" });
const routine = await seedRoutine(companyId, "Daily report", folder.id);
const deleted = await svc.deleteFolder(companyId, folder.id);
expect(deleted).toMatchObject({ id: folder.id, name: "Reports" });
const [updatedRoutine] = await db.select().from(routines).where(eq(routines.id, routine.id));
expect(updatedRoutine?.folderId).toBeNull();
expect(await db.select().from(folders).where(eq(folders.id, folder.id))).toHaveLength(0);
});
it("computes canonical paths and updates descendant paths after rename and move", async () => {
const companyId = await seedCompany();
const svc = folderService(db);
const root = await svc.create(companyId, { kind: "skill", name: "Engineering" });
const child = await svc.create(companyId, { kind: "skill", parentId: root.id, name: "Code Review" });
const destination = await svc.create(companyId, { kind: "skill", name: "Operations" });
expect(child).toMatchObject({ path: "engineering/code-review", depth: 2 });
await svc.update(companyId, root.id, { name: "Product Engineering" });
expect(await svc.getFolder(companyId, child.id)).toMatchObject({
path: "product-engineering/code-review",
depth: 2,
});
await svc.moveFolder(companyId, child.id, { parentId: destination.id, position: 0 });
expect(await svc.getFolder(companyId, child.id)).toMatchObject({
parentId: destination.id,
path: "operations/code-review",
depth: 2,
});
});
it("rejects invalid slugs, cycles, and folders deeper than four levels", async () => {
expect(folderSlugSchema.safeParse("../escape").success).toBe(false);
expect(folderSlugSchema.safeParse("Valid Slug").success).toBe(false);
expect(folderSlugSchema.safeParse("valid-slug-2").success).toBe(true);
const companyId = await seedCompany();
const svc = folderService(db);
const level1 = await svc.create(companyId, { kind: "skill", name: "Level 1" });
const level2 = await svc.create(companyId, { kind: "skill", parentId: level1.id, name: "Level 2" });
const level3 = await svc.create(companyId, { kind: "skill", parentId: level2.id, name: "Level 3" });
const level4 = await svc.create(companyId, { kind: "skill", parentId: level3.id, name: "Level 4" });
await expect(svc.create(companyId, {
kind: "skill",
parentId: level4.id,
name: "Level 5",
})).rejects.toMatchObject({ status: 422, message: "Folder depth cannot exceed 4" });
await expect(svc.moveFolder(companyId, level1.id, {
parentId: level3.id,
position: 0,
})).rejects.toMatchObject({ status: 422, message: "A folder cannot be moved into its own subtree" });
});
it("creates stable personal roots and protects bundled folders", async () => {
const companyId = await seedCompany();
const svc = folderService(db);
const personal = await svc.ensureMyFolder(companyId, "user-1", "Ada Lovelace");
const repeated = await svc.ensureMyFolder(companyId, "user-1", "Ada Lovelace");
const bundled = await svc.ensureBundledCategory(companyId, "software-development");
expect(repeated.id).toBe(personal.id);
expect(personal).toMatchObject({ systemKey: "my:user-1", path: "my/ada-lovelace", depth: 2 });
expect(bundled.path).toBe("bundled/software-development");
await expect(svc.create(companyId, {
kind: "skill",
parentId: bundled.id,
name: "Nested",
})).rejects.toMatchObject({ status: 403, message: "Bundled folders are read-only" });
await expect(svc.update(companyId, bundled.id, { name: "Changed" })).rejects.toMatchObject({ status: 403 });
});
it("heals legacy bundled category names without changing folder identity", async () => {
const companyId = await seedCompany();
const svc = folderService(db);
const legacy = await svc.ensureBundledCategory(companyId, "software-development");
const reconciled = await svc.ensureBundledCategory(companyId, "Software Development");
expect(reconciled).toMatchObject({
id: legacy.id,
name: "Software Development",
path: "bundled/software-development",
systemKey: "bundled:software-development",
});
});
it("creates reserved folders idempotently under concurrent requests", async () => {
const companyId = await seedCompany();
const svc = folderService(db);
const [personalA, personalB] = await Promise.all([
svc.ensureMyFolder(companyId, "user-1", "Ada Lovelace"),
svc.ensureMyFolder(companyId, "user-1", "Ada Lovelace"),
]);
const [projectA, projectB] = await Promise.all([
svc.ensureProjectFolder(companyId, "project-1", "Core App"),
svc.ensureProjectFolder(companyId, "project-1", "Core App"),
]);
const [bundledA, bundledB] = await Promise.all([
svc.ensureBundledCategory(companyId, "software-development"),
svc.ensureBundledCategory(companyId, "software-development"),
]);
expect(personalA.id).toBe(personalB.id);
expect(projectA.id).toBe(projectB.id);
expect(bundledA.id).toBe(bundledB.id);
});
it("reserves system skill roots from manual create, update, and move", async () => {
const companyId = await seedCompany();
const svc = folderService(db);
for (const slug of ["bundled", "my", "projects"]) {
await expect(svc.create(companyId, {
kind: "skill",
name: slug,
slug,
})).rejects.toMatchObject({ status: 403, message: "Reserved skill folders are system-managed" });
}
const editable = await svc.create(companyId, { kind: "skill", name: "Editable" });
await expect(svc.update(companyId, editable.id, { slug: "bundled" })).rejects.toMatchObject({ status: 403 });
const parent = await svc.create(companyId, { kind: "skill", name: "Parent" });
const nestedReserved = await svc.create(companyId, { kind: "skill", parentId: parent.id, name: "Projects" });
await expect(svc.moveFolder(companyId, nestedReserved.id, { parentId: null, position: 0 })).rejects.toMatchObject({
status: 403,
message: "Reserved skill folders are system-managed",
});
});
it("allows only system helpers to create children under personal and project roots", async () => {
const companyId = await seedCompany();
const svc = folderService(db);
const personal = await svc.ensureMyFolder(companyId, "user-1", "Ada Lovelace");
const project = await svc.ensureProjectFolder(companyId, "project-1", "Core App");
const myRoot = await svc.getFolder(companyId, personal.parentId!);
const projectsRoot = await svc.getFolder(companyId, project.parentId!);
const movable = await svc.create(companyId, { kind: "skill", name: "Movable" });
expect(myRoot?.systemKey).toBe("my");
expect(projectsRoot?.systemKey).toBe("projects");
await expect(svc.create(companyId, {
kind: "skill",
parentId: myRoot!.id,
name: "Spoofed User",
})).rejects.toMatchObject({ status: 403 });
await expect(svc.moveFolder(companyId, movable.id, {
parentId: projectsRoot!.id,
position: 0,
})).rejects.toMatchObject({ status: 403 });
});
it("moves squatted roots aside instead of adopting them as system containers", async () => {
const companyId = await seedCompany();
const svc = folderService(db);
const [squattedMy, squattedProjects] = await db.insert(folders).values([
{ companyId, kind: "skill", parentId: null, name: "Attacker My", slug: "my", position: 0 },
{ companyId, kind: "skill", parentId: null, name: "Attacker Projects", slug: "projects", position: 1 },
]).returning();
const personal = await svc.ensureMyFolder(companyId, "user-1", "Ada Lovelace");
const project = await svc.ensureProjectFolder(companyId, "project-1", "Core App");
const myRoot = await svc.getFolder(companyId, personal.parentId!);
const projectsRoot = await svc.getFolder(companyId, project.parentId!);
const repairedMy = await svc.getFolder(companyId, squattedMy!.id);
const repairedProjects = await svc.getFolder(companyId, squattedProjects!.id);
expect(myRoot).toMatchObject({ slug: "my", systemKey: "my" });
expect(projectsRoot).toMatchObject({ slug: "projects", systemKey: "projects" });
expect(repairedMy).toMatchObject({ name: "Attacker My", systemKey: null });
expect(repairedMy?.slug).toMatch(/^my-[a-f0-9]{8}$/);
expect(repairedProjects).toMatchObject({ name: "Attacker Projects", systemKey: null });
expect(repairedProjects?.slug).toMatch(/^projects-[a-f0-9]{8}$/);
});
it("suffixes system children when legacy rows squat personal and project slugs", async () => {
const companyId = await seedCompany();
const svc = folderService(db);
const initialPersonal = await svc.ensureMyFolder(companyId, "seed-user", "Seed User");
const initialProject = await svc.ensureProjectFolder(companyId, "seed-project", "Seed Project");
const myRootId = initialPersonal.parentId!;
const projectsRootId = initialProject.parentId!;
await db.insert(folders).values([
{ companyId, kind: "skill", parentId: myRootId, name: "Ada Squat", slug: "ada-lovelace", position: 1 },
{ companyId, kind: "skill", parentId: projectsRootId, name: "Core Squat", slug: "core-app", position: 1 },
]);
const personal = await svc.ensureMyFolder(companyId, "user-12345678", "Ada Lovelace");
const project = await svc.ensureProjectFolder(companyId, "project-12345678", "Core App");
expect(personal).toMatchObject({ path: "my/ada-lovelace-user-12345678", systemKey: "my:user-12345678" });
expect(project).toMatchObject({ path: "projects/core-app-project-12345678", systemKey: "project:project-12345678" });
});
it("does not adopt a legacy category row under the bundled root", async () => {
const companyId = await seedCompany();
const svc = folderService(db);
const initialCategory = await svc.ensureBundledCategory(companyId, "initial");
const bundledRootId = initialCategory.parentId!;
const [squatted] = await db.insert(folders).values({
companyId,
kind: "skill",
parentId: bundledRootId,
name: "User Software Development",
slug: "software-development",
position: 1,
}).returning();
const category = await svc.ensureBundledCategory(companyId, "software-development");
expect(category).toMatchObject({
path: "bundled/software-development-bundled",
systemKey: "bundled:software-development",
});
expect(await svc.getFolder(companyId, squatted!.id)).toMatchObject({
path: "bundled/software-development",
systemKey: null,
});
});
it("serializes concurrent system folder ensures", async () => {
const companyId = await seedCompany();
const svc = folderService(db);
await db.insert(folders).values({
companyId,
kind: "skill",
name: "Squatted My",
slug: "my",
position: 0,
});
const personalFolders = await Promise.all(
Array.from({ length: 8 }, () => svc.ensureMyFolder(companyId, "user-123", "Ada Lovelace")),
);
expect(new Set(personalFolders.map((folder) => folder.id)).size).toBe(1);
const rows = await db.select().from(folders).where(eq(folders.companyId, companyId));
expect(rows.filter((row) => row.systemKey === "my")).toHaveLength(1);
expect(rows.filter((row) => row.systemKey === "my:user-123")).toHaveLength(1);
expect(rows.find((row) => row.systemKey === null)).toMatchObject({ name: "Squatted My" });
});
it("rechecks nested folders after waiting for the company mutation lock", async () => {
const companyId = await seedCompany();
const svc = folderService(db);
const parent = await svc.create(companyId, { kind: "routine", name: "Parent" });
let releaseLock!: () => void;
let markLockAcquired!: () => void;
const lockAcquired = new Promise<void>((resolve) => { markLockAcquired = resolve; });
const holdLock = new Promise<void>((resolve) => { releaseLock = resolve; });
const lockKey = `paperclip:folders:${companyId}`;
const blocker = db.transaction(async (tx) => {
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${lockKey}, 0))`);
markLockAcquired();
await holdLock;
});
await lockAcquired;
const deletion = svc.deleteFolder(companyId, parent.id);
await db.insert(folders).values({
companyId,
kind: "routine",
parentId: parent.id,
name: "Child",
slug: "child",
position: 0,
});
releaseLock();
await blocker;
await expect(deletion).rejects.toMatchObject({
status: 409,
message: "Move or delete nested folders first",
});
await expect(svc.getFolder(companyId, parent.id)).resolves.toMatchObject({ id: parent.id });
});
});

View File

@ -30,6 +30,7 @@ const apiPrefixes: Record<string, string> = {
"environments.ts": "/api",
"execution-workspaces.ts": "/api",
"file-resources.ts": "/api",
"folders.ts": "/api",
"goals.ts": "/api",
"health.ts": "/api/health",
"inbox-dismissals.ts": "/api",
@ -181,6 +182,10 @@ describe("openapi routes", () => {
name: { type: "string" },
},
});
expect(res.body.paths["/api/companies/{companyId}/folders"].post.responses["201"]).toBeDefined();
expect(res.body.paths["/api/companies/{companyId}/folders/items/move"].post.summary).toBe(
"Move an item into or out of a folder",
);
expect(JSON.stringify(res.body.paths["/api/tool-gateway/tools"].get)).not.toContain("sessionToken");
expect(JSON.stringify(res.body.paths["/api/tool-gateway/tools/call"].post)).not.toContain("sessionToken");
});

View File

@ -12,6 +12,7 @@ import {
documentRevisions,
documents,
executionWorkspaces,
folders,
heartbeatRuns,
instanceSettings,
issueInboxArchives,
@ -68,6 +69,7 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
await db.delete(routineRuns);
await db.delete(routineTriggers);
await db.delete(routines);
await db.delete(folders);
await db.delete(routineDocuments);
await db.delete(documents);
await db.delete(documentRevisions);
@ -271,6 +273,39 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
expect(allRoutines.map((entry) => entry.id)).toEqual(expect.arrayContaining([routine.id, otherRoutine.id]));
});
it("does not reveal folders owned by another company", async () => {
const { companyId, agentId, projectId, svc } = await seedFixture();
const otherCompanyId = randomUUID();
await db.insert(companies).values({
id: otherCompanyId,
name: "Other company",
issuePrefix: `T${otherCompanyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
defaultResponsibleUserId: randomUUID(),
requireBoardApprovalForNewAgents: false,
});
const [otherFolder] = await db.insert(folders).values({
companyId: otherCompanyId,
kind: "routine",
name: "Private folder",
slug: "private-folder",
position: 0,
}).returning();
await expect(svc.create(companyId, {
projectId,
folderId: otherFolder!.id,
goalId: null,
parentIssueId: null,
title: "cross-company folder probe",
description: null,
assigneeAgentId: agentId,
priority: "medium",
status: "active",
concurrencyPolicy: "coalesce_if_active",
catchUpPolicy: "skip_missed",
}, {})).rejects.toMatchObject({ status: 404, message: "Folder not found" });
});
it("defaults activity gates to always at company scope", async () => {
const { routine } = await seedFixture();

View File

@ -16,6 +16,7 @@ import { companyRoutes } from "./routes/companies.js";
import { companySkillRoutes } from "./routes/company-skills.js";
import { companySkillPolicyRoutes } from "./routes/company-skill-policy.js";
import { builtInAgentRoutes } from "./routes/built-in-agents.js";
import { folderRoutes } from "./routes/folders.js";
import { teamsCatalogRoutes } from "./routes/teams-catalog.js";
import { agentRoutes } from "./routes/agents.js";
import { projectRoutes } from "./routes/projects.js";
@ -235,6 +236,7 @@ export async function createApp(
api.use(openApiRoutes());
api.use("/companies", companyRoutes(db, opts.storageService));
api.use(llmRoutes(db));
api.use(folderRoutes(db));
api.use(companySkillRoutes(db));
api.use(companySkillPolicyRoutes(db));
api.use(builtInAgentRoutes(db));

View File

@ -38,7 +38,7 @@ import {
listCatalogSkillsOrEmpty,
readCatalogSkillFile,
} from "../services/skills-catalog.js";
import { forbidden, unauthorized } from "../errors.js";
import { badRequest, forbidden, unauthorized } from "../errors.js";
import { assertAuthenticated, assertCompanyAccess, getActorInfo } from "./authz.js";
import { getTelemetryClient } from "../telemetry.js";
import {
@ -115,6 +115,14 @@ export function companySkillRoutes(db: Db) {
return undefined;
}
function optionalQueryBoolean(value: unknown) {
const parsed = firstQueryString(value);
if (parsed === undefined) return undefined;
if (parsed === "true") return true;
if (parsed === "false") return false;
throw badRequest("Boolean query parameters must be true or false");
}
function queryStringArray(value: unknown): string[] {
if (typeof value === "string") return [value];
if (Array.isArray(value)) return value.filter((entry): entry is string => typeof entry === "string");
@ -305,12 +313,17 @@ export function companySkillRoutes(db: Db) {
...queryStringArray(req.query.category),
...queryStringArray(req.query.categories),
...queryStringArray(req.query["categories[]"]),
...queryStringArray(req.query.tag),
...queryStringArray(req.query.tags),
...queryStringArray(req.query["tags[]"]),
],
scope: firstQueryString(req.query.scope),
include: [
...queryStringArray(req.query.include),
...queryStringArray(req.query["include[]"]),
],
folderId: firstQueryString(req.query.folderId),
includeSubtree: optionalQueryBoolean(req.query.includeSubtree),
}));
res.json(result);
});

View File

@ -0,0 +1,169 @@
import { Router } from "express";
import type { Db } from "@paperclipai/db";
import {
createFolderSchema,
ensureMySkillFolderSchema,
folderKindSchema,
moveFolderItemSchema,
moveFolderSchema,
updateFolderSchema,
} from "@paperclipai/shared";
import { validate } from "../middleware/validate.js";
import { badRequest, forbidden } from "../errors.js";
import { folderService, logActivity } from "../services/index.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js";
export function folderRoutes(db: Db) {
const router = Router();
const svc = folderService(db);
function parseKind(value: unknown) {
const result = folderKindSchema.safeParse(value);
if (!result.success) throw badRequest("Folder kind query parameter is required");
return result.data;
}
router.get("/companies/:companyId/folders", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
res.json(await svc.list(companyId, parseKind(req.query.kind)));
});
router.post("/companies/:companyId/folders", validate(createFolderSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const created = await svc.create(companyId, req.body);
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "folder.created",
entityType: "folder",
entityId: created.id,
details: { kind: created.kind, name: created.name, path: created.path, parentId: created.parentId, position: created.position },
});
res.status(201).json(created);
});
router.post(
"/companies/:companyId/folders/ensure-my",
validate(ensureMySkillFolderSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
if (req.actor.type !== "board" || !req.actor.userId) {
throw forbidden("A signed-in board user is required to create a personal skill folder");
}
const folder = await svc.ensureMyFolder(companyId, req.actor.userId, req.actor.userName ?? null, req.body.slug);
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "folder.personal_ensured",
entityType: "folder",
entityId: folder.id,
details: { path: folder.path, systemKey: folder.systemKey },
});
res.json(folder);
},
);
router.patch("/companies/:companyId/folders/:folderId", validate(updateFolderSchema), async (req, res) => {
const companyId = req.params.companyId as string;
const folderId = req.params.folderId as string;
assertCompanyAccess(req, companyId);
const updated = await svc.update(companyId, folderId, req.body);
if (!updated) {
res.status(404).json({ error: "Folder not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "folder.updated",
entityType: "folder",
entityId: updated.id,
details: { kind: updated.kind, name: updated.name, path: updated.path, position: updated.position },
});
res.json(updated);
});
router.post("/companies/:companyId/folders/items/move", validate(moveFolderItemSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const moved = await svc.moveItem(companyId, req.body);
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "folder.item_moved",
entityType: req.body.kind === "routine" ? "routine" : "company_skill",
entityId: moved.itemId,
details: { kind: moved.kind, folderId: moved.folderId },
});
res.json(moved);
});
router.post("/companies/:companyId/folders/:folderId/move", validate(moveFolderSchema), async (req, res) => {
const companyId = req.params.companyId as string;
const folderId = req.params.folderId as string;
assertCompanyAccess(req, companyId);
const updated = await svc.moveFolder(companyId, folderId, req.body);
if (!updated) {
res.status(404).json({ error: "Folder not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "folder.moved",
entityType: "folder",
entityId: updated.id,
details: { kind: updated.kind, parentId: updated.parentId, path: updated.path, position: updated.position },
});
res.json(updated);
});
router.delete("/companies/:companyId/folders/:folderId", async (req, res) => {
const companyId = req.params.companyId as string;
const folderId = req.params.folderId as string;
assertCompanyAccess(req, companyId);
const deleted = await svc.deleteFolder(companyId, folderId);
if (!deleted) {
res.status(404).json({ error: "Folder not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "folder.deleted",
entityType: "folder",
entityId: deleted.id,
details: { kind: deleted.kind, name: deleted.name },
});
res.json({ deleted });
});
return router;
}

View File

@ -3,6 +3,7 @@ export { companyRoutes } from "./companies.js";
export { companySkillRoutes } from "./company-skills.js";
export { companySkillPolicyRoutes } from "./company-skill-policy.js";
export { builtInAgentRoutes } from "./built-in-agents.js";
export { folderRoutes } from "./folders.js";
export { teamsCatalogRoutes } from "./teams-catalog.js";
export { agentRoutes } from "./agents.js";
export { projectRoutes } from "./projects.js";

View File

@ -47,6 +47,13 @@ import {
updateRoutineTriggerSchema,
rotateRoutineTriggerSecretSchema,
runRoutineSchema,
// Folders
createFolderSchema,
ensureMySkillFolderSchema,
folderKindSchema,
moveFolderItemSchema,
moveFolderSchema,
updateFolderSchema,
// Goal
createGoalSchema,
updateGoalSchema,
@ -862,6 +869,8 @@ const CREATED_OPERATIONS = new Set([
"POST /api/companies/{companyId}/projects",
"POST /api/projects/{id}/workspaces",
"POST /api/companies/{companyId}/routines",
"POST /api/companies/{companyId}/folders",
"POST /api/companies/{companyId}/folders/ensure-my",
"POST /api/routines/{id}/triggers",
"POST /api/companies/{companyId}/secrets",
"POST /api/companies/{companyId}/user-secret-definitions",
@ -5168,6 +5177,61 @@ for (const route of [
});
}
registerCurrentRoute({
method: "get",
path: "/api/companies/{companyId}/folders",
tags: ["folders"],
summary: "List folders for a company item kind",
query: z.object({ kind: folderKindSchema }),
});
registerCurrentRoute({
method: "post",
path: "/api/companies/{companyId}/folders",
tags: ["folders"],
summary: "Create a folder",
body: createFolderSchema,
});
registerCurrentRoute({
method: "post",
path: "/api/companies/{companyId}/folders/ensure-my",
tags: ["folders"],
summary: "Ensure the current user's personal skill folder exists",
body: ensureMySkillFolderSchema,
});
registerCurrentRoute({
method: "patch",
path: "/api/companies/{companyId}/folders/{folderId}",
tags: ["folders"],
summary: "Update a folder",
body: updateFolderSchema,
});
registerCurrentRoute({
method: "post",
path: "/api/companies/{companyId}/folders/items/move",
tags: ["folders"],
summary: "Move an item into or out of a folder",
body: moveFolderItemSchema,
});
registerCurrentRoute({
method: "post",
path: "/api/companies/{companyId}/folders/{folderId}/move",
tags: ["folders"],
summary: "Move or reorder a folder",
body: moveFolderSchema,
});
registerCurrentRoute({
method: "delete",
path: "/api/companies/{companyId}/folders/{folderId}",
tags: ["folders"],
summary: "Delete a folder",
});
registerCurrentRoute({
method: "get",
path: "/api/issues/{id}/cost-summary",

View File

@ -97,6 +97,7 @@ import { issueDocumentSelect, mapIssueDocumentRow } from "./documents.js";
import { toIssueWorkProduct } from "./work-products.js";
import { projectService } from "./projects.js";
import { normalizePortablePath } from "./portable-path.js";
import { folderService } from "./folders.js";
import {
copyCatalogSkillFile,
getCatalogPackageMetadata,
@ -119,6 +120,7 @@ type CompanySkillListDbRow = Pick<
CompanySkillRow,
| "id"
| "companyId"
| "folderId"
| "key"
| "slug"
| "name"
@ -151,6 +153,8 @@ type CompanySkillListRow = Pick<
CompanySkill,
| "id"
| "companyId"
| "folderId"
| "folderPath"
| "key"
| "slug"
| "name"
@ -213,6 +217,7 @@ type ImportedSkill = {
type ImportedSkillPersistValues = Pick<
CompanySkill,
| "companyId"
| "folderId"
| "key"
| "slug"
| "name"
@ -376,6 +381,7 @@ function selectCompanySkillColumns() {
return {
id: companySkills.id,
companyId: companySkills.companyId,
folderId: companySkills.folderId,
key: companySkills.key,
slug: companySkills.slug,
name: companySkills.name,
@ -1208,6 +1214,26 @@ function isPaperclipBundledSkillKey(key: string) {
return key.startsWith("paperclipai/paperclip/");
}
function paperclipBundledFolderCategory(key: string, metadata?: unknown) {
const keyParts = key.split("/");
if (keyParts[0] === "paperclipai" && keyParts[1] === "bundled" && keyParts[2]) {
return keyParts[2];
}
if (isPaperclipBundledSkillKey(key)) return "paperclip-core";
if (isPlainRecord(metadata) && asString(metadata.sourceKind) === "paperclip_bundled") {
return "paperclip-core";
}
return null;
}
function bundledFolderLabel(category: string) {
return category
.split(/[-_\s]+/)
.filter(Boolean)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
}
function stripDerivedPaperclipBundledMetadata(key: string, metadata: unknown): unknown {
if (metadata === null || metadata === undefined) return {};
const comparable = stableJsonComparable(metadata);
@ -1239,6 +1265,7 @@ function importedSkillPersistValuesMatchExisting(
values: ImportedSkillPersistValues,
) {
return existing.companyId === values.companyId
&& existing.folderId === values.folderId
&& existing.key === values.key
&& existing.slug === values.slug
&& existing.name === values.name
@ -2643,6 +2670,8 @@ function toCompanySkillListItem(skill: CompanySkillListRow, attachedAgentCount:
return {
id: skill.id,
companyId: skill.companyId,
folderId: skill.folderId,
folderPath: skill.folderPath ?? null,
key: skill.key,
slug: skill.slug,
name: skill.name,
@ -2743,6 +2772,7 @@ async function listLastEditorsBySkillId(
}
export function companySkillService(db: Db) {
const folderSvc = folderService(db);
const agents = agentService(db);
const projects = projectService(db);
@ -2798,6 +2828,39 @@ export function companySkillService(db: Db) {
return [];
}
async function reconcilePaperclipSkillFolders(companyId: string) {
const shippedSkills = await db
.select({
id: companySkills.id,
key: companySkills.key,
folderId: companySkills.folderId,
metadata: companySkills.metadata,
})
.from(companySkills)
.where(eq(companySkills.companyId, companyId))
.then((rows) => rows.flatMap((skill) => {
const category = paperclipBundledFolderCategory(skill.key, skill.metadata);
return category ? [{ ...skill, category }] : [];
}));
const foldersByCategory = new Map<string, Awaited<ReturnType<typeof folderSvc.ensureBundledCategory>>>();
for (const skill of shippedSkills) {
let folder = foldersByCategory.get(skill.category);
if (!folder) {
folder = await folderSvc.ensureBundledCategory(companyId, bundledFolderLabel(skill.category));
foldersByCategory.set(skill.category, folder);
}
if (skill.folderId === folder.id) continue;
await db
.update(companySkills)
.set({ folderId: folder.id, updatedAt: new Date() })
.where(and(eq(companySkills.companyId, companyId), eq(companySkills.id, skill.id)));
}
await folderSvc.pruneEmptyBundledCategories(
companyId,
Array.from(foldersByCategory.keys(), bundledFolderLabel),
);
}
async function reconcileLocalPathSkillSources(companyId: string) {
const rows = await db
.select({
@ -2889,6 +2952,7 @@ export function companySkillService(db: Db) {
throw notFound("Company not found");
}
await ensureBundledSkills(companyId);
await reconcilePaperclipSkillFolders(companyId);
await reconcileLocalPathSkillSources(companyId);
})();
@ -2904,10 +2968,11 @@ export function companySkillService(db: Db) {
async function list(companyId: string, query: CompanySkillListQuery = {}): Promise<CompanySkillListItem[]> {
await ensureSkillInventoryCurrent(companyId);
const rows = await db
const [dbRows, folderListing] = await Promise.all([db
.select({
id: companySkills.id,
companyId: companySkills.companyId,
folderId: companySkills.folderId,
key: companySkills.key,
slug: companySkills.slug,
name: companySkills.name,
@ -2939,7 +3004,21 @@ export function companySkillService(db: Db) {
.from(companySkills)
.where(eq(companySkills.companyId, companyId))
.orderBy(asc(companySkills.name), asc(companySkills.key))
.then((entries) => entries.map((entry) => toCompanySkillListRow(entry as CompanySkillListDbRow)));
.then((entries) => entries.map((entry) => toCompanySkillListRow(entry as CompanySkillListDbRow))),
folderSvc.list(companyId, "skill"),
]);
const folderPaths = new Map(folderListing.folders.map((folder) => [folder.id, folder.path]));
const rows = dbRows.map((skill) => ({
...skill,
folderPath: skill.folderId ? folderPaths.get(skill.folderId) ?? null : null,
}));
let selectedFolderIds: Set<string> | null = null;
if (query.folderId) {
await folderSvc.validateSkillFolder(companyId, query.folderId, { allowBundled: true });
selectedFolderIds = query.includeSubtree
? await folderSvc.descendantIds(companyId, "skill", query.folderId)
: new Set([query.folderId]);
}
const agentRows = await agents.list(companyId);
const q = query.q?.trim().toLowerCase() ?? "";
const categories = new Set(
@ -2950,6 +3029,7 @@ export function companySkillService(db: Db) {
);
const filtered = rows.filter((skill) => {
if (query.scope && skill.sharingScope !== query.scope) return false;
if (!q && selectedFolderIds && (!skill.folderId || !selectedFolderIds.has(skill.folderId))) return false;
if (categories.size > 0 && !skill.categories.some((category) => categories.has(categoryLookupKey(category)))) return false;
if (q) {
const haystack = [
@ -3032,7 +3112,7 @@ export function companySkillService(db: Db) {
.from(companySkills)
.where(and(eq(companySkills.companyId, companyId), eq(companySkills.id, id)))
.then((rows) => rows[0] ?? null);
return row ? toCompanySkill(row) : null;
return row ? enrichFolderPath(companyId, toCompanySkill(row)) : null;
}
async function getByKey(companyId: string, key: string) {
@ -3041,7 +3121,7 @@ export function companySkillService(db: Db) {
.from(companySkills)
.where(and(eq(companySkills.companyId, companyId), eq(companySkills.key, key)))
.then((rows) => rows[0] ?? null);
return row ? toCompanySkill(row) : null;
return row ? enrichFolderPath(companyId, toCompanySkill(row)) : null;
}
async function getBySlugIfUnique(companyId: string, slug: string) {
@ -3049,7 +3129,13 @@ export function companySkillService(db: Db) {
.select(selectCompanySkillColumns())
.from(companySkills)
.where(and(eq(companySkills.companyId, companyId), eq(companySkills.slug, slug)));
return rows.length === 1 ? toCompanySkill(rows[0]!) : null;
return rows.length === 1 ? enrichFolderPath(companyId, toCompanySkill(rows[0]!)) : null;
}
async function enrichFolderPath(companyId: string, skill: CompanySkill): Promise<CompanySkill> {
if (!skill.folderId) return { ...skill, folderPath: null };
const folder = await folderSvc.getFolder(companyId, skill.folderId);
return { ...skill, folderPath: folder?.kind === "skill" ? folder.path : null };
}
async function getByRouteRef(companyId: string, ref: string) {
@ -3927,6 +4013,7 @@ export function companySkillService(db: Db) {
input: CompanySkillCreateRequest,
actor: SkillActor | null = null,
): Promise<CompanySkill> {
if (input.folderId) await folderSvc.validateSkillFolder(companyId, input.folderId);
const slug = normalizeSkillSlug(input.slug ?? input.name) ?? "skill";
const key = `company/${companyId}/${slug}`;
const existing = await getByKey(companyId, key);
@ -4013,6 +4100,7 @@ export function companySkillService(db: Db) {
authorName: normalizeStoreText(input.authorName, 200) ?? forkSource?.authorName ?? created.authorName,
homepageUrl: normalizeStoreText(input.homepageUrl, 2000) ?? forkSource?.homepageUrl ?? created.homepageUrl,
categories: input.categories ? normalizeCategoryList(input.categories) : forkSource?.categories ?? created.categories,
folderId: input.folderId ?? null,
sharingScope,
forkedFromSkillId: forkSource?.id ?? null,
forkedFromCompanyId: forkSource?.companyId ?? null,
@ -4557,6 +4645,15 @@ export function companySkillService(db: Db) {
existingSkillId: existingBySource.id,
reason: "This skill is already installed from the same path.",
});
if (mode === "preview" || !selected) continue;
const persisted = (await upsertImportedSkills(companyId, [{
...nextSkill,
key: existingBySource.key,
slug: existingBySource.slug,
}]))[0];
if (!persisted) continue;
updated.push(persisted);
upsertAcceptedSkill(persisted);
continue;
}
@ -5050,6 +5147,10 @@ export function companySkillService(db: Db) {
}
const markdown = await fs.readFile(path.join(originSnapshotLocator, catalogSkill.entrypoint), "utf8");
const metadata = buildCatalogSkillMetadata(catalogSkill, existingByKey, originSnapshotLocator);
const bundledCategory = paperclipBundledFolderCategory(catalogSkill.key, metadata);
const bundledFolder = bundledCategory
? await folderSvc.ensureBundledCategory(companyId, bundledFolderLabel(bundledCategory))
: null;
const parsed = parseFrontmatterMarkdown(markdown);
const storeMetadata = readSkillStoreMetadata(parsed.frontmatter, {
...metadata,
@ -5057,6 +5158,7 @@ export function companySkillService(db: Db) {
});
const values = {
companyId,
folderId: bundledFolder?.id ?? existingByKey?.folderId ?? null,
key: catalogSkill.key,
slug,
name: catalogSkill.name,
@ -5446,8 +5548,18 @@ export function companySkillService(db: Db) {
};
const parsed = parseFrontmatterMarkdown(skill.markdown);
const storeMetadata = readSkillStoreMetadata(parsed.frontmatter, metadata);
const bundledCategory = paperclipBundledFolderCategory(skill.key, incomingMeta);
const bundledFolder = bundledCategory
? await folderSvc.ensureBundledCategory(companyId, bundledFolderLabel(bundledCategory))
: null;
const projectId = asString(incomingMeta.projectId);
const projectName = asString(incomingMeta.projectName);
const projectFolder = !existing && incomingKind === "project_scan" && projectId && projectName
? await folderSvc.ensureProjectFolder(companyId, projectId, projectName)
: null;
const values: ImportedSkillPersistValues = {
companyId,
folderId: bundledFolder?.id ?? projectFolder?.id ?? existing?.folderId ?? null,
key: skill.key,
slug: skill.slug,
name: skill.name,

View File

@ -0,0 +1,619 @@
import { and, asc, eq, max, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { companySkills, folders, routines } from "@paperclipai/db";
import type {
CreateFolder,
Folder,
FolderKind,
FolderListResult,
MoveFolder,
MoveFolderItem,
UpdateFolder,
} from "@paperclipai/shared";
import { conflict, forbidden, notFound, unprocessable } from "../errors.js";
const MAX_FOLDER_DEPTH = 4;
const RESERVED_ROOT_SLUGS = new Set(["bundled", "my", "projects"]);
const RESERVED_CHILD_ROOT_SYSTEM_KEYS = new Set(["my", "projects"]);
type FolderRow = typeof folders.$inferSelect;
function isPostgresError(error: unknown, code: string) {
return typeof error === "object" && error !== null && "code" in error && error.code === code;
}
function normalizeName(name: string) {
return name.trim();
}
function normalizeColor(color: string | null | undefined) {
if (color === undefined) return undefined;
const trimmed = color?.trim() ?? "";
return trimmed.length > 0 ? trimmed : null;
}
export function normalizeFolderSlug(value: string) {
const slug = value
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.replace(/-+/g, "-");
return slug || "folder";
}
function buildFolderViews(rows: FolderRow[]) {
const byId = new Map(rows.map((row) => [row.id, row]));
const views = new Map<string, Folder>();
const visiting = new Set<string>();
function resolve(row: FolderRow): Folder {
const existing = views.get(row.id);
if (existing) return existing;
if (visiting.has(row.id)) throw unprocessable("Folder hierarchy contains a cycle");
visiting.add(row.id);
const parent = row.parentId ? byId.get(row.parentId) : null;
if (row.parentId && !parent) throw unprocessable("Folder hierarchy contains an invalid parent");
const parentView = parent ? resolve(parent) : null;
const view: Folder = {
...row,
parentId: row.parentId ?? null,
systemKey: row.systemKey ?? null,
color: row.color ?? null,
path: parentView ? `${parentView.path}/${row.slug}` : row.slug,
depth: (parentView?.depth ?? 0) + 1,
};
visiting.delete(row.id);
views.set(row.id, view);
return view;
}
for (const row of rows) resolve(row);
return views;
}
export function folderService(db: Db, mutationLockHeld = false) {
async function withCompanyFolderLock<T>(companyId: string, operation: (lockedDb: Db) => Promise<T>) {
if (mutationLockHeld) return operation(db);
return db.transaction(async (tx) => {
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`paperclip:folders:${companyId}`}, 0))`);
return operation(tx as unknown as Db);
});
}
async function getRows(companyId: string, kind: FolderKind) {
return db
.select()
.from(folders)
.where(and(eq(folders.companyId, companyId), eq(folders.kind, kind)))
.orderBy(asc(folders.position), asc(folders.name), asc(folders.id));
}
async function getFolderRow(companyId: string, folderId: string) {
return db
.select()
.from(folders)
.where(and(eq(folders.companyId, companyId), eq(folders.id, folderId)))
.then((rows) => rows[0] ?? null);
}
async function getFolder(companyId: string, folderId: string) {
const row = await getFolderRow(companyId, folderId);
if (!row) return null;
const views = buildFolderViews(await getRows(companyId, row.kind));
return views.get(row.id) ?? null;
}
async function assertNoSlugConflict(
companyId: string,
kind: FolderKind,
parentId: string | null,
slug: string,
excludeFolderId?: string,
) {
const existing = await db
.select({ id: folders.id })
.from(folders)
.where(and(
eq(folders.companyId, companyId),
eq(folders.kind, kind),
parentId === null ? sql`${folders.parentId} is null` : eq(folders.parentId, parentId),
eq(folders.slug, slug),
))
.then((rows) => rows[0] ?? null);
if (existing && existing.id !== excludeFolderId) {
throw conflict("Folder slug already exists under this parent");
}
}
async function nextPosition(companyId: string, kind: FolderKind, parentId: string | null) {
const row = await db
.select({ value: max(folders.position) })
.from(folders)
.where(and(
eq(folders.companyId, companyId),
eq(folders.kind, kind),
parentId === null ? sql`${folders.parentId} is null` : eq(folders.parentId, parentId),
))
.then((rows) => rows[0] ?? null);
return Number(row?.value ?? -1) + 1;
}
async function routineCounts(companyId: string) {
return db
.select({ folderId: routines.folderId, count: sql<number>`count(*)::int` })
.from(routines)
.where(eq(routines.companyId, companyId))
.groupBy(routines.folderId);
}
async function skillCounts(companyId: string) {
return db
.select({ folderId: companySkills.folderId, count: sql<number>`count(*)::int` })
.from(companySkills)
.where(eq(companySkills.companyId, companyId))
.groupBy(companySkills.folderId);
}
async function list(companyId: string, kind: FolderKind): Promise<FolderListResult> {
const [folderRows, countRows] = await Promise.all([
getRows(companyId, kind),
kind === "routine" ? routineCounts(companyId) : skillCounts(companyId),
]);
const views = buildFolderViews(folderRows);
const countsByFolderId = new Map<string | null, number>();
for (const row of countRows) countsByFolderId.set(row.folderId ?? null, Number(row.count ?? 0));
return {
kind,
folders: folderRows.map((row) => ({
...views.get(row.id)!,
itemCount: countsByFolderId.get(row.id) ?? 0,
})),
allCount: Array.from(countsByFolderId.values()).reduce((sum, count) => sum + count, 0),
unfiledCount: countsByFolderId.get(null) ?? 0,
};
}
function isReservedRootSlug(kind: FolderKind, parentId: string | null, slug: string) {
return kind === "skill" && parentId === null && RESERVED_ROOT_SLUGS.has(slug);
}
async function isBundledFolder(companyId: string, folderId: string) {
let current = await getFolder(companyId, folderId);
const visited = new Set<string>();
while (current) {
if (current.systemKey === "bundled") return true;
if (!current.parentId || visited.has(current.id)) return false;
visited.add(current.id);
current = await getFolder(companyId, current.parentId);
}
return false;
}
async function assertMutableFolder(companyId: string, folder: Folder) {
if (folder.systemKey || await isBundledFolder(companyId, folder.id)) {
throw forbidden("System-managed folders cannot be changed");
}
}
async function validateParent(companyId: string, kind: FolderKind, parentId: string | null) {
if (!parentId) return null;
const parent = await getFolder(companyId, parentId);
if (!parent || parent.kind !== kind) throw notFound("Parent folder not found");
if (await isBundledFolder(companyId, parent.id)) throw forbidden("Bundled folders are read-only");
if (
parent.kind === "skill"
&& parent.parentId === null
&& (RESERVED_CHILD_ROOT_SYSTEM_KEYS.has(parent.systemKey ?? "") || RESERVED_CHILD_ROOT_SYSTEM_KEYS.has(parent.slug))
) {
throw forbidden("Reserved skill folders are system-managed");
}
return parent;
}
async function create(companyId: string, input: CreateFolder): Promise<Folder> {
if (!mutationLockHeld) {
return withCompanyFolderLock(companyId, (lockedDb) => folderService(lockedDb, true).create(companyId, input));
}
const parentId = input.parentId ?? null;
const parent = await validateParent(companyId, input.kind, parentId);
if ((parent?.depth ?? 0) + 1 > MAX_FOLDER_DEPTH) {
throw unprocessable(`Folder depth cannot exceed ${MAX_FOLDER_DEPTH}`);
}
const name = normalizeName(input.name);
const slug = input.slug ?? normalizeFolderSlug(name);
if (isReservedRootSlug(input.kind, parentId, slug)) {
throw forbidden("Reserved skill folders are system-managed");
}
await assertNoSlugConflict(companyId, input.kind, parentId, slug);
const position = input.position ?? await nextPosition(companyId, input.kind, parentId);
let row: FolderRow;
try {
row = await db
.insert(folders)
.values({ companyId, kind: input.kind, parentId, name, slug, color: normalizeColor(input.color) ?? null, position })
.returning()
.then((rows) => rows[0]!);
} catch (error) {
if (isPostgresError(error, "23505")) throw conflict("Folder slug already exists under this parent");
throw error;
}
return (await getFolder(companyId, row.id))!;
}
async function update(companyId: string, folderId: string, patch: UpdateFolder): Promise<Folder | null> {
if (!mutationLockHeld) {
return withCompanyFolderLock(companyId, (lockedDb) => folderService(lockedDb, true).update(companyId, folderId, patch));
}
const existing = await getFolder(companyId, folderId);
if (!existing) return null;
await assertMutableFolder(companyId, existing);
const name = patch.name === undefined ? existing.name : normalizeName(patch.name);
const slug = patch.slug ?? (patch.name === undefined ? existing.slug : normalizeFolderSlug(name));
if (isReservedRootSlug(existing.kind, existing.parentId, slug)) {
throw forbidden("Reserved skill folders are system-managed");
}
await assertNoSlugConflict(companyId, existing.kind, existing.parentId, slug, existing.id);
try {
await db
.update(folders)
.set({
name,
slug,
color: patch.color === undefined ? existing.color : normalizeColor(patch.color),
position: patch.position ?? existing.position,
updatedAt: new Date(),
})
.where(and(eq(folders.companyId, companyId), eq(folders.id, folderId)));
} catch (error) {
if (isPostgresError(error, "23505")) throw conflict("Folder slug already exists under this parent");
throw error;
}
return getFolder(companyId, folderId);
}
function descendantIdsFromRows(rows: FolderRow[], folderId: string) {
if (!rows.some((row) => row.id === folderId)) throw notFound("Folder not found");
const children = new Map<string, string[]>();
for (const row of rows) {
if (!row.parentId) continue;
children.set(row.parentId, [...(children.get(row.parentId) ?? []), row.id]);
}
const result = new Set([folderId]);
const queue = [folderId];
while (queue.length > 0) {
const current = queue.shift()!;
for (const childId of children.get(current) ?? []) {
if (result.has(childId)) throw unprocessable("Folder hierarchy contains a cycle");
result.add(childId);
queue.push(childId);
}
}
return result;
}
async function descendantIds(companyId: string, kind: FolderKind, folderId: string) {
return descendantIdsFromRows(await getRows(companyId, kind), folderId);
}
async function moveFolder(companyId: string, folderId: string, input: MoveFolder): Promise<Folder | null> {
if (!mutationLockHeld) {
return withCompanyFolderLock(companyId, (lockedDb) => folderService(lockedDb, true).moveFolder(companyId, folderId, input));
}
const existing = await getFolder(companyId, folderId);
if (!existing) return null;
await assertMutableFolder(companyId, existing);
const parentId = input.parentId === undefined ? existing.parentId : input.parentId;
if (parentId === existing.id) throw unprocessable("A folder cannot be its own parent");
const rows = await getRows(companyId, existing.kind);
const descendants = descendantIdsFromRows(rows, existing.id);
if (parentId && descendants.has(parentId)) throw unprocessable("A folder cannot be moved into its own subtree");
const parent = await validateParent(companyId, existing.kind, parentId);
const views = buildFolderViews(rows);
const relativeDepth = Math.max(...Array.from(descendants).map((id) => views.get(id)!.depth - existing.depth + 1));
if ((parent?.depth ?? 0) + relativeDepth > MAX_FOLDER_DEPTH) {
throw unprocessable(`Folder depth cannot exceed ${MAX_FOLDER_DEPTH}`);
}
if (isReservedRootSlug(existing.kind, parentId, existing.slug)) {
throw forbidden("Reserved skill folders are system-managed");
}
await assertNoSlugConflict(companyId, existing.kind, parentId, existing.slug, existing.id);
try {
await db
.update(folders)
.set({ parentId, position: input.position, updatedAt: new Date() })
.where(and(eq(folders.companyId, companyId), eq(folders.id, folderId)));
} catch (error) {
if (isPostgresError(error, "23505")) throw conflict("Folder slug already exists under this parent");
if (isPostgresError(error, "23503")) throw conflict("Parent folder changed during move");
throw error;
}
return getFolder(companyId, folderId);
}
async function deleteFolder(companyId: string, folderId: string): Promise<Folder | null> {
if (!mutationLockHeld) {
return withCompanyFolderLock(companyId, (lockedDb) => folderService(lockedDb, true).deleteFolder(companyId, folderId));
}
const existing = await getFolder(companyId, folderId);
if (!existing) return null;
await assertMutableFolder(companyId, existing);
const child = await db
.select({ id: folders.id })
.from(folders)
.where(and(eq(folders.companyId, companyId), eq(folders.parentId, folderId)))
.then((rows) => rows[0] ?? null);
if (child) throw conflict("Move or delete nested folders first");
try {
await db.delete(folders).where(and(eq(folders.companyId, companyId), eq(folders.id, folderId)));
} catch (error) {
if (isPostgresError(error, "23503")) throw conflict("Move or delete nested folders first");
throw error;
}
return existing;
}
async function validateSkillFolder(companyId: string, folderId: string, options?: { allowBundled?: boolean }) {
const folder = await getFolder(companyId, folderId);
if (!folder || folder.kind !== "skill") throw notFound("Skill folder not found");
if (!options?.allowBundled && await isBundledFolder(companyId, folder.id)) {
throw forbidden("Bundled folders are read-only");
}
return folder;
}
async function moveItem(companyId: string, input: MoveFolderItem) {
if (input.folderId) {
const target = await getFolder(companyId, input.folderId);
if (!target) throw notFound("Folder not found");
if (target.kind !== input.kind) throw unprocessable("Folder kind must match item kind");
if (await isBundledFolder(companyId, target.id)) throw forbidden("Bundled folders are read-only");
}
if (input.kind === "routine") {
const row = await db
.update(routines)
.set({ folderId: input.folderId ?? null, updatedAt: new Date() })
.where(and(eq(routines.companyId, companyId), eq(routines.id, input.itemId)))
.returning({ id: routines.id, folderId: routines.folderId })
.then((rows) => rows[0] ?? null);
if (!row) throw notFound("Routine not found");
return { kind: input.kind, itemId: row.id, folderId: row.folderId ?? null };
}
const existing = await db
.select({ id: companySkills.id, folderId: companySkills.folderId })
.from(companySkills)
.where(and(eq(companySkills.companyId, companyId), eq(companySkills.id, input.itemId)))
.then((rows) => rows[0] ?? null);
if (!existing) throw notFound("Skill not found");
if (existing.folderId && await isBundledFolder(companyId, existing.folderId)) {
throw forbidden("Bundled skills cannot be moved");
}
const row = await db
.update(companySkills)
.set({ folderId: input.folderId ?? null, updatedAt: new Date() })
.where(and(eq(companySkills.companyId, companyId), eq(companySkills.id, input.itemId)))
.returning({ id: companySkills.id, folderId: companySkills.folderId })
.then((rows) => rows[0]!);
return { kind: input.kind, itemId: row.id, folderId: row.folderId ?? null };
}
async function uniqueSiblingSlug(companyId: string, parentId: string | null, baseSlug: string, stableSuffix: string) {
const siblingSlugs = new Set(await db
.select({ slug: folders.slug })
.from(folders)
.where(and(
eq(folders.companyId, companyId),
eq(folders.kind, "skill"),
parentId === null ? sql`${folders.parentId} is null` : eq(folders.parentId, parentId),
))
.then((rows) => rows.map((row) => row.slug)));
if (!siblingSlugs.has(baseSlug)) return baseSlug;
const suffix = normalizeFolderSlug(stableSuffix).slice(0, 24);
let candidate = `${baseSlug}-${suffix}`;
let duplicateNumber = 2;
while (siblingSlugs.has(candidate)) {
candidate = `${baseSlug}-${suffix}-${duplicateNumber}`;
duplicateNumber += 1;
}
return candidate;
}
async function findSystemFolder(companyId: string, systemKey: string) {
return db
.select()
.from(folders)
.where(and(
eq(folders.companyId, companyId),
eq(folders.kind, "skill"),
eq(folders.systemKey, systemKey),
))
.then((rows) => rows[0] ?? null);
}
async function insertSystemFolder(input: {
companyId: string;
parentId: string | null;
name: string;
slug: string;
systemKey: string;
}) {
const inserted = await db
.insert(folders)
.values({
companyId: input.companyId,
kind: "skill",
parentId: input.parentId,
name: input.name,
slug: input.slug,
systemKey: input.systemKey,
position: await nextPosition(input.companyId, "skill", input.parentId),
})
.onConflictDoNothing()
.returning({ id: folders.id })
.then((rows) => rows[0] ?? null);
if (inserted) return (await getFolder(input.companyId, inserted.id))!;
const existing = await findSystemFolder(input.companyId, input.systemKey);
return existing ? (await getFolder(input.companyId, existing.id))! : null;
}
async function ensureContainer(companyId: string, slug: "bundled" | "my" | "projects", name: string) {
for (let attempt = 0; attempt < 3; attempt += 1) {
const existingSystem = await findSystemFolder(companyId, slug);
if (existingSystem) return (await getFolder(companyId, existingSystem.id))!;
const squatted = await db
.select({ id: folders.id })
.from(folders)
.where(and(
eq(folders.companyId, companyId),
eq(folders.kind, "skill"),
sql`${folders.parentId} is null`,
eq(folders.slug, slug),
))
.then((rows) => rows[0] ?? null);
if (squatted) {
await db
.update(folders)
.set({ slug: await uniqueSiblingSlug(companyId, null, slug, squatted.id.slice(0, 8)), updatedAt: new Date() })
.where(and(eq(folders.companyId, companyId), eq(folders.id, squatted.id)));
}
const created = await insertSystemFolder({ companyId, parentId: null, name, slug, systemKey: slug });
if (created) return created;
}
throw conflict(`Could not create ${name} folder`);
}
async function uniqueSystemSlug(
companyId: string,
parentId: string,
baseSlug: string,
systemKey: string,
stableSuffix = systemKey.split(":").at(-1) ?? systemKey,
) {
const existingSystem = await db
.select({ id: folders.id })
.from(folders)
.where(and(eq(folders.companyId, companyId), eq(folders.kind, "skill"), eq(folders.systemKey, systemKey)))
.then((rows) => rows[0] ?? null);
if (existingSystem) return { id: existingSystem.id, slug: null };
return {
id: null,
slug: await uniqueSiblingSlug(companyId, parentId, baseSlug, stableSuffix),
};
}
async function ensureMyFolder(companyId: string, userId: string, userName: string | null, requestedSlug?: string | null): Promise<Folder> {
if (!mutationLockHeld) {
return withCompanyFolderLock(companyId, (lockedDb) => folderService(lockedDb, true).ensureMyFolder(companyId, userId, userName, requestedSlug));
}
const parent = await ensureContainer(companyId, "my", "My Skills");
const systemKey = `my:${userId}`;
for (let attempt = 0; attempt < 3; attempt += 1) {
const resolved = await uniqueSystemSlug(companyId, parent.id, requestedSlug ?? normalizeFolderSlug(userName ?? userId), systemKey);
if (resolved.id) return (await getFolder(companyId, resolved.id))!;
const created = await insertSystemFolder({
companyId,
parentId: parent.id,
name: userName?.trim() || "My Skills",
slug: resolved.slug!,
systemKey,
});
if (created) return created;
}
throw conflict("Could not create personal skill folder");
}
async function ensureProjectFolder(companyId: string, projectId: string, projectName: string): Promise<Folder> {
if (!mutationLockHeld) {
return withCompanyFolderLock(companyId, (lockedDb) => folderService(lockedDb, true).ensureProjectFolder(companyId, projectId, projectName));
}
const parent = await ensureContainer(companyId, "projects", "Projects");
const systemKey = `project:${projectId}`;
for (let attempt = 0; attempt < 3; attempt += 1) {
const resolved = await uniqueSystemSlug(companyId, parent.id, normalizeFolderSlug(projectName), systemKey);
if (resolved.id) return (await getFolder(companyId, resolved.id))!;
const created = await insertSystemFolder({
companyId,
parentId: parent.id,
name: projectName,
slug: resolved.slug!,
systemKey,
});
if (created) return created;
}
throw conflict("Could not create project skill folder");
}
async function ensureBundledCategory(companyId: string, category: string): Promise<Folder> {
if (!mutationLockHeld) {
return withCompanyFolderLock(companyId, (lockedDb) => folderService(lockedDb, true).ensureBundledCategory(companyId, category));
}
const root = await ensureContainer(companyId, "bundled", "Bundled");
const name = normalizeName(category);
const slug = normalizeFolderSlug(category);
const systemKey = `bundled:${slug}`;
for (let attempt = 0; attempt < 3; attempt += 1) {
const resolved = await uniqueSystemSlug(companyId, root.id, slug, systemKey, "bundled");
if (resolved.id) {
const existing = await getFolder(companyId, resolved.id);
if (!existing) continue;
if (existing.name === name) return existing;
await db
.update(folders)
.set({ name, updatedAt: new Date() })
.where(and(eq(folders.companyId, companyId), eq(folders.id, existing.id)));
return (await getFolder(companyId, existing.id))!;
}
const created = await insertSystemFolder({
companyId,
parentId: root.id,
name,
slug: resolved.slug!,
systemKey,
});
if (created) return created;
}
throw conflict("Could not create bundled skill folder");
}
async function pruneEmptyBundledCategories(companyId: string, retainedCategories: string[]): Promise<void> {
if (!mutationLockHeld) {
return withCompanyFolderLock(companyId, (lockedDb) => folderService(lockedDb, true).pruneEmptyBundledCategories(companyId, retainedCategories));
}
const root = await findSystemFolder(companyId, "bundled");
if (!root) return;
const rows = await getRows(companyId, "skill");
const retainedSystemKeys = new Set(
retainedCategories.map((category) => `bundled:${normalizeFolderSlug(category)}`),
);
const usedFolderIds = new Set(
await db
.select({ folderId: companySkills.folderId })
.from(companySkills)
.where(eq(companySkills.companyId, companyId))
.then((skills) => skills.flatMap((skill) => skill.folderId ? [skill.folderId] : [])),
);
const parentIds = new Set(rows.flatMap((row) => row.parentId ? [row.parentId] : []));
for (const row of rows) {
if (row.parentId !== root.id || !row.systemKey?.startsWith("bundled:")) continue;
if (retainedSystemKeys.has(row.systemKey) || usedFolderIds.has(row.id) || parentIds.has(row.id)) continue;
await db.delete(folders).where(and(eq(folders.companyId, companyId), eq(folders.id, row.id)));
}
}
return {
list,
create,
update,
moveFolder,
deleteFolder,
moveItem,
getFolder,
descendantIds,
validateSkillFolder,
ensureMyFolder,
ensureProjectFolder,
ensureBundledCategory,
pruneEmptyBundledCategories,
};
}

View File

@ -5,6 +5,7 @@ export { companySearchExtractService } from "./company-search-extract.js";
export { feedbackService } from "./feedback.js";
export { companySkillService } from "./company-skills.js";
export { companySkillPolicyService, normalizeSkillPolicySourceType } from "./company-skill-policy.js";
export { folderService } from "./folders.js";
export { agentService, deduplicateAgentName } from "./agents.js";
export {
builtInAgentService,

View File

@ -12,6 +12,7 @@ import {
documentRevisions,
documents,
executionWorkspaces,
folders,
goals,
heartbeatRuns,
issueInboxArchives,
@ -974,6 +975,17 @@ export function routineService(
if (parentIssue.companyId !== companyId) throw unprocessable("Parent issue must belong to same company");
}
async function assertRoutineFolder(companyId: string, folderId: string | null | undefined) {
if (!folderId) return;
const folder = await db
.select({ id: folders.id, kind: folders.kind })
.from(folders)
.where(and(eq(folders.companyId, companyId), eq(folders.id, folderId)))
.then((rows) => rows[0] ?? null);
if (!folder) throw notFound("Folder not found");
if (folder.kind !== "routine") throw unprocessable("Folder kind must match routine");
}
async function listTriggersForRoutineIds(companyId: string, routineIds: string[]) {
if (routineIds.length === 0) return new Map<string, RoutineTrigger[]>();
const rows = await db
@ -2066,6 +2078,7 @@ export function routineService(
create: async (companyId: string, input: CreateRoutine, actor: Actor): Promise<Routine> => {
await assertProject(companyId, input.projectId ?? null);
await assertRoutineFolder(companyId, input.folderId ?? null);
await assertAssignableAgent(db, companyId, input.assigneeAgentId ?? null, { kind: "routine" });
if (input.goalId) await assertGoal(companyId, input.goalId);
if (input.parentIssueId) await assertParentIssue(companyId, input.parentIssueId);
@ -2092,6 +2105,7 @@ export function routineService(
.values({
companyId,
projectId: input.projectId ?? null,
folderId: input.folderId ?? null,
goalId: input.goalId ?? null,
parentIssueId: input.parentIssueId ?? null,
title: input.title,
@ -2130,6 +2144,7 @@ export function routineService(
const existing = await getRoutineById(id);
if (!existing) return null;
const nextProjectId = patch.projectId === undefined ? existing.projectId : patch.projectId;
const nextFolderId = patch.folderId === undefined ? existing.folderId : patch.folderId;
const nextAssigneeAgentId = patch.assigneeAgentId === undefined ? existing.assigneeAgentId : patch.assigneeAgentId;
const nextTitle = patch.title ?? existing.title;
const nextDescription = patch.description === undefined ? existing.description : patch.description;
@ -2153,6 +2168,7 @@ export function routineService(
patch.variables === undefined ? existing.variables : sanitizeRoutineVariableInputs(patch.variables),
);
if (patch.projectId !== undefined) await assertProject(existing.companyId, nextProjectId);
if (patch.folderId !== undefined) await assertRoutineFolder(existing.companyId, nextFolderId);
if (patch.assigneeAgentId !== undefined || patch.status === "active") {
await assertAssignableAgent(db, existing.companyId, nextAssigneeAgentId, { kind: "routine" });
}
@ -2202,6 +2218,7 @@ export function routineService(
const candidate: RoutineRow = {
...locked,
projectId: nextProjectId,
folderId: nextFolderId,
goalId: patch.goalId === undefined ? locked.goalId : patch.goalId,
parentIssueId: patch.parentIssueId === undefined ? locked.parentIssueId : patch.parentIssueId,
title: nextTitle,
@ -2218,8 +2235,20 @@ export function routineService(
updatedByUserId: actor.userId ?? null,
};
const folderChanged = patch.folderId !== undefined && locked.folderId !== candidate.folderId;
if (locked.latestRevisionId && routineCurrentFieldsMatch(locked, candidate)) {
return locked;
if (!folderChanged) return locked;
const [updated] = await txDb
.update(routines)
.set({
folderId: candidate.folderId,
updatedByAgentId: actor.agentId ?? null,
updatedByUserId: actor.userId ?? null,
updatedAt: new Date(),
})
.where(eq(routines.id, id))
.returning();
return updated ?? locked;
}
const nextSnapshot = await buildRoutineRevisionSnapshot(txDb, candidate);
@ -2252,6 +2281,7 @@ export function routineService(
.update(routines)
.set({
projectId: candidate.projectId,
folderId: candidate.folderId,
goalId: candidate.goalId,
parentIssueId: candidate.parentIssueId,
title: candidate.title,

39
ui/src/api/folders.ts Normal file
View File

@ -0,0 +1,39 @@
import type {
CreateFolderRequest,
EnsureMySkillFolderRequest,
Folder,
FolderKind,
FolderListResult,
MoveFolderItemRequest,
MoveFolderRequest,
UpdateFolderRequest,
} from "@paperclipai/shared";
import { api } from "./client";
export const foldersApi = {
list: (companyId: string, kind: FolderKind) =>
api.get<FolderListResult>(`/companies/${encodeURIComponent(companyId)}/folders?kind=${kind}`),
create: (companyId: string, payload: CreateFolderRequest) =>
api.post<Folder>(`/companies/${encodeURIComponent(companyId)}/folders`, payload),
ensureMy: (companyId: string, payload: EnsureMySkillFolderRequest = {}) =>
api.post<Folder>(`/companies/${encodeURIComponent(companyId)}/folders/ensure-my`, payload),
update: (companyId: string, folderId: string, payload: UpdateFolderRequest) =>
api.patch<Folder>(
`/companies/${encodeURIComponent(companyId)}/folders/${encodeURIComponent(folderId)}`,
payload,
),
moveFolder: (companyId: string, folderId: string, payload: MoveFolderRequest) =>
api.post<Folder>(
`/companies/${encodeURIComponent(companyId)}/folders/${encodeURIComponent(folderId)}/move`,
payload,
),
moveItem: (companyId: string, payload: MoveFolderItemRequest) =>
api.post<MoveFolderItemRequest>(
`/companies/${encodeURIComponent(companyId)}/folders/items/move`,
payload,
),
delete: (companyId: string, folderId: string) =>
api.delete<{ deleted: Folder }>(
`/companies/${encodeURIComponent(companyId)}/folders/${encodeURIComponent(folderId)}`,
),
};

View File

@ -64,6 +64,10 @@ export function RoutineListRow<TRoutine extends RoutineListRowItem>({
disableToggle = false,
hideArchiveAction = false,
divider = true,
selected = false,
selectMode = false,
extraMenuItems,
onSelectChange,
onRunNow,
onToggleEnabled,
onToggleArchived,
@ -83,6 +87,10 @@ export function RoutineListRow<TRoutine extends RoutineListRowItem>({
hideArchiveAction?: boolean;
/** Render a bottom divider between consecutive rows. Off when the group is its own card. */
divider?: boolean;
selected?: boolean;
selectMode?: boolean;
extraMenuItems?: ReactNode;
onSelectChange?: (routine: TRoutine, selected: boolean) => void;
onRunNow: (routine: TRoutine) => void;
onToggleEnabled: (routine: TRoutine, enabled: boolean) => void;
onToggleArchived?: (routine: TRoutine) => void;
@ -102,6 +110,23 @@ export function RoutineListRow<TRoutine extends RoutineListRowItem>({
divider ? " border-b border-border last:border-b-0" : ""
}`}
>
{selectMode ? (
<div
className="flex items-start pt-0.5 sm:pt-1"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
}}
>
<input
type="checkbox"
className="h-4 w-4 rounded border-border"
checked={selected}
aria-label={`Select ${routine.title}`}
onChange={(event) => onSelectChange?.(routine, event.target.checked)}
/>
</div>
) : null}
<div className="min-w-0 flex-1 space-y-1.5">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium">{routine.title}</span>
@ -178,6 +203,12 @@ export function RoutineListRow<TRoutine extends RoutineListRowItem>({
>
{runningRoutineId === routine.id ? "Running..." : "Run now"}
</DropdownMenuItem>
{extraMenuItems ? (
<>
<DropdownMenuSeparator />
{extraMenuItems}
</>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onToggleEnabled(routine, enabled)}

View File

@ -0,0 +1,480 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { FolderListResult } from "@paperclipai/shared";
import {
AllUnfiledBanner,
BulkBar,
DeleteFolderDialog,
FolderFormDialog,
FolderRail,
MobileFolderSheet,
MoveToMenu,
folderSearchValue,
normalizeFolderSelection,
} from "./FolderControls";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
if (!globalThis.PointerEvent) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).PointerEvent = MouseEvent;
}
if (!Element.prototype.hasPointerCapture) {
Element.prototype.hasPointerCapture = () => false;
}
if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = () => undefined;
}
function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> | undefined;
flushSync(() => {
result = callback();
});
return result;
}
const folderResult: FolderListResult = {
kind: "routine",
allCount: 4,
unfiledCount: 1,
folders: [
{
id: "folder-reporting",
companyId: "company-1",
kind: "routine",
parentId: null,
name: "Reporting",
slug: "reporting",
systemKey: null,
path: "reporting",
depth: 1,
color: "indigo",
position: 0,
itemCount: 3,
createdAt: new Date("2026-07-01T00:00:00.000Z"),
updatedAt: new Date("2026-07-01T00:00:00.000Z"),
},
],
};
const skillFolderResult: FolderListResult = {
kind: "skill",
allCount: 8,
unfiledCount: 1,
folders: [
{
...folderResult.folders[0]!,
id: "my",
kind: "skill",
name: "my",
slug: "my",
systemKey: "my",
path: "my",
itemCount: 1,
},
{
...folderResult.folders[0]!,
id: "engineering",
kind: "skill",
name: "Engineering",
slug: "engineering",
path: "engineering",
itemCount: 3,
},
{
...folderResult.folders[0]!,
id: "code-review",
kind: "skill",
parentId: "engineering",
name: "Code Review",
slug: "code-review",
path: "engineering/code-review",
depth: 2,
itemCount: 2,
},
{
...folderResult.folders[0]!,
id: "projects",
kind: "skill",
name: "projects",
slug: "projects",
systemKey: "projects",
path: "projects",
itemCount: 2,
},
{
...folderResult.folders[0]!,
id: "bundled",
kind: "skill",
name: "bundled",
slug: "bundled",
systemKey: "bundled",
path: "bundled",
itemCount: 1,
},
],
};
describe("FolderControls", () => {
let container: HTMLDivElement;
let root: Root | null;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = null;
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
container.remove();
document.body.innerHTML = "";
});
it("normalizes URL selection values for folder persistence", () => {
expect(normalizeFolderSelection(null)).toBe("all");
expect(normalizeFolderSelection("unfiled")).toBe("unfiled");
expect(normalizeFolderSelection("folder-reporting")).toBe("folder-reporting");
expect(folderSearchValue("all")).toBe("");
expect(folderSearchValue("unfiled")).toBe("unfiled");
expect(folderSearchValue("folder-reporting")).toBe("folder-reporting");
});
it("renders All, user folders, and Unfiled with counts and selection callbacks", () => {
const onSelect = vi.fn();
root = createRoot(container);
act(() => {
root?.render(
<FolderRail
result={folderResult}
selection="all"
itemLabelPlural="routines"
allLabel="All routines"
onSelect={onSelect}
onCreate={vi.fn()}
onRename={vi.fn()}
onEdit={vi.fn()}
onDelete={vi.fn()}
/>,
);
});
expect(container.textContent).toContain("All routines");
expect(container.textContent).toContain("Reporting");
expect(container.textContent).toContain("Unfiled");
expect(container.textContent).toContain("4");
expect(container.textContent).toContain("3");
expect(container.textContent).toContain("1");
const reportingButton = Array.from(container.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Reporting"),
);
expect(reportingButton).toBeTruthy();
act(() => {
reportingButton?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
});
expect(onSelect).toHaveBeenCalledWith("folder-reporting");
});
it("marks the active row with aria-current, including virtual Unfiled", () => {
root = createRoot(container);
act(() => {
root?.render(
<FolderRail
result={folderResult}
selection="unfiled"
itemLabelPlural="routines"
allLabel="All routines"
onSelect={vi.fn()}
onCreate={vi.fn()}
onRename={vi.fn()}
onEdit={vi.fn()}
onDelete={vi.fn()}
/>,
);
});
const current = container.querySelector('[aria-current="page"]');
expect(current?.textContent).toContain("Unfiled");
});
it("renames a folder inline via double-click and Enter", () => {
const onRename = vi.fn();
root = createRoot(container);
act(() => {
root?.render(
<FolderRail
result={folderResult}
selection="all"
itemLabelPlural="routines"
allLabel="All routines"
onSelect={vi.fn()}
onCreate={vi.fn()}
onRename={onRename}
onEdit={vi.fn()}
onDelete={vi.fn()}
/>,
);
});
const nameButton = Array.from(container.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Reporting"),
);
act(() => {
nameButton?.dispatchEvent(new MouseEvent("dblclick", { bubbles: true, cancelable: true }));
});
const input = container.querySelector<HTMLInputElement>("input");
expect(input).toBeTruthy();
expect(input?.value).toBe("Reporting");
act(() => {
if (!input) return;
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
setter?.call(input, "Monthly reports");
input.dispatchEvent(new Event("input", { bubbles: true }));
});
act(() => {
input?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
});
expect(onRename).toHaveBeenCalledWith(folderResult.folders[0], "Monthly reports");
});
it("moves via MoveToMenu: Unfiled, a folder, and new-folder chaining", () => {
const onMove = vi.fn();
const onCreateAndMove = vi.fn();
root = createRoot(container);
act(() => {
root?.render(
<DropdownMenu open modal={false}>
<DropdownMenuTrigger asChild>
<button type="button">Row actions</button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<MoveToMenu
folders={folderResult.folders}
currentFolderId={null}
onMove={onMove}
onCreateAndMove={onCreateAndMove}
/>
</DropdownMenuContent>
</DropdownMenu>,
);
});
const subTrigger = Array.from(document.querySelectorAll("[data-radix-collection-item]")).find(
(element) => element.textContent?.includes("Move to"),
);
expect(subTrigger).toBeTruthy();
act(() => {
subTrigger?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }));
});
const menuItems = Array.from(document.querySelectorAll('[role="menuitem"]'));
const unfiledItem = menuItems.find((element) => element.textContent?.includes("Unfiled"));
const folderItem = menuItems.find((element) => element.textContent?.includes("Reporting"));
const newFolderItem = menuItems.find((element) => element.textContent?.includes("New folder"));
expect(unfiledItem).toBeTruthy();
expect(folderItem).toBeTruthy();
expect(newFolderItem).toBeTruthy();
act(() => {
unfiledItem?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
});
expect(onMove).toHaveBeenCalledWith(null);
act(() => {
folderItem?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
});
expect(onMove).toHaveBeenCalledWith("folder-reporting");
act(() => {
newFolderItem?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
});
expect(onCreateAndMove).toHaveBeenCalled();
});
it("creates a folder through FolderFormDialog and disables submit on empty name", () => {
const onSubmit = vi.fn();
root = createRoot(container);
act(() => {
root?.render(
<FolderFormDialog
open
kind="routine"
folder={null}
onOpenChange={vi.fn()}
onSubmit={onSubmit}
/>,
);
});
const submit = Array.from(document.querySelectorAll("button")).find(
(button) => button.textContent === "Create folder",
) as HTMLButtonElement | undefined;
expect(submit).toBeTruthy();
expect(submit?.disabled).toBe(true);
const nameInput = document.querySelector<HTMLInputElement>("#folder-name");
act(() => {
if (!nameInput) return;
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
setter?.call(nameInput, "Reporting");
nameInput.dispatchEvent(new Event("input", { bubbles: true }));
});
expect(submit?.disabled).toBe(false);
act(() => {
submit?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
});
expect(onSubmit).toHaveBeenCalledWith({ name: "Reporting", color: expect.any(String) });
});
it("states the forgiving delete behavior and confirms", () => {
const onConfirm = vi.fn();
root = createRoot(container);
act(() => {
root?.render(
<DeleteFolderDialog
open
folder={folderResult.folders[0]!}
itemLabelPlural="routines"
onOpenChange={vi.fn()}
onConfirm={onConfirm}
/>,
);
});
expect(document.body.textContent).toContain("3 routines in this folder won't be deleted");
expect(document.body.textContent).toContain("They'll move to Unfiled");
const confirmButton = Array.from(document.querySelectorAll("button")).find(
(button) => button.textContent === "Delete folder",
);
act(() => {
confirmButton?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
});
expect(onConfirm).toHaveBeenCalled();
});
it("selects a folder from the mobile sheet and dismisses", () => {
const onSelect = vi.fn();
const onOpenChange = vi.fn();
root = createRoot(container);
act(() => {
root?.render(
<MobileFolderSheet
open
onOpenChange={onOpenChange}
result={folderResult}
selection="all"
allLabel="All routines"
itemLabelPlural="Routines"
onSelect={onSelect}
onCreate={vi.fn()}
/>,
);
});
expect(document.body.textContent).toContain("All routines");
expect(document.body.textContent).toContain("Unfiled");
const reportingRow = Array.from(document.querySelectorAll("button")).find(
(button) => button.textContent?.includes("Reporting"),
);
act(() => {
reportingRow?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
});
expect(onSelect).toHaveBeenCalledWith("folder-reporting");
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it("preserves skill root grouping and child hierarchy in the mobile sheet", () => {
root = createRoot(container);
act(() => {
root?.render(
<MobileFolderSheet
open
onOpenChange={vi.fn()}
result={skillFolderResult}
selection="all"
allLabel="All skills"
itemLabelPlural="Skills"
onSelect={vi.fn()}
onCreate={vi.fn()}
/>,
);
});
const body = document.body.textContent ?? "";
expect(body.indexOf("My Skills")).toBeLessThan(body.indexOf("Company"));
expect(body.indexOf("Company")).toBeLessThan(body.indexOf("Engineering"));
expect(body.indexOf("Engineering")).toBeLessThan(body.indexOf("Code Review"));
expect(body.indexOf("Code Review")).toBeLessThan(body.indexOf("Projects"));
expect(body.indexOf("Projects")).toBeLessThan(body.indexOf("Bundled"));
expect(document.querySelector('[data-folder-id="engineering"] > .pl-3 [data-folder-id="code-review"]')).not.toBeNull();
});
it("persists AllUnfiledBanner dismissal across mounts", () => {
const storageKey = "paperclip:test-folder-nudge";
window.localStorage.removeItem(storageKey);
const onCreateFolder = vi.fn();
root = createRoot(container);
act(() => {
root?.render(
<AllUnfiledBanner
storageKey={storageKey}
itemLabelPlural="routines"
onCreateFolder={onCreateFolder}
/>,
);
});
expect(container.textContent).toContain("Create your first folder");
const dismissButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="Dismiss folder suggestion"]',
);
act(() => {
dismissButton?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
});
expect(container.textContent).not.toContain("Create your first folder");
expect(window.localStorage.getItem(storageKey)).toBe("1");
act(() => {
root?.unmount();
});
root = createRoot(container);
act(() => {
root?.render(
<AllUnfiledBanner
storageKey={storageKey}
itemLabelPlural="routines"
onCreateFolder={onCreateFolder}
/>,
);
});
expect(container.textContent).not.toContain("Create your first folder");
window.localStorage.removeItem(storageKey);
});
});

View File

@ -0,0 +1,776 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import {
Check,
ChevronDown,
Folder as FolderIcon,
MoreHorizontal,
Plus,
Search,
Trash2,
X,
} from "lucide-react";
import type { FolderKind, FolderListItem, FolderListResult } from "@paperclipai/shared";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { cn } from "@/lib/utils";
import {
reservedRootLabel,
treeFromResult,
type FolderTreeNode,
} from "./skill-folder-tree";
export type FolderSelection = "all" | "unfiled" | string;
export const FOLDER_COLORS = [
"indigo",
"violet",
"emerald",
"cyan",
"amber",
"slate",
];
const FOLDER_COLOR_VALUES: Record<(typeof FOLDER_COLORS)[number], string> = {
indigo: "var(--folder-color-indigo)",
violet: "var(--folder-color-violet)",
emerald: "var(--folder-color-emerald)",
cyan: "var(--folder-color-cyan)",
amber: "var(--folder-color-amber)",
slate: "var(--folder-color-slate)",
};
export function normalizeFolderSelection(value: string | null | undefined): FolderSelection {
if (!value) return "all";
if (value === "unfiled") return "unfiled";
return value;
}
export function folderSearchValue(selection: FolderSelection): string {
return selection === "all" ? "" : selection === "unfiled" ? "unfiled" : selection;
}
export function selectedFolderFromList(
folders: FolderListItem[],
selection: FolderSelection,
): FolderListItem | null {
if (selection === "all" || selection === "unfiled") return null;
return folders.find((folder) => folder.id === selection) ?? null;
}
export function FolderSwatch({
color,
className,
}: {
color: string | null | undefined;
className?: string;
}) {
const backgroundColor = color
? (FOLDER_COLOR_VALUES[color] ?? color)
: "var(--folder-color-slate)";
return (
<span
aria-hidden="true"
className={cn("h-2.5 w-2.5 shrink-0 rounded-sm border border-border/40", className)}
style={{ backgroundColor }}
/>
);
}
function selectionLabel({
folders,
selection,
allLabel,
}: {
folders: FolderListItem[];
selection: FolderSelection;
allLabel: string;
}) {
if (selection === "all") return allLabel;
if (selection === "unfiled") return "Unfiled";
return folders.find((folder) => folder.id === selection)?.name ?? allLabel;
}
function selectionCount(result: FolderListResult | null | undefined, selection: FolderSelection) {
if (!result) return 0;
if (selection === "all") return result.allCount;
if (selection === "unfiled") return result.unfiledCount;
return result.folders.find((folder) => folder.id === selection)?.itemCount ?? 0;
}
export function FolderChip({
result,
selection,
allLabel,
onClick,
}: {
result: FolderListResult | null | undefined;
selection: FolderSelection;
allLabel: string;
onClick: () => void;
}) {
const folder = result ? selectedFolderFromList(result.folders, selection) : null;
return (
<Button variant="outline" size="sm" className="max-w-full justify-start" onClick={onClick}>
{selection === "all" ? <FolderIcon className="mr-2 h-3.5 w-3.5" /> : <FolderSwatch color={folder?.color} className="mr-2" />}
<span className="truncate">{selectionLabel({ folders: result?.folders ?? [], selection, allLabel })}</span>
<span className="ml-2 text-xs text-muted-foreground">{selectionCount(result, selection)}</span>
<ChevronDown className="ml-1 h-3.5 w-3.5 shrink-0" />
</Button>
);
}
export function FolderRail({
result,
selection,
itemLabelPlural,
allLabel,
loading = false,
onSelect,
onCreate,
onRename,
onEdit,
onDelete,
}: {
result: FolderListResult | null | undefined;
selection: FolderSelection;
itemLabelPlural: string;
allLabel: string;
loading?: boolean;
onSelect: (selection: FolderSelection) => void;
onCreate: () => void;
onRename: (folder: FolderListItem, name: string) => void;
onEdit: (folder: FolderListItem) => void;
onDelete: (folder: FolderListItem) => void;
}) {
const folders = result?.folders ?? [];
const [renamingFolderId, setRenamingFolderId] = useState<string | null>(null);
const [renameDraft, setRenameDraft] = useState("");
useEffect(() => {
if (!renamingFolderId) return;
const folder = folders.find((entry) => entry.id === renamingFolderId);
if (!folder) setRenamingFolderId(null);
}, [folders, renamingFolderId]);
function startRename(folder: FolderListItem) {
setRenamingFolderId(folder.id);
setRenameDraft(folder.name);
}
function commitRename(folder: FolderListItem) {
const name = renameDraft.trim();
if (name && name !== folder.name) onRename(folder, name);
setRenamingFolderId(null);
}
function renderVirtualRow(
key: FolderSelection,
label: string,
count: number,
icon: ReactNode,
) {
const active = selection === key;
return (
<button
type="button"
className={cn(
"grid w-full grid-cols-(--gtc-folder-row) items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent/40",
active ? "bg-accent/60 text-foreground" : "text-muted-foreground",
)}
aria-current={active ? "page" : undefined}
onClick={() => onSelect(key)}
>
<span className="h-4 w-4">{icon}</span>
<span className="truncate">{label}</span>
<span className="text-xs text-muted-foreground">{count}</span>
</button>
);
}
return (
<nav aria-label={`${itemLabelPlural} folders`} className="hidden w-(--sz-folder-rail) shrink-0 border-r border-border pr-3 md:block">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="text-(length:--text-micro) font-medium uppercase tracking-wide text-muted-foreground">Folders</div>
<Button variant="ghost" size="icon-sm" title="New folder" onClick={onCreate}>
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
{loading ? (
<div className="space-y-2">
<div className="h-7 rounded-md bg-muted/60" />
<div className="h-7 rounded-md bg-muted/40" />
<div className="h-7 rounded-md bg-muted/30" />
</div>
) : (
<div className="space-y-0.5">
{renderVirtualRow("all", allLabel, result?.allCount ?? 0, <FolderIcon className="h-3.5 w-3.5" />)}
{folders.map((folder) => (
<FolderRailItem
key={folder.id}
folder={folder}
active={selection === folder.id}
renaming={renamingFolderId === folder.id}
renameDraft={renameDraft}
onRenameDraftChange={setRenameDraft}
onRenameCommit={() => commitRename(folder)}
onRenameCancel={() => setRenamingFolderId(null)}
onSelect={() => onSelect(folder.id)}
onStartRename={() => startRename(folder)}
onEdit={() => onEdit(folder)}
onDelete={() => onDelete(folder)}
/>
))}
<div className="px-2 pb-1 pt-3 text-(length:--text-micro) font-medium uppercase tracking-wide text-muted-foreground">
System
</div>
{renderVirtualRow("unfiled", "Unfiled", result?.unfiledCount ?? 0, <FolderSwatch color={null} className="mt-0.5" />)}
</div>
)}
</nav>
);
}
/**
* One selectable rail row. The leading 1rem grid column is the reserved
* disclosure/indent slot so nested folders can slot in later without relayout
* (ux-spec §3.1/§3.2 "nesting-ready").
*/
export function FolderRailItem({
folder,
active,
renaming,
renameDraft,
onRenameDraftChange,
onRenameCommit,
onRenameCancel,
onSelect,
onStartRename,
onEdit,
onDelete,
}: {
folder: FolderListItem;
active: boolean;
renaming: boolean;
renameDraft: string;
onRenameDraftChange: (value: string) => void;
onRenameCommit: () => void;
onRenameCancel: () => void;
onSelect: () => void;
onStartRename: () => void;
onEdit: () => void;
onDelete: () => void;
}) {
return (
<div
className={cn(
"group grid grid-cols-(--gtc-folder-row-actions) items-center gap-2 rounded-md px-2 py-1 text-sm transition-colors hover:bg-accent/40",
active ? "bg-accent/60 text-foreground" : "text-muted-foreground",
)}
>
<span className="h-4 w-4" />
<button
type="button"
className="flex min-w-0 items-center gap-2 text-left"
aria-current={active ? "page" : undefined}
onClick={onSelect}
onDoubleClick={onStartRename}
>
<FolderSwatch color={folder.color} />
{renaming ? (
<input
value={renameDraft}
onChange={(event) => onRenameDraftChange(event.target.value)}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key === "Enter") onRenameCommit();
if (event.key === "Escape") onRenameCancel();
}}
onBlur={onRenameCommit}
className="h-6 min-w-0 flex-1 rounded-sm border border-border bg-background px-1 text-sm outline-none"
autoFocus
/>
) : (
<span className="truncate">{folder.name}</span>
)}
</button>
<span className="text-xs text-muted-foreground">{folder.itemCount}</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="h-6 w-6 opacity-0 group-hover:opacity-100 data-[state=open]:opacity-100"
aria-label={`Folder actions for ${folder.name}`}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={onStartRename}>Rename</DropdownMenuItem>
<DropdownMenuItem onSelect={onEdit}>Edit color</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onSelect={onDelete}>
<Trash2 className="h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
/**
* Dismissible nudge shown when items exist but no folders do (ux-spec §6.3).
* Dismissal persists per storage key.
*/
export function AllUnfiledBanner({
storageKey,
itemLabelPlural,
onCreateFolder,
}: {
storageKey: string;
itemLabelPlural: string;
onCreateFolder: () => void;
}) {
const [dismissed, setDismissed] = useState(() => {
try {
return window.localStorage.getItem(storageKey) === "1";
} catch {
return false;
}
});
if (dismissed) return null;
function dismiss() {
setDismissed(true);
try {
window.localStorage.setItem(storageKey, "1");
} catch {
// Ignore storage failures; the banner just reappears next visit.
}
}
return (
<div className="mb-3 flex flex-wrap items-center gap-2 rounded-md border border-border bg-muted/40 px-3 py-2 text-sm">
<FolderIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 text-muted-foreground">
Group these {itemLabelPlural} into folders to keep things tidy.
</span>
<Button size="sm" variant="outline" onClick={onCreateFolder}>
Create your first folder
</Button>
<Button size="icon-sm" variant="ghost" aria-label="Dismiss folder suggestion" onClick={dismiss}>
<X className="h-3.5 w-3.5" />
</Button>
</div>
);
}
export function MobileFolderSheet({
open,
onOpenChange,
result,
selection,
allLabel,
itemLabelPlural,
onSelect,
onCreate,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
result: FolderListResult | null | undefined;
selection: FolderSelection;
allLabel: string;
itemLabelPlural: string;
onSelect: (selection: FolderSelection) => void;
onCreate: () => void;
}) {
function select(next: FolderSelection) {
onSelect(next);
onOpenChange(false);
}
const model = useMemo(() => treeFromResult(result), [result]);
function renderBranch(node: FolderTreeNode, rootLabel?: string) {
return (
<div key={node.folder.id} data-folder-id={node.folder.id}>
<MobileFolderRow
id={node.folder.id}
label={rootLabel ?? node.folder.name}
count={node.folder.itemCount}
color={node.folder.color}
selected={selection === node.folder.id}
onSelect={select}
/>
{node.children.length > 0 ? (
<div className="pl-3">
{node.children.map((child) => renderBranch(child))}
</div>
) : null}
</div>
);
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="bottom" className="max-h-(--sz-folder-sheet-max) rounded-t-lg pb-4">
<SheetHeader className="border-b border-border px-4 py-3">
<SheetTitle>{itemLabelPlural} folders</SheetTitle>
</SheetHeader>
<div className="overflow-y-auto px-3">
<MobileFolderRow
id="all"
label={allLabel}
count={result?.allCount ?? 0}
selected={selection === "all"}
onSelect={select}
all
/>
{result?.kind === "skill" ? (
<>
{model.my ? renderBranch(model.my, "My Skills") : null}
<div className="px-2 pb-0.5 pt-2 text-(length:--text-micro) font-medium uppercase tracking-wide text-muted-foreground">
Company
</div>
{model.company.map((node) => renderBranch(node))}
{model.projects ? renderBranch(model.projects, "Projects") : null}
{model.bundled ? renderBranch(model.bundled, "Bundled") : null}
</>
) : (
model.roots.map((node) => renderBranch(node, reservedRootLabel(node.folder)))
)}
<MobileFolderRow
id="unfiled"
label="Unfiled"
count={result?.unfiledCount ?? 0}
selected={selection === "unfiled"}
onSelect={select}
/>
</div>
<div className="border-t border-border px-4 pt-3">
<Button size="sm" variant="outline" className="w-full" onClick={onCreate}>
<Plus className="mr-2 h-3.5 w-3.5" />
New folder
</Button>
</div>
</SheetContent>
</Sheet>
);
}
function MobileFolderRow({
id,
label,
count,
color,
selected,
all = false,
onSelect,
}: {
id: FolderSelection;
label: string;
count: number;
color?: string | null;
selected: boolean;
all?: boolean;
onSelect: (selection: FolderSelection) => void;
}) {
return (
<button
type="button"
className="flex w-full items-center gap-2 rounded-md px-2 py-2 text-left text-sm hover:bg-accent/50"
onClick={() => onSelect(id)}
>
{all ? <FolderIcon className="h-3.5 w-3.5 text-muted-foreground" /> : <FolderSwatch color={color} />}
<span className="min-w-0 flex-1 truncate">{label}</span>
<span className="text-xs text-muted-foreground">{count}</span>
{selected ? <Check className="h-3.5 w-3.5" /> : null}
</button>
);
}
export function MoveToMenu({
folders,
currentFolderId,
onMove,
onCreateAndMove,
}: {
folders: FolderListItem[];
currentFolderId: string | null | undefined;
onMove: (folderId: string | null) => void;
onCreateAndMove: () => void;
}) {
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger>Move to...</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-56">
<MoveToMenuItems
folders={folders}
currentFolderId={currentFolderId}
onMove={onMove}
onCreateAndMove={onCreateAndMove}
/>
</DropdownMenuSubContent>
</DropdownMenuSub>
);
}
function MoveToMenuItems({
folders,
currentFolderId,
onMove,
onCreateAndMove,
}: {
folders: FolderListItem[];
currentFolderId: string | null | undefined;
onMove: (folderId: string | null) => void;
onCreateAndMove: () => void;
}) {
const [query, setQuery] = useState("");
const visibleFolders = useMemo(() => {
const lowered = query.trim().toLowerCase();
if (!lowered) return folders;
return folders.filter((folder) => folder.name.toLowerCase().includes(lowered));
}, [folders, query]);
return (
<>
<div className="flex items-center gap-2 px-2 py-1.5">
<Search className="h-3.5 w-3.5 text-muted-foreground" />
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => event.stopPropagation()}
placeholder="Search folders"
className="h-7 min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => onMove(null)}>
<FolderSwatch color={null} />
Unfiled
{currentFolderId == null ? <Check className="ml-auto h-3.5 w-3.5" /> : null}
</DropdownMenuItem>
{visibleFolders.map((folder) => (
<DropdownMenuItem key={folder.id} onSelect={() => onMove(folder.id)}>
<FolderSwatch color={folder.color} />
<span className="min-w-0 flex-1 truncate">{folder.name}</span>
{currentFolderId === folder.id ? <Check className="ml-auto h-3.5 w-3.5" /> : null}
</DropdownMenuItem>
))}
{visibleFolders.length === 0 ? (
<div className="px-2 py-2 text-xs text-muted-foreground">No folders match.</div>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onCreateAndMove}>
<Plus className="h-3.5 w-3.5" />
New folder...
</DropdownMenuItem>
</>
);
}
export function FolderFormDialog({
open,
kind,
folder,
onOpenChange,
onSubmit,
pending = false,
}: {
open: boolean;
kind: FolderKind;
folder: FolderListItem | null;
onOpenChange: (open: boolean) => void;
onSubmit: (payload: { name: string; color: string | null }) => void;
pending?: boolean;
}) {
const [name, setName] = useState("");
const [color, setColor] = useState<string | null>(FOLDER_COLORS[0] ?? null);
const isEdit = Boolean(folder);
useEffect(() => {
if (!open) return;
setName(folder?.name ?? "");
setColor(folder?.color ?? FOLDER_COLORS[0] ?? null);
}, [folder, open]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{isEdit ? "Edit folder" : "Create folder"}</DialogTitle>
<DialogDescription>
{kind === "routine" ? "Organize routines in this company." : "Organize installed company skills."}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="folder-name">Name</label>
<Input
id="folder-name"
value={name}
onChange={(event) => setName(event.target.value)}
autoFocus
onKeyDown={(event) => {
if (event.key === "Enter" && name.trim()) onSubmit({ name: name.trim(), color });
}}
/>
</div>
<div className="space-y-2">
<div className="text-sm font-medium">Color</div>
<div className="flex flex-wrap gap-2">
{FOLDER_COLORS.map((swatch) => (
<button
key={swatch}
type="button"
aria-label={`Use folder color ${swatch}`}
className={cn(
"h-7 w-7 rounded-md border border-border",
color === swatch && "ring-2 ring-ring ring-offset-2 ring-offset-background",
)}
style={{ backgroundColor: FOLDER_COLOR_VALUES[swatch] }}
onClick={() => setColor(swatch)}
/>
))}
<button
type="button"
className={cn(
"h-7 rounded-md border border-border px-2 text-xs text-muted-foreground",
color == null && "ring-2 ring-ring ring-offset-2 ring-offset-background",
)}
onClick={() => setColor(null)}
>
None
</button>
</div>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={pending}>
Cancel
</Button>
<Button onClick={() => onSubmit({ name: name.trim(), color })} disabled={pending || !name.trim()}>
{pending ? "Saving..." : isEdit ? "Save" : "Create folder"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function DeleteFolderDialog({
open,
folder,
itemLabelPlural,
onOpenChange,
onConfirm,
pending = false,
}: {
open: boolean;
folder: FolderListItem | null;
itemLabelPlural: string;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
pending?: boolean;
}) {
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete folder</AlertDialogTitle>
<AlertDialogDescription>
The {folder?.itemCount ?? 0} {itemLabelPlural} in this folder won't be deleted. They'll move to Unfiled.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={pending}>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={pending || !folder}
onClick={(event) => {
event.preventDefault();
onConfirm();
}}
>
{pending ? "Deleting..." : "Delete folder"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
export function BulkBar({
selectedCount,
folders,
onMove,
onCreateAndMove,
onClear,
onDone,
}: {
selectedCount: number;
folders: FolderListItem[];
onMove: (folderId: string | null) => void;
onCreateAndMove: () => void;
onClear: () => void;
onDone: () => void;
}) {
if (selectedCount === 0) return null;
return (
<div className="sticky top-2 z-10 flex flex-wrap items-center gap-2 rounded-md border border-border bg-background/95 px-3 py-2 shadow-sm backdrop-blur">
<span className="mr-auto text-sm text-muted-foreground">{selectedCount} selected</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="outline">Move to...</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<MoveToMenuItems
folders={folders}
currentFolderId={undefined}
onMove={onMove}
onCreateAndMove={onCreateAndMove}
/>
</DropdownMenuContent>
</DropdownMenu>
<Button size="sm" variant="ghost" onClick={onClear}>Deselect all</Button>
<Button size="sm" onClick={onDone}>Done</Button>
</div>
);
}

View File

@ -0,0 +1,131 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { FolderListResult } from "@paperclipai/shared";
import { SkillFolderRail } from "./SkillFolderTree";
function pointerEvent(type: string, clientX: number) {
const event = new MouseEvent(type, { bubbles: true, clientX });
Object.defineProperty(event, "pointerId", { value: 1 });
return event;
}
const result: FolderListResult = {
kind: "skill",
allCount: 12,
unfiledCount: 2,
folders: [
{
id: "my-root",
companyId: "company-1",
kind: "skill",
parentId: null,
name: "My Skills",
slug: "my",
systemKey: "my",
path: "my",
depth: 1,
color: null,
position: 0,
itemCount: 4,
createdAt: new Date("2026-07-16T00:00:00.000Z"),
updatedAt: new Date("2026-07-16T00:00:00.000Z"),
},
{
id: "personal-root",
companyId: "company-1",
kind: "skill",
parentId: "my-root",
name: "Ada",
slug: "ada",
systemKey: "my:user-1",
path: "my/ada",
depth: 2,
color: null,
position: 0,
itemCount: 4,
createdAt: new Date("2026-07-16T00:00:00.000Z"),
updatedAt: new Date("2026-07-16T00:00:00.000Z"),
},
],
};
describe("SkillFolderRail", () => {
let container: HTMLDivElement;
let root: Root;
const onSelect = vi.fn<(selection: string) => void>();
beforeEach(() => {
window.localStorage.clear();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
onSelect.mockClear();
flushSync(() => {
root.render(
<SkillFolderRail
result={result}
selection="all"
tags={[]}
activeTag={null}
onSelect={onSelect}
onSelectTag={vi.fn()}
onCreateFolder={vi.fn()}
onRenameFolder={vi.fn()}
onEditFolder={vi.fn()}
onMoveFolder={vi.fn()}
onDeleteFolder={vi.fn()}
/>,
);
});
});
afterEach(() => {
flushSync(() => root.unmount());
container.remove();
window.localStorage.clear();
});
it("uses the wider default and persists drag resizing", () => {
const rail = container.firstElementChild as HTMLDivElement;
const separator = container.querySelector('[role="separator"]') as HTMLDivElement;
expect(rail.style.width).toBe("288px");
separator.setPointerCapture = vi.fn();
flushSync(() => {
separator.dispatchEvent(pointerEvent("pointerdown", 288));
separator.dispatchEvent(pointerEvent("pointermove", 320));
separator.dispatchEvent(pointerEvent("pointerup", 320));
});
expect(rail.style.width).toBe("320px");
expect(window.localStorage.getItem("paperclip.skills.folderRail.width")).toBe("320");
});
it("keeps virtual and folder counts on the same grid column", () => {
const allRow = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("All skills"));
const myLabel = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("My Skills"));
const myRow = myLabel?.parentElement;
expect(allRow?.className).toContain("grid-cols-(--gtc-folder-row-actions)");
expect(myRow?.className).toContain("grid-cols-(--gtc-folder-row-actions)");
expect(allRow?.querySelector(".tabular-nums")?.textContent).toBe("12");
expect(myRow?.querySelector(".tabular-nums")?.textContent).toBe("4");
});
it("selects and toggles a folder when its row label is clicked", () => {
const myLabel = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("My Skills"));
expect(container.textContent).not.toContain("Ada");
flushSync(() => myLabel?.click());
expect(onSelect).toHaveBeenCalledWith("my-root");
expect(container.textContent).toContain("Ada");
expect(container.querySelector('[aria-label="Collapse folder"]')).not.toBeNull();
});
});

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,118 @@
import { describe, expect, it } from "vitest";
import type { FolderListItem } from "@paperclipai/shared";
import {
buildSkillFolderTree,
folderBreadcrumbTrail,
isBundledFolder,
isProjectsFolder,
reservedRootLabel,
skillFolderDisplayPath,
skillFolderPathDisplayFallback,
subtreeFolderIds,
} from "./skill-folder-tree";
function folder(partial: Partial<FolderListItem> & { id: string; slug: string; path: string }): FolderListItem {
return {
companyId: "co",
kind: "skill",
parentId: null,
name: partial.slug,
systemKey: null,
depth: 1,
color: null,
position: 0,
itemCount: 0,
createdAt: new Date(),
updatedAt: new Date(),
...partial,
};
}
const folders: FolderListItem[] = [
folder({ id: "my", slug: "my", path: "my", systemKey: "my", name: "my", position: 0 }),
folder({ id: "mine", slug: "dotta", path: "my/dotta", parentId: "my", systemKey: "my:u1", depth: 2, position: 0 }),
folder({ id: "eng", slug: "engineering", path: "engineering", name: "Engineering", position: 1 }),
folder({ id: "eng-review", slug: "review", path: "engineering/review", parentId: "eng", depth: 2, name: "Review", position: 0 }),
folder({ id: "proj", slug: "projects", path: "projects", systemKey: "projects", name: "projects", position: 2 }),
folder({ id: "proj-a", slug: "acme", path: "projects/acme", parentId: "proj", systemKey: "project:a", depth: 2 }),
folder({ id: "bundled", slug: "bundled", path: "bundled", systemKey: "bundled", name: "bundled", position: 3 }),
folder({ id: "bundled-git", slug: "git", path: "bundled/git", parentId: "bundled", systemKey: "bundled:git", depth: 2 }),
];
describe("buildSkillFolderTree", () => {
it("groups reserved roots and company folders in order", () => {
const model = buildSkillFolderTree(folders);
expect(model.my?.folder.id).toBe("my");
expect(model.projects?.folder.id).toBe("proj");
expect(model.bundled?.folder.id).toBe("bundled");
expect(model.company.map((n) => n.folder.id)).toEqual(["eng"]);
// Ordered roots: My → company → Projects → Bundled.
expect(model.roots.map((n) => n.folder.id)).toEqual(["my", "eng", "proj", "bundled"]);
});
it("nests children under their parent", () => {
const model = buildSkillFolderTree(folders);
expect(model.my?.children.map((n) => n.folder.id)).toEqual(["mine"]);
expect(model.company[0]?.children.map((n) => n.folder.id)).toEqual(["eng-review"]);
expect(model.childrenById.get("bundled")?.map((n) => n.folder.id)).toEqual(["bundled-git"]);
});
});
describe("subtreeFolderIds", () => {
it("includes the folder and all descendants", () => {
const model = buildSkillFolderTree(folders);
expect([...subtreeFolderIds(model, "eng")].sort()).toEqual(["eng", "eng-review"]);
expect([...subtreeFolderIds(model, "my")].sort()).toEqual(["mine", "my"]);
expect([...subtreeFolderIds(model, "eng-review")]).toEqual(["eng-review"]);
});
});
describe("folderBreadcrumbTrail", () => {
it("walks from top-level root down to the target", () => {
const model = buildSkillFolderTree(folders);
expect(folderBreadcrumbTrail(model, "eng-review").map((f) => f.id)).toEqual(["eng", "eng-review"]);
expect(folderBreadcrumbTrail(model, "mine").map((f) => f.id)).toEqual(["my", "mine"]);
});
});
describe("reserved subtree detection", () => {
it("flags bundled root and descendants", () => {
expect(isBundledFolder({ path: "bundled", systemKey: "bundled" })).toBe(true);
expect(isBundledFolder({ path: "bundled/git", systemKey: "bundled:git" })).toBe(true);
expect(isBundledFolder({ path: "engineering", systemKey: null })).toBe(false);
});
it("flags projects root and descendants", () => {
expect(isProjectsFolder({ path: "projects", systemKey: "projects" })).toBe(true);
expect(isProjectsFolder({ path: "projects/acme", systemKey: "project:a" })).toBe(true);
expect(isProjectsFolder({ path: "my/dotta", systemKey: "my:u1" })).toBe(false);
});
});
describe("reservedRootLabel", () => {
it("renames reserved roots and passes through others", () => {
expect(reservedRootLabel({ systemKey: "my", name: "my" })).toBe("My Skills");
expect(reservedRootLabel({ systemKey: "projects", name: "projects" })).toBe("Projects");
expect(reservedRootLabel({ systemKey: "bundled", name: "bundled" })).toBe("Bundled");
expect(reservedRootLabel({ systemKey: null, name: "Engineering" })).toBe("Engineering");
});
});
describe("skillFolderDisplayPath", () => {
it("prefixes company folders and preserves reserved-root labels", () => {
const model = buildSkillFolderTree(folders);
expect(skillFolderDisplayPath(model, "eng-review")).toBe("Company / Engineering / Review");
expect(skillFolderDisplayPath(model, "mine")).toBe("My Skills / dotta");
expect(skillFolderDisplayPath(model, "bundled-git")).toBe("Bundled / git");
expect(skillFolderDisplayPath(model, null)).toBeNull();
});
});
describe("skillFolderPathDisplayFallback", () => {
it("humanizes cold detail paths before folder metadata loads", () => {
expect(skillFolderPathDisplayFallback("engineering/code-review")).toBe("Company / Engineering / Code Review");
expect(skillFolderPathDisplayFallback("my/local-board/drafts")).toBe("My Skills / Local Board / Drafts");
expect(skillFolderPathDisplayFallback("bundled/review-pr")).toBe("Bundled / Review Pr");
expect(skillFolderPathDisplayFallback(null)).toBeNull();
});
});

View File

@ -0,0 +1,202 @@
import type { FolderListItem, FolderListResult } from "@paperclipai/shared";
/**
* Pure tree helpers for the skill folder browser (Idea A, PAP-14038).
*
* The server returns a *flat* {@link FolderListResult}; every folder carries a
* `parentId`, a canonical slug `path` (root = slug, children = `parent/slug`),
* a `depth`, and a `systemKey`. Reserved roots have a stable `systemKey`:
* - `my` "My Skills" (personal folders live beneath it as `my:<userId>`)
* - `projects` "Projects" (auto-created project folders `project:<id>`)
* - `bundled` "Bundled" (read-only; categories `bundled:<slug>`)
* Everything else at the top level is a plain company folder and is grouped
* under the virtual "Company" heading in the rail.
*/
export interface FolderTreeNode {
folder: FolderListItem;
children: FolderTreeNode[];
}
export type ReservedRootKey = "my" | "projects" | "bundled";
export interface SkillFolderTreeModel {
/** "My Skills" reserved root, or null when it hasn't been provisioned yet. */
my: FolderTreeNode | null;
/** Non-reserved top-level folders, shown under the "Company" heading. */
company: FolderTreeNode[];
/** "Projects" reserved root. */
projects: FolderTreeNode | null;
/** "Bundled" reserved root (read-only subtree). */
bundled: FolderTreeNode | null;
/** Every folder by id, for O(1) lookups. */
byId: Map<string, FolderListItem>;
/** Direct child nodes for a given folder id. */
childrenById: Map<string, FolderTreeNode[]>;
/** All top-level nodes in reserved-then-company order (for move pickers). */
roots: FolderTreeNode[];
}
const RESERVED_ROOT_SYSTEM_KEYS = new Set<string>(["my", "projects", "bundled"]);
export function isReservedRootSystemKey(systemKey: string | null | undefined): boolean {
return Boolean(systemKey && RESERVED_ROOT_SYSTEM_KEYS.has(systemKey));
}
/** True for the Bundled root or anything nested inside it (read-only subtree). */
export function isBundledFolder(folder: Pick<FolderListItem, "path" | "systemKey">): boolean {
if (folder.systemKey === "bundled" || folder.systemKey?.startsWith("bundled:")) return true;
return folder.path === "bundled" || folder.path.startsWith("bundled/");
}
/** True for the Projects root or anything nested inside it (auto-managed subtree). */
export function isProjectsFolder(folder: Pick<FolderListItem, "path" | "systemKey">): boolean {
if (folder.systemKey === "projects" || folder.systemKey?.startsWith("project:")) return true;
return folder.path === "projects" || folder.path.startsWith("projects/");
}
function sortNodes(nodes: FolderTreeNode[]): void {
nodes.sort(
(a, b) => a.folder.position - b.folder.position || a.folder.name.localeCompare(b.folder.name),
);
for (const node of nodes) sortNodes(node.children);
}
export function buildSkillFolderTree(folders: FolderListItem[]): SkillFolderTreeModel {
const byId = new Map<string, FolderListItem>();
const nodeById = new Map<string, FolderTreeNode>();
for (const folder of folders) {
byId.set(folder.id, folder);
nodeById.set(folder.id, { folder, children: [] });
}
const roots: FolderTreeNode[] = [];
for (const folder of folders) {
const node = nodeById.get(folder.id)!;
const parent = folder.parentId ? nodeById.get(folder.parentId) : null;
if (parent) parent.children.push(node);
else roots.push(node);
}
sortNodes(roots);
let my: FolderTreeNode | null = null;
let projects: FolderTreeNode | null = null;
let bundled: FolderTreeNode | null = null;
const company: FolderTreeNode[] = [];
for (const node of roots) {
switch (node.folder.systemKey) {
case "my":
my = node;
break;
case "projects":
projects = node;
break;
case "bundled":
bundled = node;
break;
default:
company.push(node);
}
}
const childrenById = new Map<string, FolderTreeNode[]>();
for (const [id, node] of nodeById) childrenById.set(id, node.children);
// Ordered roots: reserved first (My → Projects → Bundled), then company folders.
const orderedRoots: FolderTreeNode[] = [];
if (my) orderedRoots.push(my);
orderedRoots.push(...company);
if (projects) orderedRoots.push(projects);
if (bundled) orderedRoots.push(bundled);
return { my, company, projects, bundled, byId, childrenById, roots: orderedRoots };
}
/** Folder id + every descendant id (the subtree rooted at `folderId`). */
export function subtreeFolderIds(model: SkillFolderTreeModel, folderId: string): Set<string> {
const out = new Set<string>([folderId]);
const queue = [folderId];
while (queue.length > 0) {
const id = queue.pop()!;
for (const child of model.childrenById.get(id) ?? []) {
if (!out.has(child.folder.id)) {
out.add(child.folder.id);
queue.push(child.folder.id);
}
}
}
return out;
}
/** The chain of folders from the top-level root down to `folderId` (inclusive). */
export function folderBreadcrumbTrail(
model: SkillFolderTreeModel,
folderId: string,
): FolderListItem[] {
const trail: FolderListItem[] = [];
let current: FolderListItem | undefined = model.byId.get(folderId);
const guard = new Set<string>();
while (current && !guard.has(current.id)) {
guard.add(current.id);
trail.unshift(current);
current = current.parentId ? model.byId.get(current.parentId) : undefined;
}
return trail;
}
/** Human label for a reserved root, used when composing breadcrumb prefixes. */
export function reservedRootLabel(folder: Pick<FolderListItem, "systemKey" | "name">): string {
switch (folder.systemKey) {
case "my":
return "My Skills";
case "projects":
return "Projects";
case "bundled":
return "Bundled";
default:
return folder.name;
}
}
/** Human-readable canonical path used by detail surfaces and breadcrumbs. */
export function skillFolderDisplayPath(
model: SkillFolderTreeModel,
folderId: string | null | undefined,
): string | null {
if (!folderId) return null;
const trail = folderBreadcrumbTrail(model, folderId);
if (trail.length === 0) return null;
const labels = trail.map((folder) => reservedRootLabel(folder));
if (!trail[0]?.systemKey) labels.unshift("Company");
return labels.join(" / ");
}
function humanizeFolderPathSegment(segment: string): string {
return segment
.replace(/[-_]+/g, " ")
.replace(/\b\w/g, (character) => character.toUpperCase());
}
export function skillFolderPathDisplayFallback(folderPath: string | null | undefined): string | null {
if (!folderPath) return null;
if (folderPath.includes(" / ")) return folderPath;
const segments = folderPath.split("/").filter(Boolean);
if (segments.length === 0) return null;
const root = segments[0]?.toLowerCase();
const labels = segments.map(humanizeFolderPathSegment);
if (root === "my") labels[0] = "My Skills";
else if (root === "projects") labels[0] = "Projects";
else if (root === "bundled") labels[0] = "Bundled";
else labels.unshift("Company");
return labels.join(" / ");
}
export function emptySkillFolderTree(): SkillFolderTreeModel {
return buildSkillFolderTree([]);
}
export function treeFromResult(result: FolderListResult | null | undefined): SkillFolderTreeModel {
return buildSkillFolderTree(result?.folders ?? []);
}

View File

@ -182,6 +182,17 @@
--status-task-icon-blocked: var(--status-task-blocked); /* #dc2626 both modes */
--status-task-icon-cancelled: #52585d;
--status-task-icon-in_queue: var(--status-task-in_progress); /* blocked shape, blue */
--folder-color-indigo: #6366f1;
--folder-color-violet: #8b5cf6;
--folder-color-emerald: #10b981;
--folder-color-cyan: #06b6d4;
--folder-color-amber: #f59e0b;
--folder-color-slate: #64748b;
--gtc-folder-row: 1rem minmax(0, 1fr) auto;
--gtc-folder-row-actions: 1rem minmax(0, 1fr) auto auto;
--sz-folder-rail: 13.25rem;
--sz-folder-sheet-max: 80dvh;
}
.dark {

View File

@ -84,5 +84,11 @@ describe("company skill routes", () => {
expect(skillStudioNewRoute("skill/with spaces")).toBe(
"/skills/studio/new?forkFrom=skill%2Fwith%20spaces",
);
expect(skillStudioNewRoute(null, "folder/with spaces")).toBe(
"/skills/studio/new?folderId=folder%2Fwith%20spaces",
);
expect(skillStudioNewRoute("skill 1", "folder 1")).toBe(
"/skills/studio/new?forkFrom=skill%201&folderId=folder%201",
);
});
});

View File

@ -191,9 +191,13 @@ export function skillStudioRoute(skillId: string) {
return `/skills/studio/${encodeURIComponent(skillId)}`;
}
export function skillStudioNewRoute(forkFromSkillId?: string | null) {
export function skillStudioNewRoute(forkFromSkillId?: string | null, folderId?: string | null) {
const basePath = "/skills/studio/new";
return forkFromSkillId ? `${basePath}?forkFrom=${encodeURIComponent(forkFromSkillId)}` : basePath;
const params: string[] = [];
if (forkFromSkillId) params.push(`forkFrom=${encodeURIComponent(forkFromSkillId)}`);
if (folderId) params.push(`folderId=${encodeURIComponent(folderId)}`);
const query = params.join("&");
return query ? `${basePath}?${query}` : basePath;
}
export function withRouteSkill(

View File

@ -193,6 +193,9 @@ export const queryKeys = {
documentAnnotations: (routineId: string, key: "description", status: "open" | "resolved" | "all" = "all") =>
["routines", "document-annotations", routineId, key, status] as const,
},
folders: {
list: (companyId: string, kind: string) => ["folders", companyId, kind] as const,
},
pipelines: {
list: (companyId: string) => ["pipelines", companyId] as const,
detail: (pipelineId: string) => ["pipelines", "detail", pipelineId] as const,

View File

@ -83,7 +83,7 @@ describe("skill create helpers", () => {
});
it("builds fork drafts from the source skill metadata", () => {
const draft = buildForkSkillDraft(skill({ color: "#123456" }));
const draft = buildForkSkillDraft(skill({ color: "#123456", folderId: "bundled-folder" }));
expect(draft.name).toBe("Demo Skill Fork");
expect(draft.slug).toBe("demo-skill-fork");
@ -91,6 +91,7 @@ describe("skill create helpers", () => {
expect(draft.categories).toEqual(["engineering", "review"]);
expect(draft.forkedFromSkillId).toBe("skill-1");
expect(draft.forkedFromName).toBe("Demo Skill");
expect(draft.folderId).toBeNull();
expect(draft.markdown).toContain("name: Demo Skill Fork");
});

View File

@ -21,6 +21,8 @@ export type SkillCreateDraft = {
sharingScope: Exclude<CompanySkillSharingScope, "public_link">;
forkedFromSkillId: string | null;
forkedFromName: string | null;
/** Destination folder for the new skill (null = Unfiled / top level). */
folderId: string | null;
};
export function normalizeSkillDraftSlug(value: string) {
@ -92,6 +94,7 @@ export function buildBlankSkillDraft(): SkillCreateDraft {
sharingScope: "company",
forkedFromSkillId: null,
forkedFromName: null,
folderId: null,
};
}
@ -109,6 +112,7 @@ export function buildForkSkillDraft(skill: CompanySkillDetail): SkillCreateDraft
sharingScope: "company",
forkedFromSkillId: skill.id,
forkedFromName: skill.name,
folderId: null,
};
}
@ -128,5 +132,6 @@ export function skillCreateDraftToPayload(draft: SkillCreateDraft): CompanySkill
categories: draft.categories,
sharingScope: draft.sharingScope,
forkedFromSkillId: draft.forkedFromSkillId,
folderId: draft.folderId ?? undefined,
};
}

View File

@ -3,9 +3,16 @@
import type { ComponentProps, ReactNode } from "react";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import type { CompanySkillDetail, CompanySkillVersion } from "@paperclipai/shared";
import type { CompanySkillDetail, CompanySkillVersion, FolderListResult } from "@paperclipai/shared";
import { afterEach, describe, expect, it, vi } from "vitest";
import { DiscoveryGrid, SkillDetailPage, getSkillVersionDiffSelection } from "./CompanySkills";
import {
DiscoveryGrid,
SkillDetailPage,
getSkillVersionDiffSelection,
resolveDiscoveryTab,
withDiscoveryTab,
skillDetailBreadcrumbs,
} from "./CompanySkills";
import { skillStudioNewRoute } from "../lib/company-skill-routes";
vi.mock("@/lib/router", () => ({
@ -51,6 +58,9 @@ vi.mock("@/components/ui/dropdown-menu", () => ({
<button type="button" onClick={onSelect}>{children}</button>
),
DropdownMenuSeparator: () => <hr />,
DropdownMenuSub: ({ children }: { children: ReactNode }) => <>{children}</>,
DropdownMenuSubContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuSubTrigger: ({ children }: { children: ReactNode }) => <button type="button">{children}</button>,
DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
}));
@ -327,6 +337,171 @@ describe("DiscoveryGrid Studio entry points", () => {
expect(onCreate).toHaveBeenCalledTimes(2);
});
it("does not open a skill when keyboard-activating its actions button", async () => {
const onOpenCard = vi.fn();
const card = {
key: "demo-skill",
skillId: "skill-1",
folderId: null,
catalogRef: null,
name: "Demo Skill",
slug: "demo-skill",
author: "Paperclip",
version: null,
tagline: null,
description: null,
categories: [],
iconUrl: null,
color: null,
starCount: 0,
agentCount: 0,
forkCount: 0,
installed: true,
required: false,
forkedFrom: false,
updatedAt: 0,
};
const node = await renderDiscoveryGrid({
cards: [card],
totalCount: 1,
onOpenCard,
folderResult: { kind: "skill", folders: [], allCount: 1, unfiledCount: 1 },
onMoveCard: vi.fn(),
onCreateFolderAndMoveCard: vi.fn(),
});
const actionsButton = node.querySelector<HTMLButtonElement>('[aria-label="More actions for Demo Skill"]');
await act(async () => {
actionsButton?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
});
expect(onOpenCard).not.toHaveBeenCalled();
});
it("does not offer move actions for skills in the bundled folder", async () => {
const card = {
key: "bundled-skill",
skillId: "skill-1",
folderId: "bundled-folder",
catalogRef: null,
name: "Bundled Skill",
slug: "bundled-skill",
author: "Paperclip",
version: null,
tagline: null,
description: null,
categories: [],
iconUrl: null,
color: null,
starCount: 0,
agentCount: 0,
forkCount: 0,
installed: true,
required: false,
forkedFrom: false,
updatedAt: 0,
};
const node = await renderDiscoveryGrid({
cards: [card],
totalCount: 1,
selectMode: true,
folderResult: {
kind: "skill",
folders: [{
id: "bundled-folder",
companyId: "company-1",
kind: "skill",
parentId: null,
name: "Bundled",
slug: "bundled",
systemKey: "bundled",
path: "bundled",
depth: 1,
color: null,
position: 0,
createdAt: new Date("2026-01-01T00:00:00Z"),
updatedAt: new Date("2026-01-01T00:00:00Z"),
itemCount: 1,
}],
allCount: 1,
unfiledCount: 0,
},
onMoveCard: vi.fn(),
onCreateFolderAndMoveCard: vi.fn(),
onOpenMoveCard: vi.fn(),
});
expect(node.querySelector('[aria-label="More actions for Bundled Skill"]')).toBeNull();
expect(node.querySelector('input[type="checkbox"]')).toBeNull();
expect(node.textContent).not.toContain("Move to folder");
});
});
describe("skills discovery tab routing", () => {
it("opens the folder-first installed view when the URL has no tab", () => {
expect(resolveDiscoveryTab(null)).toBe("installed");
expect(resolveDiscoveryTab("all")).toBe("all");
});
it("keeps All explicit and makes Installed the canonical default URL", () => {
const allParams = withDiscoveryTab(new URLSearchParams("folder=my&category=writing"), "all");
expect(allParams.toString()).toBe("tab=all");
const installedParams = withDiscoveryTab(new URLSearchParams("tab=all&folder=my"), "installed");
expect(installedParams.toString()).toBe("folder=my");
});
});
describe("skill detail breadcrumbs", () => {
it("links each folder ancestor back to the installed folder view", () => {
const folders: FolderListResult = {
kind: "skill",
allCount: 1,
unfiledCount: 0,
folders: [
{
id: "my-root",
companyId: "company-1",
kind: "skill",
parentId: null,
name: "My Skills",
slug: "my",
systemKey: "my",
path: "my",
depth: 1,
color: null,
position: 0,
itemCount: 1,
createdAt: new Date("2026-07-16T00:00:00.000Z"),
updatedAt: new Date("2026-07-16T00:00:00.000Z"),
},
{
id: "review-folder",
companyId: "company-1",
kind: "skill",
parentId: "my-root",
name: "Review",
slug: "review",
systemKey: null,
path: "my/review",
depth: 2,
color: null,
position: 0,
itemCount: 1,
createdAt: new Date("2026-07-16T00:00:00.000Z"),
updatedAt: new Date("2026-07-16T00:00:00.000Z"),
},
],
};
expect(skillDetailBreadcrumbs({ name: "Deal with PR", folderId: "review-folder" }, folders)).toEqual([
{ label: "Skills", href: "/skills" },
{ label: "My Skills", href: "/skills?folder=my-root" },
{ label: "Review", href: "/skills?folder=review-folder" },
{ label: "Deal with PR" },
]);
});
});
describe("skillStudioNewRoute", () => {
@ -366,6 +541,16 @@ describe("SkillDetailPage versions tab", () => {
});
describe("SkillDetailPage settings", () => {
it("humanizes the server folder path on a cold detail render", async () => {
const v1 = makeVersion(1, "# Demo Skill");
const node = await renderSkillDetail([v1], {
activeTab: "overview",
detail: makeDetail(v1, { folderPath: "engineering/code-review" }),
});
expect(node.textContent).toContain("Company / Engineering / Code Review");
});
it("shows a direct fork action for read-only skills", async () => {
const v1 = makeVersion(1, "# Demo Skill");
const onFork = vi.fn();

File diff suppressed because it is too large Load Diff

View File

@ -4,7 +4,7 @@ import type { AnchorHTMLAttributes, ReactNode } from "react";
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Issue, RoutineListItem } from "@paperclipai/shared";
import type { FolderListResult, Issue, RoutineListItem } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Routines, buildRoutineGroups, buildRoutineSections, sortRoutines } from "./Routines";
@ -20,6 +20,7 @@ async function act(callback: () => void | Promise<void>) {
const navigateMock = vi.fn();
const routinesListMock = vi.fn<(companyId: string) => Promise<RoutineListItem[]>>();
const foldersListMock = vi.fn<(companyId: string, kind: string) => Promise<FolderListResult>>();
const issuesListMock = vi.fn<(companyId: string, filters?: Record<string, unknown>) => Promise<Issue[]>>();
const markdownEditorRenderMock = vi.fn((props: { mentions?: Array<{ id: string; name: string }> }) => props);
const issuesListRenderMock = vi.fn(({ issues }: { issues: Issue[] }) => (
@ -58,6 +59,16 @@ vi.mock("../api/routines", () => ({
},
}));
vi.mock("../api/folders", () => ({
foldersApi: {
list: (companyId: string, kind: string) => foldersListMock(companyId, kind),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
moveItem: vi.fn(),
},
}));
vi.mock("../api/issues", () => ({
issuesApi: {
list: (companyId: string, filters?: Record<string, unknown>) => issuesListMock(companyId, filters),
@ -275,6 +286,7 @@ function createRoutine(overrides: Partial<RoutineListItem>): RoutineListItem {
triggers: [],
lastRun: null,
activeIssue: null,
folderId: null,
...overrides,
};
}
@ -343,6 +355,13 @@ describe("Routines page", () => {
currentSearch = "";
navigateMock.mockReset();
routinesListMock.mockReset();
foldersListMock.mockReset();
foldersListMock.mockResolvedValue({
kind: "routine",
folders: [],
allCount: 0,
unfiledCount: 0,
});
issuesListMock.mockReset();
markdownEditorRenderMock.mockClear();
issuesListRenderMock.mockClear();
@ -398,6 +417,22 @@ describe("Routines page", () => {
expect(groups[1]?.items.map((item) => item.title)).toEqual(["Reflection review"]);
});
it("uses a flat group when Folder grouping is active", () => {
const routines = [
createRoutine({ id: "routine-1", title: "Morning sync", projectId: "project-1" }),
createRoutine({ id: "routine-2", title: "Weekly digest", projectId: "project-2" }),
];
const groups = buildRoutineGroups(
routines,
"folder",
new Map(),
new Map(),
);
expect(groups).toEqual([{ key: "__all", label: null, items: routines }]);
});
it("sorts routines by selected field and direction without mutating the source list", () => {
const routines = [
createRoutine({
@ -489,7 +524,7 @@ describe("Routines page", () => {
});
});
it("defaults the routines list to project groups sorted by title", async () => {
it("defaults the routines list to folder mode without rendering project groups", async () => {
routinesListMock.mockResolvedValue([
createRoutine({ id: "routine-1", title: "Weekly digest", projectId: "project-1" }),
createRoutine({ id: "routine-2", title: "Morning sync", projectId: "project-1" }),
@ -513,17 +548,15 @@ describe("Routines page", () => {
await flush();
});
for (let attempts = 0; attempts < 5 && !container.textContent?.includes("Project Alpha"); attempts += 1) {
for (let attempts = 0; attempts < 5 && !container.textContent?.includes("Morning sync"); attempts += 1) {
await act(async () => {
await flush();
});
}
const text = container.textContent ?? "";
expect(text.indexOf("Project Alpha")).toBeLessThan(text.indexOf("Project Beta"));
expect(text.indexOf("Morning sync")).toBeLessThan(text.indexOf("Weekly digest"));
expect(text.indexOf("Project Alpha")).toBeLessThan(text.indexOf("Morning sync"));
expect(text.indexOf("Weekly digest")).toBeLessThan(text.indexOf("Project Beta"));
expect(text).toContain("New folder");
await act(async () => {
root.unmount();
@ -546,7 +579,6 @@ describe("Routines page", () => {
}),
]);
issuesListMock.mockResolvedValue([]);
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: {
@ -570,7 +602,6 @@ describe("Routines page", () => {
}
const text = container.textContent ?? "";
expect(text.indexOf("Project Alpha")).toBeLessThan(text.indexOf("Morning sync"));
expect(text.indexOf("Morning sync")).toBeLessThan(text.indexOf("Built-in routines"));
expect(text.indexOf("Built-in routines")).toBeLessThan(text.indexOf("Reflection review"));
@ -579,6 +610,109 @@ describe("Routines page", () => {
});
});
it("filters to Unfiled and shows the empty-folder state with a create CTA", async () => {
foldersListMock.mockResolvedValue({
kind: "routine",
allCount: 2,
unfiledCount: 1,
folders: [
{
id: "folder-reporting",
companyId: "company-1",
kind: "routine",
parentId: null,
name: "Reporting",
slug: "reporting",
systemKey: null,
path: "reporting",
depth: 1,
color: "#6366f1",
position: 0,
itemCount: 1,
createdAt: new Date("2026-07-01T00:00:00.000Z"),
updatedAt: new Date("2026-07-01T00:00:00.000Z"),
},
{
id: "folder-empty",
companyId: "company-1",
kind: "routine",
parentId: null,
name: "Empty folder",
slug: "empty-folder",
systemKey: null,
path: "empty-folder",
depth: 1,
color: null,
position: 1,
itemCount: 0,
createdAt: new Date("2026-07-01T00:00:00.000Z"),
updatedAt: new Date("2026-07-01T00:00:00.000Z"),
},
],
});
routinesListMock.mockResolvedValue([
createRoutine({ id: "routine-1", title: "Filed digest", folderId: "folder-reporting" }),
createRoutine({ id: "routine-2", title: "Loose routine", folderId: null }),
]);
issuesListMock.mockResolvedValue([]);
currentSearch = "folder=unfiled";
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<Routines />
</QueryClientProvider>,
);
await flush();
});
for (let attempts = 0; attempts < 5 && !container.textContent?.includes("Loose routine"); attempts += 1) {
await act(async () => {
await flush();
});
}
// Unfiled filter: only the folderless routine renders; the rail still lists folders.
expect(container.textContent).toContain("Loose routine");
expect(container.textContent).not.toContain("Filed digest");
expect(container.textContent).toContain("Reporting");
expect(container.textContent).toContain("Empty folder");
await act(async () => {
root.unmount();
});
// Remount filtered to the empty folder: empty state + create-into-folder CTA.
currentSearch = "folder=folder-empty";
const secondRoot = createRoot(container);
await act(async () => {
secondRoot.render(
<QueryClientProvider client={queryClient}>
<Routines />
</QueryClientProvider>,
);
await flush();
});
for (let attempts = 0; attempts < 5 && !container.textContent?.includes("This folder is empty"); attempts += 1) {
await act(async () => {
await flush();
});
}
expect(container.textContent).toContain("This folder is empty");
expect(container.textContent).toContain("New routine in this folder");
await act(async () => {
secondRoot.unmount();
});
});
it("hides archived routines from the routines list", async () => {
routinesListMock.mockResolvedValue([
createRoutine({ id: "routine-1", title: "Morning sync", status: "active" }),

View File

@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, useNavigate, useSearchParams } from "@/lib/router";
import { ArrowUpDown, Check, ChevronDown, ChevronRight, Layers, Plus, Repeat } from "lucide-react";
import { routinesApi } from "../api/routines";
import { foldersApi } from "../api/folders";
import { agentsApi } from "../api/agents";
import { projectsApi } from "../api/projects";
import { issuesApi } from "../api/issues";
@ -12,6 +13,7 @@ import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useToastActions } from "../context/ToastContext";
import { buildMarkdownMentionOptions } from "../lib/company-members";
import { cn } from "../lib/utils";
import { queryKeys } from "../lib/queryKeys";
import { groupBy } from "../lib/groupBy";
import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb";
@ -46,6 +48,22 @@ import {
} from "@/components/ui/select";
import { Tabs, TabsContent } from "@/components/ui/tabs";
import type { RoutineListItem, RoutineVariable } from "@paperclipai/shared";
import type { FolderListItem } from "@paperclipai/shared";
import {
AllUnfiledBanner,
BulkBar,
DeleteFolderDialog,
FolderChip,
FolderFormDialog,
FolderRail,
FolderSwatch,
MobileFolderSheet,
MoveToMenu,
folderSearchValue,
normalizeFolderSelection,
selectedFolderFromList,
type FolderSelection,
} from "../components/folders/FolderControls";
const concurrencyPolicies = ["coalesce_if_active", "always_enqueue", "skip_if_active"];
const catchUpPolicies = ["skip_missed", "enqueue_missed_with_cap"];
@ -66,7 +84,7 @@ function autoResizeTextarea(element: HTMLTextAreaElement | null) {
}
type RoutinesTab = "routines" | "runs";
type RoutineGroupBy = "none" | "project" | "assignee";
type RoutineGroupBy = "folder" | "none" | "project" | "assignee";
type RoutineSortField = "updated" | "created" | "title" | "lastRun";
type RoutineSortDir = "asc" | "desc";
@ -88,7 +106,7 @@ const builtInRoutineGroupKey = "__built_in_routines";
const defaultRoutineViewState: RoutineViewState = {
sortField: "title",
sortDir: "asc",
groupBy: "project",
groupBy: "folder",
collapsedGroups: [],
};
@ -120,6 +138,7 @@ function buildRoutineMutationPayload(input: {
title: string;
description: string;
projectId: string;
folderId: string | null;
assigneeAgentId: string;
priority: string;
concurrencyPolicy: string;
@ -130,6 +149,7 @@ function buildRoutineMutationPayload(input: {
...input,
description: input.description.trim() || null,
projectId: input.projectId || null,
folderId: input.folderId || null,
assigneeAgentId: input.assigneeAgentId || null,
};
}
@ -140,7 +160,7 @@ export function buildRoutineGroups(
projectById: Map<string, { name: string }>,
agentById: Map<string, { name: string }>,
): RoutineGroup[] {
if (groupByValue === "none") {
if (groupByValue === "none" || groupByValue === "folder") {
return [{ key: "__all", label: null, items: routines }];
}
@ -267,12 +287,19 @@ export function Routines() {
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [searchParams, setSearchParams] = useSearchParams();
const { pushToast } = useToastActions();
const descriptionEditorRef = useRef<MarkdownEditorRef>(null);
const titleInputRef = useRef<HTMLTextAreaElement | null>(null);
const assigneeSelectorRef = useRef<HTMLButtonElement | null>(null);
const projectSelectorRef = useRef<HTMLButtonElement | null>(null);
const [folderDialogOpen, setFolderDialogOpen] = useState(false);
const [folderDialogTarget, setFolderDialogTarget] = useState<FolderListItem | null>(null);
const [deleteFolderTarget, setDeleteFolderTarget] = useState<FolderListItem | null>(null);
const [mobileFoldersOpen, setMobileFoldersOpen] = useState(false);
const [selectMode, setSelectMode] = useState(false);
const [selectedRoutineIds, setSelectedRoutineIds] = useState<string[]>([]);
const [moveAfterCreateIds, setMoveAfterCreateIds] = useState<string[]>([]);
const [runningRoutineId, setRunningRoutineId] = useState<string | null>(null);
const [statusMutationRoutineId, setStatusMutationRoutineId] = useState<string | null>(null);
const [runDialogRoutine, setRunDialogRoutine] = useState<RoutineListItem | null>(null);
@ -283,6 +310,7 @@ export function Routines() {
title: string;
description: string;
projectId: string;
folderId: string | null;
assigneeAgentId: string;
priority: string;
concurrencyPolicy: string;
@ -292,6 +320,7 @@ export function Routines() {
title: "",
description: "",
projectId: "",
folderId: null,
assigneeAgentId: "",
priority: "medium",
concurrencyPolicy: "coalesce_if_active",
@ -302,6 +331,7 @@ export function Routines() {
? `paperclip:routines-view:${selectedCompanyId}`
: "paperclip:routines-view";
const [routineViewState, setRoutineViewState] = useState<RoutineViewState>(() => getRoutineViewState(routineViewStateKey));
const folderSelection = normalizeFolderSelection(searchParams.get("folder"));
useEffect(() => {
setBreadcrumbs([{ label: "Routines" }]);
@ -316,6 +346,11 @@ export function Routines() {
queryFn: () => routinesApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const { data: routineFolders, isLoading: foldersLoading } = useQuery({
queryKey: queryKeys.folders.list(selectedCompanyId!, "routine"),
queryFn: () => foldersApi.list(selectedCompanyId!, "routine"),
enabled: !!selectedCompanyId && activeTab === "routines",
});
const { data: agents } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!),
queryFn: () => agentsApi.list(selectedCompanyId!),
@ -374,6 +409,7 @@ export function Routines() {
title: "",
description: "",
projectId: "",
folderId: null,
assigneeAgentId: "",
priority: "medium",
concurrencyPolicy: "coalesce_if_active",
@ -393,6 +429,97 @@ export function Routines() {
navigate(`/routines/${routine.id}?tab=triggers`);
},
});
const createFolder = useMutation({
mutationFn: (payload: { name: string; color: string | null }) =>
foldersApi.create(selectedCompanyId!, { kind: "routine", ...payload }),
onSuccess: async (folder) => {
setFolderDialogOpen(false);
setFolderDialogTarget(null);
await queryClient.invalidateQueries({ queryKey: queryKeys.folders.list(selectedCompanyId!, "routine") });
if (moveAfterCreateIds.length > 0) {
const ids = moveAfterCreateIds;
setMoveAfterCreateIds([]);
try {
await Promise.all(ids.map((itemId) =>
foldersApi.moveItem(selectedCompanyId!, { kind: "routine", itemId, folderId: folder.id })
));
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.folders.list(selectedCompanyId!, "routine") }),
]);
} catch (moveError) {
pushToast({
title: "Folder created, move failed",
body: moveError instanceof Error ? moveError.message : "Paperclip could not move the selected routines.",
tone: "error",
});
return;
}
} else {
setFolderSelection(folder.id);
}
pushToast({ title: "Folder created", body: folder.name, tone: "success" });
},
onError: (mutationError) => {
pushToast({
title: "Failed to save folder",
body: mutationError instanceof Error ? mutationError.message : "Paperclip could not save the folder.",
tone: "error",
});
},
});
const updateFolder = useMutation({
mutationFn: ({ folderId, payload }: { folderId: string; payload: { name?: string; color?: string | null } }) =>
foldersApi.update(selectedCompanyId!, folderId, payload),
onSuccess: async () => {
setFolderDialogOpen(false);
setFolderDialogTarget(null);
await queryClient.invalidateQueries({ queryKey: queryKeys.folders.list(selectedCompanyId!, "routine") });
},
onError: (mutationError) => {
pushToast({
title: "Folder save failed",
body: mutationError instanceof Error ? mutationError.message : "Paperclip could not update the folder.",
tone: "error",
});
},
});
const deleteFolder = useMutation({
mutationFn: (folderId: string) => foldersApi.delete(selectedCompanyId!, folderId),
onSuccess: async (_, folderId) => {
if (folderSelection === folderId) setFolderSelection("all");
setDeleteFolderTarget(null);
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.folders.list(selectedCompanyId!, "routine") }),
]);
pushToast({ title: "Folder deleted", body: "Items moved to Unfiled.", tone: "success" });
},
onError: (mutationError) => {
pushToast({
title: "Folder delete failed",
body: mutationError instanceof Error ? mutationError.message : "Paperclip could not delete the folder.",
tone: "error",
});
},
});
const moveRoutineToFolder = useMutation({
mutationFn: ({ itemId, folderId }: { itemId: string; folderId: string | null }) =>
foldersApi.moveItem(selectedCompanyId!, { kind: "routine", itemId, folderId }),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.folders.list(selectedCompanyId!, "routine") }),
]);
},
onError: (mutationError) => {
pushToast({
title: "Move failed",
body: mutationError instanceof Error ? mutationError.message : "Paperclip could not move the routine.",
tone: "error",
});
},
});
const updateIssue = useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
issuesApi.update(id, data),
@ -495,9 +622,35 @@ export function Routines() {
() => (routines ?? []).filter((routine) => routine.status !== "archived"),
[routines],
);
const folderFilteredRoutines = useMemo(() => {
if (routineViewState.groupBy !== "folder") return visibleRoutines;
if (folderSelection === "all") return visibleRoutines;
if (folderSelection === "unfiled") return visibleRoutines.filter((routine) => !routine.folderId);
return visibleRoutines.filter((routine) => routine.folderId === folderSelection);
}, [folderSelection, routineViewState.groupBy, visibleRoutines]);
// Rail counts reflect the page's visible scope (archived hidden), not raw DB
// counts (ux-spec §5.3).
const railFolderResult = useMemo(() => {
if (!routineFolders) return routineFolders;
const counts = new Map<string, number>();
let unfiled = 0;
for (const routine of visibleRoutines) {
if (routine.folderId) counts.set(routine.folderId, (counts.get(routine.folderId) ?? 0) + 1);
else unfiled += 1;
}
return {
...routineFolders,
allCount: visibleRoutines.length,
unfiledCount: unfiled,
folders: routineFolders.folders.map((folder) => ({
...folder,
itemCount: counts.get(folder.id) ?? 0,
})),
};
}, [routineFolders, visibleRoutines]);
const sortedRoutines = useMemo(
() => sortRoutines(visibleRoutines, routineViewState.sortField, routineViewState.sortDir),
[routineViewState.sortDir, routineViewState.sortField, visibleRoutines],
() => sortRoutines(folderFilteredRoutines, routineViewState.sortField, routineViewState.sortDir),
[folderFilteredRoutines, routineViewState.sortDir, routineViewState.sortField],
);
const routineSections = useMemo(
() => buildRoutineSections(sortedRoutines, routineViewState.groupBy, projectById, agentById),
@ -514,6 +667,9 @@ export function Routines() {
);
const currentAssignee = draft.assigneeAgentId ? agentById.get(draft.assigneeAgentId) ?? null : null;
const currentProject = draft.projectId ? projectById.get(draft.projectId) ?? null : null;
const activeFolder = selectedFolderFromList(routineFolders?.folders ?? [], folderSelection);
const hasRoutineFolders = (routineFolders?.folders.length ?? 0) > 0;
const showFolderRail = activeTab === "routines" && routineViewState.groupBy === "folder" && hasRoutineFolders;
function updateRoutineView(patch: Partial<RoutineViewState>) {
setRoutineViewState((current) => {
@ -530,6 +686,51 @@ export function Routines() {
});
}
function setFolderSelection(selection: FolderSelection) {
setSearchParams((current) => {
const params = new URLSearchParams(current);
const value = folderSearchValue(selection);
if (value) params.set("folder", value);
else params.delete("folder");
return params;
});
}
function openCreateFolder(moveItemIds: string[] = []) {
setMoveAfterCreateIds(moveItemIds);
setFolderDialogTarget(null);
setFolderDialogOpen(true);
}
function openCreateRoutine() {
setDraft((current) => ({
...current,
folderId: folderSelection === "all" || folderSelection === "unfiled" ? null : folderSelection,
}));
setComposerOpen(true);
}
async function moveSelectedRoutines(folderId: string | null) {
const ids = selectedRoutineIds;
if (ids.length === 0) return;
try {
await Promise.all(ids.map((itemId) => foldersApi.moveItem(selectedCompanyId!, { kind: "routine", itemId, folderId })));
setSelectedRoutineIds([]);
setSelectMode(false);
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.folders.list(selectedCompanyId!, "routine") }),
]);
pushToast({ title: "Routines moved", body: `${ids.length} routine${ids.length === 1 ? "" : "s"} filed.`, tone: "success" });
} catch (moveError) {
pushToast({
title: "Failed to move routines",
body: moveError instanceof Error ? moveError.message : "Paperclip could not move the selected routines.",
tone: "error",
});
}
}
function handleRunNow(routine: RoutineListItem) {
setRunDialogRoutine(routine);
}
@ -575,7 +776,7 @@ export function Routines() {
Recurring work definitions that materialize into auditable execution tasks.
</p>
</div>
<Button onClick={() => setComposerOpen(true)}>
<Button onClick={openCreateRoutine}>
<Plus className="mr-2 h-4 w-4" />
Create routine
</Button>
@ -648,6 +849,7 @@ export function Routines() {
<PopoverContent align="end" className="w-44 p-0">
<div className="p-2 space-y-0.5">
{([
["folder", "Folder"],
["project", "Project"],
["assignee", "Agent"],
["none", "None"],
@ -668,8 +870,29 @@ export function Routines() {
</div>
</PopoverContent>
</Popover>
{routineViewState.groupBy === "folder" && !hasRoutineFolders ? (
<Button variant="outline" size="sm" onClick={() => openCreateFolder()}>
<Plus className="mr-2 h-3.5 w-3.5" />
New folder
</Button>
) : null}
{showFolderRail ? (
<Button variant="ghost" size="sm" className="text-xs" onClick={() => setSelectMode((current) => !current)}>
{selectMode ? "Done" : "Select"}
</Button>
) : null}
</div>
</div>
{routineViewState.groupBy === "folder" ? (
<div className="md:hidden">
<FolderChip
result={railFolderResult}
selection={folderSelection}
allLabel="All routines"
onClick={() => setMobileFoldersOpen(true)}
/>
</div>
) : null}
</TabsContent>
<TabsContent value="runs">
<IssuesList
@ -844,6 +1067,26 @@ export function Routines() {
);
}}
/>
<span>filed in</span>
<Select
value={draft.folderId ?? "__unfiled"}
onValueChange={(value) => setDraft((current) => ({
...current,
folderId: value === "__unfiled" ? null : value,
}))}
>
<SelectTrigger className="h-8 w-auto min-w-32 border-0 bg-muted/50 px-2 shadow-none">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__unfiled">Unfiled</SelectItem>
{(routineFolders?.folders ?? []).map((folder) => (
<SelectItem key={folder.id} value={folder.id}>
{folder.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
@ -950,7 +1193,56 @@ export function Routines() {
) : null}
{activeTab === "routines" ? (
<div>
<div className={cn(showFolderRail && "flex gap-4")}>
{showFolderRail ? (
<FolderRail
result={railFolderResult}
selection={folderSelection}
allLabel="All routines"
itemLabelPlural="routines"
loading={foldersLoading}
onSelect={setFolderSelection}
onCreate={() => openCreateFolder()}
onRename={(folder, name) => updateFolder.mutate({ folderId: folder.id, payload: { name } })}
onEdit={(folder) => {
setFolderDialogTarget(folder);
setFolderDialogOpen(true);
}}
onDelete={setDeleteFolderTarget}
/>
) : null}
<div className="min-w-0 flex-1">
{routineViewState.groupBy === "folder" && hasRoutineFolders ? (
<div className="mb-3 flex flex-wrap items-center gap-2">
{folderSelection === "all" ? <FolderIconHeader label="All routines" count={sortedRoutines.length} /> : (
<div className="flex min-w-0 items-center gap-2 text-sm">
<FolderSwatch color={activeFolder?.color} />
<span className="truncate font-medium">{folderSelection === "unfiled" ? "Unfiled" : activeFolder?.name ?? "Folder"}</span>
<span className="text-muted-foreground">{sortedRoutines.length} routine{sortedRoutines.length === 1 ? "" : "s"}</span>
</div>
)}
</div>
) : null}
{routineViewState.groupBy === "folder" && !hasRoutineFolders && !foldersLoading && visibleRoutines.length > 0 ? (
<AllUnfiledBanner
storageKey={`paperclip:routines-folder-nudge:${selectedCompanyId ?? "none"}`}
itemLabelPlural="routines"
onCreateFolder={() => openCreateFolder()}
/>
) : null}
{selectMode ? (
<BulkBar
selectedCount={selectedRoutineIds.length}
folders={routineFolders?.folders ?? []}
onMove={(folderId) => void moveSelectedRoutines(folderId)}
onCreateAndMove={() => openCreateFolder(selectedRoutineIds)}
onClear={() => setSelectedRoutineIds([])}
onDone={() => {
setSelectMode(false);
setSelectedRoutineIds([]);
}}
/>
) : null}
{visibleRoutines.length === 0 ? (
<div className="py-12">
<EmptyState
@ -958,6 +1250,21 @@ export function Routines() {
message="No active routines. Use Create routine to define the first recurring workflow."
/>
</div>
) : sortedRoutines.length === 0 ? (
<div className="py-12">
<EmptyState
icon={Repeat}
message={folderSelection === "all" ? "No routines match this view." : "This folder is empty."}
/>
{folderSelection !== "all" ? (
<div className="mt-3 flex justify-center">
<Button size="sm" onClick={openCreateRoutine}>
<Plus className="mr-2 h-3.5 w-3.5" />
New routine in this folder
</Button>
</div>
) : null}
</div>
) : (
<div className="flex flex-col gap-3">
{routineSections.map((group) => {
@ -996,6 +1303,37 @@ export function Routines() {
onRunNow={handleRunNow}
onToggleEnabled={handleToggleEnabled}
onToggleArchived={handleToggleArchived}
selectMode={selectMode}
selected={selectedRoutineIds.includes(routine.id)}
onSelectChange={(selectedRoutine, selected) => {
setSelectedRoutineIds((current) =>
selected
? Array.from(new Set([...current, selectedRoutine.id]))
: current.filter((id) => id !== selectedRoutine.id)
);
}}
extraMenuItems={
<MoveToMenu
folders={routineFolders?.folders ?? []}
currentFolderId={routine.folderId ?? null}
onMove={(folderId) => {
const previousFolderId = routine.folderId ?? null;
moveRoutineToFolder.mutate({ itemId: routine.id, folderId });
pushToast({
title: "Routine moved",
body: folderId
? `Moved "${routine.title}" to ${routineFolders?.folders.find((folder) => folder.id === folderId)?.name ?? "folder"}.`
: `Moved "${routine.title}" to Unfiled.`,
tone: "success",
action: {
label: "Undo",
onClick: () => moveRoutineToFolder.mutate({ itemId: routine.id, folderId: previousFolderId }),
},
});
}}
onCreateAndMove={() => openCreateFolder([routine.id])}
/>
}
/>
))}
</CollapsibleContent>
@ -1004,9 +1342,44 @@ export function Routines() {
})}
</div>
)}
</div>
</div>
) : null}
<FolderFormDialog
open={folderDialogOpen}
kind="routine"
folder={folderDialogTarget}
pending={createFolder.isPending || updateFolder.isPending}
onOpenChange={setFolderDialogOpen}
onSubmit={(payload) => {
if (folderDialogTarget) updateFolder.mutate({ folderId: folderDialogTarget.id, payload });
else createFolder.mutate(payload);
}}
/>
<DeleteFolderDialog
open={deleteFolderTarget !== null}
folder={deleteFolderTarget}
itemLabelPlural="routines"
pending={deleteFolder.isPending}
onOpenChange={(open) => {
if (!open) setDeleteFolderTarget(null);
}}
onConfirm={() => {
if (deleteFolderTarget) deleteFolder.mutate(deleteFolderTarget.id);
}}
/>
<MobileFolderSheet
open={mobileFoldersOpen}
onOpenChange={setMobileFoldersOpen}
result={railFolderResult}
selection={folderSelection}
allLabel="All routines"
itemLabelPlural="Routines"
onSelect={setFolderSelection}
onCreate={() => openCreateFolder()}
/>
<RoutineRunVariablesDialog
open={runDialogRoutine !== null}
onOpenChange={(next) => {
@ -1028,3 +1401,13 @@ export function Routines() {
</div>
);
}
function FolderIconHeader({ label, count }: { label: string; count: number }) {
return (
<div className="flex min-w-0 items-center gap-2 text-sm">
<Repeat className="h-3.5 w-3.5 text-muted-foreground" />
<span className="truncate font-medium">{label}</span>
<span className="text-muted-foreground">{count} routine{count === 1 ? "" : "s"}</span>
</div>
);
}

View File

@ -368,6 +368,26 @@ describe("SkillStudio create mode", () => {
await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith("/skills/studio/created-skill"));
});
it("forwards the folderId query param so the new skill is filed there (PAP-14086)", async () => {
routeState.search = "?folderId=folder-my-skills";
const node = await renderStudio();
await waitFor(() => expect(node.querySelector("#skill-name")).toBeTruthy());
await inputValue(node.querySelector("#skill-name") as HTMLInputElement, "Code Review");
await click(buttonsNamed(node, "Create skill")[0] as HTMLButtonElement);
await waitFor(() => expect(mockCompanySkillsApi.create).toHaveBeenCalled());
expect(mockCompanySkillsApi.create).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({
name: "Code Review",
folderId: "folder-my-skills",
}),
);
});
it("keeps category commas and spaces editable while creating a skill", async () => {
const node = await renderStudio();

View File

@ -277,6 +277,10 @@ export function SkillStudio() {
const companyId = selectedCompanyId ?? "";
const isCreateMode = location.pathname.replace(/\/+$/, "").endsWith("/skills/studio/new");
const forkFromSkillId = isCreateMode ? searchParams.get("forkFrom")?.trim() || null : null;
// New skills created from a folder context (e.g. My Skills) carry their
// destination folder through this query param; without it the created skill
// silently lands in Unfiled (PAP-14086).
const newSkillFolderId = isCreateMode ? searchParams.get("folderId")?.trim() || null : null;
const skillsQuery = useQuery({
queryKey: queryKeys.companySkills.list(companyId),
@ -333,6 +337,7 @@ export function SkillStudio() {
skills={skillsQuery.data ?? []}
skillsLoading={skillsQuery.isLoading}
forkFromSkillId={forkFromSkillId}
folderId={newSkillFolderId}
forkSkill={forkDetailQuery.data ?? null}
forkLoading={forkDetailQuery.isLoading}
forkError={forkDetailQuery.isError}
@ -373,6 +378,7 @@ function StudioCreateMode({
skills,
skillsLoading,
forkFromSkillId,
folderId,
forkSkill,
forkLoading,
forkError,
@ -382,6 +388,7 @@ function StudioCreateMode({
skills: CompanySkillListItem[];
skillsLoading: boolean;
forkFromSkillId: string | null;
folderId: string | null;
forkSkill: CompanySkillDetail | null;
forkLoading: boolean;
forkError: boolean;
@ -403,6 +410,7 @@ function StudioCreateMode({
<StudioNewSkillPanel
companyId={companyId}
forkFromSkillId={forkFromSkillId}
folderId={folderId}
forkSkill={forkSkill}
forkLoading={forkLoading}
forkError={forkError}
@ -416,12 +424,14 @@ function StudioCreateMode({
function StudioNewSkillPanel({
companyId,
forkFromSkillId,
folderId,
forkSkill,
forkLoading,
forkError,
}: {
companyId: string;
forkFromSkillId: string | null;
folderId: string | null;
forkSkill: CompanySkillDetail | null;
forkLoading: boolean;
forkError: boolean;
@ -429,10 +439,12 @@ function StudioNewSkillPanel({
const navigate = useNavigate();
const queryClient = useQueryClient();
const toast = useOptionalToastActions();
const initialDraft = useMemo(
() => (forkSkill ? buildForkSkillDraft(forkSkill) : buildBlankSkillDraft()),
[forkSkill],
);
const initialDraft = useMemo(() => {
const base = forkSkill ? buildForkSkillDraft(forkSkill) : buildBlankSkillDraft();
// An explicit folder context from the URL wins over a fork source's folder
// so the new skill is filed where the user launched creation (PAP-14086).
return folderId ? { ...base, folderId } : base;
}, [forkSkill, folderId]);
const [draft, setDraft] = useState<SkillCreateDraft>(initialDraft);
const [slugDirty, setSlugDirty] = useState(initialDraft.slug.trim().length > 0);
const [categoryDraft, setCategoryDraft] = useState(initialDraft.categories.join(", "));