[codex] Add pipeline automation title templates (#8787)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Pipeline automations let operators standardize repeated issue and workflow actions. > - Pipeline-created issues currently need a way to derive useful titles from routine variables. > - Without a configurable title template, automated pipeline output is harder to scan and distinguish. > - This pull request adds a title-template field through shared contracts, server persistence, API routes, and the pipeline settings UI. > - The benefit is clearer issue titles for pipeline-created work while preserving the existing pipeline behavior when no template is configured. ## Linked Issues or Issue Description Refs #8790 This PR adds configurable generated-issue title templates for pipeline automations. ## What Changed - Added `issueTitleTemplate` to the shared pipeline automation contract and field constants. - Persisted and returned the title template through pipeline service and route code. - Applied title-template rendering when pipeline automations create issue work. - Added pipeline settings UI controls for editing the title template and reusing routine variables. - Moved title-token cursor restoration out of the React state updater and into a layout effect. - Added server and UI coverage for storing, returning, and rendering pipeline title templates. ## Verification - `NODE_ENV=test pnpm run preflight:workspace-links && NODE_ENV=test pnpm exec vitest run server/src/__tests__/pipelines-service.test.ts server/src/__tests__/pipelines-routes.test.ts ui/src/pages/PipelineSettings.test.ts` - Result before review follow-up: 3 files passed, 58 tests passed. - `pnpm --filter @paperclipai/ui exec vitest run src/pages/PipelineSettings.test.ts` - Result after review follow-up: 1 file passed, 7 tests passed. - Branch was merged with current `paperclipai:master` at `f019f54bb3` before opening this PR. - Searched existing PRs for the same head branch and for pipeline title-template duplicates; no matching existing PR was found. - Note: GitHub could not open a PR directly from `cryppadotta/paperclip` because that repository is not a fork of `paperclipai/paperclip`. The same updated branch SHA was pushed to `paperclipai/paperclip` so this PR can compare normally against `master`. ## Risks Low to moderate risk. The change touches pipeline automation persistence and generated issue creation, so regressions would most likely appear as missing or incorrectly rendered generated issue titles. Existing behavior should remain unchanged when `issueTitleTemplate` is unset. ## Model Used OpenAI Codex, GPT-5-based coding agent, tool-enabled execution in a Paperclip heartbeat, with repository inspection, Git, GitHub CLI, and local test execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
f019f54bb3
commit
3e31bf09bc
|
|
@ -334,6 +334,7 @@ export type IssueTreeHoldReleasePolicyStrategy = (typeof ISSUE_TREE_HOLD_RELEASE
|
|||
|
||||
export const ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY = "continuation-summary" as const;
|
||||
export const PIPELINE_CASE_BODY_DOCUMENT_KEY = "pipeline-case-body" as const;
|
||||
export const PIPELINE_AUTOMATION_DEFAULT_TITLE_TEMPLATE = "{{pipeline_name}} / {{stage_name}}: {{case_title}}" as const;
|
||||
export const SYSTEM_ISSUE_DOCUMENT_KEYS = [
|
||||
ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY,
|
||||
PIPELINE_CASE_BODY_DOCUMENT_KEY,
|
||||
|
|
|
|||
|
|
@ -233,6 +233,7 @@ export {
|
|||
WORKSPACE_OVERVIEW_DEFAULT_LIMIT,
|
||||
WORKSPACE_OVERVIEW_MAX_LIMIT,
|
||||
WORKSPACE_OVERVIEW_LINKED_ISSUE_LIMIT,
|
||||
PIPELINE_AUTOMATION_DEFAULT_TITLE_TEMPLATE,
|
||||
PLUGIN_EVENT_TYPES,
|
||||
PLUGIN_BRIDGE_ERROR_CODES,
|
||||
type CompanyStatus,
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ export interface PipelineCaseConversationSource {
|
|||
export interface PipelineStageAutomation {
|
||||
routineId: string;
|
||||
assigneeAgentId: string | null;
|
||||
titleTemplate: string;
|
||||
instructionsBody: string;
|
||||
projectId: string | null;
|
||||
projectWorkspaceId: string | null;
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import { errorHandler } from "../middleware/error-handler.js";
|
|||
import { issueRoutes } from "../routes/issues.js";
|
||||
import { pipelineRoutes } from "../routes/pipelines.js";
|
||||
import {
|
||||
PIPELINE_AUTOMATION_DEFAULT_TITLE_TEMPLATE,
|
||||
PIPELINE_CASE_EVENTS_MAX_LIMIT,
|
||||
PIPELINE_CONTEXT_PACK_EVENT_LIMIT,
|
||||
} from "../services/pipelines.js";
|
||||
|
|
@ -677,6 +678,7 @@ describeEmbeddedPostgres("pipeline routes", () => {
|
|||
const automatedStage = detail.body.stages.find((stage: { key: string }) => stage.key === "in_progress");
|
||||
expect(automatedStage.config.automation).toMatchObject({
|
||||
assigneeAgentId: agent.id,
|
||||
titleTemplate: PIPELINE_AUTOMATION_DEFAULT_TITLE_TEMPLATE,
|
||||
instructionsBody: "Legacy automation body.",
|
||||
projectId: null,
|
||||
projectWorkspaceId: null,
|
||||
|
|
@ -685,6 +687,14 @@ describeEmbeddedPostgres("pipeline routes", () => {
|
|||
executionWorkspaceSettings: null,
|
||||
});
|
||||
|
||||
await db
|
||||
.update(routines)
|
||||
.set({ title: "Custom automation for {{case_key}}" })
|
||||
.where(eq(routines.id, automatedStage.config.automation.routineId));
|
||||
const detailAfterRoutineEdit = await http.get(`/api/pipelines/${pipeline.body.id}`).expect(200);
|
||||
const editedStage = detailAfterRoutineEdit.body.stages.find((stage: { key: string }) => stage.key === "in_progress");
|
||||
expect(editedStage.config.automation.titleTemplate).toBe("Custom automation for {{case_key}}");
|
||||
|
||||
const created = await http
|
||||
.post(`/api/pipelines/${pipeline.body.id}/cases`)
|
||||
.send({ caseKey: "legacy-automation", title: "Legacy automation" })
|
||||
|
|
|
|||
|
|
@ -28,7 +28,11 @@ import {
|
|||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { pipelineService, type PipelineActor } from "../services/pipelines.ts";
|
||||
import {
|
||||
PIPELINE_AUTOMATION_DEFAULT_TITLE_TEMPLATE,
|
||||
pipelineService,
|
||||
type PipelineActor,
|
||||
} from "../services/pipelines.ts";
|
||||
import { routineService } from "../services/routines.ts";
|
||||
import { instanceSettingsService } from "../services/instance-settings.ts";
|
||||
|
||||
|
|
@ -1471,6 +1475,105 @@ describeEmbeddedPostgres("pipelineService", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("defaults, preserves, and interpolates pipeline automation issue title templates", async () => {
|
||||
const { company, pipeline, byKey } = await seedPipeline();
|
||||
const routineSeed = await seedRoutine(company.id, "Automation seed");
|
||||
const stageId = byKey.get("in_progress")!.id;
|
||||
|
||||
const firstSave = await svc.updateStage({
|
||||
companyId: company.id,
|
||||
pipelineId: pipeline.id,
|
||||
stageId,
|
||||
patch: {
|
||||
config: {
|
||||
automation: {
|
||||
assigneeAgentId: routineSeed.assigneeAgentId,
|
||||
instructionsBody: "Draft from {{body}} for {{case_title}}.",
|
||||
},
|
||||
},
|
||||
},
|
||||
actor: userActor,
|
||||
});
|
||||
const firstRoutineId = (firstSave.config as { onEnter?: { routineId?: string } }).onEnter?.routineId;
|
||||
expect(firstRoutineId).toBeTruthy();
|
||||
const [defaultRoutine] = await db.select().from(routines).where(eq(routines.id, firstRoutineId!));
|
||||
expect(defaultRoutine!.title).toBe(PIPELINE_AUTOMATION_DEFAULT_TITLE_TEMPLATE);
|
||||
expect((defaultRoutine!.variables ?? []).map((variable) => variable.name)).toEqual([
|
||||
"pipeline_name",
|
||||
"stage_name",
|
||||
"case_title",
|
||||
"body",
|
||||
]);
|
||||
|
||||
await db
|
||||
.update(routines)
|
||||
.set({ title: "Custom {{case_key}}: {{case_title}}" })
|
||||
.where(eq(routines.id, firstRoutineId!));
|
||||
await svc.updateStage({
|
||||
companyId: company.id,
|
||||
pipelineId: pipeline.id,
|
||||
stageId,
|
||||
patch: {
|
||||
config: {
|
||||
automation: {
|
||||
assigneeAgentId: routineSeed.assigneeAgentId,
|
||||
instructionsBody: "Updated instructions for {{case_title}}.",
|
||||
},
|
||||
},
|
||||
},
|
||||
actor: userActor,
|
||||
});
|
||||
const [customRoutine] = await db.select().from(routines).where(eq(routines.id, firstRoutineId!));
|
||||
expect(customRoutine!.title).toBe("Custom {{case_key}}: {{case_title}}");
|
||||
expect((customRoutine!.variables ?? []).map((variable) => variable.name)).toContain("case_key");
|
||||
|
||||
await db
|
||||
.update(routines)
|
||||
.set({ title: "In progress automation" })
|
||||
.where(eq(routines.id, firstRoutineId!));
|
||||
await svc.updateStage({
|
||||
companyId: company.id,
|
||||
pipelineId: pipeline.id,
|
||||
stageId,
|
||||
patch: {
|
||||
config: {
|
||||
automation: {
|
||||
assigneeAgentId: routineSeed.assigneeAgentId,
|
||||
instructionsBody: "Runtime interpolation for {{case_title}}.",
|
||||
},
|
||||
},
|
||||
},
|
||||
actor: userActor,
|
||||
});
|
||||
const [upgradedRoutine] = await db.select().from(routines).where(eq(routines.id, firstRoutineId!));
|
||||
expect(upgradedRoutine!.title).toBe(PIPELINE_AUTOMATION_DEFAULT_TITLE_TEMPLATE);
|
||||
|
||||
const created = await svc.ingestCase({
|
||||
companyId: company.id,
|
||||
pipelineId: pipeline.id,
|
||||
caseKey: "pulpit-opinion",
|
||||
title: "Pulpit opinion piece",
|
||||
body: "Agentic work should be composed, not rebuilt",
|
||||
actor: userActor,
|
||||
});
|
||||
const moved = await svc.transitionCase({
|
||||
companyId: company.id,
|
||||
caseId: created.case.id,
|
||||
toStageKey: "in_progress",
|
||||
expectedVersion: 1,
|
||||
actor: userActor,
|
||||
});
|
||||
expect(moved.automationExecution.status).toBe("succeeded");
|
||||
const executionIssueId = moved.automationExecution.status === "succeeded"
|
||||
? moved.automationExecution.execution.executionIssueId
|
||||
: null;
|
||||
const [issue] = await db
|
||||
.select({ title: issues.title })
|
||||
.from(issues)
|
||||
.where(eq(issues.id, executionIssueId!));
|
||||
expect(issue!.title).toBe("Content / In progress: Pulpit opinion piece");
|
||||
});
|
||||
|
||||
it("rejects cross-company stage automation routines at save and execution", async () => {
|
||||
const company = await seedCompany();
|
||||
const otherCompany = await seedCompany();
|
||||
|
|
|
|||
|
|
@ -266,6 +266,7 @@ function withDerivedStageAutomation(
|
|||
stage: typeof pipelineStages.$inferSelect,
|
||||
routineById: Map<string, {
|
||||
assigneeAgentId: string | null;
|
||||
title: string;
|
||||
description: string | null;
|
||||
env: PipelineStageAutomation["env"];
|
||||
latestRevisionId: string | null;
|
||||
|
|
@ -285,6 +286,7 @@ function withDerivedStageAutomation(
|
|||
automation: {
|
||||
routineId,
|
||||
assigneeAgentId: routine.assigneeAgentId,
|
||||
titleTemplate: routine.title,
|
||||
instructionsBody: routine.description ?? "",
|
||||
...stageAutomationContext(config),
|
||||
env: routine.env ?? null,
|
||||
|
|
@ -986,6 +988,7 @@ export function pipelineRoutes(db: Db, options: Parameters<typeof pipelineServic
|
|||
.select({
|
||||
id: routines.id,
|
||||
assigneeAgentId: routines.assigneeAgentId,
|
||||
title: routines.title,
|
||||
description: routines.description,
|
||||
env: routines.env,
|
||||
latestRevisionId: routines.latestRevisionId,
|
||||
|
|
@ -998,6 +1001,7 @@ export function pipelineRoutes(db: Db, options: Parameters<typeof pipelineServic
|
|||
row.id,
|
||||
{
|
||||
assigneeAgentId: row.assigneeAgentId,
|
||||
title: row.title,
|
||||
description: row.description,
|
||||
env: row.env,
|
||||
latestRevisionId: row.latestRevisionId,
|
||||
|
|
@ -1076,6 +1080,7 @@ export function pipelineRoutes(db: Db, options: Parameters<typeof pipelineServic
|
|||
.select({
|
||||
id: routines.id,
|
||||
assigneeAgentId: routines.assigneeAgentId,
|
||||
title: routines.title,
|
||||
description: routines.description,
|
||||
env: routines.env,
|
||||
latestRevisionId: routines.latestRevisionId,
|
||||
|
|
@ -1088,6 +1093,7 @@ export function pipelineRoutes(db: Db, options: Parameters<typeof pipelineServic
|
|||
row.id,
|
||||
{
|
||||
assigneeAgentId: row.assigneeAgentId,
|
||||
title: row.title,
|
||||
description: row.description,
|
||||
env: row.env,
|
||||
latestRevisionId: row.latestRevisionId,
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import {
|
|||
type ExecutionWorkspaceMode,
|
||||
type IssueExecutionWorkspaceSettings,
|
||||
type PipelineStageAutomation,
|
||||
PIPELINE_AUTOMATION_DEFAULT_TITLE_TEMPLATE,
|
||||
PIPELINE_CASE_BODY_DOCUMENT_KEY,
|
||||
type RoutineVariable,
|
||||
type RoutineRevisionSnapshotV1,
|
||||
|
|
@ -65,6 +66,11 @@ const PIPELINE_CASE_BODY_DOCUMENT_TITLE = "Item body document";
|
|||
export const PIPELINE_CASE_EVENTS_DEFAULT_LIMIT = 50;
|
||||
export const PIPELINE_CASE_EVENTS_MAX_LIMIT = 100;
|
||||
export const PIPELINE_CONTEXT_PACK_EVENT_LIMIT = 20;
|
||||
export { PIPELINE_AUTOMATION_DEFAULT_TITLE_TEMPLATE };
|
||||
|
||||
function legacyPipelineAutomationTitle(stageName: string) {
|
||||
return `${stageName} automation`;
|
||||
}
|
||||
|
||||
const DEFAULT_STAGES = [
|
||||
{ key: "intake", name: "Intake", kind: "working", position: 100 },
|
||||
|
|
@ -126,6 +132,7 @@ export type PipelineStageConfig = Record<string, unknown> & {
|
|||
automation?: {
|
||||
routineId?: string | null;
|
||||
assigneeAgentId?: string | null;
|
||||
titleTemplate?: string | null;
|
||||
instructionsBody?: string | null;
|
||||
projectId?: string | null;
|
||||
projectWorkspaceId?: string | null;
|
||||
|
|
@ -822,15 +829,38 @@ function readStageAutomationRequest(config?: PipelineStageConfig | null) {
|
|||
const automation = config?.automation;
|
||||
if (!automation || typeof automation !== "object" || Array.isArray(automation)) return null;
|
||||
const assigneeAgentId = readOptionalTrimmedString(automation.assigneeAgentId);
|
||||
const titleTemplate =
|
||||
typeof automation.titleTemplate === "string" && automation.titleTemplate.trim().length > 0
|
||||
? automation.titleTemplate.trim()
|
||||
: null;
|
||||
const instructionsBody =
|
||||
typeof automation.instructionsBody === "string" ? automation.instructionsBody : "";
|
||||
return {
|
||||
assigneeAgentId,
|
||||
titleTemplate,
|
||||
instructionsBody,
|
||||
executionContext: readAutomationExecutionContext(automation),
|
||||
};
|
||||
}
|
||||
|
||||
function resolvePipelineAutomationTitleTemplate(input: {
|
||||
requestedTitleTemplate: string | null;
|
||||
previousRoutine: typeof routines.$inferSelect | null;
|
||||
stageName: string;
|
||||
previousStageName: string;
|
||||
}) {
|
||||
if (input.requestedTitleTemplate) return input.requestedTitleTemplate;
|
||||
const previousTitle = input.previousRoutine?.title;
|
||||
if (
|
||||
previousTitle &&
|
||||
previousTitle !== legacyPipelineAutomationTitle(input.previousStageName) &&
|
||||
previousTitle !== legacyPipelineAutomationTitle(input.stageName)
|
||||
) {
|
||||
return previousTitle;
|
||||
}
|
||||
return PIPELINE_AUTOMATION_DEFAULT_TITLE_TEMPLATE;
|
||||
}
|
||||
|
||||
function persistedStageConfig(config?: PipelineStageConfig | null): PipelineStageConfig {
|
||||
const {
|
||||
automation: _automation,
|
||||
|
|
@ -1103,6 +1133,7 @@ function derivedStageAutomationPayload(
|
|||
return {
|
||||
routineId: routine.id,
|
||||
assigneeAgentId: routine.assigneeAgentId,
|
||||
titleTemplate: routine.title,
|
||||
instructionsBody: routine.description ?? "",
|
||||
...executionContext,
|
||||
env: routine.env ?? null,
|
||||
|
|
@ -2684,14 +2715,16 @@ export function pipelineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeu
|
|||
companyId: string;
|
||||
pipelineId: string;
|
||||
stage: typeof pipelineStages.$inferSelect;
|
||||
previousStageName: string;
|
||||
previousRoutineId: string | null;
|
||||
config: PipelineStageConfig;
|
||||
assigneeAgentId: string | null;
|
||||
titleTemplate: string | null;
|
||||
instructionsBody: string;
|
||||
executionContext: PipelineAutomationExecutionContext;
|
||||
actor: PipelineActor;
|
||||
},
|
||||
): Promise<PipelineStageConfig> {
|
||||
const previousRoutineId = stageAutomationRoutineIdFromConfig(input.config);
|
||||
if (!input.assigneeAgentId) {
|
||||
const { onEnter: _onEnter, ...rest } = input.config;
|
||||
return rest as PipelineStageConfig;
|
||||
|
|
@ -2699,23 +2732,25 @@ export function pipelineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeu
|
|||
|
||||
await assertAssignableAgent(dbOrTx as Db, input.companyId, input.assigneeAgentId, { kind: "routine" });
|
||||
const actorPatch = routineActorPatch(input.actor);
|
||||
const variables = syncRoutineVariablesWithTemplate(
|
||||
[input.stage.name, input.instructionsBody],
|
||||
sanitizePipelineRoutineVariables(input.config.variables),
|
||||
);
|
||||
const title = `${input.stage.name} automation`;
|
||||
const description = input.instructionsBody.trim();
|
||||
|
||||
const previousRoutine = previousRoutineId
|
||||
const previousRoutine = input.previousRoutineId
|
||||
? await dbOrTx
|
||||
.select()
|
||||
.from(routines)
|
||||
.where(and(eq(routines.id, previousRoutineId), eq(routines.companyId, input.companyId)))
|
||||
.where(and(eq(routines.id, input.previousRoutineId), eq(routines.companyId, input.companyId)))
|
||||
.then((rows) => rows[0] ?? null)
|
||||
: null;
|
||||
const canReusePrevious =
|
||||
previousRoutine &&
|
||||
(previousRoutine.originKind === "pipeline_automation" || previousRoutine.originKind === "manual");
|
||||
const title = resolvePipelineAutomationTitleTemplate({
|
||||
requestedTitleTemplate: input.titleTemplate,
|
||||
previousRoutine: canReusePrevious ? previousRoutine : null,
|
||||
stageName: input.stage.name,
|
||||
previousStageName: input.previousStageName,
|
||||
});
|
||||
const configWithVariables = reconcilePipelineStageConfigVariables(input.config, [title, input.instructionsBody]);
|
||||
const variables = sanitizePipelineRoutineVariables(configWithVariables.variables);
|
||||
const description = input.instructionsBody.trim();
|
||||
|
||||
if (canReusePrevious) {
|
||||
const now = nowDate();
|
||||
|
|
@ -2742,7 +2777,7 @@ export function pipelineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeu
|
|||
"Updated pipeline automation",
|
||||
);
|
||||
return {
|
||||
...input.config,
|
||||
...configWithVariables,
|
||||
onEnter: {
|
||||
type: "run_routine" as const,
|
||||
routineId: revised.id,
|
||||
|
|
@ -2781,7 +2816,7 @@ export function pipelineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeu
|
|||
"Created pipeline automation",
|
||||
);
|
||||
return {
|
||||
...input.config,
|
||||
...configWithVariables,
|
||||
onEnter: {
|
||||
type: "run_routine" as const,
|
||||
routineId: revised.id,
|
||||
|
|
@ -3604,7 +3639,10 @@ export function pipelineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeu
|
|||
const stageName = input.patch.name ?? existing.name;
|
||||
let config = normalizeStageConfig(kind, input.patch.config !== undefined ? input.patch.config : stageConfig(existing));
|
||||
if (automationRequest) {
|
||||
config = reconcilePipelineStageConfigVariables(config, [stageName, automationRequest.instructionsBody]);
|
||||
config = reconcilePipelineStageConfigVariables(config, [
|
||||
automationRequest.titleTemplate ?? PIPELINE_AUTOMATION_DEFAULT_TITLE_TEMPLATE,
|
||||
automationRequest.instructionsBody,
|
||||
]);
|
||||
}
|
||||
await validateStageTargets(input.companyId, input.pipelineId, kind, config);
|
||||
await validateStageAutomationConfig(input.companyId, config);
|
||||
|
|
@ -3614,8 +3652,11 @@ export function pipelineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeu
|
|||
companyId: input.companyId,
|
||||
pipelineId: input.pipelineId,
|
||||
stage: { ...existing, name: stageName, kind },
|
||||
previousStageName: existing.name,
|
||||
previousRoutineId,
|
||||
config,
|
||||
assigneeAgentId: automationRequest.assigneeAgentId,
|
||||
titleTemplate: automationRequest.titleTemplate,
|
||||
instructionsBody: automationRequest.instructionsBody,
|
||||
executionContext: automationRequest.executionContext,
|
||||
actor: input.actor ?? { type: "system" },
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ export function RoutineVariablesEditor({
|
|||
<div>
|
||||
<p className="text-sm font-medium">Variables</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Detected from `{"{{name}}"}` placeholders in the routine title and instructions.
|
||||
Detected from `{"{{name}}"}` placeholders in the title and instructions.
|
||||
</p>
|
||||
</div>
|
||||
{open ? <ChevronDown className="h-4 w-4 text-muted-foreground" /> : <ChevronRight className="h-4 w-4 text-muted-foreground" />}
|
||||
|
|
@ -275,7 +275,7 @@ export function RoutineVariablesHint() {
|
|||
<>
|
||||
<div className="flex items-center justify-between gap-2 rounded-lg border border-dashed border-border/70 px-3 py-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
Use `{"{{variable_name}}"}` placeholders in the instructions to prompt for inputs when the routine runs.
|
||||
Use `{"{{variable_name}}"}` placeholders in the title or instructions to prompt for inputs when the routine runs.
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -525,7 +525,10 @@ function IssueDetailLoadingState({
|
|||
<span className="text-sm font-mono text-muted-foreground shrink-0">{identifier}</span>
|
||||
) : null}
|
||||
{headerSeed.originKind === "routine_execution" && headerSeed.originId ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-violet-500/30 bg-violet-500/10 px-2 py-0.5 text-[10px] font-medium text-violet-600 dark:text-violet-400 shrink-0">
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded-full border border-violet-500/30 bg-violet-500/10 px-2 py-0.5 text-[10px] font-medium text-violet-600 dark:text-violet-400 shrink-0"
|
||||
title={`Routine execution from routine ${headerSeed.originId}`}
|
||||
>
|
||||
<Repeat className="h-3 w-3" />
|
||||
Routine
|
||||
</span>
|
||||
|
|
@ -3699,6 +3702,7 @@ export function IssueDetail() {
|
|||
<Link
|
||||
to={`/routines/${issue.originId}`}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-violet-500/10 border border-violet-500/30 px-2 py-0.5 text-[10px] font-medium text-violet-600 dark:text-violet-400 shrink-0 hover:bg-violet-500/20 transition-colors"
|
||||
title={`Routine execution from routine ${issue.originId}`}
|
||||
>
|
||||
<Repeat className="h-3 w-3" />
|
||||
Routine
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { PIPELINE_AUTOMATION_DEFAULT_TITLE_TEMPLATE } from "@paperclipai/shared";
|
||||
import {
|
||||
buildStageAutomationForSave,
|
||||
isPipelineSettingsStageSectionAvailable,
|
||||
pipelineAutomationTitleTemplate,
|
||||
resolvePipelineSettingsFallbackStageId,
|
||||
syncPipelineStageAutomationVariables,
|
||||
} from "./PipelineSettings";
|
||||
|
||||
const stages = [{ id: "first-stage" }, { id: "break-assets" }];
|
||||
|
|
@ -29,3 +33,51 @@ describe("isPipelineSettingsStageSectionAvailable", () => {
|
|||
expect(isPipelineSettingsStageSectionAvailable("working", "history")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pipeline automation issue title templates", () => {
|
||||
it("defaults blank title templates for saved stage automation", () => {
|
||||
expect(pipelineAutomationTitleTemplate("")).toBe(PIPELINE_AUTOMATION_DEFAULT_TITLE_TEMPLATE);
|
||||
expect(pipelineAutomationTitleTemplate(" ")).toBe(PIPELINE_AUTOMATION_DEFAULT_TITLE_TEMPLATE);
|
||||
});
|
||||
|
||||
it("includes a custom issue title template in the stage automation save payload", () => {
|
||||
expect(
|
||||
buildStageAutomationForSave({
|
||||
assigneeAgentId: "agent-1",
|
||||
titleTemplate: "Review {{case_key}} for {{market}}",
|
||||
instructionsBody: "Score {{market}} and move the item forward.",
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: "workspace-1",
|
||||
executionWorkspaceId: "execution-workspace-1",
|
||||
executionWorkspacePreference: "reuse_existing",
|
||||
executionWorkspaceSettings: { mode: "isolated_workspace" },
|
||||
}),
|
||||
).toEqual({
|
||||
assigneeAgentId: "agent-1",
|
||||
titleTemplate: "Review {{case_key}} for {{market}}",
|
||||
instructionsBody: "Score {{market}} and move the item forward.",
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: "workspace-1",
|
||||
executionWorkspaceId: "execution-workspace-1",
|
||||
executionWorkspacePreference: "reuse_existing",
|
||||
executionWorkspaceSettings: { mode: "isolated_workspace" },
|
||||
});
|
||||
});
|
||||
|
||||
it("detects variables from the issue title template and instructions", () => {
|
||||
const variables = syncPipelineStageAutomationVariables(
|
||||
"{{pipeline_name}} / {{stage_name}}: {{case_title}} for {{market}}",
|
||||
"Summarize {{case_summary}} by {{dueDate}}.",
|
||||
[],
|
||||
);
|
||||
|
||||
expect(variables.map((variable) => [variable.name, variable.type])).toEqual([
|
||||
["pipeline_name", "text"],
|
||||
["stage_name", "text"],
|
||||
["case_title", "text"],
|
||||
["market", "text"],
|
||||
["case_summary", "text"],
|
||||
["dueDate", "date"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent, type ReactNode } from "react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type FormEvent, type ReactNode } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
extractRoutineVariableNames,
|
||||
groupWarningsByStage,
|
||||
isBuiltinRoutineVariable,
|
||||
isPipelineTerminalStageKind,
|
||||
PIPELINE_AUTOMATION_DEFAULT_TITLE_TEMPLATE,
|
||||
syncRoutineVariablesWithTemplate,
|
||||
type ExecutionWorkspaceMode,
|
||||
type ExecutionWorkspaceSummary,
|
||||
|
|
@ -126,6 +127,7 @@ type StageConfig = {
|
|||
disabledReason?: string | null;
|
||||
automation?: {
|
||||
assigneeAgentId?: string | null;
|
||||
titleTemplate?: string | null;
|
||||
instructionsBody?: string | null;
|
||||
projectId?: string | null;
|
||||
projectWorkspaceId?: string | null;
|
||||
|
|
@ -337,6 +339,48 @@ function nullableExecutionWorkspaceSettings(value: unknown): IssueExecutionWorks
|
|||
: null;
|
||||
}
|
||||
|
||||
export function pipelineAutomationTitleTemplate(value: unknown): string {
|
||||
return typeof value === "string" && value.trim().length > 0
|
||||
? value.trim()
|
||||
: PIPELINE_AUTOMATION_DEFAULT_TITLE_TEMPLATE;
|
||||
}
|
||||
|
||||
export function syncPipelineStageAutomationVariables(
|
||||
titleTemplate: string,
|
||||
instructionsBody: string,
|
||||
existing: RoutineVariable[],
|
||||
): RoutineVariable[] {
|
||||
const synced = syncRoutineVariablesWithTemplate([titleTemplate, instructionsBody], existing);
|
||||
const syncedNames = new Set(synced.map((variable) => variable.name));
|
||||
return [...synced, ...existing.filter((variable) => !syncedNames.has(variable.name))];
|
||||
}
|
||||
|
||||
export function buildStageAutomationForSave(input: {
|
||||
assigneeAgentId: string;
|
||||
titleTemplate: string;
|
||||
instructionsBody: string;
|
||||
projectId: string;
|
||||
projectWorkspaceId: string;
|
||||
executionWorkspaceId: string;
|
||||
executionWorkspacePreference: ExecutionWorkspaceMode | "";
|
||||
executionWorkspaceSettings: IssueExecutionWorkspaceSettings | null;
|
||||
}) {
|
||||
return {
|
||||
assigneeAgentId: input.assigneeAgentId || null,
|
||||
titleTemplate: pipelineAutomationTitleTemplate(input.titleTemplate),
|
||||
instructionsBody: input.instructionsBody,
|
||||
projectId: input.projectId || null,
|
||||
projectWorkspaceId: input.projectId && input.projectWorkspaceId ? input.projectWorkspaceId : null,
|
||||
executionWorkspaceId:
|
||||
input.projectId && input.executionWorkspacePreference === "reuse_existing" && input.executionWorkspaceId
|
||||
? input.executionWorkspaceId
|
||||
: null,
|
||||
executionWorkspacePreference:
|
||||
input.projectId && input.executionWorkspacePreference ? input.executionWorkspacePreference : null,
|
||||
executionWorkspaceSettings: input.executionWorkspaceSettings,
|
||||
};
|
||||
}
|
||||
|
||||
function executionWorkspaceSettingsForPreference(
|
||||
preference: ExecutionWorkspaceMode | "",
|
||||
reusableWorkspace: Pick<ExecutionWorkspaceSummary, "mode"> | null,
|
||||
|
|
@ -354,6 +398,7 @@ function stageAutomation(stage: PipelineStage | null | undefined) {
|
|||
if (!automation || typeof automation !== "object" || Array.isArray(automation)) {
|
||||
return {
|
||||
assigneeAgentId: "",
|
||||
titleTemplate: PIPELINE_AUTOMATION_DEFAULT_TITLE_TEMPLATE,
|
||||
instructionsBody: null as string | null,
|
||||
projectId: "",
|
||||
projectWorkspaceId: "",
|
||||
|
|
@ -365,6 +410,7 @@ function stageAutomation(stage: PipelineStage | null | undefined) {
|
|||
const executionWorkspaceSettings = nullableExecutionWorkspaceSettings(automation.executionWorkspaceSettings);
|
||||
return {
|
||||
assigneeAgentId: nullableString(automation.assigneeAgentId),
|
||||
titleTemplate: pipelineAutomationTitleTemplate(automation.titleTemplate),
|
||||
instructionsBody: typeof automation.instructionsBody === "string" ? automation.instructionsBody : null,
|
||||
projectId: nullableString(automation.projectId),
|
||||
projectWorkspaceId: nullableString(automation.projectWorkspaceId),
|
||||
|
|
@ -406,11 +452,13 @@ function stageAutomationDetail(stage: PipelineStage | null | undefined) {
|
|||
* body-driven. Placeholder-derived fields are added while existing manual
|
||||
* fields stay in place when instructions change.
|
||||
*/
|
||||
function savedStageVariables(stage: PipelineStage | null | undefined, savedBody: string): RoutineVariable[] {
|
||||
function savedStageVariables(
|
||||
stage: PipelineStage | null | undefined,
|
||||
savedTitleTemplate: string,
|
||||
savedBody: string,
|
||||
): RoutineVariable[] {
|
||||
const existing = toRoutineVariables(stageConfig(stage).variables);
|
||||
const synced = syncRoutineVariablesWithTemplate(["", savedBody], existing);
|
||||
const syncedNames = new Set(synced.map((variable) => variable.name));
|
||||
return [...synced, ...existing.filter((variable) => !syncedNames.has(variable.name))];
|
||||
return syncPipelineStageAutomationVariables(savedTitleTemplate, savedBody, existing);
|
||||
}
|
||||
|
||||
function stripVariableEditorMetadata(variables: RoutineVariable[]): RoutineVariable[] {
|
||||
|
|
@ -693,6 +741,7 @@ type StageFormValues = {
|
|||
automationExecutionWorkspaceId: string;
|
||||
automationExecutionWorkspacePreference: ExecutionWorkspaceMode | "";
|
||||
automationExecutionWorkspaceSettings: IssueExecutionWorkspaceSettings | null;
|
||||
automationTitleTemplate: string;
|
||||
};
|
||||
|
||||
type PipelineTransitionRecord = { fromStageId: string; toStageId: string; label?: string | null };
|
||||
|
|
@ -737,6 +786,7 @@ function computeStageForm(
|
|||
automationExecutionWorkspaceId: automation.executionWorkspaceId,
|
||||
automationExecutionWorkspacePreference: automation.executionWorkspacePreference,
|
||||
automationExecutionWorkspaceSettings: automation.executionWorkspaceSettings,
|
||||
automationTitleTemplate: automation.titleTemplate,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1077,16 +1127,18 @@ function CarriedFieldTokenHelper({
|
|||
function AutomationVariableTokenHelper({
|
||||
groups,
|
||||
onInsert,
|
||||
label = "Available variables",
|
||||
}: {
|
||||
groups: AutomationVariableGroup[];
|
||||
onInsert: (fieldKey: string) => void;
|
||||
label?: string;
|
||||
}) {
|
||||
if (groups.length === 0) return null;
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-muted/20 px-3 py-2">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs font-semibold uppercase text-muted-foreground">
|
||||
Available variables
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
|
|
@ -1243,8 +1295,11 @@ export function PipelineSettings() {
|
|||
const [stageExecutionWorkspaceSettings, setStageExecutionWorkspaceSettings] =
|
||||
useState<IssueExecutionWorkspaceSettings | null>(null);
|
||||
const [selectedApproval, setSelectedApproval] = useState("any_human");
|
||||
const [issueTitleTemplate, setIssueTitleTemplate] = useState<string>(PIPELINE_AUTOMATION_DEFAULT_TITLE_TEMPLATE);
|
||||
const [instructionsBody, setInstructionsBody] = useState("");
|
||||
const [instructionsVariables, setInstructionsVariables] = useState<RoutineVariable[]>([]);
|
||||
const issueTitleTemplateInputRef = useRef<HTMLInputElement>(null);
|
||||
const pendingIssueTitleCursorRef = useRef<number | null>(null);
|
||||
const instructionsEditorRef = useRef<MarkdownEditorRef>(null);
|
||||
// Stage secrets (the automation routine's env). Edited independently of the
|
||||
// rest of the stage form and saved through the narrow automation-env route.
|
||||
|
|
@ -1399,7 +1454,7 @@ export function PipelineSettings() {
|
|||
() => [...new Set([...incomingCarryOverFieldKeys, ...automationVariableKeys])],
|
||||
[automationVariableKeys, incomingCarryOverFieldKeys],
|
||||
);
|
||||
const insertAutomationVariableToken = useCallback((fieldKey: string) => {
|
||||
const insertInstructionsVariableToken = useCallback((fieldKey: string) => {
|
||||
const token = `{{${fieldKey}}}`;
|
||||
if (instructionsEditorRef.current) {
|
||||
instructionsEditorRef.current.insertMarkdown(token);
|
||||
|
|
@ -1407,6 +1462,28 @@ export function PipelineSettings() {
|
|||
}
|
||||
setInstructionsBody((current) => `${current}${current ? " " : ""}${token}`);
|
||||
}, []);
|
||||
const insertIssueTitleVariableToken = useCallback((fieldKey: string) => {
|
||||
const token = `{{${fieldKey}}}`;
|
||||
setIssueTitleTemplate((current) => {
|
||||
const input = issueTitleTemplateInputRef.current;
|
||||
if (!input) return `${current}${current ? " " : ""}${token}`;
|
||||
const start = input.selectionStart ?? current.length;
|
||||
const end = input.selectionEnd ?? start;
|
||||
const next = `${current.slice(0, start)}${token}${current.slice(end)}`;
|
||||
pendingIssueTitleCursorRef.current = start + token.length;
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const cursor = pendingIssueTitleCursorRef.current;
|
||||
if (cursor == null) return;
|
||||
pendingIssueTitleCursorRef.current = null;
|
||||
const input = issueTitleTemplateInputRef.current;
|
||||
if (!input) return;
|
||||
input.focus();
|
||||
input.setSelectionRange(cursor, cursor);
|
||||
}, [issueTitleTemplate]);
|
||||
|
||||
const instructionsKey = selectedStage ? stageInstructionsKey(selectedStage.id) : null;
|
||||
const instructionsQuery = useQuery({
|
||||
|
|
@ -1429,13 +1506,14 @@ export function PipelineSettings() {
|
|||
const savedInstructionsBody = instructionsDocument
|
||||
? stageAutomation(selectedStage).instructionsBody ?? instructionsDocument.revision?.body ?? instructionsDocument.document?.latestBody ?? ""
|
||||
: stageAutomation(selectedStage).instructionsBody ?? stageConfig(selectedStage).whatHappensHere ?? "";
|
||||
const savedIssueTitleTemplate = stageAutomation(selectedStage).titleTemplate;
|
||||
const savedInstructionsVariables = useMemo(
|
||||
() => savedStageVariables(selectedStage, savedInstructionsBody),
|
||||
[selectedStage, savedInstructionsBody],
|
||||
() => savedStageVariables(selectedStage, savedIssueTitleTemplate, savedInstructionsBody),
|
||||
[selectedStage, savedInstructionsBody, savedIssueTitleTemplate],
|
||||
);
|
||||
const savedManualVariableNames = useMemo(
|
||||
() => manualVariableNamesForTemplate(savedInstructionsVariables, [selectedStage?.name ?? "", savedInstructionsBody]),
|
||||
[savedInstructionsBody, savedInstructionsVariables, selectedStage?.name],
|
||||
() => manualVariableNamesForTemplate(savedInstructionsVariables, [savedIssueTitleTemplate, savedInstructionsBody]),
|
||||
[savedInstructionsBody, savedInstructionsVariables, savedIssueTitleTemplate],
|
||||
);
|
||||
|
||||
const mentionOptions = useStandardMarkdownMentionOptions({
|
||||
|
|
@ -1602,6 +1680,7 @@ export function PipelineSettings() {
|
|||
setStageExecutionWorkspacePreference(form.automationExecutionWorkspacePreference);
|
||||
setStageExecutionWorkspaceId(form.automationExecutionWorkspaceId);
|
||||
setStageExecutionWorkspaceSettings(form.automationExecutionWorkspaceSettings);
|
||||
setIssueTitleTemplate(form.automationTitleTemplate);
|
||||
setSelectedApproval(form.approval);
|
||||
setApproveTarget(form.approveTarget);
|
||||
setRejectTarget(form.rejectTarget);
|
||||
|
|
@ -1650,12 +1729,14 @@ export function PipelineSettings() {
|
|||
}
|
||||
}, [activeStageSection, requestedStageSection, selectedStage]);
|
||||
|
||||
// Instructions body + variables hydrate from the per-stage document (or the
|
||||
// legacy field). Resetting on the saved value clears dirty after save/reload.
|
||||
// Instructions body + title + variables hydrate from the backing automation
|
||||
// routine (or legacy fields). Resetting on the saved value clears dirty after
|
||||
// save/reload.
|
||||
useEffect(() => {
|
||||
setIssueTitleTemplate(savedIssueTitleTemplate);
|
||||
setInstructionsBody(savedInstructionsBody);
|
||||
setInstructionsVariables(savedInstructionsVariables);
|
||||
}, [selectedStage?.id, savedInstructionsBody, savedInstructionsVariables]);
|
||||
}, [selectedStage?.id, savedInstructionsBody, savedInstructionsVariables, savedIssueTitleTemplate]);
|
||||
|
||||
// Stage secrets hydrate from the backing routine's derived env. Re-running on
|
||||
// the serialized saved env clears the dirty state after a save/refetch.
|
||||
|
|
@ -1703,19 +1784,16 @@ export function PipelineSettings() {
|
|||
variables: stripVariablesByName(instructionsVariables, resolvedAutomationVariableKeys),
|
||||
disabled: newEntriesDisabled,
|
||||
disabledReason: newEntriesDisabled ? disableReason.trim() || null : null,
|
||||
automation: {
|
||||
assigneeAgentId: stageAssigneeAgentId || null,
|
||||
automation: buildStageAutomationForSave({
|
||||
assigneeAgentId: stageAssigneeAgentId,
|
||||
titleTemplate: issueTitleTemplate,
|
||||
instructionsBody,
|
||||
projectId: stageProjectId || null,
|
||||
projectWorkspaceId: stageProjectId && stageProjectWorkspaceId ? stageProjectWorkspaceId : null,
|
||||
executionWorkspaceId:
|
||||
stageProjectId && stageExecutionWorkspacePreference === "reuse_existing" && stageExecutionWorkspaceId
|
||||
? stageExecutionWorkspaceId
|
||||
: null,
|
||||
executionWorkspacePreference:
|
||||
stageProjectId && stageExecutionWorkspacePreference ? stageExecutionWorkspacePreference : null,
|
||||
projectId: stageProjectId,
|
||||
projectWorkspaceId: stageProjectWorkspaceId,
|
||||
executionWorkspaceId: stageExecutionWorkspaceId,
|
||||
executionWorkspacePreference: stageExecutionWorkspacePreference,
|
||||
executionWorkspaceSettings: currentAutomationExecutionWorkspaceSettings,
|
||||
},
|
||||
}),
|
||||
requireApproval: nextRequiresApproval,
|
||||
approver: nextRequiresApproval && parsedApproval.kind !== "any_human"
|
||||
? { kind: parsedApproval.kind, id: parsedApproval.id }
|
||||
|
|
@ -2113,11 +2191,14 @@ export function PipelineSettings() {
|
|||
stageProjectId && stageExecutionWorkspacePreference === "reuse_existing" ? stageExecutionWorkspaceId : "",
|
||||
automationExecutionWorkspacePreference: stageProjectId ? stageExecutionWorkspacePreference : "",
|
||||
automationExecutionWorkspaceSettings: currentAutomationExecutionWorkspaceSettings,
|
||||
automationTitleTemplate: pipelineAutomationTitleTemplate(issueTitleTemplate),
|
||||
}
|
||||
: null;
|
||||
const selectedStageKindOption =
|
||||
STAGE_KIND_OPTIONS.find((option) => option.value === stageKind) ?? STAGE_KIND_OPTIONS[0]!;
|
||||
const SelectedStageKindIcon = selectedStageKindOption.icon;
|
||||
const issueTitleTemplateDirty =
|
||||
selectedStage != null && pipelineAutomationTitleTemplate(issueTitleTemplate) !== savedIssueTitleTemplate;
|
||||
const instructionsBodyDirty = selectedStage != null && instructionsBody !== savedInstructionsBody;
|
||||
const variablesDirty =
|
||||
selectedStage != null &&
|
||||
|
|
@ -2129,6 +2210,7 @@ export function PipelineSettings() {
|
|||
(savedStageForm != null &&
|
||||
currentStageForm != null &&
|
||||
JSON.stringify(savedStageForm) !== JSON.stringify(currentStageForm)) ||
|
||||
issueTitleTemplateDirty ||
|
||||
instructionsBodyDirty ||
|
||||
variablesDirty;
|
||||
|
||||
|
|
@ -2977,6 +3059,21 @@ export function PipelineSettings() {
|
|||
<AgentIcon icon={selectedAutomationAgent.icon} className="h-4 w-4 shrink-0" />
|
||||
<span>{selectedAutomationAgent.name} runs this step automatically.</span>
|
||||
</div>
|
||||
<FieldRow label="Issue title">
|
||||
<Input
|
||||
ref={issueTitleTemplateInputRef}
|
||||
aria-label="Issue title template"
|
||||
value={issueTitleTemplate}
|
||||
onChange={(event) => setIssueTitleTemplate(event.target.value)}
|
||||
placeholder={PIPELINE_AUTOMATION_DEFAULT_TITLE_TEMPLATE}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</FieldRow>
|
||||
<AutomationVariableTokenHelper
|
||||
groups={automationVariableGroups}
|
||||
onInsert={insertIssueTitleVariableToken}
|
||||
label="Issue title variables"
|
||||
/>
|
||||
{breakdownEnabled ? (
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold text-foreground">What should the agent decide?</h3>
|
||||
|
|
@ -3007,11 +3104,11 @@ export function PipelineSettings() {
|
|||
</div>
|
||||
<AutomationVariableTokenHelper
|
||||
groups={automationVariableGroups}
|
||||
onInsert={insertAutomationVariableToken}
|
||||
onInsert={insertInstructionsVariableToken}
|
||||
/>
|
||||
<CarriedFieldTokenHelper
|
||||
groups={incomingCarryOverFieldGroups}
|
||||
onInsert={insertAutomationVariableToken}
|
||||
onInsert={insertInstructionsVariableToken}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
|
|
@ -3021,11 +3118,10 @@ export function PipelineSettings() {
|
|||
/>
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
<RoutineVariablesHint
|
||||
/>
|
||||
<RoutineVariablesHint />
|
||||
<RoutineVariablesEditor
|
||||
key={selectedStage?.id ?? "stage"}
|
||||
title={stageName}
|
||||
title={issueTitleTemplate}
|
||||
description={instructionsBody}
|
||||
value={instructionsVariables}
|
||||
onChange={setInstructionsVariables}
|
||||
|
|
|
|||
Loading…
Reference in New Issue