Add folder UI for routines and skills

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-15 17:13:24 +00:00
1 parent a072fafbb8
commit b309aa0fc5
10 files changed
+1597 -40

No files matched your search

@@ -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();
});
});
+19 -19
View File
@@ -71,6 +71,25 @@ export function folderRoutes(db: Db) {
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;
@@ -95,25 +114,6 @@ export function folderRoutes(db: Db) {
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.delete("/companies/:companyId/folders/:folderId", async (req, res) => {
const companyId = req.params.companyId as string;
const folderId = req.params.folderId as string;
+36
View File
@@ -0,0 +1,36 @@
import type {
CreateFolderRequest,
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),
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)}`,
),
};
+31
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)}
@@ -0,0 +1,110 @@
// @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 {
FolderRail,
folderSearchValue,
normalizeFolderSelection,
} from "./FolderControls";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
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",
name: "Reporting",
color: "#6366f1",
position: 0,
itemCount: 3,
createdAt: new Date("2026-07-01T00:00:00.000Z"),
updatedAt: new Date("2026-07-01T00:00:00.000Z"),
},
],
};
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");
});
});
@@ -0,0 +1,583 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import {
Check,
ChevronDown,
Folder as FolderIcon,
MoreHorizontal,
Plus,
Search,
Trash2,
} 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";
export type FolderSelection = "all" | "unfiled" | string;
export const FOLDER_COLORS = [
"#6366f1",
"#8b5cf6",
"#10b981",
"#06b6d4",
"#f59e0b",
"#64748b",
];
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;
}) {
return (
<span
aria-hidden="true"
className={cn("h-2.5 w-2.5 shrink-0 rounded-[3px] border border-border/40", className)}
style={{ backgroundColor: color || "#64748b" }}
/>
);
}
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-[1rem_minmax(0,1fr)_auto] 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-[13.25rem] shrink-0 border-r border-border pr-3 md:block">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="text-[11px] 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) => {
const active = selection === folder.id;
const isRenaming = renamingFolderId === folder.id;
return (
<div
key={folder.id}
className={cn(
"group grid grid-cols-[1rem_minmax(0,1fr)_auto_auto] 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(folder.id)}
onDoubleClick={() => startRename(folder)}
>
<FolderSwatch color={folder.color} />
{isRenaming ? (
<input
value={renameDraft}
onChange={(event) => setRenameDraft(event.target.value)}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key === "Enter") commitRename(folder);
if (event.key === "Escape") setRenamingFolderId(null);
}}
onBlur={() => commitRename(folder)}
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={() => startRename(folder)}>Rename</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onEdit(folder)}>Edit color</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onSelect={() => onDelete(folder)}>
<Trash2 className="h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
})}
<div className="px-2 pb-1 pt-3 text-[11px] 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>
);
}
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 folders = result?.folders ?? [];
const rows = [
{ id: "all" as FolderSelection, label: allLabel, count: result?.allCount ?? 0, color: null },
...folders.map((folder) => ({ id: folder.id, label: folder.name, count: folder.itemCount, color: folder.color })),
{ id: "unfiled" as FolderSelection, label: "Unfiled", count: result?.unfiledCount ?? 0, color: null },
];
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="bottom" className="max-h-[80dvh] 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">
{rows.map((row) => (
<button
key={row.id}
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={() => select(row.id)}
>
{row.id === "all" ? <FolderIcon className="h-3.5 w-3.5 text-muted-foreground" /> : <FolderSwatch color={row.color} />}
<span className="min-w-0 flex-1 truncate">{row.label}</span>
<span className="text-xs text-muted-foreground">{row.count}</span>
{selection === row.id ? <Check className="h-3.5 w-3.5" /> : null}
</button>
))}
</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>
);
}
export function MoveToMenu({
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 (
<DropdownMenuSub>
<DropdownMenuSubTrigger>Move to...</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-56">
<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>
</DropdownMenuSubContent>
</DropdownMenuSub>
);
}
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: 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">
<MoveToMenu
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>
);
}
+3
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,
+376 -7
View File
@@ -22,6 +22,7 @@ import type {
CompanySkillVersion,
} from "@paperclipai/shared";
import { companySkillsApi } from "../api/companySkills";
import { foldersApi } from "../api/folders";
import { agentsApi } from "../api/agents";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
@@ -89,6 +90,18 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import {
BulkBar,
DeleteFolderDialog,
FolderChip,
FolderFormDialog,
FolderRail,
MobileFolderSheet,
MoveToMenu,
folderSearchValue,
normalizeFolderSelection,
type FolderSelection,
} from "../components/folders/FolderControls";
import {
AlertTriangle,
ArrowUpCircle,
@@ -114,6 +127,7 @@ import {
Lock,
ExternalLink,
FlaskConical,
MoreHorizontal,
Paperclip,
Pause,
Pencil,
@@ -131,6 +145,7 @@ import {
History,
XOctagon,
} from "lucide-react";
import type { FolderListItem, FolderListResult } from "@paperclipai/shared";
type SkillTreeNode = {
name: string;
@@ -537,6 +552,7 @@ const DISCOVERY_SORTS: DiscoverySort[] = ["agents", "stars", "forks", "recent",
export type DiscoveryCard = {
key: string;
skillId: string | null;
folderId?: string | null;
catalogRef: string | null;
name: string;
slug: string;
@@ -610,6 +626,7 @@ function buildDiscoveryCards(
cards.push({
key: skill.key,
skillId: skill.id,
folderId: skill.folderId ?? null,
catalogRef: catalogMatch ? catalogMatch.id : null,
name: skill.name,
slug: skill.slug,
@@ -638,6 +655,7 @@ function buildDiscoveryCards(
cards.push({
key: entry.key,
skillId: null,
folderId: null,
catalogRef: entry.id,
name: entry.name,
slug: entry.slug,
@@ -730,11 +748,36 @@ function SkillCategoryChip({ label }: { label: string }) {
);
}
function SkillCard({ card, onOpen }: { card: DiscoveryCard; onOpen: (card: DiscoveryCard) => void }) {
function SkillCard({
card,
folders,
selected = false,
selectMode = false,
onOpen,
onSelectChange,
onMove,
onCreateFolderAndMove,
}: {
card: DiscoveryCard;
folders?: FolderListItem[];
selected?: boolean;
selectMode?: boolean;
onOpen: (card: DiscoveryCard) => void;
onSelectChange?: (card: DiscoveryCard, selected: boolean) => void;
onMove?: (card: DiscoveryCard, folderId: string | null) => void;
onCreateFolderAndMove?: (card: DiscoveryCard) => void;
}) {
return (
<button
type="button"
<div
onClick={() => onOpen(card)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onOpen(card);
}
}}
role="button"
tabIndex={0}
className={cn(
// Quiet interactive-card affordance (DECISION-SHEET: one recipe for
// clickable cards): pointer cursor, border darkens, slight lift.
@@ -743,6 +786,16 @@ function SkillCard({ card, onOpen }: { card: DiscoveryCard; onOpen: (card: Disco
)}
>
<div className="flex items-start gap-3">
{selectMode && card.installed ? (
<input
type="checkbox"
className="mt-1 h-4 w-4 rounded border-border"
checked={selected}
aria-label={`Select ${card.name}`}
onClick={(event) => event.stopPropagation()}
onChange={(event) => onSelectChange?.(card, event.target.checked)}
/>
) : null}
<SkillCardIcon card={card} />
<div className="min-w-0 flex-1">
<div className="truncate font-mono text-sm font-medium text-foreground">{card.name}</div>
@@ -760,6 +813,29 @@ function SkillCard({ card, onOpen }: { card: DiscoveryCard; onOpen: (card: Disco
</span>
);
})()}
{card.installed && folders && onMove && onCreateFolderAndMove ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="-mr-1 -mt-1 opacity-70 group-hover:opacity-100"
aria-label={`More actions for ${card.name}`}
onClick={(event) => event.stopPropagation()}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" onClick={(event) => event.stopPropagation()}>
<MoveToMenu
folders={folders}
currentFolderId={card.folderId}
onMove={(folderId) => onMove(card, folderId)}
onCreateAndMove={() => onCreateFolderAndMove(card)}
/>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
{card.forkedFrom ? (
@@ -813,7 +889,7 @@ function SkillCard({ card, onOpen }: { card: DiscoveryCard; onOpen: (card: Disco
) : null}
</div>
</div>
</button>
</div>
);
}
@@ -884,6 +960,24 @@ export function DiscoveryGrid({
onScan,
scanPending,
scanStatus,
folderResult,
folderSelection = "all",
foldersLoading = false,
selectMode = false,
selectedSkillIds = [],
onFolderSelect,
onCreateFolder,
onRenameFolder,
onEditFolder,
onDeleteFolder,
onToggleSelectMode,
onSelectCard,
onMoveCard,
onCreateFolderAndMoveCard,
onMoveSelected,
onCreateFolderAndMoveSelected,
onClearSelected,
onOpenMobileFolders,
}: {
tab: DiscoveryTab;
tabCounts: Record<DiscoveryTab, number>;
@@ -907,6 +1001,24 @@ export function DiscoveryGrid({
onScan: () => void;
scanPending: boolean;
scanStatus: string | null;
folderResult?: FolderListResult | null;
folderSelection?: FolderSelection;
foldersLoading?: boolean;
selectMode?: boolean;
selectedSkillIds?: string[];
onFolderSelect?: (selection: FolderSelection) => void;
onCreateFolder?: () => void;
onRenameFolder?: (folder: FolderListItem, name: string) => void;
onEditFolder?: (folder: FolderListItem) => void;
onDeleteFolder?: (folder: FolderListItem) => void;
onToggleSelectMode?: () => void;
onSelectCard?: (card: DiscoveryCard, selected: boolean) => void;
onMoveCard?: (card: DiscoveryCard, folderId: string | null) => void;
onCreateFolderAndMoveCard?: (card: DiscoveryCard) => void;
onMoveSelected?: (folderId: string | null) => void;
onCreateFolderAndMoveSelected?: () => void;
onClearSelected?: () => void;
onOpenMobileFolders?: () => void;
}) {
// Source filter (github / skills.sh / local / …) lives in the grid so it
// narrows whatever the parent already filtered by tab/category/search (PAP-10907 E).
@@ -926,15 +1038,33 @@ export function DiscoveryGrid({
[cards, sourceBadgeFilter],
);
const sourceFilterActive = sourceBadgeFilter !== "all";
const showFolderRail = Boolean(folderResult && folderResult.folders.length > 0 && onFolderSelect);
const folderActionsReady = Boolean(onCreateFolder && onRenameFolder && onEditFolder && onDeleteFolder);
return (
// On desktop the store is bounded to the viewport so the category sidebar
// and the results pane each scroll independently (PAP-10907). Mobile keeps
// the natural page flow.
<div className="flex min-h-(--sz-calc-30) md:h-(--sz-calc-33) md:min-h-0 md:overflow-hidden">
{showFolderRail && folderActionsReady ? (
<div className="hidden shrink-0 pl-4 pt-4 md:block">
<FolderRail
result={folderResult}
selection={folderSelection}
itemLabelPlural="skills"
allLabel="All skills"
loading={foldersLoading}
onSelect={onFolderSelect!}
onCreate={onCreateFolder!}
onRename={onRenameFolder!}
onEdit={onEditFolder!}
onDelete={onDeleteFolder!}
/>
</div>
) : null}
{/* Secondary category sidebar — the main app nav collapses to a rail while
this is present (handled in Layout). */}
<aside className="hidden w-60 shrink-0 flex-col overflow-hidden border-r border-border md:flex">
<aside className={cn("hidden w-60 shrink-0 flex-col overflow-hidden border-r border-border md:flex", showFolderRail && "md:hidden")}>
<div className="border-b border-border px-4 py-4">
<h2 className="text-sm font-semibold text-foreground">Skills Store</h2>
<p className="text-xs text-muted-foreground">Discover, install, fork, share</p>
@@ -1043,6 +1173,27 @@ export function DiscoveryGrid({
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{folderResult && onFolderSelect ? (
<div className="w-full md:hidden">
<FolderChip
result={folderResult}
selection={folderSelection}
allLabel="All skills"
onClick={onOpenMobileFolders ?? (() => undefined)}
/>
</div>
) : null}
{onCreateFolder ? (
<Button variant="outline" size="sm" onClick={onCreateFolder}>
<Plus className="mr-1 h-3.5 w-3.5" />
New folder
</Button>
) : null}
{onToggleSelectMode ? (
<Button variant="ghost" size="sm" onClick={onToggleSelectMode}>
{selectMode ? "Done" : "Select"}
</Button>
) : null}
</div>
{/* Mobile category selector (sidebar is hidden below md) */}
@@ -1099,6 +1250,18 @@ export function DiscoveryGrid({
{/* Grid body */}
<div className="min-h-0 flex-1 overflow-auto p-4">
{scanStatus ? <p className="mb-3 text-xs text-muted-foreground">{scanStatus}</p> : null}
{selectMode && onMoveSelected && onCreateFolderAndMoveSelected && onClearSelected ? (
<div className="mb-3">
<BulkBar
selectedCount={selectedSkillIds.length}
folders={folderResult?.folders ?? []}
onMove={onMoveSelected}
onCreateAndMove={onCreateFolderAndMoveSelected}
onClear={onClearSelected}
onDone={onToggleSelectMode ?? onClearSelected}
/>
</div>
) : null}
{loading ? (
<PageSkeleton variant="list" />
) : error ? (
@@ -1148,7 +1311,17 @@ export function DiscoveryGrid({
</p>
<div className="grid gap-3 [grid-template-columns:repeat(auto-fill,minmax(19rem,1fr))]">
{sourceFilteredCards.map((card) => (
<SkillCard key={card.key} card={card} onOpen={onOpenCard} />
<SkillCard
key={card.key}
card={card}
folders={folderResult?.folders}
selected={selectedSkillIds.includes(card.skillId ?? "")}
selectMode={selectMode}
onOpen={onOpenCard}
onSelectChange={onSelectCard}
onMove={onMoveCard}
onCreateFolderAndMove={onCreateFolderAndMoveCard}
/>
))}
</div>
</>
@@ -3541,6 +3714,13 @@ export function CompanySkills() {
const [discoverySort, setDiscoverySort] = useState<DiscoverySort>("agents");
const [createError, setCreateError] = useState<string | null>(null);
const [importDialogOpen, setImportDialogOpen] = useState(false);
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 [selectedSkillIds, setSelectedSkillIds] = useState<string[]>([]);
const [moveAfterCreateSkillIds, setMoveAfterCreateSkillIds] = useState<string[]>([]);
const parsedRoute = useMemo(() => parseSkillRoute(routePath), [routePath]);
const isStudioNew = routePath === "studio/new";
const routeSkillToken = isStudioNew ? null : parsedRoute.skillToken;
@@ -3566,6 +3746,7 @@ export function CompanySkills() {
// Discovery grid owns `/skills` whenever no specific skill or catalog entry is
// selected; selecting either drops into the existing master/detail surfaces.
const isDiscovery = !isStudioNew && !routeSkillToken && !selectedCatalogRef;
const folderSelection = normalizeFolderSelection(searchParams.get("folder"));
function setDiscoveryTab(tab: DiscoveryTab) {
setSearchParams((current) => {
@@ -3573,10 +3754,29 @@ export function CompanySkills() {
if (tab === "all") params.delete("tab");
else params.set("tab", tab);
params.delete("category");
if (tab !== "installed") params.delete("folder");
return params;
});
}
function setFolderSelection(selection: FolderSelection) {
setSearchParams((current) => {
const params = new URLSearchParams(current);
params.set("tab", "installed");
params.delete("category");
const value = folderSearchValue(selection);
if (value) params.set("folder", value);
else params.delete("folder");
return params;
});
}
function openCreateFolder(moveSkillIds: string[] = []) {
setMoveAfterCreateSkillIds(moveSkillIds);
setFolderDialogTarget(null);
setFolderDialogOpen(true);
}
function setDetailTab(tab: SkillDetailTab) {
setSearchParams((current) => {
const params = new URLSearchParams(current);
@@ -3646,6 +3846,11 @@ export function CompanySkills() {
queryFn: () => companySkillsApi.list(selectedCompanyId!),
enabled: Boolean(selectedCompanyId),
});
const skillFoldersQuery = useQuery({
queryKey: queryKeys.folders.list(selectedCompanyId ?? "", "skill"),
queryFn: () => foldersApi.list(selectedCompanyId!, "skill"),
enabled: Boolean(selectedCompanyId && isDiscovery && discoveryTab === "installed"),
});
const installedSkills = skillsQuery.data ?? [];
const routeResolution = useMemo(
@@ -4025,10 +4230,14 @@ export function CompanySkills() {
const visibleDiscoveryCards = useMemo(() => {
const filtered = discoveryTabCards.filter((card) => {
if (discoveryCategory && !card.categories.includes(discoveryCategory)) return false;
if (discoveryTab === "installed") {
if (folderSelection === "unfiled" && card.folderId) return false;
if (folderSelection !== "all" && folderSelection !== "unfiled" && card.folderId !== folderSelection) return false;
}
return discoveryMatchesSearch(card, discoverySearch.trim());
});
return sortDiscoveryCards(filtered, discoverySort, discoveryTab !== "bundled");
}, [discoveryTabCards, discoveryCategory, discoverySearch, discoverySort, discoveryTab]);
}, [discoveryTabCards, discoveryCategory, discoverySearch, discoverySort, discoveryTab, folderSelection]);
const selectedCatalogSkill = catalogDetailQuery.data
?? (catalogListQuery.data ?? []).find((entry) => entry.id === selectedCatalogRef || entry.key === selectedCatalogRef)
@@ -4096,6 +4305,87 @@ export function CompanySkills() {
policyDenial.capture(error, "Installing this skill");
},
});
const createFolder = useMutation({
mutationFn: (payload: { name: string; color: string | null }) =>
foldersApi.create(selectedCompanyId!, { kind: "skill", ...payload }),
onSuccess: async (folder) => {
setFolderDialogOpen(false);
setFolderDialogTarget(null);
await queryClient.invalidateQueries({ queryKey: queryKeys.folders.list(selectedCompanyId!, "skill") });
if (moveAfterCreateSkillIds.length > 0) {
const ids = moveAfterCreateSkillIds;
setMoveAfterCreateSkillIds([]);
await Promise.all(ids.map((itemId) =>
foldersApi.moveItem(selectedCompanyId!, { kind: "skill", itemId, folderId: folder.id })
));
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.list(selectedCompanyId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.folders.list(selectedCompanyId!, "skill") }),
]);
} else {
setFolderSelection(folder.id);
}
pushToast({ tone: "success", title: "Folder created", body: folder.name });
},
onError: (error) => {
pushToast({
tone: "error",
title: "Folder save failed",
body: error instanceof Error ? error.message : "Failed to save folder.",
});
},
});
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!, "skill") });
},
});
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.companySkills.list(selectedCompanyId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.folders.list(selectedCompanyId!, "skill") }),
]);
pushToast({ tone: "success", title: "Folder deleted", body: "Skills moved to Unfiled." });
},
});
const moveSkillToFolder = useMutation({
mutationFn: ({ itemId, folderId }: { itemId: string; folderId: string | null }) =>
foldersApi.moveItem(selectedCompanyId!, { kind: "skill", itemId, folderId }),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.list(selectedCompanyId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.folders.list(selectedCompanyId!, "skill") }),
]);
},
onError: (error) => {
pushToast({
tone: "error",
title: "Move failed",
body: error instanceof Error ? error.message : "Failed to move skill.",
});
},
});
async function moveSelectedSkills(folderId: string | null) {
const ids = selectedSkillIds;
if (ids.length === 0) return;
await Promise.all(ids.map((itemId) => foldersApi.moveItem(selectedCompanyId!, { kind: "skill", itemId, folderId })));
setSelectedSkillIds([]);
setSelectMode(false);
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.list(selectedCompanyId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.folders.list(selectedCompanyId!, "skill") }),
]);
pushToast({ tone: "success", title: "Skills moved", body: `${ids.length} skill${ids.length === 1 ? "" : "s"} filed.` });
}
const eligibleAgentsForAttach = useMemo(() => {
const data = agentsQuery.data ?? [];
@@ -4263,6 +4553,8 @@ export function CompanySkills() {
const studioDescription = studioForkFromId
? "Review the fork metadata and create an editable company copy."
: "Create an editable company skill in the Paperclip workspace.";
const skillFolderResult = skillFoldersQuery.data ?? null;
const showInstalledFolders = isDiscovery && discoveryTab === "installed";
return (
<>
@@ -4431,6 +4723,40 @@ export function CompanySkills() {
</DialogContent>
</Dialog>
<FolderFormDialog
open={folderDialogOpen}
kind="skill"
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="skills"
pending={deleteFolder.isPending}
onOpenChange={(open) => {
if (!open) setDeleteFolderTarget(null);
}}
onConfirm={() => {
if (deleteFolderTarget) deleteFolder.mutate(deleteFolderTarget.id);
}}
/>
<MobileFolderSheet
open={mobileFoldersOpen}
onOpenChange={setMobileFoldersOpen}
result={skillFolderResult}
selection={folderSelection}
allLabel="All skills"
itemLabelPlural="Skills"
onSelect={setFolderSelection}
onCreate={() => openCreateFolder()}
/>
{isStudioNew ? (
<div className="min-h-(--sz-calc-30)">
<div className="border-b border-border px-4 py-5">
@@ -4486,6 +4812,49 @@ export function CompanySkills() {
onScan={() => scanProjects.mutate()}
scanPending={scanProjects.isPending}
scanStatus={scanStatusMessage}
folderResult={showInstalledFolders ? skillFolderResult : null}
folderSelection={folderSelection}
foldersLoading={skillFoldersQuery.isLoading}
selectMode={showInstalledFolders && selectMode}
selectedSkillIds={selectedSkillIds}
onFolderSelect={showInstalledFolders ? setFolderSelection : undefined}
onOpenMobileFolders={showInstalledFolders ? () => setMobileFoldersOpen(true) : undefined}
onCreateFolder={showInstalledFolders ? () => openCreateFolder() : undefined}
onRenameFolder={showInstalledFolders ? (folder, name) => updateFolder.mutate({ folderId: folder.id, payload: { name } }) : undefined}
onEditFolder={showInstalledFolders ? (folder) => {
setFolderDialogTarget(folder);
setFolderDialogOpen(true);
} : undefined}
onDeleteFolder={showInstalledFolders ? setDeleteFolderTarget : undefined}
onToggleSelectMode={showInstalledFolders ? () => {
setSelectMode((current) => !current);
if (selectMode) setSelectedSkillIds([]);
} : undefined}
onSelectCard={showInstalledFolders ? (card, selected) => {
if (!card.skillId) return;
setSelectedSkillIds((current) =>
selected
? Array.from(new Set([...current, card.skillId!]))
: current.filter((id) => id !== card.skillId)
);
} : undefined}
onMoveCard={showInstalledFolders ? (card, folderId) => {
if (!card.skillId) return;
moveSkillToFolder.mutate({ itemId: card.skillId, folderId });
pushToast({
tone: "success",
title: "Skill moved",
body: folderId
? `Moved to ${skillFolderResult?.folders.find((folder) => folder.id === folderId)?.name ?? "folder"}.`
: "Moved to Unfiled.",
});
} : undefined}
onCreateFolderAndMoveCard={showInstalledFolders ? (card) => {
if (card.skillId) openCreateFolder([card.skillId]);
} : undefined}
onMoveSelected={showInstalledFolders ? (folderId) => void moveSelectedSkills(folderId) : undefined}
onCreateFolderAndMoveSelected={showInstalledFolders ? () => openCreateFolder(selectedSkillIds) : undefined}
onClearSelected={showInstalledFolders ? () => setSelectedSkillIds([]) : undefined}
/>
) : activeView === "installed" && selectedSkillId ? (
<SkillDetailPage
+38 -6
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),
@@ -343,6 +354,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 +416,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 +523,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 +547,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();
+327 -8
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,21 @@ 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 {
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 +83,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 +105,7 @@ const builtInRoutineGroupKey = "__built_in_routines";
const defaultRoutineViewState: RoutineViewState = {
sortField: "title",
sortDir: "asc",
groupBy: "project",
groupBy: "folder",
collapsedGroups: [],
};
@@ -120,6 +137,7 @@ function buildRoutineMutationPayload(input: {
title: string;
description: string;
projectId: string;
folderId: string | null;
assigneeAgentId: string;
priority: string;
concurrencyPolicy: string;
@@ -130,6 +148,7 @@ function buildRoutineMutationPayload(input: {
...input,
description: input.description.trim() || null,
projectId: input.projectId || null,
folderId: input.folderId || null,
assigneeAgentId: input.assigneeAgentId || null,
};
}
@@ -140,7 +159,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 +286,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 +309,7 @@ export function Routines() {
title: string;
description: string;
projectId: string;
folderId: string | null;
assigneeAgentId: string;
priority: string;
concurrencyPolicy: string;
@@ -292,6 +319,7 @@ export function Routines() {
title: "",
description: "",
projectId: "",
folderId: null,
assigneeAgentId: "",
priority: "medium",
concurrencyPolicy: "coalesce_if_active",
@@ -302,6 +330,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 +345,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!),
@@ -373,6 +407,7 @@ export function Routines() {
title: "",
description: "",
projectId: "",
folderId: null,
assigneeAgentId: "",
priority: "medium",
concurrencyPolicy: "coalesce_if_active",
@@ -392,6 +427,74 @@ 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([]);
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") }),
]);
} 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") });
},
});
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" });
},
});
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),
@@ -494,9 +597,15 @@ 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]);
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),
@@ -513,6 +622,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) => {
@@ -529,6 +641,43 @@ 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;
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" });
}
function handleRunNow(routine: RoutineListItem) {
setRunDialogRoutine(routine);
}
@@ -574,7 +723,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>
@@ -647,6 +796,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"],
@@ -667,8 +817,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={routineFolders}
selection={folderSelection}
allLabel="All routines"
onClick={() => setMobileFoldersOpen(true)}
/>
</div>
) : null}
</TabsContent>
<TabsContent value="runs">
<IssuesList
@@ -843,6 +1014,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>
@@ -949,7 +1140,49 @@ export function Routines() {
) : null}
{activeTab === "routines" ? (
<div>
<div className={cn(showFolderRail && "flex gap-4")}>
{showFolderRail ? (
<FolderRail
result={routineFolders}
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}
{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
@@ -957,6 +1190,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) => {
@@ -995,6 +1243,32 @@ 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) => {
moveRoutineToFolder.mutate({ itemId: routine.id, folderId });
pushToast({
title: "Routine moved",
body: folderId
? `Moved to ${routineFolders?.folders.find((folder) => folder.id === folderId)?.name ?? "folder"}.`
: "Moved to Unfiled.",
tone: "success",
});
}}
onCreateAndMove={() => openCreateFolder([routine.id])}
/>
}
/>
))}
</CollapsibleContent>
@@ -1003,9 +1277,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={routineFolders}
selection={folderSelection}
allLabel="All routines"
itemLabelPlural="Routines"
onSelect={setFolderSelection}
onCreate={() => openCreateFolder()}
/>
<RoutineRunVariablesDialog
open={runDialogRoutine !== null}
onOpenChange={(next) => {
@@ -1027,3 +1336,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>
);
}