diff --git a/ui/src/lib/workspace-routines.test.ts b/ui/src/lib/workspace-routines.test.ts index c585a8014bf..e40fa456565 100644 --- a/ui/src/lib/workspace-routines.test.ts +++ b/ui/src/lib/workspace-routines.test.ts @@ -2,6 +2,7 @@ import type { RoutineListItem } from "@paperclipai/shared"; import { describe, expect, it } from "vitest"; import { getWorkspaceSpecificRoutineVariableNames, + groupWorkspaceSpecificRoutines, routineHasWorkspaceSpecificVariables, sortWorkspaceRoutinesByName, } from "./workspace-routines"; @@ -96,4 +97,46 @@ describe("workspace routine helpers", () => { ]); expect(routines.map((routine) => routine.id)).toEqual(["routine-2", "routine-3", "routine-1"]); }); + + it("groups current-project workspace routines before other workspace routines and ignores non-workspace routines", () => { + const currentRoutine = createRoutine({ + id: "routine-current", + projectId: "project-1", + title: "Current review", + variables: [ + { name: "workspaceBranch", label: null, type: "text", defaultValue: null, required: true, options: [] }, + ], + }); + const otherProjectRoutine = createRoutine({ + id: "routine-other-project", + projectId: "project-2", + title: "Beta other review", + variables: [ + { name: "workspaceBranch", label: null, type: "text", defaultValue: null, required: true, options: [] }, + ], + }); + const noProjectRoutine = createRoutine({ + id: "routine-no-project", + projectId: null, + title: "Alpha other review", + variables: [ + { name: "workspaceBranch", label: null, type: "text", defaultValue: null, required: true, options: [] }, + ], + }); + const generalRoutine = createRoutine({ + id: "routine-general", + projectId: "project-1", + variables: [ + { name: "repo", label: null, type: "text", defaultValue: null, required: true, options: [] }, + ], + }); + + expect(groupWorkspaceSpecificRoutines( + [otherProjectRoutine, generalRoutine, currentRoutine, noProjectRoutine], + "project-1", + )).toEqual({ + thisWorkspace: [currentRoutine], + otherWorkspaces: [noProjectRoutine, otherProjectRoutine], + }); + }); }); diff --git a/ui/src/lib/workspace-routines.ts b/ui/src/lib/workspace-routines.ts index eddc5f93f13..a901c029054 100644 --- a/ui/src/lib/workspace-routines.ts +++ b/ui/src/lib/workspace-routines.ts @@ -37,3 +37,33 @@ export function sortWorkspaceRoutinesByName(routines: RoutineListItem[]): Routin return left.id.localeCompare(right.id); }); } + +export interface WorkspaceRoutineGroups { + thisWorkspace: RoutineListItem[]; + otherWorkspaces: RoutineListItem[]; +} + +export function groupWorkspaceSpecificRoutines( + routines: RoutineListItem[], + currentProjectId: string | null, +): WorkspaceRoutineGroups { + const groups: WorkspaceRoutineGroups = { + thisWorkspace: [], + otherWorkspaces: [], + }; + + for (const routine of routines) { + if (!routineHasWorkspaceSpecificVariables(routine)) continue; + + if (routine.projectId === currentProjectId) { + groups.thisWorkspace.push(routine); + } else { + groups.otherWorkspaces.push(routine); + } + } + + return { + thisWorkspace: sortWorkspaceRoutinesByName(groups.thisWorkspace), + otherWorkspaces: sortWorkspaceRoutinesByName(groups.otherWorkspaces), + }; +} diff --git a/ui/src/pages/ExecutionWorkspaceDetail.test.tsx b/ui/src/pages/ExecutionWorkspaceDetail.test.tsx index c96aad1120c..9fbd8f07e5a 100644 --- a/ui/src/pages/ExecutionWorkspaceDetail.test.tsx +++ b/ui/src/pages/ExecutionWorkspaceDetail.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import type { ExecutionWorkspace, Project } from "@paperclipai/shared"; +import type { ExecutionWorkspace, Project, RoutineListItem } from "@paperclipai/shared"; import { act, type ReactNode } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -181,6 +181,41 @@ function project(overrides: Partial = {}): Project { }; } +function routine(overrides: Partial = {}): RoutineListItem { + const now = new Date("2026-05-01T00:00:00Z"); + return { + id: "routine-1", + companyId: "company-1", + projectId: "project-1", + goalId: null, + parentIssueId: null, + title: "Workspace routine", + description: null, + assigneeAgentId: "agent-1", + priority: "medium", + status: "active", + concurrencyPolicy: "coalesce_if_active", + catchUpPolicy: "skip_missed", + variables: [ + { name: "workspaceBranch", label: null, type: "text", defaultValue: null, required: true, options: [] }, + ], + latestRevisionId: null, + latestRevisionNumber: 1, + createdByAgentId: null, + createdByUserId: null, + updatedByAgentId: null, + updatedByUserId: null, + lastTriggeredAt: null, + lastEnqueuedAt: null, + createdAt: now, + updatedAt: now, + triggers: [], + lastRun: null, + activeIssue: null, + ...overrides, + }; +} + function pluginSlot(overrides: Record = {}) { return { id: "changes-tab", @@ -201,6 +236,16 @@ async function flush() { await new Promise((resolve) => setTimeout(resolve, 0)); } +async function waitForCondition(condition: () => boolean) { + for (let index = 0; index < 20; index += 1) { + if (condition()) return; + await act(async () => { + await flush(); + }); + } + expect(condition()).toBe(true); +} + describe("ExecutionWorkspaceDetail plugin slots", () => { let root: Root | null = null; let container: HTMLDivElement; @@ -319,4 +364,32 @@ describe("ExecutionWorkspaceDetail plugin slots", () => { "Default", ]); }); + + it("loads all company routines and renders current and other workspace groups", async () => { + mockRouteLocation.pathname = "/execution-workspaces/workspace-1/routines"; + mockRoutinesApi.list.mockResolvedValue([ + routine({ id: "routine-current", title: "Current workspace routine", projectId: "project-1" }), + routine({ id: "routine-other", title: "Other workspace routine", projectId: "project-2" }), + routine({ + id: "routine-general", + title: "General routine", + projectId: "project-1", + variables: [ + { name: "repo", label: null, type: "text", defaultValue: null, required: true, options: [] }, + ], + }), + ]); + + await render(); + + await waitForCondition(() => container.textContent?.includes("This workspace") === true); + + expect(mockRoutinesApi.list).toHaveBeenCalledWith("company-1"); + expect(mockRoutinesApi.list).not.toHaveBeenCalledWith("company-1", { projectId: "project-1" }); + expect(container.textContent).toContain("This workspace"); + expect(container.textContent).toContain("Current workspace routine"); + expect(container.textContent).toContain("Other workspaces"); + expect(container.textContent).toContain("Other workspace routine"); + expect(container.textContent).not.toContain("General routine"); + }); }); diff --git a/ui/src/pages/ExecutionWorkspaceDetail.tsx b/ui/src/pages/ExecutionWorkspaceDetail.tsx index 75141c30940..add151e02cc 100644 --- a/ui/src/pages/ExecutionWorkspaceDetail.tsx +++ b/ui/src/pages/ExecutionWorkspaceDetail.tsx @@ -39,8 +39,7 @@ import { queryKeys } from "../lib/queryKeys"; import { cn, formatDateTime, issueUrl, projectRouteRef, projectWorkspaceUrl } from "../lib/utils"; import { getWorkspaceSpecificRoutineVariableNames, - routineHasWorkspaceSpecificVariables, - sortWorkspaceRoutinesByName, + groupWorkspaceSpecificRoutines, } from "../lib/workspace-routines"; type WorkspaceFormState = { @@ -442,6 +441,37 @@ function WorkspaceRoutineRow({ ); } +function WorkspaceRoutineGroup({ + title, + routines, + runningRoutineId, + onRunNow, +}: { + title: string; + routines: RoutineListItem[]; + runningRoutineId: string | null; + onRunNow: (routine: RoutineListItem) => void; +}) { + if (routines.length === 0) return null; + + return ( +
+

{title}

+
+ {routines.map((routine) => ( + + ))} +
+
+ ); +} + function ExecutionWorkspaceRoutinesList({ workspace, project, @@ -455,8 +485,8 @@ function ExecutionWorkspaceRoutinesList({ const [runningRoutineId, setRunningRoutineId] = useState(null); const { data: routines, isLoading, error } = useQuery({ - queryKey: queryKeys.routines.list(workspace.companyId, { projectId: workspace.projectId }), - queryFn: () => routinesApi.list(workspace.companyId, { projectId: workspace.projectId }), + queryKey: queryKeys.routines.list(workspace.companyId), + queryFn: () => routinesApi.list(workspace.companyId), }); const { data: agents } = useQuery({ @@ -464,10 +494,12 @@ function ExecutionWorkspaceRoutinesList({ queryFn: () => agentsApi.list(workspace.companyId), }); - const workspaceRoutines = useMemo( - () => sortWorkspaceRoutinesByName((routines ?? []).filter(routineHasWorkspaceSpecificVariables)), - [routines], + const workspaceRoutineGroups = useMemo( + () => groupWorkspaceSpecificRoutines(routines ?? [], workspace.projectId), + [routines, workspace.projectId], ); + const hasWorkspaceRoutines = + workspaceRoutineGroups.thisWorkspace.length > 0 || workspaceRoutineGroups.otherWorkspaces.length > 0; const runRoutine = useMutation({ mutationFn: ({ id, data }: { id: string; data?: RoutineRunDialogSubmitData }) => routinesApi.run(id, { @@ -488,7 +520,7 @@ function ExecutionWorkspaceRoutinesList({ onSuccess: async (_, { id }) => { setRunDialogRoutine(null); await Promise.all([ - queryClient.invalidateQueries({ queryKey: ["routines", workspace.companyId] }), + queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(workspace.companyId) }), queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(id) }), queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByExecutionWorkspace(workspace.companyId, workspace.id) }), queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(workspace.companyId) }), @@ -527,7 +559,7 @@ function ExecutionWorkspaceRoutinesList({

{error instanceof Error ? error.message : "Failed to load routines."}

- ) : workspaceRoutines.length === 0 ? ( + ) : !hasWorkspaceRoutines ? (

@@ -535,16 +567,19 @@ function ExecutionWorkspaceRoutinesList({

) : ( -
- {workspaceRoutines.map((routine) => ( - - ))} +
+ +
)}