Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions .tickets/_docs/TRIGGER_UX_REDESIGN.md
Original file line number Diff line number Diff line change
@@ -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.<id>.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.
65 changes: 65 additions & 0 deletions src/components/kanban/column-automation-sentence.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<AutomationSentence
onEntry={onEntry}
setOnEntry={setOnEntry}
exitCriteria={exit}
setExitCriteria={setExitCriteria}
/>,
)
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',
)
})
})
124 changes: 124 additions & 0 deletions src/components/kanban/column-automation-sentence.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<select
aria-label={ariaLabel}
value={value}
onChange={(e) => { onChange(e.target.value) }}
style={{ cursor: 'pointer' }}
className="mx-0.5 rounded-md border border-accent/30 bg-accent/10 px-1.5 py-0.5 text-sm font-medium text-accent focus:border-accent focus:outline-none"
>
{options.map((o) => (
<option key={o.value} value={o.value} className="bg-surface text-text-primary">
{o.label}
</option>
))}
</select>
)
}

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<SpawnCliAction>) => {
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 (
<div className="space-y-4">
{/* Recipes */}
<div>
<div className="mb-1.5 text-xs font-medium text-text-secondary">Start from a recipe</div>
<div className="flex flex-wrap gap-1.5">
{COLUMN_RECIPES.map((r) => (
<button
key={r.id}
type="button"
title={r.hint}
onClick={() => {
const { onEntry: e, exitCriteria: c } = r.build()
setOnEntry(e)
setExitCriteria(c)
}}
style={{ cursor: 'pointer' }}
className="rounded-lg border border-border-default bg-bg px-2.5 py-1.5 text-xs font-medium text-text-secondary transition-colors hover:border-accent/50 hover:text-text-primary"
>
<span className="mr-1">{r.icon}</span>
{r.label}
</button>
))}
</div>
</div>

{/* The sentence */}
<div
data-testid="automation-sentence"
className="flex flex-wrap items-center gap-y-2 rounded-lg border border-border-default bg-bg/40 px-3 py-3 text-sm leading-7 text-text-secondary"
>
<span>When a task enters this column,</span>
<Token ariaLabel="Action" value={actionType} onChange={setActionType} options={ACTION_CLAUSES} />

{spawn && (
<>
<span>using</span>
<Token
ariaLabel="CLI"
value={spawn.cli ?? 'claude'}
onChange={(v) => { setSpawn({ cli: v as SpawnCliAction['cli'] }) }}
options={CLI_TYPES}
/>
<Token
ariaLabel="Model"
value={spawn.model ?? ''}
onChange={(v) => { setSpawn({ model: v || undefined }) }}
options={SENTENCE_MODELS}
/>
</>
)}

<span>· then move it on when</span>
<Token ariaLabel="Advance when" value={exitCriteria.type} onChange={setExitType} options={EXIT_CLAUSES} />
<span>.</span>
</div>

<p className="text-[11px] text-text-secondary/60">
The prompt, runtime, retries and more live under Advanced. Most columns never need them.
</p>
</div>
)
}
Loading
Loading