diff --git a/.tickets/_docs/TRIGGER_UX_REDESIGN.md b/.tickets/_docs/TRIGGER_UX_REDESIGN.md new file mode 100644 index 00000000..c988740c --- /dev/null +++ b/.tickets/_docs/TRIGGER_UX_REDESIGN.md @@ -0,0 +1,132 @@ +# Trigger Config UX Redesign + +> **Status:** Planned (2026-06-08) · **Type:** presentation-layer redesign (no backend change) +> **Decision:** "When → run → advance" sentence builder + recipe gallery, inside the existing modal. +> Supersedes the 3-tab form in `column-config-dialog.tsx`. Backend model +> (`ColumnTriggers`/`TriggerAction`/`ExitCriteria` in `src/types/column.ts`) is unchanged. + +## Why + +Column automation is KaitenCode's deepest feature and its hardest UI. Today the +config is ~2,000 LOC across 13 files and 3 tabs (General · Triggers · Exit). The +single most common column — "run Claude, advance when done" — requires opening a +modal, the Triggers tab, expanding a hidden *advanced editor*, picking Spawn CLI, +switching to the Exit tab, picking Agent Complete, and toggling Auto Advance. The +**action and the exit criterion that form one rule live on different tabs with no +visible link.** A user must hold ~10 concepts at once (triggers, actions, exit +criteria, entry/exit, retries, runtime modes, models, prompts, resource profiles, +dependencies). + +Worst offenders (from the UX + code audit): +- **Jargon / impl leaks:** "Runtime: Terminal (tmux) / Managed (events)", + "Resource profile: Heavy/Exclusive", `{dep..last_output}`, "re-fires the + on_entry trigger". +- **Duplicated, inconsistent controls:** model is `claude-opus-4-5` (column) vs + `opus` (task); concurrency shows "max 3" in one panel, free-form max in another, + backend default 5 — three sources of truth. +- **Implicit coupling:** `script_success` exit only works with a Run Script + action; `pr_approved` with Create PR — never linked or hinted. +- **Dead / confusing surface:** `trigger_task` action defined but unreachable; + `default_runtime_mode` can't be set from the UI; an "Effective: trigger > task > + column > workspace > global > default" line exposes the 6-tier resolver because + the layering itself confuses people. + +## The model the best tools use + +Trello Butler, Asana, Notion, Jira all converge on a **natural-language sentence** +where the grammar *is* the UI, front-loaded by **recipes**, with the long tail +**progressively disclosed**. Avoid node-canvas (n8n) / YAML (GitHub Actions) — they +push time-to-first-success from minutes to weeks. Our per-column automation is +short and linear (When → Do → Advance) — the exact shape the sentence model fits. + +## The redesign + +One panel inside the existing modal: + +``` +┌ Automate "In Progress" ──────────────────────────────────────┐ +│ Start from a recipe: │ +│ [⚡ Code it] [👀 Review + approve] [🧪 Run tests] [🔀 Open PR] │ +│ ───────────────────────────────────────────────────────────── │ +│ When a task enters this column, │ +│ run [Claude ▾ · opus ▾] with [the default prompt ▾] │ +│ then move it on when [the agent finishes ▾]. │ +│ │ +│ ▸ Advanced — runs on, retries, timeout, runtime, queue, │ +│ on-exit action, prompt variables │ +└───────────────────────────────────────────────────────────────┘ + collapsed column header shows: ▸ Runs Claude · advances when done +``` + +### The sentence +- **"When a task enters this column"** — the on_entry trigger (fixed lead-in; + on_exit moves to Advanced since few columns need it). +- **"run [action]"** — the action picker. The bracket is a dropdown; choosing it + swaps the inline sub-controls: + - *run Claude · opus* — spawn_cli (model inline) `with [prompt ▾]` + - *run the script [name ▾]* — run_script + - *open a PR (base [main])* — create_pr + - *set up a branch + worktree* — auto_setup + - *do nothing* — none +- **"then move it on when [criterion]"** — the exit criteria, as the second clause + (this is what makes "exit criteria" finally legible). Options in plain language: + *the agent finishes · a script passes · I approve it · a checklist is done · + N seconds pass · a PR is approved · manually*. Auto-advance is implied by the + phrasing ("move it on when…"); a "but wait for me to confirm" sub-toggle covers + the manual_approval gate. + +The sentence **is** the plain-language summary — so the separate "Generate +Triggers" LLM box is dropped. + +### Recipes (one click → a complete, runnable rule) +| Recipe | Wires | +|---|---| +| ⚡ Code it | spawn_cli claude (default cmd/prompt) · exit agent_complete · auto-advance | +| 👀 Review + approve | exit manual_approval (review gate) · auto-advance off | +| 🧪 Run tests | run_script (pick script) · exit script_success · auto-advance | +| 🔀 Open a PR | create_pr (base from git settings) · exit agent_complete | +| ▫ Manual column | none · exit manual (a plain board column, no automation) | + +Recipes extend the existing pipeline-template system conceptually but are +per-column and inline. + +### Advanced (progressive disclosure, same panel — not a separate mode) +Runs on (entry/exit) · runtime mode · max retries · timeout · agent queue / +column concurrency (ONE source of truth) · on-exit action (move / trigger +another task) · prompt-variable reference. Most columns never open it. + +### Zero-config default +A freshly added automation is already a legal, runnable rule using the global +`default_agent_cli` / `default_model` from settings — never a half-filled form +demanding 8 selections before it can save. + +## Cleanups folded in +- **Unify model vocabulary:** show `opus/sonnet/haiku` everywhere; map to dated + CLI ids at the backend boundary only. +- **De-jargon:** "Terminal (tmux) / Managed (events)" → plain "Headless / …"; + drop "Resource profile" buttons (fold into Advanced as plain concurrency). +- **One concurrency source** (kill the hardcoded "max 3" pill). +- **Remove dead surface** (`trigger_task` either exposed properly under Advanced + or removed; wire or drop `default_runtime_mode`). +- **Column summary chip** doubles as at-a-glance state + pre-save confirmation. +- Keep the per-task override modal, but make its controls *match* the column ones + (same vocab) and lean on the live "Effective:" line less. + +## Phases +1. **Recipe gallery + sentence for the common path** (spawn_cli + exit criteria) + inside the modal; keep existing per-action editors mounted under Advanced + (reuse, don't rewrite yet). Ship the 80% win. +2. **Cleanups** — unified model vocab, de-jargon, one concurrency source, drop + dead/duplicate surface, drop the LLM-generate box. +3. **Column summary chip** + zero-config legal defaults. +4. *(optional)* "describe it in words" that fills the sentence; Butler-style + "automate this?" suggestions from repeated manual drags. + +## Files +- Replace the inside of `src/components/kanban/column-config-dialog.tsx` and its + tab/editor children with the sentence panel + recipe gallery + an Advanced + section that reuses the existing per-action editors initially. +- `src/components/kanban/column-config-constants.ts` — recipe definitions, plain + labels. +- No change to `src/types/column.ts` or the Rust pipeline — the sentence reads/ + writes the same `ColumnTriggers` JSON. diff --git a/src/components/kanban/column-automation-sentence.test.tsx b/src/components/kanban/column-automation-sentence.test.tsx new file mode 100644 index 00000000..f5a0a5b8 --- /dev/null +++ b/src/components/kanban/column-automation-sentence.test.tsx @@ -0,0 +1,65 @@ +import { describe, it, expect, vi } from 'vitest' +import { fireEvent, render, screen } from '@testing-library/react' +import type { ExitCriteria, TriggerAction } from '@/types' +import { AutomationSentence } from './column-automation-sentence' +import { automationSummary } from './column-recipes' + +function setup(onEntry: TriggerAction = { type: 'none' }, exit: ExitCriteria = { type: 'manual' }) { + const setOnEntry = vi.fn() + const setExitCriteria = vi.fn() + render( + , + ) + return { setOnEntry, setExitCriteria } +} + +describe('AutomationSentence', () => { + it('renders the sentence and the recipe gallery', () => { + setup() + expect(screen.getByTestId('automation-sentence')).toBeInTheDocument() + expect(screen.getByText('Code it')).toBeInTheDocument() + expect(screen.getByText('Review + approve')).toBeInTheDocument() + }) + + it('"Code it" recipe wires spawn_cli + agent_complete + auto-advance', () => { + const { setOnEntry, setExitCriteria } = setup() + fireEvent.click(screen.getByText('Code it')) + expect(setOnEntry).toHaveBeenCalledWith(expect.objectContaining({ type: 'spawn_cli', cli: 'claude' })) + expect(setExitCriteria).toHaveBeenCalledWith({ type: 'agent_complete', auto_advance: true }) + }) + + it('shows the CLI + model tokens only for the run-an-agent action', () => { + setup({ type: 'spawn_cli', cli: 'claude' }) + expect(screen.getByLabelText('Model')).toBeInTheDocument() + expect(screen.getByLabelText('CLI')).toBeInTheDocument() + }) + + it('hides the agent tokens for non-agent actions', () => { + setup({ type: 'create_pr', base_branch: 'main' }) + expect(screen.queryByLabelText('Model')).not.toBeInTheDocument() + }) + + it('picking a real exit condition implies auto-advance; "manually" does not', () => { + const { setExitCriteria } = setup({ type: 'spawn_cli' }, { type: 'manual' }) + fireEvent.change(screen.getByLabelText('Advance when'), { target: { value: 'agent_complete' } }) + expect(setExitCriteria).toHaveBeenCalledWith(expect.objectContaining({ type: 'agent_complete', auto_advance: true })) + fireEvent.change(screen.getByLabelText('Advance when'), { target: { value: 'manual' } }) + expect(setExitCriteria).toHaveBeenCalledWith(expect.objectContaining({ type: 'manual', auto_advance: false })) + }) +}) + +describe('automationSummary', () => { + it('reads "No automation" for a plain column', () => { + expect(automationSummary({ type: 'none' }, { type: 'manual' })).toBe('No automation') + }) + it('reads a plain-language summary for an agent column', () => { + expect(automationSummary({ type: 'spawn_cli' }, { type: 'agent_complete' })).toBe( + 'Run an AI agent · advance when the agent finishes', + ) + }) +}) diff --git a/src/components/kanban/column-automation-sentence.tsx b/src/components/kanban/column-automation-sentence.tsx new file mode 100644 index 00000000..b9a74947 --- /dev/null +++ b/src/components/kanban/column-automation-sentence.tsx @@ -0,0 +1,124 @@ +import type { ActionType, ExitCriteria, ExitCriteriaType, SpawnCliAction, TriggerAction } from '@/types' +import { CLI_TYPES } from './column-config-constants' +import { + ACTION_CLAUSES, + COLUMN_RECIPES, + EXIT_CLAUSES, + SENTENCE_MODELS, + defaultActionForType, +} from './column-recipes' + +type Props = { + onEntry: TriggerAction + setOnEntry: (a: TriggerAction) => void + exitCriteria: ExitCriteria + setExitCriteria: (c: ExitCriteria) => void +} + +/** An inline dropdown styled to read as a token inside the sentence. */ +function Token({ + value, + onChange, + options, + ariaLabel, +}: { + value: string + onChange: (v: string) => void + options: { value: string; label: string }[] + ariaLabel: string +}) { + return ( + + ) +} + +export function AutomationSentence({ onEntry, setOnEntry, exitCriteria, setExitCriteria }: Props) { + const actionType = onEntry.type + const spawn = onEntry.type === 'spawn_cli' ? onEntry : null + + const setActionType = (t: string) => { setOnEntry(defaultActionForType(t as ActionType)) } + const setSpawn = (patch: Partial) => { + if (onEntry.type === 'spawn_cli') setOnEntry({ ...onEntry, ...patch }) + } + const setExitType = (t: string) => { + const type = t as ExitCriteriaType + // Picking a real condition implies "advance when it's met"; "manual" means + // it waits for the user. (Power users can override auto_advance in Advanced.) + setExitCriteria({ ...exitCriteria, type, auto_advance: type !== 'manual' }) + } + + return ( +
+ {/* Recipes */} +
+
Start from a recipe
+
+ {COLUMN_RECIPES.map((r) => ( + + ))} +
+
+ + {/* The sentence */} +
+ When a task enters this column, + + + {spawn && ( + <> + using + { setSpawn({ cli: v as SpawnCliAction['cli'] }) }} + options={CLI_TYPES} + /> + { setSpawn({ model: v || undefined }) }} + options={SENTENCE_MODELS} + /> + + )} + + · then move it on when + + . +
+ +

+ The prompt, runtime, retries and more live under Advanced. Most columns never need them. +

+
+ ) +} diff --git a/src/components/kanban/column-config-dialog.tsx b/src/components/kanban/column-config-dialog.tsx index 4b52dea6..e2b2f455 100644 --- a/src/components/kanban/column-config-dialog.tsx +++ b/src/components/kanban/column-config-dialog.tsx @@ -10,8 +10,10 @@ import type { ResourceProfile } from '@/types/column' import { useColumnStore } from '@/stores/column-store' import { getColumnTriggers } from '@/types/column' import { ICONS, COLUMN_COLORS } from './column-config-constants' -import { TriggersTab } from './column-trigger-editor' +import { ActionEditor } from './column-trigger-action-editors' import { ExitTab } from './column-exit-editor' +import { AutomationSentence } from './column-automation-sentence' +import { automationSummary } from './column-recipes' /** Auto-suggest an icon based on column name keywords */ function suggestIcon(name: string): string | null { @@ -58,7 +60,6 @@ type ColumnConfigDialogProps = { onDelete?: () => void } -type Tab = 'general' | 'triggers' | 'exit' type ResourceProfileOption = ResourceProfile | 'auto' const RESOURCE_PROFILE_OPTIONS: Array<{ value: ResourceProfileOption; label: string; hint: string }> = [ @@ -74,23 +75,21 @@ const RESOURCE_PROFILE_OPTIONS: Array<{ value: ResourceProfileOption; label: str export function ColumnConfigDialog({ column, onClose, onDelete }: ColumnConfigDialogProps) { const updateColumnAsync = useColumnStore((s) => s.updateColumnAsync) - const [tab, setTab] = useState('general') const [name, setName] = useState(column.name) const [icon, setIcon] = useState(column.icon || 'list') const [color, setColor] = useState(column.color || '#E8A87C') const [userPickedIcon, setUserPickedIcon] = useState(false) const [userPickedColor, setUserPickedColor] = useState(false) const [confirmDelete, setConfirmDelete] = useState(false) + const [advancedOpen, setAdvancedOpen] = useState(false) - // Reset the delete-confirm state after 3s if the user doesn't follow through — - // prevents a stale "armed" state if focus moves away. + // Reset the delete-confirm state after 3s if the user doesn't follow through. useEffect(() => { if (!confirmDelete) return const t = setTimeout(() => { setConfirmDelete(false) }, 3000) return () => { clearTimeout(t) } }, [confirmDelete]) - // Reset manual override flags when dialog opens for a different column useEffect(() => { setUserPickedIcon(false) setUserPickedColor(false) @@ -98,7 +97,6 @@ export function ColumnConfigDialog({ column, onClose, onDelete }: ColumnConfigDi const handleNameChange = useCallback((newName: string) => { setName(newName) - // Auto-suggest icon/color only if user hasn't manually picked them if (!userPickedIcon) { const suggested = suggestIcon(newName) if (suggested) setIcon(suggested) @@ -109,9 +107,7 @@ export function ColumnConfigDialog({ column, onClose, onDelete }: ColumnConfigDi } }, [userPickedIcon, userPickedColor]) - const initialTriggers = useMemo((): ColumnTriggers => { - return getColumnTriggers(column) - }, [column]) + const initialTriggers = useMemo((): ColumnTriggers => getColumnTriggers(column), [column]) const [onEntry, setOnEntry] = useState(initialTriggers.on_entry || { type: 'none' }) const [onExit, setOnExit] = useState(initialTriggers.on_exit || { type: 'none' }) @@ -180,105 +176,81 @@ export function ColumnConfigDialog({ column, onClose, onDelete }: ColumnConfigDi > {/* Header */}
-

- Configure Column -

+

Configure Column

- {/* Tabs */} -
- {(['general', 'triggers', 'exit'] as Tab[]).map((t) => ( - - ))} -
- - {/* Content */}
{ void handleSubmit(e) }} className="flex-1 overflow-y-auto"> -
- {tab === 'general' && ( -
- { setUserPickedIcon(true); setIcon(v) }} - color={color} - setColor={(v) => { setUserPickedColor(true); setColor(v) }} - maxConcurrent={maxConcurrent} - setMaxConcurrent={setMaxConcurrent} - resourceProfile={resourceProfile} - setResourceProfile={setResourceProfile} - /> -
- )} - {tab === 'triggers' && ( -
- + {/* Identity */} + { setUserPickedIcon(true); setIcon(v) }} + color={color} + setColor={(v) => { setUserPickedColor(true); setColor(v) }} + /> + + {/* Automation */} +
+

Automation

+ -
- )} - {tab === 'exit' && ( -
- -
- )} + + + {/* Advanced */} +
+ + + {advancedOpen && ( +
+
+

On entry — full action editor

+ +
+
+

On exit — before the task leaves

+ +
+
+

Exit details — retries, timeout, auto-advance

+ +
+ +
+ )} +
- {/* Actions — DOM order is Cancel, Save, Delete (safe → destructive) so - keyboard focus reaches Cancel/Save before the destructive Delete. - `order-*` classes restore the visual left-Delete / right-Save layout. */} + {/* Actions — DOM order Cancel, Save, Delete (safe → destructive). */}
+ + ▸ {automationSummary(onEntry, exitCriteria)} + @@ -318,19 +290,15 @@ export function ColumnConfigDialog({ column, onClose, onDelete }: ColumnConfigDi ) } -// ─── General Tab ──────────────────────────────────────────────────────────── +// ─── Identity ─────────────────────────────────────────────────────────────── -function GeneralTab({ +function IdentityFields({ name, setName, icon, setIcon, color, setColor, - maxConcurrent, - setMaxConcurrent, - resourceProfile, - setResourceProfile, }: { name: string setName: (v: string) => void @@ -338,20 +306,11 @@ function GeneralTab({ setIcon: (v: string) => void color: string setColor: (v: string) => void - maxConcurrent: number | undefined - setMaxConcurrent: (v: number | undefined) => void - resourceProfile: ResourceProfile | undefined - setResourceProfile: (v: ResourceProfile | undefined) => void }) { - const shouldSuggestSingleLane = resourceProfile === 'heavy' || resourceProfile === 'exclusive' - return ( -
- {/* Name */} +
- +
- {/* Icon & Color */}
- +
-
- +
- {Object.entries(COLUMN_COLORS).map(([name, c], idx, all) => ( + {Object.entries(COLUMN_COLORS).map(([cname, c], idx, all) => (
+
+ ) +} - {/* Capacity */} -
-
-

Capacity

-

Column resource profile and concurrency.

-
+// ─── Capacity (advanced) ──────────────────────────────────────────────────── -
-
- -
- {RESOURCE_PROFILE_OPTIONS.map((option) => { - const selected = option.value === (resourceProfile ?? 'auto') - return ( - - ) - })} -
+function CapacityFields({ + maxConcurrent, + setMaxConcurrent, + resourceProfile, + setResourceProfile, +}: { + maxConcurrent: number | undefined + setMaxConcurrent: (v: number | undefined) => void + resourceProfile: ResourceProfile | undefined + setResourceProfile: (v: ResourceProfile | undefined) => void +}) { + const shouldSuggestSingleLane = resourceProfile === 'heavy' || resourceProfile === 'exclusive' + return ( +
+

Capacity — concurrency for this column

+
+
+ +
+ {RESOURCE_PROFILE_OPTIONS.map((option) => { + const selected = option.value === (resourceProfile ?? 'auto') + return ( + + ) + })}
+
-
-
- - { - const v = e.target.value - if (v === '') { setMaxConcurrent(undefined); return } - const n = Number.parseInt(v, 10) - setMaxConcurrent(Number.isFinite(n) && n > 0 ? n : undefined) - }} - className="w-full rounded-lg border border-border-default bg-bg px-3 py-2 text-sm text-text-primary placeholder:text-text-secondary/50 focus:border-accent focus:outline-none sm:max-w-40" - /> -
- - {shouldSuggestSingleLane && maxConcurrent !== 1 && ( - - )} +
+
+ + { + const v = e.target.value + if (v === '') { setMaxConcurrent(undefined); return } + const n = Number.parseInt(v, 10) + setMaxConcurrent(Number.isFinite(n) && n > 0 ? n : undefined) + }} + className="w-full rounded-lg border border-border-default bg-bg px-3 py-2 text-sm text-text-primary placeholder:text-text-secondary/50 focus:border-accent focus:outline-none sm:max-w-40" + />
- {shouldSuggestSingleLane && maxConcurrent !== 1 && ( -

- {resourceProfile === 'exclusive' ? 'Exclusive' : 'Heavy'} columns usually run best at 1. -

+ )}
+ + {shouldSuggestSingleLane && maxConcurrent !== 1 && ( +

+ {resourceProfile === 'exclusive' ? 'Exclusive' : 'Heavy'} columns usually run best at 1. +

+ )}
) diff --git a/src/components/kanban/column-recipes.ts b/src/components/kanban/column-recipes.ts new file mode 100644 index 00000000..16a916c2 --- /dev/null +++ b/src/components/kanban/column-recipes.ts @@ -0,0 +1,133 @@ +import type { ActionType, ExitCriteria, ExitCriteriaType, TriggerAction } from '@/types' +import { DEFAULT_SPAWN_CLI } from '@/types/column' + +/** + * One-click presets for the column automation sentence. Each builds a complete, + * runnable rule (an on_entry action + an exit criterion) so the common cases + * never start from a blank form. See .tickets/_docs/TRIGGER_UX_REDESIGN.md. + */ +export type ColumnRecipe = { + id: string + icon: string + label: string + hint: string + build: () => { onEntry: TriggerAction; exitCriteria: ExitCriteria } +} + +export const COLUMN_RECIPES: ColumnRecipe[] = [ + { + id: 'code', + icon: '⚡', + label: 'Code it', + hint: 'Run Claude, advance when it finishes', + build: () => ({ + onEntry: { ...DEFAULT_SPAWN_CLI }, + exitCriteria: { type: 'agent_complete', auto_advance: true }, + }), + }, + { + id: 'review', + icon: '👀', + label: 'Review + approve', + hint: 'Hold here until you approve', + build: () => ({ + onEntry: { type: 'none' }, + exitCriteria: { type: 'manual_approval', auto_advance: true }, + }), + }, + { + id: 'test', + icon: '🧪', + label: 'Run tests', + hint: 'Run a script, advance if it passes', + build: () => ({ + onEntry: { type: 'run_script', script_id: '' }, + exitCriteria: { type: 'script_success', auto_advance: true }, + }), + }, + { + id: 'pr', + icon: '🔀', + label: 'Open a PR', + hint: 'Create a pull request', + build: () => ({ + onEntry: { type: 'create_pr', base_branch: 'main' }, + exitCriteria: { type: 'agent_complete', auto_advance: true }, + }), + }, + { + id: 'manual', + icon: '▫', + label: 'Manual column', + hint: 'No automation — just a board column', + build: () => ({ + onEntry: { type: 'none' }, + exitCriteria: { type: 'manual', auto_advance: false }, + }), + }, +] + +/** Plain-language action verbs for the sentence's "do" clause. */ +export const ACTION_CLAUSES: { value: ActionType; label: string }[] = [ + { value: 'spawn_cli', label: 'run an AI agent' }, + { value: 'run_script', label: 'run a script' }, + { value: 'create_pr', label: 'open a PR' }, + { value: 'auto_setup', label: 'set up a branch + worktree' }, + { value: 'none', label: 'do nothing' }, +] + +/** Plain-language exit criteria for the sentence's "advance when" clause. */ +export const EXIT_CLAUSES: { value: ExitCriteriaType; label: string }[] = [ + { value: 'agent_complete', label: 'the agent finishes' }, + { value: 'script_success', label: 'a script passes' }, + { value: 'manual_approval', label: 'I approve it' }, + { value: 'checklist_done', label: 'the checklist is done' }, + { value: 'time_elapsed', label: 'a timeout passes' }, + { value: 'pr_approved', label: 'the PR is approved' }, + { value: 'notification_sent', label: 'I mark it notified' }, + { value: 'manual', label: 'I move it manually' }, +] + +/** + * Model choices for the sentence (plain labels). Values match the spawn-cli + * editor so the sentence and the advanced editor write identical config. + * (Unifying the vocabulary to bare opus/sonnet/haiku is a Phase-2 cleanup.) + */ +export const SENTENCE_MODELS: { value: string; label: string }[] = [ + { value: '', label: 'auto' }, + { value: 'claude-opus-4-5', label: 'opus' }, + { value: 'claude-sonnet-4-5', label: 'sonnet' }, + { value: 'claude-haiku-4-5', label: 'haiku' }, +] + +/** One-line plain-language readout of a column's automation (for the summary chip). */ +export function automationSummary(onEntry: TriggerAction | undefined, exit: ExitCriteria | undefined): string { + if ((onEntry?.type ?? 'none') === 'none' && (exit?.type ?? 'manual') === 'manual') { + return 'No automation' + } + const action = ACTION_CLAUSES.find((a) => a.value === onEntry?.type)?.label ?? 'do nothing' + const when = EXIT_CLAUSES.find((e) => e.value === exit?.type)?.label ?? 'I move it manually' + return `${action.charAt(0).toUpperCase()}${action.slice(1)} · advance when ${when}` +} + +/** The on_entry action a freshly-picked action type should default to. Mirrors + * the dispatcher in column-trigger-action-editors so the sentence and the + * advanced editor agree on defaults. */ +export function defaultActionForType(type: ActionType): TriggerAction { + switch (type) { + case 'spawn_cli': + return { ...DEFAULT_SPAWN_CLI } + case 'run_script': + return { type: 'run_script', script_id: '' } + case 'create_pr': + return { type: 'create_pr', base_branch: 'main' } + case 'auto_merge': + return { type: 'auto_merge', base_branch: 'main' } + case 'move_column': + return { type: 'move_column', target: 'next' } + case 'auto_setup': + return { type: 'auto_setup' } + default: + return { type: 'none' } + } +}