From cdea5bd6420a3c3e6a098d5f6452433a69201495 Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 8 Jul 2026 20:58:35 +0000 Subject: [PATCH] Separate built-in routines on routines page Co-Authored-By: Paperclip --- ui/src/pages/Routines.test.tsx | 73 +++++++++++++++++++++++++++- ui/src/pages/Routines.tsx | 88 +++++++++++++++++++++++++++------- 2 files changed, 142 insertions(+), 19 deletions(-) diff --git a/ui/src/pages/Routines.test.tsx b/ui/src/pages/Routines.test.tsx index 2022b98f959..30da1797c00 100644 --- a/ui/src/pages/Routines.test.tsx +++ b/ui/src/pages/Routines.test.tsx @@ -6,7 +6,7 @@ import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { Issue, RoutineListItem } from "@paperclipai/shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { Routines, buildRoutineGroups, sortRoutines } from "./Routines"; +import { Routines, buildRoutineGroups, buildRoutineSections, sortRoutines } from "./Routines"; let currentSearch = ""; @@ -376,6 +376,28 @@ describe("Routines page", () => { expect(groups[1]?.items.map((item) => item.title)).toEqual(["Weekly digest"]); }); + it("keeps built-in routines in their own section before configured groups", () => { + const groups = buildRoutineSections( + [ + createRoutine({ + id: "routine-1", + title: "Reflection review", + projectId: "project-1", + originKind: "built_in_agent_bundle", + originId: "reflection-coach:recent-agent-reflection", + }), + createRoutine({ id: "routine-2", title: "Morning sync", projectId: "project-1" }), + ], + "project", + new Map([["project-1", { name: "Project Alpha" }]]), + new Map([["agent-1", { name: "Agent One" }]]), + ); + + expect(groups.map((group) => group.label)).toEqual(["Built-in routines", "Project Alpha"]); + expect(groups[0]?.items.map((item) => item.title)).toEqual(["Reflection review"]); + expect(groups[1]?.items.map((item) => item.title)).toEqual(["Morning sync"]); + }); + it("sorts routines by selected field and direction without mutating the source list", () => { const routines = [ createRoutine({ @@ -508,6 +530,55 @@ describe("Routines page", () => { }); }); + it("renders built-in routines in a dedicated section on the routines tab", async () => { + routinesListMock.mockResolvedValue([ + createRoutine({ + id: "routine-1", + title: "Morning sync", + projectId: "project-1", + }), + createRoutine({ + id: "routine-2", + title: "Reflection review", + projectId: null, + originKind: "built_in_agent_bundle", + originId: "reflection-coach:recent-agent-reflection", + }), + ]); + issuesListMock.mockResolvedValue([]); + + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); + + await act(async () => { + root.render( + + + , + ); + await flush(); + }); + + for (let attempts = 0; attempts < 5 && !container.textContent?.includes("Built-in routines"); attempts += 1) { + await act(async () => { + await flush(); + }); + } + + const text = container.textContent ?? ""; + expect(text.indexOf("Built-in routines")).toBeLessThan(text.indexOf("Reflection review")); + expect(text.indexOf("Reflection review")).toBeLessThan(text.indexOf("Project Alpha")); + expect(text.indexOf("Project Alpha")).toBeLessThan(text.indexOf("Morning sync")); + + await act(async () => { + root.unmount(); + }); + }); + it("hides archived routines from the routines list", async () => { routinesListMock.mockResolvedValue([ createRoutine({ id: "routine-1", title: "Morning sync", status: "active" }), diff --git a/ui/src/pages/Routines.tsx b/ui/src/pages/Routines.tsx index 5805384502f..75c68ecc3cc 100644 --- a/ui/src/pages/Routines.tsx +++ b/ui/src/pages/Routines.tsx @@ -82,6 +82,8 @@ type RoutineGroup = { items: RoutineListItem[]; }; +const builtInRoutineGroupKey = "__built_in_routines"; + const defaultRoutineViewState: RoutineViewState = { sortField: "title", sortDir: "asc", @@ -170,6 +172,38 @@ export function buildRoutineGroups( })); } +export function isBuiltInRoutine(routine: Pick) { + return routine.originKind === "built_in_agent_bundle"; +} + +export function buildRoutineSections( + routines: RoutineListItem[], + groupByValue: RoutineGroupBy, + projectById: Map, + agentById: Map, +): RoutineGroup[] { + const builtInRoutines = routines.filter(isBuiltInRoutine); + const customRoutines = routines.filter((routine) => !isBuiltInRoutine(routine)); + const customGroups = buildRoutineGroups(customRoutines, groupByValue, projectById, agentById) + .filter((group) => group.items.length > 0) + .map((group) => ( + builtInRoutines.length > 0 && groupByValue === "none" && group.key === "__all" + ? { ...group, label: "Custom routines" } + : group + )); + + if (builtInRoutines.length === 0) return customGroups; + + return [ + { + key: builtInRoutineGroupKey, + label: "Built-in routines", + items: builtInRoutines, + }, + ...customGroups, + ]; +} + export function sortRoutines( routines: RoutineListItem[], sortField: RoutineSortField, @@ -199,6 +233,34 @@ function buildRoutinesTabHref(tab: RoutinesTab) { return tab === "runs" ? "/routines?tab=runs" : "/routines"; } +function RoutineSectionHeader({ + label, + count, + isOpen, +}: { + label: string; + count: number; + isOpen: boolean; +}) { + return ( +
+ + + + {label} + + + + {count} + +
+ ); +} + export function Routines() { const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); @@ -425,8 +487,8 @@ export function Routines() { () => sortRoutines(visibleRoutines, routineViewState.sortField, routineViewState.sortDir), [routineViewState.sortDir, routineViewState.sortField, visibleRoutines], ); - const routineGroups = useMemo( - () => buildRoutineGroups(sortedRoutines, routineViewState.groupBy, projectById, agentById), + const routineSections = useMemo( + () => buildRoutineSections(sortedRoutines, routineViewState.groupBy, projectById, agentById), [agentById, projectById, routineViewState.groupBy, sortedRoutines], ); const recentRunsIssueLinkState = useMemo( @@ -886,7 +948,7 @@ export function Routines() { ) : (
- {routineGroups.map((group) => { + {routineSections.map((group) => { const isOpen = !routineViewState.collapsedGroups.includes(group.key); return ( {group.label ? ( -
- - - - {group.label} - - - - {group.items.length} - -
+ ) : null} {group.items.map((routine) => (