From c9881223e49ca7486c71f79eed767ec98e5f4eb6 Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Fri, 24 Jul 2026 12:20:35 -0700 Subject: [PATCH] fix(routines): show routines grouped by folder name (#10201) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 Co-authored-by: Paperclip --- ui/src/pages/Routines.test.tsx | 127 +++++++++++++++++++++++++++++---- ui/src/pages/Routines.tsx | 46 ++++++++++-- 2 files changed, 154 insertions(+), 19 deletions(-) diff --git a/ui/src/pages/Routines.test.tsx b/ui/src/pages/Routines.test.tsx index 9e1dae7e8f..ee70969230 100644 --- a/ui/src/pages/Routines.test.tsx +++ b/ui/src/pages/Routines.test.tsx @@ -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(); diff --git a/ui/src/pages/Routines.tsx b/ui/src/pages/Routines.tsx index be259704cd..213396320e 100644 --- a/ui/src/pages/Routines.tsx +++ b/ui/src/pages/Routines.tsx @@ -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, agentById: Map, + folderById: Map, ): 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, agentById: Map, + folderById: Map, ): 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( () =>