[aidan] feat/steps: per-step enable toggle

This commit is contained in:
abccodes
2026-06-22 23:47:31 -07:00
parent e09dad24e8
commit 3eff6d6b37
4 changed files with 39 additions and 21 deletions
+1 -1
View File
@@ -217,7 +217,7 @@ async def execute(
session = None
try:
steps = [s for s in wf.steps if s.text and s.text.strip()]
steps = [s for s in wf.steps if s.enabled and s.text and s.text.strip()]
if not steps:
raise ValueError("Workflow has no steps")
+3
View File
@@ -83,6 +83,9 @@ class WorkflowStep(BaseModel):
# 3 to 6 word LLM-generated headline shown in the collapsed step row.
# The full prompt lives in `text`; this is the "at-a-glance" label.
label: Optional[str] = None
# Disabled steps stay in the list but the executor skips them, so a user
# can mute a step without losing its prompt. Defaults true for old records.
enabled: bool = True
def _empty_str_default() -> str:
@@ -1,26 +1,37 @@
import React, { useEffect, useState } from 'react';
import { useAppDispatch } from '@/shared/hooks';
import { commitDraft, discardDraft } from '@/shared/state/workflowsSlice';
import { commitDraft } from '@/shared/state/workflowsSlice';
import type { Workflow, WorkflowStep } from '@/shared/state/workflowsSlice';
import { stepsSignature } from '@/app/pages/Workflows/scheduleUtils';
import { WC, FONT_SERIF, FONT_SANS } from './uiKit';
import { useWC, FONT_SERIF, FONT_SANS, track, knob } from './uiKit';
import { useWorkflowPatch } from './useWorkflowPatch';
interface LocalStep { id: string; label: string; text: string; open: boolean; }
interface LocalStep { id: string; label: string; text: string; open: boolean; enabled: boolean; }
function toLocal(steps: WorkflowStep[]): LocalStep[] {
return steps.map((s) => ({ id: s.id, label: s.label || s.text.slice(0, 48), text: s.text, open: false }));
return steps.map((s) => ({ id: s.id, label: s.label || s.text.slice(0, 48), text: s.text, open: false, enabled: s.enabled !== false }));
}
function newStepId(): string {
return `step-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e6).toString(36)}`;
}
const StepsCard: React.FC<{ workflow: Workflow }> = ({ workflow }) => {
const WC = useWC();
const dispatch = useAppDispatch();
const patch = useWorkflowPatch();
const [local, setLocal] = useState<LocalStep[]>(() => toLocal(workflow.steps));
const [draft, setDraft] = useState('');
// Agent-proposed step changes apply silently (no Apply/Discard popup): commit
// any staged draft as soon as it lands so the steps just update live. Guarded
// on real content, the edit session snapshots an empty draft on open and
// committing that 400s.
useEffect(() => {
if (workflow.has_draft && (workflow.draft_steps || []).some((s) => s.text && s.text.trim())) {
dispatch(commitDraft({ id: workflow.id, keep_session: true }));
}
}, [workflow.has_draft, workflow.draft_steps, workflow.id, dispatch]);
const sig = stepsSignature(workflow.steps);
// Reseed when the server steps change underneath us (commit, agent edit,
// another surface) but not on our own in-progress keystrokes.
@@ -33,14 +44,19 @@ const StepsCard: React.FC<{ workflow: Workflow }> = ({ workflow }) => {
}, [sig]);
const commit = (next: LocalStep[]) => {
patch(workflow, { steps: next.map((s) => ({ id: s.id, text: s.text, label: s.label })) });
patch(workflow, { steps: next.map((s) => ({ id: s.id, text: s.text, label: s.label, enabled: s.enabled })) });
};
const update = (id: string, p: Partial<LocalStep>) => setLocal((prev) => prev.map((s) => (s.id === id ? { ...s, ...p } : s)));
const toggleEnabled = (id: string) => {
const next = local.map((s) => (s.id === id ? { ...s, enabled: !s.enabled } : s));
setLocal(next);
commit(next);
};
const onAdd = () => {
const t = draft.trim();
if (!t) return;
const next = [...local, { id: newStepId(), label: t, text: t, open: false }];
const next = [...local, { id: newStepId(), label: t, text: t, open: false, enabled: true }];
setLocal(next);
setDraft('');
commit(next);
@@ -52,32 +68,25 @@ const StepsCard: React.FC<{ workflow: Workflow }> = ({ workflow }) => {
};
return (
<div style={{ background: WC.paper, border: '1px solid rgba(33,30,27,0.08)', borderRadius: 13, padding: 16 }}>
<div style={{ background: WC.paper, border: `1px solid rgba(${WC.inkRGB},0.08)`, borderRadius: WC.radius.lg, padding: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 13 }}>
<span style={{ fontFamily: FONT_SERIF, fontSize: 16, fontWeight: 500, color: WC.ink }}>Steps</span>
<span style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: 11, color: WC.muted2 }}>{local.length} step{local.length === 1 ? '' : 's'}</span>
</div>
{workflow.has_draft && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 11, padding: '9px 11px', background: 'rgba(185,138,46,0.10)', border: '1px solid rgba(185,138,46,0.30)', borderRadius: 9 }}>
<span style={{ flex: 1, fontSize: 12, color: '#8A6418', fontWeight: 600 }}>The build agent proposed step changes.</span>
<button onClick={() => dispatch(commitDraft(workflow.id))} style={{ background: WC.ink, color: WC.paper, border: 'none', borderRadius: 7, padding: '5px 10px', fontSize: 12, fontWeight: 600, cursor: 'pointer' }}>Apply</button>
<button onClick={() => dispatch(discardDraft(workflow.id))} style={{ background: 'transparent', border: '1px solid rgba(33,30,27,0.16)', borderRadius: 7, padding: '5px 10px', fontSize: 12, fontWeight: 600, color: WC.ink3, cursor: 'pointer' }}>Discard</button>
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
{local.map((s, i) => (
<div key={s.id} style={{ border: `1px solid ${s.open ? 'rgba(33,30,27,0.16)' : 'rgba(33,30,27,0.10)'}`, borderRadius: 10, background: s.open ? '#FFFFFF' : WC.paper, overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 9, padding: '9px 9px 9px 11px' }}>
<div key={s.id} style={{ border: `1px solid ${s.open ? `rgba(${WC.inkRGB},0.16)` : `rgba(${WC.inkRGB},0.10)`}`, borderRadius: WC.radius.md, background: s.open ? WC.raised : WC.paper, overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 9, padding: '9px 9px 9px 11px', opacity: s.enabled ? 1 : 0.5 }}>
<span style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: 11, color: WC.faint, width: 13, flex: 'none' }}>{i + 1}</span>
<input
value={s.label}
onChange={(e) => update(s.id, { label: e.target.value })}
onBlur={() => commit(local)}
placeholder="Step title"
style={{ flex: 1, minWidth: 0, border: 'none', background: 'transparent', padding: 0, fontSize: 13, fontWeight: 600, color: WC.ink }}
style={{ flex: 1, minWidth: 0, border: 'none', background: 'transparent', padding: 0, fontSize: 13, fontWeight: 600, color: WC.ink, textDecoration: s.enabled ? 'none' : 'line-through' }}
/>
<div onClick={() => toggleEnabled(s.id)} title={s.enabled ? 'Disable step' : 'Enable step'} style={{ ...track(s.enabled, WC), transform: 'scale(0.82)' }}><div style={knob(s.enabled)} /></div>
<div onClick={() => update(s.id, { open: !s.open })} style={{ width: 22, height: 22, borderRadius: 6, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', color: WC.muted, flex: 'none' }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" style={{ transform: s.open ? 'rotate(180deg)' : 'none', transition: 'transform .15s' }}><path d="M6 9l6 6 6-6" /></svg>
</div>
@@ -85,6 +94,9 @@ const StepsCard: React.FC<{ workflow: Workflow }> = ({ workflow }) => {
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M5 12h14" /></svg>
</div>
</div>
{!s.open && s.text.trim() && s.text.trim() !== s.label.trim() && (
<div style={{ padding: '0 11px 10px 34px', fontSize: 12, color: WC.muted, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{s.text}</div>
)}
{s.open && (
<div style={{ padding: '0 11px 12px 34px' }}>
<div style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: 9.5, letterSpacing: '0.05em', textTransform: 'uppercase', color: WC.muted2, marginBottom: 6 }}>Prompt</div>
@@ -93,7 +105,7 @@ const StepsCard: React.FC<{ workflow: Workflow }> = ({ workflow }) => {
onChange={(e) => update(s.id, { text: e.target.value })}
onBlur={() => commit(local)}
placeholder="What should this step do?"
style={{ width: '100%', boxSizing: 'border-box', border: '1px solid rgba(33,30,27,0.12)', borderRadius: 8, background: WC.paper, padding: '9px 11px', fontSize: 12.5, lineHeight: 1.5, color: WC.ink2, resize: 'vertical', minHeight: 76, fontFamily: FONT_SANS }}
style={{ width: '100%', boxSizing: 'border-box', border: `1px solid rgba(${WC.inkRGB},0.12)`, borderRadius: 8, background: WC.paper, padding: '9px 11px', fontSize: 12.5, lineHeight: 1.5, color: WC.ink2, resize: 'vertical', minHeight: 76, fontFamily: FONT_SANS }}
/>
</div>
)}
@@ -107,7 +119,7 @@ const StepsCard: React.FC<{ workflow: Workflow }> = ({ workflow }) => {
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); onAdd(); } }}
placeholder="Add a step…"
style={{ flex: 1, background: '#FFFFFF', border: '1px solid rgba(33,30,27,0.12)', borderRadius: 8, padding: '8px 11px', fontSize: 13, color: WC.ink }}
style={{ flex: 1, background: WC.raised, border: `1px solid rgba(${WC.inkRGB},0.12)`, borderRadius: 8, padding: '8px 11px', fontSize: 13, color: WC.ink }}
/>
<button onClick={onAdd} style={{ background: WC.ink, color: WC.paper, border: 'none', borderRadius: 8, width: 34, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', flex: 'none' }}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2"><path d="M12 5v14M5 12h14" /></svg>
@@ -53,6 +53,9 @@ export interface WorkflowStep {
/** LLM-generated 3-6 word label shown when the step row is collapsed. The
* full `text` is what the agent actually runs; this is just the title. */
label?: string | null;
/** Disabled steps stay in the list but the executor skips them. Undefined
* (legacy records) is treated as enabled. */
enabled?: boolean;
}
export interface Workflow {