Files
PaperClipAI/ui/src/components/RoutineList.tsx
T
DottaandPaperclip 52aea90263 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>
2026-07-16 15:50:45 -05:00

233 lines
8.0 KiB
TypeScript

import type { ReactNode } from "react";
import { MoreHorizontal, Play } from "lucide-react";
import { Link } from "@/lib/router";
import { AgentIcon } from "@/components/AgentIconPicker";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
export type RoutineListProjectSummary = {
name: string;
color?: string | null;
};
export type RoutineListAgentSummary = {
name: string;
icon?: string | null;
};
export type RoutineListRowItem = {
id: string;
title: string;
status: string;
projectId: string | null;
assigneeAgentId: string | null;
lastRun?: {
triggeredAt?: Date | string | null;
status?: string | null;
} | null;
};
export function formatLastRunTimestamp(value: Date | string | null | undefined) {
if (!value) return "Never";
return new Date(value).toLocaleString();
}
export function formatRoutineRunStatus(value: string | null | undefined) {
if (!value) return null;
return value.replaceAll("_", " ");
}
export function nextRoutineStatus(currentStatus: string, enabled: boolean) {
if (currentStatus === "archived" && enabled) return "active";
return enabled ? "active" : "paused";
}
export function RoutineListRow<TRoutine extends RoutineListRowItem>({
routine,
projectById,
agentById,
runningRoutineId,
statusMutationRoutineId,
href,
configureLabel = "Edit",
managedByLabel,
secondaryDetails,
runNowButton = false,
disableRunNow = false,
disableToggle = false,
hideArchiveAction = false,
divider = true,
selected = false,
selectMode = false,
extraMenuItems,
onSelectChange,
onRunNow,
onToggleEnabled,
onToggleArchived,
}: {
routine: TRoutine;
projectById: Map<string, RoutineListProjectSummary>;
agentById: Map<string, RoutineListAgentSummary>;
runningRoutineId: string | null;
statusMutationRoutineId: string | null;
href: string;
configureLabel?: string;
managedByLabel?: string | null;
secondaryDetails?: ReactNode;
runNowButton?: boolean;
disableRunNow?: boolean;
disableToggle?: boolean;
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;
}) {
const enabled = routine.status === "active";
const isArchived = routine.status === "archived";
const isStatusPending = statusMutationRoutineId === routine.id;
const project = routine.projectId ? projectById.get(routine.projectId) ?? null : null;
const agent = routine.assigneeAgentId ? agentById.get(routine.assigneeAgentId) ?? null : null;
const isDraft = !isArchived && !routine.assigneeAgentId;
const runDisabled = runningRoutineId === routine.id || isArchived || disableRunNow;
return (
<Link
to={href}
className={`group flex flex-col gap-3 px-3 py-3 transition-colors hover:bg-accent/50 sm:flex-row sm:items-center no-underline text-inherit${
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>
{(isArchived || routine.status === "paused" || isDraft) ? (
<span className="text-xs text-muted-foreground">
{isArchived ? "archived" : isDraft ? "draft" : "paused"}
</span>
) : null}
{managedByLabel ? (
<span className="text-xs text-muted-foreground">{managedByLabel}</span>
) : null}
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span className="flex items-center gap-2">
<span
className="h-2.5 w-2.5 shrink-0 rounded-sm"
style={{ backgroundColor: project?.color ?? "var(--project-none)" }}
/>
<span>{routine.projectId ? (project?.name ?? "Unknown project") : "No project"}</span>
</span>
<span className="flex items-center gap-2">
{agent?.icon ? <AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0" /> : null}
<span>{routine.assigneeAgentId ? (agent?.name ?? "Unknown agent") : "No default agent"}</span>
</span>
<span>
{formatLastRunTimestamp(routine.lastRun?.triggeredAt)}
{routine.lastRun ? ` · ${formatRoutineRunStatus(routine.lastRun.status)}` : ""}
</span>
</div>
{secondaryDetails ? (
<div className="text-xs text-muted-foreground">{secondaryDetails}</div>
) : null}
</div>
<div className="flex items-center gap-3" onClick={(event) => { event.preventDefault(); event.stopPropagation(); }}>
{runNowButton ? (
<Button
variant="outline"
size="sm"
disabled={runDisabled}
onClick={() => onRunNow(routine)}
>
<Play className="h-3.5 w-3.5" />
{runningRoutineId === routine.id ? "Running..." : "Run now"}
</Button>
) : null}
<div className="flex items-center gap-3">
<ToggleSwitch
size="lg"
checked={enabled}
onCheckedChange={() => onToggleEnabled(routine, enabled)}
disabled={isStatusPending || isArchived || disableToggle}
aria-label={enabled ? `Disable ${routine.title}` : `Enable ${routine.title}`}
/>
<span className="w-12 text-xs text-muted-foreground">
{isArchived ? "Archived" : isDraft ? "Draft" : enabled ? "On" : "Off"}
</span>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon-sm" aria-label={`More actions for ${routine.title}`}>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link to={href}>{configureLabel}</Link>
</DropdownMenuItem>
<DropdownMenuItem
disabled={runDisabled}
onClick={() => onRunNow(routine)}
>
{runningRoutineId === routine.id ? "Running..." : "Run now"}
</DropdownMenuItem>
{extraMenuItems ? (
<>
<DropdownMenuSeparator />
{extraMenuItems}
</>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onToggleEnabled(routine, enabled)}
disabled={isStatusPending || isArchived || disableToggle}
>
{enabled ? "Pause" : "Enable"}
</DropdownMenuItem>
{!hideArchiveAction && onToggleArchived ? (
<DropdownMenuItem
onClick={() => onToggleArchived(routine)}
disabled={isStatusPending}
>
{routine.status === "archived" ? "Restore" : "Archive"}
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
</div>
</Link>
);
}