Separate built-in routines on routines page

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-09 18:50:41 +00:00
1 parent dc5cbb7d9a
commit cdea5bd642
2 files changed
+142 -19

No files matched your search

+72 -1
View File
@@ -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(
<QueryClientProvider client={queryClient}>
<Routines />
</QueryClientProvider>,
);
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" }),
+70 -18
View File
@@ -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<RoutineListItem, "originKind">) {
return routine.originKind === "built_in_agent_bundle";
}
export function buildRoutineSections(
routines: RoutineListItem[],
groupByValue: RoutineGroupBy,
projectById: Map<string, { name: string }>,
agentById: Map<string, { name: string }>,
): 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 (
<div
className={`flex items-center gap-2 rounded-lg border border-border px-3 py-2${
isOpen ? " mb-1" : ""
}`}
>
<CollapsibleTrigger className="flex items-center gap-1.5">
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform [[data-state=open]>&]:rotate-90" />
<span className="text-sm font-semibold uppercase tracking-wide">
{label}
</span>
</CollapsibleTrigger>
<span className="text-xs text-muted-foreground">
{count}
</span>
</div>
);
}
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() {
</div>
) : (
<div className="flex flex-col gap-3">
{routineGroups.map((group) => {
{routineSections.map((group) => {
const isOpen = !routineViewState.collapsedGroups.includes(group.key);
return (
<Collapsible
@@ -901,21 +963,11 @@ export function Routines() {
}}
>
{group.label ? (
<div
className={`flex items-center gap-2 rounded-lg border border-border px-3 py-2${
isOpen ? " mb-1" : ""
}`}
>
<CollapsibleTrigger className="flex items-center gap-1.5">
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform [[data-state=open]>&]:rotate-90" />
<span className="text-sm font-semibold uppercase tracking-wide">
{group.label}
</span>
</CollapsibleTrigger>
<span className="text-xs text-muted-foreground">
{group.items.length}
</span>
</div>
<RoutineSectionHeader
label={group.label}
count={group.items.length}
isOpen={isOpen}
/>
) : null}
<CollapsibleContent>
{group.items.map((routine) => (