mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
Add draft routine defaults and run-time overrides
This commit is contained in:
parent
b4a58ba8a6
commit
5d021583be
18 changed files with 592 additions and 113 deletions
|
|
@ -23,6 +23,8 @@ interface InlineEntitySelectorProps {
|
|||
renderOption?: (option: InlineEntityOption, isSelected: boolean) => ReactNode;
|
||||
/** Skip the Portal so the popover stays in the DOM tree (fixes scroll inside Dialogs). */
|
||||
disablePortal?: boolean;
|
||||
/** Open the popover when the trigger receives keyboard/programmatic focus. */
|
||||
openOnFocus?: boolean;
|
||||
}
|
||||
|
||||
export const InlineEntitySelector = forwardRef<HTMLButtonElement, InlineEntitySelectorProps>(
|
||||
|
|
@ -40,6 +42,7 @@ export const InlineEntitySelector = forwardRef<HTMLButtonElement, InlineEntitySe
|
|||
renderTriggerValue,
|
||||
renderOption,
|
||||
disablePortal,
|
||||
openOnFocus = true,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
|
|
@ -103,7 +106,7 @@ export const InlineEntitySelector = forwardRef<HTMLButtonElement, InlineEntitySe
|
|||
)}
|
||||
onPointerDown={() => { isPointerDownRef.current = true; }}
|
||||
onFocus={() => {
|
||||
if (!isPointerDownRef.current) setOpen(true);
|
||||
if (openOnFocus && !isPointerDownRef.current) setOpen(true);
|
||||
isPointerDownRef.current = false;
|
||||
}}
|
||||
>
|
||||
|
|
@ -123,7 +126,9 @@ export const InlineEntitySelector = forwardRef<HTMLButtonElement, InlineEntitySe
|
|||
// On touch devices, don't auto-focus the search input to avoid
|
||||
// opening the virtual keyboard which reshapes the viewport and
|
||||
// pushes the popover off-screen.
|
||||
const isTouch = window.matchMedia("(pointer: coarse)").matches;
|
||||
const isTouch = typeof window.matchMedia === "function"
|
||||
? window.matchMedia("(pointer: coarse)").matches
|
||||
: false;
|
||||
if (!isTouch) {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { Project } from "@paperclipai/shared";
|
||||
import type { Agent, Project } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { RoutineRunVariablesDialog } from "./RoutineRunVariablesDialog";
|
||||
|
||||
|
|
@ -85,6 +85,33 @@ function createProject(): Project {
|
|||
};
|
||||
}
|
||||
|
||||
function createAgent(): Agent {
|
||||
return {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Routine Agent",
|
||||
role: "engineer",
|
||||
title: null,
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
capabilities: null,
|
||||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
budgetMonthlyCents: 0,
|
||||
spentMonthlyCents: 0,
|
||||
lastHeartbeatAt: null,
|
||||
icon: "code",
|
||||
metadata: null,
|
||||
createdAt: new Date("2026-04-02T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-02T00:00:00.000Z"),
|
||||
urlKey: "routine-agent",
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
permissions: { canCreateAgents: false },
|
||||
};
|
||||
}
|
||||
|
||||
describe("RoutineRunVariablesDialog", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
|
|
@ -116,7 +143,10 @@ describe("RoutineRunVariablesDialog", () => {
|
|||
open
|
||||
onOpenChange={() => {}}
|
||||
companyId="company-1"
|
||||
project={createProject()}
|
||||
projects={[createProject()]}
|
||||
agents={[createAgent()]}
|
||||
defaultProjectId="project-1"
|
||||
defaultAssigneeAgentId="agent-1"
|
||||
variables={[]}
|
||||
isPending={false}
|
||||
onSubmit={() => {}}
|
||||
|
|
@ -129,6 +159,8 @@ describe("RoutineRunVariablesDialog", () => {
|
|||
|
||||
expect(issueWorkspaceDraftCalls).toBeLessThanOrEqual(2);
|
||||
expect(document.body.textContent).toContain("Run routine");
|
||||
expect(document.body.textContent).not.toContain("Search agents...");
|
||||
expect(document.body.textContent).not.toContain("Search projects...");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { IssueExecutionWorkspaceSettings, Project, RoutineVariable } from "@paperclipai/shared";
|
||||
import type { Agent, IssueExecutionWorkspaceSettings, Project, RoutineVariable } from "@paperclipai/shared";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { IssueWorkspaceCard } from "./IssueWorkspaceCard";
|
||||
import { AgentIcon } from "./AgentIconPicker";
|
||||
import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector";
|
||||
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -28,6 +31,16 @@ function buildInitialValues(variables: RoutineVariable[]) {
|
|||
return Object.fromEntries(variables.map((variable) => [variable.name, variable.defaultValue ?? ""]));
|
||||
}
|
||||
|
||||
function buildInitialRunSelection(input: {
|
||||
defaultAssigneeAgentId?: string | null;
|
||||
defaultProjectId?: string | null;
|
||||
}) {
|
||||
return {
|
||||
assigneeAgentId: input.defaultAssigneeAgentId ?? "",
|
||||
projectId: input.defaultProjectId ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function defaultProjectWorkspaceIdForProject(project: Project | null | undefined) {
|
||||
if (!project) return null;
|
||||
return project.executionWorkspacePolicy?.defaultProjectWorkspaceId
|
||||
|
|
@ -107,6 +120,8 @@ export function routineRunNeedsConfiguration(input: {
|
|||
|
||||
export interface RoutineRunDialogSubmitData {
|
||||
variables?: Record<string, string | number | boolean>;
|
||||
assigneeAgentId?: string | null;
|
||||
projectId?: string | null;
|
||||
executionWorkspaceId?: string | null;
|
||||
executionWorkspacePreference?: string | null;
|
||||
executionWorkspaceSettings?: IssueExecutionWorkspaceSettings | null;
|
||||
|
|
@ -116,7 +131,10 @@ export function RoutineRunVariablesDialog({
|
|||
open,
|
||||
onOpenChange,
|
||||
companyId,
|
||||
project,
|
||||
projects,
|
||||
agents,
|
||||
defaultProjectId,
|
||||
defaultAssigneeAgentId,
|
||||
variables,
|
||||
isPending,
|
||||
onSubmit,
|
||||
|
|
@ -124,13 +142,48 @@ export function RoutineRunVariablesDialog({
|
|||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
companyId: string | null | undefined;
|
||||
project: Project | null | undefined;
|
||||
projects: Project[];
|
||||
agents: Agent[];
|
||||
defaultProjectId?: string | null;
|
||||
defaultAssigneeAgentId?: string | null;
|
||||
variables: RoutineVariable[];
|
||||
isPending: boolean;
|
||||
onSubmit: (data: RoutineRunDialogSubmitData) => void;
|
||||
}) {
|
||||
const [values, setValues] = useState<Record<string, unknown>>({});
|
||||
const [workspaceConfig, setWorkspaceConfig] = useState(() => buildInitialWorkspaceConfig(project));
|
||||
const [selection, setSelection] = useState(() => buildInitialRunSelection({
|
||||
defaultAssigneeAgentId,
|
||||
defaultProjectId,
|
||||
}));
|
||||
const selectedProject = useMemo(
|
||||
() => projects.find((project) => project.id === selection.projectId) ?? null,
|
||||
[projects, selection.projectId],
|
||||
);
|
||||
const recentAssigneeIds = useMemo(() => getRecentAssigneeIds(), [open]);
|
||||
const assigneeOptions = useMemo<InlineEntityOption[]>(
|
||||
() =>
|
||||
sortAgentsByRecency(
|
||||
agents.filter((agent) => agent.status !== "terminated"),
|
||||
recentAssigneeIds,
|
||||
).map((agent) => ({
|
||||
id: agent.id,
|
||||
label: agent.name,
|
||||
searchText: `${agent.name} ${agent.role} ${agent.title ?? ""}`,
|
||||
})),
|
||||
[agents, recentAssigneeIds],
|
||||
);
|
||||
const projectOptions = useMemo<InlineEntityOption[]>(
|
||||
() => projects.map((project) => ({
|
||||
id: project.id,
|
||||
label: project.name,
|
||||
searchText: project.description ?? "",
|
||||
})),
|
||||
[projects],
|
||||
);
|
||||
const currentAssignee = selection.assigneeAgentId
|
||||
? agents.find((agent) => agent.id === selection.assigneeAgentId) ?? null
|
||||
: null;
|
||||
const [workspaceConfig, setWorkspaceConfig] = useState(() => buildInitialWorkspaceConfig(selectedProject));
|
||||
const [workspaceConfigValid, setWorkspaceConfigValid] = useState(true);
|
||||
|
||||
const { data: experimentalSettings } = useQuery({
|
||||
|
|
@ -140,16 +193,18 @@ export function RoutineRunVariablesDialog({
|
|||
});
|
||||
|
||||
const workspaceSelectionEnabled = supportsRoutineRunWorkspaceSelection(
|
||||
project,
|
||||
selectedProject,
|
||||
experimentalSettings?.enableIsolatedWorkspaces === true,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setValues(buildInitialValues(variables));
|
||||
setWorkspaceConfig(buildInitialWorkspaceConfig(project));
|
||||
const nextSelection = buildInitialRunSelection({ defaultAssigneeAgentId, defaultProjectId });
|
||||
setSelection(nextSelection);
|
||||
setWorkspaceConfig(buildInitialWorkspaceConfig(projects.find((project) => project.id === nextSelection.projectId) ?? null));
|
||||
setWorkspaceConfigValid(true);
|
||||
}, [open, project, variables]);
|
||||
}, [defaultAssigneeAgentId, defaultProjectId, open, projects, variables]);
|
||||
|
||||
const missingRequired = useMemo(
|
||||
() =>
|
||||
|
|
@ -162,7 +217,7 @@ export function RoutineRunVariablesDialog({
|
|||
|
||||
const workspaceIssue = useMemo(() => ({
|
||||
companyId: companyId ?? null,
|
||||
projectId: project?.id ?? null,
|
||||
projectId: selectedProject?.id ?? null,
|
||||
projectWorkspaceId: workspaceConfig.projectWorkspaceId,
|
||||
executionWorkspaceId: workspaceConfig.executionWorkspaceId,
|
||||
executionWorkspacePreference: workspaceConfig.executionWorkspacePreference,
|
||||
|
|
@ -170,14 +225,17 @@ export function RoutineRunVariablesDialog({
|
|||
currentExecutionWorkspace: null,
|
||||
}), [
|
||||
companyId,
|
||||
project?.id,
|
||||
selectedProject?.id,
|
||||
workspaceConfig.executionWorkspaceId,
|
||||
workspaceConfig.executionWorkspacePreference,
|
||||
workspaceConfig.executionWorkspaceSettings,
|
||||
workspaceConfig.projectWorkspaceId,
|
||||
]);
|
||||
|
||||
const canSubmit = missingRequired.length === 0 && (!workspaceSelectionEnabled || workspaceConfigValid);
|
||||
const canSubmit =
|
||||
selection.assigneeAgentId.trim().length > 0 &&
|
||||
missingRequired.length === 0 &&
|
||||
(!workspaceSelectionEnabled || workspaceConfigValid);
|
||||
|
||||
const handleWorkspaceUpdate = useCallback((data: Record<string, unknown>) => {
|
||||
setWorkspaceConfig((current) => applyWorkspaceDraft(current, data));
|
||||
|
|
@ -197,11 +255,100 @@ export function RoutineRunVariablesDialog({
|
|||
<DialogHeader>
|
||||
<DialogTitle>Run routine</DialogTitle>
|
||||
<DialogDescription>
|
||||
Fill in the routine variables before starting the execution issue.
|
||||
Choose the agent and optional project for this one run. Routine defaults are prefilled and won't be changed.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Agent *</Label>
|
||||
<InlineEntitySelector
|
||||
value={selection.assigneeAgentId}
|
||||
options={assigneeOptions}
|
||||
placeholder="Agent"
|
||||
noneLabel="Select an agent"
|
||||
searchPlaceholder="Search agents..."
|
||||
emptyMessage="No agents found."
|
||||
disablePortal
|
||||
openOnFocus={false}
|
||||
onChange={(assigneeAgentId) => {
|
||||
if (assigneeAgentId) trackRecentAssignee(assigneeAgentId);
|
||||
setSelection((current) => ({ ...current, assigneeAgentId }));
|
||||
}}
|
||||
renderTriggerValue={(option) =>
|
||||
option ? (
|
||||
currentAssignee ? (
|
||||
<>
|
||||
<AgentIcon icon={currentAssignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{option.label}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="truncate">{option.label}</span>
|
||||
)
|
||||
) : (
|
||||
<span className="text-muted-foreground">Select an agent</span>
|
||||
)
|
||||
}
|
||||
renderOption={(option) => {
|
||||
if (!option.id) return <span className="truncate">{option.label}</span>;
|
||||
const assignee = agents.find((agent) => agent.id === option.id);
|
||||
return (
|
||||
<>
|
||||
{assignee ? <AgentIcon icon={assignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null}
|
||||
<span className="truncate">{option.label}</span>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Project</Label>
|
||||
<InlineEntitySelector
|
||||
value={selection.projectId}
|
||||
options={projectOptions}
|
||||
placeholder="Project"
|
||||
noneLabel="No project"
|
||||
searchPlaceholder="Search projects..."
|
||||
emptyMessage="No projects found."
|
||||
disablePortal
|
||||
openOnFocus={false}
|
||||
onChange={(projectId) => {
|
||||
const project = projects.find((entry) => entry.id === projectId) ?? null;
|
||||
setSelection((current) => ({ ...current, projectId }));
|
||||
setWorkspaceConfig(buildInitialWorkspaceConfig(project));
|
||||
setWorkspaceConfigValid(true);
|
||||
}}
|
||||
renderTriggerValue={(option) =>
|
||||
option && selectedProject ? (
|
||||
<>
|
||||
<span
|
||||
className="h-3.5 w-3.5 shrink-0 rounded-sm"
|
||||
style={{ backgroundColor: selectedProject.color ?? "#64748b" }}
|
||||
/>
|
||||
<span className="truncate">{option.label}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground">No project</span>
|
||||
)
|
||||
}
|
||||
renderOption={(option) => {
|
||||
if (!option.id) return <span className="truncate">{option.label}</span>;
|
||||
const project = projects.find((entry) => entry.id === option.id);
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
className="h-3.5 w-3.5 shrink-0 rounded-sm"
|
||||
style={{ backgroundColor: project?.color ?? "#64748b" }}
|
||||
/>
|
||||
<span className="truncate">{option.label}</span>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{variables.map((variable) => (
|
||||
<div key={variable.name} className="space-y-1.5">
|
||||
<Label className="text-xs">
|
||||
|
|
@ -259,11 +406,11 @@ export function RoutineRunVariablesDialog({
|
|||
</div>
|
||||
))}
|
||||
|
||||
{workspaceSelectionEnabled && project && companyId ? (
|
||||
{workspaceSelectionEnabled && selectedProject && companyId ? (
|
||||
<IssueWorkspaceCard
|
||||
key={`${open ? "open" : "closed"}:${project.id}`}
|
||||
key={`${open ? "open" : "closed"}:${selectedProject.id}`}
|
||||
issue={workspaceIssue}
|
||||
project={project}
|
||||
project={selectedProject}
|
||||
initialEditing
|
||||
livePreview
|
||||
onUpdate={handleWorkspaceUpdate}
|
||||
|
|
@ -273,7 +420,9 @@ export function RoutineRunVariablesDialog({
|
|||
</div>
|
||||
|
||||
<DialogFooter showCloseButton={false}>
|
||||
{missingRequired.length > 0 ? (
|
||||
{!selection.assigneeAgentId ? (
|
||||
<p className="mr-auto text-xs text-amber-600">Default agent required for this run.</p>
|
||||
) : missingRequired.length > 0 ? (
|
||||
<p className="mr-auto text-xs text-amber-600">
|
||||
Missing: {missingRequired.join(", ")}
|
||||
</p>
|
||||
|
|
@ -303,6 +452,8 @@ export function RoutineRunVariablesDialog({
|
|||
}
|
||||
onSubmit({
|
||||
variables: nextVariables,
|
||||
assigneeAgentId: selection.assigneeAgentId,
|
||||
projectId: selection.projectId || null,
|
||||
...(workspaceSelectionEnabled
|
||||
? {
|
||||
executionWorkspaceId: workspaceConfig.executionWorkspaceId,
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ import {
|
|||
} from "lucide-react";
|
||||
import { routinesApi, type RoutineTriggerResponse, type RotateRoutineTriggerResponse } from "../api/routines";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||
import { LiveRunWidget } from "../components/LiveRunWidget";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { projectsApi } from "../api/projects";
|
||||
|
|
@ -35,7 +34,6 @@ import { InlineEntitySelector, type InlineEntityOption } from "../components/Inl
|
|||
import { MarkdownEditor, type MarkdownEditorRef } from "../components/MarkdownEditor";
|
||||
import {
|
||||
RoutineRunVariablesDialog,
|
||||
routineRunNeedsConfiguration,
|
||||
type RoutineRunDialogSubmitData,
|
||||
} from "../components/RoutineRunVariablesDialog";
|
||||
import { RoutineVariablesEditor, RoutineVariablesHint } from "../components/RoutineVariablesEditor";
|
||||
|
|
@ -123,6 +121,24 @@ function getLocalTimezone(): string {
|
|||
}
|
||||
}
|
||||
|
||||
function buildRoutineMutationPayload(input: {
|
||||
title: string;
|
||||
description: string;
|
||||
projectId: string;
|
||||
assigneeAgentId: string;
|
||||
priority: string;
|
||||
concurrencyPolicy: string;
|
||||
catchUpPolicy: string;
|
||||
variables: RoutineVariable[];
|
||||
}) {
|
||||
return {
|
||||
...input,
|
||||
description: input.description.trim() || null,
|
||||
projectId: input.projectId || null,
|
||||
assigneeAgentId: input.assigneeAgentId || null,
|
||||
};
|
||||
}
|
||||
|
||||
function TriggerEditor({
|
||||
trigger,
|
||||
onSave,
|
||||
|
|
@ -333,11 +349,6 @@ export function RoutineDetail() {
|
|||
queryFn: () => projectsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const { data: experimentalSettings } = useQuery({
|
||||
queryKey: queryKeys.instance.experimentalSettings,
|
||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const routineDefaults = useMemo(
|
||||
() =>
|
||||
|
|
@ -345,8 +356,8 @@ export function RoutineDetail() {
|
|||
? {
|
||||
title: routine.title,
|
||||
description: routine.description ?? "",
|
||||
projectId: routine.projectId,
|
||||
assigneeAgentId: routine.assigneeAgentId,
|
||||
projectId: routine.projectId ?? "",
|
||||
assigneeAgentId: routine.assigneeAgentId ?? "",
|
||||
priority: routine.priority,
|
||||
concurrencyPolicy: routine.concurrencyPolicy,
|
||||
catchUpPolicy: routine.catchUpPolicy,
|
||||
|
|
@ -418,10 +429,7 @@ export function RoutineDetail() {
|
|||
|
||||
const saveRoutine = useMutation({
|
||||
mutationFn: () => {
|
||||
return routinesApi.update(routineId!, {
|
||||
...editDraft,
|
||||
description: editDraft.description.trim() || null,
|
||||
});
|
||||
return routinesApi.update(routineId!, buildRoutineMutationPayload(editDraft));
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
|
|
@ -443,6 +451,8 @@ export function RoutineDetail() {
|
|||
mutationFn: (data?: RoutineRunDialogSubmitData) =>
|
||||
routinesApi.run(routineId!, {
|
||||
...(data?.variables && Object.keys(data.variables).length > 0 ? { variables: data.variables } : {}),
|
||||
...(data?.assigneeAgentId !== undefined ? { assigneeAgentId: data.assigneeAgentId } : {}),
|
||||
...(data?.projectId !== undefined ? { projectId: data.projectId } : {}),
|
||||
...(data?.executionWorkspaceId !== undefined ? { executionWorkspaceId: data.executionWorkspaceId } : {}),
|
||||
...(data?.executionWorkspacePreference !== undefined
|
||||
? { executionWorkspacePreference: data.executionWorkspacePreference }
|
||||
|
|
@ -657,14 +667,15 @@ export function RoutineDetail() {
|
|||
}
|
||||
|
||||
const automationEnabled = routine.status === "active";
|
||||
const selectedProject = projects?.find((project) => project.id === routine.projectId) ?? null;
|
||||
const needsRunConfiguration = routineRunNeedsConfiguration({
|
||||
variables: routine.variables ?? [],
|
||||
project: selectedProject,
|
||||
isolatedWorkspacesEnabled: experimentalSettings?.enableIsolatedWorkspaces === true,
|
||||
});
|
||||
const selectedProject = routine.projectId ? (projects?.find((project) => project.id === routine.projectId) ?? null) : null;
|
||||
const automationToggleDisabled = updateRoutineStatus.isPending || routine.status === "archived";
|
||||
const automationLabel = routine.status === "archived" ? "Archived" : automationEnabled ? "Active" : "Paused";
|
||||
const automationLabel = routine.status === "archived"
|
||||
? "Archived"
|
||||
: !routine.assigneeAgentId
|
||||
? "Draft"
|
||||
: automationEnabled
|
||||
? "Active"
|
||||
: "Paused";
|
||||
const automationLabelClassName = routine.status === "archived"
|
||||
? "text-muted-foreground"
|
||||
: automationEnabled
|
||||
|
|
@ -708,18 +719,24 @@ export function RoutineDetail() {
|
|||
<div className="flex shrink-0 items-center gap-3 pt-1">
|
||||
<RunButton
|
||||
onClick={() => {
|
||||
if (needsRunConfiguration) {
|
||||
setRunVariablesOpen(true);
|
||||
return;
|
||||
}
|
||||
runRoutine.mutate({});
|
||||
setRunVariablesOpen(true);
|
||||
}}
|
||||
disabled={runRoutine.isPending}
|
||||
/>
|
||||
<ToggleSwitch
|
||||
size="lg"
|
||||
checked={automationEnabled}
|
||||
onCheckedChange={() => updateRoutineStatus.mutate(automationEnabled ? "paused" : "active")}
|
||||
onCheckedChange={() => {
|
||||
if (!automationEnabled && !routine.assigneeAgentId) {
|
||||
pushToast({
|
||||
title: "Default agent required",
|
||||
body: "Set a default agent before enabling routine automation.",
|
||||
tone: "warn",
|
||||
});
|
||||
return;
|
||||
}
|
||||
updateRoutineStatus.mutate(automationEnabled ? "paused" : "active");
|
||||
}}
|
||||
disabled={automationToggleDisabled}
|
||||
aria-label={automationEnabled ? "Pause automatic triggers" : "Enable automatic triggers"}
|
||||
/>
|
||||
|
|
@ -755,6 +772,12 @@ export function RoutineDetail() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{!routine.assigneeAgentId ? (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-4 text-sm text-amber-900 dark:text-amber-200">
|
||||
Default agent required. This routine can stay as a draft and still run manually, but automation stays paused until you assign a default agent.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Assignment row */}
|
||||
<div className="overflow-x-auto overscroll-x-contain">
|
||||
<div className="inline-flex min-w-full flex-wrap items-center gap-2 text-sm text-muted-foreground sm:min-w-max sm:flex-nowrap">
|
||||
|
|
@ -853,7 +876,7 @@ export function RoutineDetail() {
|
|||
bordered={false}
|
||||
contentClassName="min-h-[120px] text-[15px] leading-7"
|
||||
onSubmit={() => {
|
||||
if (!saveRoutine.isPending && editDraft.title.trim() && editDraft.projectId && editDraft.assigneeAgentId) {
|
||||
if (!saveRoutine.isPending && editDraft.title.trim()) {
|
||||
saveRoutine.mutate();
|
||||
}
|
||||
}}
|
||||
|
|
@ -921,7 +944,7 @@ export function RoutineDetail() {
|
|||
)}
|
||||
<Button
|
||||
onClick={() => saveRoutine.mutate()}
|
||||
disabled={saveRoutine.isPending || !editDraft.title.trim() || !editDraft.projectId || !editDraft.assigneeAgentId}
|
||||
disabled={saveRoutine.isPending || !editDraft.title.trim()}
|
||||
>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
Save routine
|
||||
|
|
@ -1091,7 +1114,10 @@ export function RoutineDetail() {
|
|||
open={runVariablesOpen}
|
||||
onOpenChange={setRunVariablesOpen}
|
||||
companyId={routine.companyId}
|
||||
project={selectedProject}
|
||||
agents={agents ?? []}
|
||||
projects={projects ?? []}
|
||||
defaultProjectId={routine.projectId}
|
||||
defaultAssigneeAgentId={routine.assigneeAgentId}
|
||||
variables={routine.variables ?? []}
|
||||
isPending={runRoutine.isPending}
|
||||
onSubmit={(data) => runRoutine.mutate(data)}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||
import { useNavigate, useSearchParams } from "@/lib/router";
|
||||
import { Check, ChevronDown, ChevronRight, Layers, MoreHorizontal, Plus, Repeat } from "lucide-react";
|
||||
import { routinesApi } from "../api/routines";
|
||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { issuesApi } from "../api/issues";
|
||||
|
|
@ -25,7 +24,6 @@ import { InlineEntitySelector, type InlineEntityOption } from "../components/Inl
|
|||
import { MarkdownEditor, type MarkdownEditorRef } from "../components/MarkdownEditor";
|
||||
import {
|
||||
RoutineRunVariablesDialog,
|
||||
routineRunNeedsConfiguration,
|
||||
type RoutineRunDialogSubmitData,
|
||||
} from "../components/RoutineRunVariablesDialog";
|
||||
import { RoutineVariablesEditor, RoutineVariablesHint } from "../components/RoutineVariablesEditor";
|
||||
|
|
@ -117,6 +115,24 @@ function formatRoutineRunStatus(value: string | null | undefined) {
|
|||
return value.replaceAll("_", " ");
|
||||
}
|
||||
|
||||
function buildRoutineMutationPayload(input: {
|
||||
title: string;
|
||||
description: string;
|
||||
projectId: string;
|
||||
assigneeAgentId: string;
|
||||
priority: string;
|
||||
concurrencyPolicy: string;
|
||||
catchUpPolicy: string;
|
||||
variables: RoutineVariable[];
|
||||
}) {
|
||||
return {
|
||||
...input,
|
||||
description: input.description.trim() || null,
|
||||
projectId: input.projectId || null,
|
||||
assigneeAgentId: input.assigneeAgentId || null,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRoutineGroups(
|
||||
routines: RoutineListItem[],
|
||||
groupByValue: RoutineGroupBy,
|
||||
|
|
@ -186,6 +202,7 @@ function RoutineListRow({
|
|||
const isStatusPending = statusMutationRoutineId === routine.id;
|
||||
const project = routine.projectId ? projectById.get(routine.projectId) ?? null : null;
|
||||
const agent = routine.assigneeAgentId ? agentById.get(routine.assigneeAgentId) ?? null : null;
|
||||
const isDraft = !isArchived && !routine.assigneeAgentId;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -195,9 +212,9 @@ function RoutineListRow({
|
|||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">{routine.title}</span>
|
||||
{(isArchived || routine.status === "paused") ? (
|
||||
{(isArchived || routine.status === "paused" || isDraft) ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{isArchived ? "archived" : "paused"}
|
||||
{isArchived ? "archived" : isDraft ? "draft" : "paused"}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -207,11 +224,11 @@ function RoutineListRow({
|
|||
className="h-2.5 w-2.5 shrink-0 rounded-sm"
|
||||
style={{ backgroundColor: project?.color ?? "#64748b" }}
|
||||
/>
|
||||
<span>{project?.name ?? "Unknown project"}</span>
|
||||
<span>{routine.projectId ? (project?.name ?? "Unknown project") : "No project"}</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
{agent?.icon ? <AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0" /> : null}
|
||||
<span>{agent?.name ?? "Unknown agent"}</span>
|
||||
<span>{routine.assigneeAgentId ? (agent?.name ?? "Unknown agent") : "No default agent"}</span>
|
||||
</span>
|
||||
<span>
|
||||
{formatLastRunTimestamp(routine.lastRun?.triggeredAt)}
|
||||
|
|
@ -230,7 +247,7 @@ function RoutineListRow({
|
|||
aria-label={enabled ? `Disable ${routine.title}` : `Enable ${routine.title}`}
|
||||
/>
|
||||
<span className="w-12 text-xs text-muted-foreground">
|
||||
{isArchived ? "Archived" : enabled ? "On" : "Off"}
|
||||
{isArchived ? "Archived" : isDraft ? "Draft" : enabled ? "On" : "Off"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
|
@ -334,11 +351,6 @@ export function Routines() {
|
|||
queryFn: () => projectsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const { data: experimentalSettings } = useQuery({
|
||||
queryKey: queryKeys.instance.experimentalSettings,
|
||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||
retry: false,
|
||||
});
|
||||
const { data: routineExecutionIssues, isLoading: recentRunsLoading, error: recentRunsError } = useQuery({
|
||||
queryKey: [...queryKeys.issues.list(selectedCompanyId!), "routine-executions"],
|
||||
queryFn: () => issuesApi.list(selectedCompanyId!, { originKind: "routine_execution" }),
|
||||
|
|
@ -357,10 +369,7 @@ export function Routines() {
|
|||
|
||||
const createRoutine = useMutation({
|
||||
mutationFn: () =>
|
||||
routinesApi.create(selectedCompanyId!, {
|
||||
...draft,
|
||||
description: draft.description.trim() || null,
|
||||
}),
|
||||
routinesApi.create(selectedCompanyId!, buildRoutineMutationPayload(draft)),
|
||||
onSuccess: async (routine) => {
|
||||
setDraft({
|
||||
title: "",
|
||||
|
|
@ -377,7 +386,9 @@ export function Routines() {
|
|||
await queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) });
|
||||
pushToast({
|
||||
title: "Routine created",
|
||||
body: "Add the first trigger to turn it into a live workflow.",
|
||||
body: routine.assigneeAgentId
|
||||
? "Add the first trigger to turn it into a live workflow."
|
||||
: "Draft saved. Add a default agent before enabling automation.",
|
||||
tone: "success",
|
||||
});
|
||||
navigate(`/routines/${routine.id}?tab=triggers`);
|
||||
|
|
@ -417,6 +428,8 @@ export function Routines() {
|
|||
const runRoutine = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data?: RoutineRunDialogSubmitData }) => routinesApi.run(id, {
|
||||
...(data?.variables && Object.keys(data.variables).length > 0 ? { variables: data.variables } : {}),
|
||||
...(data?.assigneeAgentId !== undefined ? { assigneeAgentId: data.assigneeAgentId } : {}),
|
||||
...(data?.projectId !== undefined ? { projectId: data.projectId } : {}),
|
||||
...(data?.executionWorkspaceId !== undefined ? { executionWorkspaceId: data.executionWorkspaceId } : {}),
|
||||
...(data?.executionWorkspacePreference !== undefined
|
||||
? { executionWorkspacePreference: data.executionWorkspacePreference }
|
||||
|
|
@ -497,7 +510,6 @@ export function Routines() {
|
|||
),
|
||||
[],
|
||||
);
|
||||
const runDialogProject = runDialogRoutine?.projectId ? projectById.get(runDialogRoutine.projectId) ?? null : null;
|
||||
const currentAssignee = draft.assigneeAgentId ? agentById.get(draft.assigneeAgentId) ?? null : null;
|
||||
const currentProject = draft.projectId ? projectById.get(draft.projectId) ?? null : null;
|
||||
|
||||
|
|
@ -517,20 +529,18 @@ export function Routines() {
|
|||
}
|
||||
|
||||
function handleRunNow(routine: RoutineListItem) {
|
||||
const project = routine.projectId ? projectById.get(routine.projectId) ?? null : null;
|
||||
const needsConfiguration = routineRunNeedsConfiguration({
|
||||
variables: routine.variables ?? [],
|
||||
project,
|
||||
isolatedWorkspacesEnabled: experimentalSettings?.enableIsolatedWorkspaces === true,
|
||||
});
|
||||
if (needsConfiguration) {
|
||||
setRunDialogRoutine(routine);
|
||||
return;
|
||||
}
|
||||
runRoutine.mutate({ id: routine.id, data: {} });
|
||||
setRunDialogRoutine(routine);
|
||||
}
|
||||
|
||||
function handleToggleEnabled(routine: RoutineListItem, enabled: boolean) {
|
||||
if (!enabled && !routine.assigneeAgentId) {
|
||||
pushToast({
|
||||
title: "Default agent required",
|
||||
body: "Set a default agent before enabling routine automation.",
|
||||
tone: "warn",
|
||||
});
|
||||
return;
|
||||
}
|
||||
updateRoutineStatus.mutate({
|
||||
id: routine.id,
|
||||
status: nextRoutineStatus(routine.status, !enabled),
|
||||
|
|
@ -648,7 +658,7 @@ export function Routines() {
|
|||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground">New routine</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Define the recurring work first. Trigger setup comes next on the detail page.
|
||||
Define the recurring work first. Default project and agent are optional for draft routines.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
|
|
@ -798,7 +808,7 @@ export function Routines() {
|
|||
bordered={false}
|
||||
contentClassName="min-h-[160px] text-sm text-muted-foreground"
|
||||
onSubmit={() => {
|
||||
if (!createRoutine.isPending && draft.title.trim() && draft.projectId && draft.assigneeAgentId) {
|
||||
if (!createRoutine.isPending && draft.title.trim()) {
|
||||
createRoutine.mutate();
|
||||
}
|
||||
}}
|
||||
|
|
@ -867,16 +877,14 @@ export function Routines() {
|
|||
|
||||
<div className="shrink-0 flex flex-col gap-3 border-t border-border/60 px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
After creation, Paperclip takes you straight to trigger setup for schedules, webhooks, or internal runs.
|
||||
After creation, Paperclip takes you straight to trigger setup. Draft routines stay paused until you add a default agent.
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:items-end">
|
||||
<Button
|
||||
onClick={() => createRoutine.mutate()}
|
||||
disabled={
|
||||
createRoutine.isPending ||
|
||||
!draft.title.trim() ||
|
||||
!draft.projectId ||
|
||||
!draft.assigneeAgentId
|
||||
!draft.title.trim()
|
||||
}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
|
|
@ -965,7 +973,10 @@ export function Routines() {
|
|||
if (!next) setRunDialogRoutine(null);
|
||||
}}
|
||||
companyId={selectedCompanyId}
|
||||
project={runDialogProject}
|
||||
agents={agents ?? []}
|
||||
projects={projects ?? []}
|
||||
defaultProjectId={runDialogRoutine?.projectId ?? null}
|
||||
defaultAssigneeAgentId={runDialogRoutine?.assigneeAgentId ?? null}
|
||||
variables={runDialogRoutine?.variables ?? []}
|
||||
isPending={runRoutine.isPending}
|
||||
onSubmit={(data) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue