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,