Start with a schedule or a product event, then tell ADE whether it should run a built-in task, send a prompt to an automation chat thread, or launch a mission.
diff --git a/apps/desktop/src/renderer/components/automations/components/RuleEditorPanel.tsx b/apps/desktop/src/renderer/components/automations/components/RuleEditorPanel.tsx
index bc94c02d5..0466fcd2a 100644
--- a/apps/desktop/src/renderer/components/automations/components/RuleEditorPanel.tsx
+++ b/apps/desktop/src/renderer/components/automations/components/RuleEditorPanel.tsx
@@ -1,16 +1,21 @@
-import { useCallback, useMemo, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
CaretDown,
CaretRight,
FloppyDisk,
Flask,
+ GitBranch,
+ Sparkle,
+ Warning,
} from "@phosphor-icons/react";
import { getDefaultModelDescriptor } from "../../../../shared/modelRegistry";
import type {
AutomationAction,
AutomationDraftConfirmationRequirement,
AutomationDraftIssue,
+ AutomationLaneMode,
+ AutomationLaneNamePreset,
AutomationRuleDraft,
AutomationTrigger,
TestSuiteDefinition,
@@ -20,7 +25,7 @@ import { Button } from "../../ui/Button";
import { Chip } from "../../ui/Chip";
import { cn } from "../../ui/cn";
import { permissionControlsForModel, patchPermissionConfig } from "../permissionControls";
-import { CARD_STYLE, INPUT_CLS, INPUT_STYLE } from "../shared";
+import { cardCls, inputCls, labelCls, selectCls, textareaCls } from "../designTokens";
import { GitHubTriggerFilters } from "../GitHubTriggerFilters";
import { LinearTriggerFilters } from "../LinearTriggerFilters";
import { ActionList } from "../ActionList";
@@ -90,6 +95,93 @@ const SCHEDULE_PRESETS: Array<{ label: string; cron: string }> = [
{ label: "Fridays at 4 PM", cron: "0 16 * * 5" },
];
+const LANE_NAME_PRESETS: Array<{
+ value: AutomationLaneNamePreset;
+ label: string;
+ template: string;
+ helpEvent: "issue" | "pr" | "any";
+}> = [
+ { value: "issue-title", label: "Use issue title", template: "{{trigger.issue.title}}", helpEvent: "issue" },
+ { value: "issue-num-title", label: "Issue #N – Title", template: "#{{trigger.issue.number}} – {{trigger.issue.title}}", helpEvent: "issue" },
+ { value: "pr-title-author", label: "PR title – Author", template: "{{trigger.pr.title}} – {{trigger.pr.author}}", helpEvent: "pr" },
+ { value: "custom", label: "Custom template…", template: "", helpEvent: "any" },
+];
+
+function presetTemplate(preset: AutomationLaneNamePreset, customTemplate: string | undefined): string {
+ if (preset === "custom") return customTemplate ?? "";
+ return LANE_NAME_PRESETS.find((p) => p.value === preset)?.template ?? "";
+}
+
+function triggerSampleContext(trigger: AutomationTrigger): {
+ issue?: { number: number; title: string; author: string; url: string; body: string };
+ pr?: { number: number; title: string; author: string; url: string };
+} {
+ const t = trigger.type;
+ if (t.startsWith("github.issue") || t.startsWith("linear.issue")) {
+ return {
+ issue: {
+ number: 427,
+ title: "Fix login bug on Safari",
+ author: "octocat",
+ url: "https://github.com/example/repo/issues/427",
+ body: "Repro: open site in Safari 17, sign in...",
+ },
+ };
+ }
+ if (t.startsWith("github.pr")) {
+ return {
+ pr: {
+ number: 314,
+ title: "Add caching to image pipeline",
+ author: "octocat",
+ url: "https://github.com/example/repo/pull/314",
+ },
+ };
+ }
+ return {};
+}
+
+// Editor-only resolver. Real `{{trigger.*}}` resolution happens server-side via
+// `resolvePlaceholders` — this is just a live preview so the user sees what
+// their template will look like.
+function previewResolve(
+ template: string,
+ sample: Record
,
+): { resolved: string; missing: string[] } {
+ const missing: string[] = [];
+ const resolved = template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, path: string) => {
+ const segments = path.split(".");
+ if (segments[0] !== "trigger") {
+ missing.push(path);
+ return ``;
+ }
+ let cursor: unknown = sample;
+ for (let i = 1; i < segments.length; i++) {
+ if (cursor && typeof cursor === "object" && segments[i]! in (cursor as Record)) {
+ cursor = (cursor as Record)[segments[i]!];
+ } else {
+ missing.push(path);
+ return ``;
+ }
+ }
+ return String(cursor ?? "");
+ });
+ return { resolved, missing };
+}
+
+function smartDefaultsForTrigger(type: AutomationTrigger["type"]): {
+ laneMode: AutomationLaneMode;
+ preset: AutomationLaneNamePreset | undefined;
+} {
+ if (type === "github.issue_opened" || type === "linear.issue_created") {
+ return { laneMode: "create", preset: "issue-title" };
+ }
+ if (type === "github.pr_opened") {
+ return { laneMode: "create", preset: "pr-title-author" };
+ }
+ return { laneMode: "reuse", preset: undefined };
+}
+
function triggerFamilyForType(type: AutomationTrigger["type"]): TriggerFamily {
if (type === "schedule") return "schedule";
if (type.startsWith("github.")) return "github";
@@ -183,7 +275,6 @@ function draftToActionRows(draft: AutomationRuleDraft): ActionRowValue[] {
kind: "agent-session",
prompt: action.prompt ?? "",
sessionTitle: action.sessionTitle ?? "",
- targetLaneId: action.targetLaneId ?? null,
modelConfig: action.modelConfig,
permissionConfig: action.permissionConfig,
});
@@ -196,18 +287,16 @@ function draftToActionRows(draft: AutomationRuleDraft): ActionRowValue[] {
}
function applyActionRowsToDraft(draft: AutomationRuleDraft, rows: ActionRowValue[]): AutomationRuleDraft {
- // If a single agent-session or launch-mission row is present alone, fold into execution.
const soloAgent = rows.length === 1 && rows[0]!.kind === "agent-session";
const soloMission = rows.length === 1 && rows[0]!.kind === "launch-mission";
if (soloAgent) {
const first = rows[0]!;
- const targetLaneId = first.targetLaneId ?? draft.execution?.targetLaneId ?? null;
return {
...draft,
execution: {
+ ...(draft.execution ?? { kind: "agent-session" }),
kind: "agent-session",
- ...(targetLaneId ? { targetLaneId } : {}),
session: { title: first.sessionTitle || null },
},
...(first.modelConfig ? { modelConfig: { orchestratorModel: first.modelConfig } } : {}),
@@ -223,8 +312,8 @@ function applyActionRowsToDraft(draft: AutomationRuleDraft, rows: ActionRowValue
return {
...draft,
execution: {
+ ...(draft.execution ?? { kind: "mission" }),
kind: "mission",
- ...(draft.execution?.targetLaneId ? { targetLaneId: draft.execution.targetLaneId } : {}),
mission: { title: first.missionTitle || null },
},
actions: [],
@@ -232,9 +321,6 @@ function applyActionRowsToDraft(draft: AutomationRuleDraft, rows: ActionRowValue
};
}
- // Otherwise treat the whole list as a built-in action pipeline (the ordered
- // list surface). Agent-session / mission rows collapse to the first non-built-in
- // entry being promoted to `execution`; the remaining rows store under `built-in`.
const builtInActions: AutomationAction[] = rows.map((row) => rowToAutomationAction(row));
const legacyDraftActions: AutomationRuleDraft["actions"] = builtInActions
.map((action) => automationActionToDraftAction(action))
@@ -243,8 +329,8 @@ function applyActionRowsToDraft(draft: AutomationRuleDraft, rows: ActionRowValue
return {
...draft,
execution: {
+ ...(draft.execution ?? { kind: "built-in" }),
kind: "built-in",
- ...(draft.execution?.targetLaneId ? { targetLaneId: draft.execution.targetLaneId } : {}),
builtIn: { actions: builtInActions },
},
prompt: "",
@@ -280,7 +366,6 @@ function rowToAutomationAction(row: ActionRowValue): AutomationAction {
case "agent-session":
return {
type: "agent-session",
- ...(row.targetLaneId ? { targetLaneId: row.targetLaneId } : {}),
...(row.modelConfig ? { modelConfig: row.modelConfig } : {}),
...(row.permissionConfig ? { permissionConfig: row.permissionConfig } : {}),
...(row.prompt ? { prompt: row.prompt } : {}),
@@ -323,7 +408,6 @@ function automationActionToDraftAction(
case "agent-session":
return {
type: "agent-session",
- ...(action.targetLaneId ? { targetLaneId: action.targetLaneId } : {}),
...(action.modelConfig ? { modelConfig: action.modelConfig } : {}),
...(action.permissionConfig ? { permissionConfig: action.permissionConfig } : {}),
...(action.prompt ? { prompt: action.prompt } : {}),
@@ -334,6 +418,10 @@ function automationActionToDraftAction(
type: "launch-mission",
...(action.sessionTitle ? { missionTitle: action.sessionTitle } : {}),
};
+ case "lane-setup":
+ // Synthetic action emitted by the runtime when execution.laneMode is
+ // "create"; never authored by the user, so it has no draft form.
+ return null;
}
}
@@ -384,6 +472,16 @@ export function RuleEditorPanel({
? draft.permissionConfig?.providers?.[permissionMeta.key] ?? ""
: "";
+ // laneMode resolution: missing → "reuse" (server-side migration handles
+ // legacy create-lane-as-first-action collapse).
+ const laneMode: AutomationLaneMode = draft.execution?.laneMode ?? "reuse";
+ const lanePreset: AutomationLaneNamePreset = draft.execution?.laneNamePreset ?? "issue-title";
+ const laneCustomTemplate = draft.execution?.laneNameTemplate ?? "";
+
+ // Tracks whether the user has manually edited the lane mode/preset. Smart
+ // defaults only fire on trigger event change while this stays false.
+ const laneDirtyRef = useRef(false);
+
const setPrimaryTrigger = (next: AutomationTrigger) => {
setDraft({ ...draft, triggers: [next], trigger: next });
};
@@ -400,19 +498,44 @@ export function RuleEditorPanel({
setDraft(applyActionRowsToDraft(draft, rows));
};
- const patchExecutionLane = (targetLaneId: string | null) => {
- setDraft({
- ...draft,
- execution: {
- kind: draft.execution?.kind ?? "agent-session",
- ...(targetLaneId ? { targetLaneId } : {}),
- ...(draft.execution?.kind === "agent-session" && draft.execution.session ? { session: draft.execution.session } : {}),
- ...(draft.execution?.kind === "mission" && draft.execution.mission ? { mission: draft.execution.mission } : {}),
- ...(draft.execution?.kind === "built-in" && draft.execution.builtIn ? { builtIn: draft.execution.builtIn } : {}),
- },
- });
+ const patchExecution = (
+ patch: Partial<{
+ laneMode: AutomationLaneMode;
+ targetLaneId: string | null;
+ laneNamePreset: AutomationLaneNamePreset;
+ laneNameTemplate: string;
+ }>,
+ ) => {
+ const current = draft.execution ?? { kind: "agent-session" as const };
+ const next = { ...current };
+ if (patch.laneMode !== undefined) next.laneMode = patch.laneMode;
+ if (patch.laneNamePreset !== undefined) next.laneNamePreset = patch.laneNamePreset;
+ if (patch.laneNameTemplate !== undefined) next.laneNameTemplate = patch.laneNameTemplate;
+ if (patch.targetLaneId !== undefined) {
+ if (patch.targetLaneId == null) delete next.targetLaneId;
+ else next.targetLaneId = patch.targetLaneId;
+ }
+ setDraft({ ...draft, execution: next });
};
+ // Smart defaults: when the trigger event changes and the user hasn't yet
+ // manually adjusted lane mode/preset, snap to a sensible default. We key on
+ // the trigger type so switching from "Issue opened" to "Issue closed"
+ // doesn't auto-reset a user choice they're happy with.
+ const lastTriggerTypeRef = useRef(primaryTrigger.type);
+ useEffect(() => {
+ if (lastTriggerTypeRef.current === primaryTrigger.type) return;
+ lastTriggerTypeRef.current = primaryTrigger.type;
+ if (laneDirtyRef.current) return;
+ const defaults = smartDefaultsForTrigger(primaryTrigger.type);
+ patchExecution({
+ laneMode: defaults.laneMode,
+ ...(defaults.preset !== undefined ? { laneNamePreset: defaults.preset } : {}),
+ });
+ // patchExecution closes over draft; intentionally narrowing deps.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [primaryTrigger.type]);
+
const errors = issues.filter((i) => i.level === "error");
const warnings = issues.filter((i) => i.level === "warning");
@@ -421,7 +544,7 @@ export function RuleEditorPanel({
-
+
{draft.id ? "Edit automation" : "New automation"}
@@ -456,44 +579,41 @@ export function RuleEditorPanel({
/>
{/* Identity */}
-
+
{/* Trigger */}
-
+
Trigger
-
- Source
+
+ Source
setTriggerFamily(event.target.value as TriggerFamily)}
>
@@ -502,11 +622,10 @@ export function RuleEditorPanel({
))}
-
- Event
+
+