Add draft routine defaults and run-time overrides

This commit is contained in:
dotta 2026-04-09 10:19:52 -05:00
parent b4a58ba8a6
commit 5d021583be
18 changed files with 592 additions and 113 deletions

View file

@ -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();
}

View file

@ -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();

View file

@ -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&apos;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,

View file

@ -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)}

View file

@ -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) => {