mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 02:20:38 +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
|
|
@ -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