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:
parent
3a16b91217
commit
c9881223e4
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
() =>
|
||||
|
|
|
|||
Loading…
Reference in New Issue