mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 02:40: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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue