fix(routines): show routines grouped by folder name (#10201)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The Routines page is part of the operator UI for scheduled routine
management
> - The grouped-by-folder view was not presenting folder sections inline
in the main pane
> - That made the folder grouping mode harder to scan and hid the
separation between custom folders, Unfiled routines, and built-in
routines
> - This pull request updates the Routines page rendering so folder
groups appear as inline sections and built-in routines still split into
their own section afterward
> - The benefit is that grouped routines stay readable and the page
matches the intended folder organization

## Linked Issues or Issue Description

No corresponding public GitHub issue exists, so the problem is described
directly below following the bug template.

### What happened

On the Routines page, selecting Group → Folder flattened the grouped
list into a single "All routines" section with only the separate
built-in routines section below it.

### Expected behavior

Group → Folder should render one inline section per folder, keep
routines with no folder in an Unfiled section, and preserve the separate
built-in routines section after the custom folder groups.

### Steps to reproduce

1. Open the Routines page.
2. Change grouping to Folder.
3. Observe the main pane.
4. The routine list is flattened instead of grouped into folder-labeled
inline sections.

### Paperclip version / commit

Current PR head: `a8e384c838e362de3437c7a88bc7aa38b10fd9c0` on
`fix/routine-folder-grouping`.

### Deployment mode

Local development workspace for the Paperclip app UI.

## What Changed

- Updated the Routines page rendering so grouped folders render as
inline sections instead of flattening into a single list.
- Kept routines without a folder grouped under Unfiled.
- Preserved the built-in routines section after custom folder groups.
- Added and updated tests for the folder-grouped rendering behavior.

## Verification

- `pnpm --filter @paperclipai/ui exec vitest run
src/pages/Routines.test.tsx`
- `pnpm --filter @paperclipai/ui typecheck`
- `pnpm check:token-gates`

## Risks

- Low risk: the change is localized to the Routines page rendering and
its test coverage.
- The main behavioral risk is accidental grouping regressions if future
routine-grouping logic changes without updating the tests.

## Model Used

OpenAI Codex, GPT-5, tool-use enabled, 256k-context class model.

## 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
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [ ] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Harold Kim <harold@paperclip.ing>
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Nicky Leach 2026-07-24 12:20:35 -07:00 committed by GitHub
parent 3a16b91217
commit c9881223e4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 154 additions and 19 deletions

View File

@ -416,6 +416,7 @@ describe("Routines page", () => {
["agent-1", { name: "Agent One" }],
["agent-2", { name: "Agent Two" }],
]),
new Map(),
);
expect(groups.map((group) => group.label)).toEqual(["Project Alpha", "Project Beta"]);
@ -438,6 +439,7 @@ describe("Routines page", () => {
"project",
new Map([["project-1", { name: "Project Alpha" }]]),
new Map([["agent-1", { name: "Agent One" }]]),
new Map(),
);
expect(groups.map((group) => group.label)).toEqual(["Project Alpha", "Built-in routines"]);
@ -445,20 +447,71 @@ 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" }),
];
it("groups routines by folder using folder names and Unfiled labels", () => {
const groups = buildRoutineGroups(
routines,
[
createRoutine({ id: "routine-1", title: "RPI review", folderId: "folder-rpi" }),
createRoutine({ id: "routine-2", title: "Unfiled sweep", folderId: null }),
createRoutine({ id: "routine-3", title: "Test summary", folderId: "folder-test" }),
],
"folder",
new Map(),
new Map(),
new Map([
["folder-rpi", { name: "RPI" }],
["folder-test", { name: "Test" }],
]),
);
expect(groups).toEqual([{ key: "__all", label: null, items: routines }]);
expect(groups.map((group) => group.label)).toEqual(["RPI", "Test", "Unfiled"]);
expect(groups[0]?.items.map((item) => item.title)).toEqual(["RPI review"]);
expect(groups[1]?.items.map((item) => item.title)).toEqual(["Test summary"]);
expect(groups[2]?.items.map((item) => item.title)).toEqual(["Unfiled sweep"]);
});
it("orders folder groups by folder position before label and keeps Unfiled after folders", () => {
const groups = buildRoutineGroups(
[
createRoutine({ id: "routine-1", title: "Beta routine", folderId: "folder-beta" }),
createRoutine({ id: "routine-2", title: "Loose routine", folderId: null }),
createRoutine({ id: "routine-3", title: "Alpha routine", folderId: "folder-alpha" }),
],
"folder",
new Map(),
new Map(),
new Map([
["folder-alpha", { name: "Alpha", position: 20 }],
["folder-beta", { name: "Beta", position: 10 }],
]),
);
expect(groups.map((group) => group.label)).toEqual(["Beta", "Alpha", "Unfiled"]);
expect(groups.map((group) => group.key)).toEqual(["folder-beta", "folder-alpha", "__unfiled"]);
});
it("keeps built-in routines in their own section after folder groups", () => {
const groups = buildRoutineSections(
[
createRoutine({ id: "routine-1", title: "RPI review", folderId: "folder-rpi" }),
createRoutine({
id: "routine-2",
title: "Reflection review",
folderId: "folder-rpi",
originKind: "built_in_agent_bundle",
originId: "reflection-coach:recent-agent-reflection",
}),
createRoutine({ id: "routine-3", title: "Unfiled sweep", folderId: null }),
],
"folder",
new Map(),
new Map(),
new Map([["folder-rpi", { name: "RPI" }]]),
);
expect(groups.map((group) => group.label)).toEqual(["RPI", "Unfiled", "Built-in routines"]);
expect(groups[0]?.items.map((item) => item.title)).toEqual(["RPI review"]);
expect(groups[1]?.items.map((item) => item.title)).toEqual(["Unfiled sweep"]);
expect(groups[2]?.items.map((item) => item.title)).toEqual(["Reflection review"]);
});
it("sorts routines by selected field and direction without mutating the source list", () => {
@ -552,11 +605,50 @@ describe("Routines page", () => {
});
});
it("defaults the routines list to folder mode without rendering project groups", async () => {
it("defaults the routines list to folder mode with inline folder sections", async () => {
foldersListMock.mockResolvedValue({
kind: "routine",
allCount: 3,
unfiledCount: 1,
folders: [
{
id: "folder-rpi",
companyId: "company-1",
kind: "routine",
parentId: null,
name: "RPI",
slug: "rpi",
systemKey: null,
path: "rpi",
depth: 1,
color: null,
position: 0,
itemCount: 1,
createdAt: new Date("2026-07-01T00:00:00.000Z"),
updatedAt: new Date("2026-07-01T00:00:00.000Z"),
},
{
id: "folder-test",
companyId: "company-1",
kind: "routine",
parentId: null,
name: "Test",
slug: "test",
systemKey: null,
path: "test",
depth: 1,
color: null,
position: 1,
itemCount: 1,
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: "Weekly digest", projectId: "project-1" }),
createRoutine({ id: "routine-2", title: "Morning sync", projectId: "project-1" }),
createRoutine({ id: "routine-3", title: "Agent review", projectId: "project-2" }),
createRoutine({ id: "routine-1", title: "RPI review", folderId: "folder-rpi", projectId: "project-1" }),
createRoutine({ id: "routine-2", title: "Unfiled sweep", folderId: null, projectId: "project-1" }),
createRoutine({ id: "routine-3", title: "Test summary", folderId: "folder-test", projectId: "project-2" }),
]);
issuesListMock.mockResolvedValue([]);
@ -576,15 +668,20 @@ describe("Routines page", () => {
await flush();
});
for (let attempts = 0; attempts < 5 && !container.textContent?.includes("Morning sync"); attempts += 1) {
for (let attempts = 0; attempts < 5 && !container.textContent?.includes("Unfiled sweep"); attempts += 1) {
await act(async () => {
await flush();
});
}
const sectionLabels = Array.from(container.querySelectorAll("span"))
.filter((element) => element.className.includes("uppercase") && element.className.includes("tracking-wide"))
.map((element) => element.textContent);
expect(sectionLabels).toEqual(["RPI", "Test", "Unfiled"]);
const text = container.textContent ?? "";
expect(text.indexOf("Morning sync")).toBeLessThan(text.indexOf("Weekly digest"));
expect(text).toContain("New folder");
expect(text.indexOf("RPI review")).toBeLessThan(text.indexOf("Test summary"));
expect(text.indexOf("Test summary")).toBeLessThan(text.indexOf("Unfiled sweep"));
await act(async () => {
root.unmount();

View File

@ -134,6 +134,8 @@ function compareNullableText(left: string | null | undefined, right: string | nu
return (left ?? "").localeCompare(right ?? "", undefined, { sensitivity: "base" });
}
type RoutineFolderGroupMeta = { name: string; position?: number | null };
function buildRoutineMutationPayload(input: {
title: string;
description: string;
@ -159,11 +161,42 @@ export function buildRoutineGroups(
groupByValue: RoutineGroupBy,
projectById: Map<string, { name: string }>,
agentById: Map<string, { name: string }>,
folderById: Map<string, RoutineFolderGroupMeta>,
): RoutineGroup[] {
if (groupByValue === "none" || groupByValue === "folder") {
if (groupByValue === "none") {
return [{ key: "__all", label: null, items: routines }];
}
if (groupByValue === "folder") {
const groups = groupBy(routines, (routine) => routine.folderId ?? "__unfiled");
return Object.keys(groups)
.sort((left, right) => {
if (left === "__unfiled" || right === "__unfiled") {
if (left === right) return 0;
return left === "__unfiled" ? 1 : -1;
}
const leftFolder = folderById.get(left);
const rightFolder = folderById.get(right);
const leftPosition = Number.isFinite(leftFolder?.position) ? leftFolder!.position! : Number.POSITIVE_INFINITY;
const rightPosition = Number.isFinite(rightFolder?.position) ? rightFolder!.position! : Number.POSITIVE_INFINITY;
const positionCompare = leftPosition - rightPosition;
if (positionCompare !== 0) return positionCompare;
const labelCompare = (leftFolder?.name ?? "Unknown folder").localeCompare(
rightFolder?.name ?? "Unknown folder",
undefined,
{ sensitivity: "base" },
);
return labelCompare || left.localeCompare(right);
})
.map((key) => ({
key,
label: key === "__unfiled" ? "Unfiled" : (folderById.get(key)?.name ?? "Unknown folder"),
items: groups[key]!,
}));
}
if (groupByValue === "project") {
const groups = groupBy(routines, (routine) => routine.projectId ?? "__no_project");
return Object.keys(groups)
@ -202,10 +235,11 @@ export function buildRoutineSections(
groupByValue: RoutineGroupBy,
projectById: Map<string, { name: string }>,
agentById: Map<string, { name: string }>,
folderById: Map<string, { name: string }>,
): RoutineGroup[] {
const builtInRoutines = routines.filter(isBuiltInRoutine);
const customRoutines = routines.filter((routine) => !isBuiltInRoutine(routine));
const customGroups = buildRoutineGroups(customRoutines, groupByValue, projectById, agentById)
const customGroups = buildRoutineGroups(customRoutines, groupByValue, projectById, agentById, folderById)
.filter((group) => group.items.length > 0)
.map((group) => (
builtInRoutines.length > 0 && groupByValue === "none" && group.key === "__all"
@ -617,6 +651,10 @@ export function Routines() {
() => new Map((projects ?? []).map((project) => [project.id, project])),
[projects],
);
const folderById = useMemo(
() => new Map((routineFolders?.folders ?? []).map((folder) => [folder.id, folder])),
[routineFolders],
);
const liveIssueIds = useMemo(() => collectLiveIssueIds(liveRuns), [liveRuns]);
const visibleRoutines = useMemo(
() => (routines ?? []).filter((routine) => routine.status !== "archived"),
@ -653,8 +691,8 @@ export function Routines() {
[folderFilteredRoutines, routineViewState.sortDir, routineViewState.sortField],
);
const routineSections = useMemo(
() => buildRoutineSections(sortedRoutines, routineViewState.groupBy, projectById, agentById),
[agentById, projectById, routineViewState.groupBy, sortedRoutines],
() => buildRoutineSections(sortedRoutines, routineViewState.groupBy, projectById, agentById, folderById),
[agentById, folderById, projectById, routineViewState.groupBy, sortedRoutines],
);
const recentRunsIssueLinkState = useMemo(
() =>