Skip to content

Commit d1b8eb5

Browse files
leonvanzylclaude
andcommitted
feat: add feature editing capability for pending/in-progress features
Add the ability for users to edit features that are not yet completed, allowing them to provide corrections or additional instructions when the agent is stuck or implementing a feature incorrectly. Backend changes: - Add FeatureUpdate schema in server/schemas.py with optional fields - Add PATCH /api/projects/{project_name}/features/{feature_id} endpoint - Validate that completed features (passes=True) cannot be edited Frontend changes: - Add FeatureUpdate type in ui/src/lib/types.ts - Add updateFeature() API function in ui/src/lib/api.ts - Add useUpdateFeature() React Query mutation hook - Create EditFeatureForm.tsx component with pre-filled form values - Update FeatureModal.tsx with Edit button for non-completed features The edit form allows modifying category, name, description, priority, and test steps. Save button is disabled until changes are detected. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 07c2010 commit d1b8eb5

8 files changed

Lines changed: 373 additions & 4 deletions

File tree

server/routers/features.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
FeatureCreate,
1818
FeatureListResponse,
1919
FeatureResponse,
20+
FeatureUpdate,
2021
)
2122
from ..utils.validation import validate_project_name
2223

@@ -217,6 +218,63 @@ async def get_feature(project_name: str, feature_id: int):
217218
raise HTTPException(status_code=500, detail="Database error occurred")
218219

219220

221+
@router.patch("/{feature_id}", response_model=FeatureResponse)
222+
async def update_feature(project_name: str, feature_id: int, update: FeatureUpdate):
223+
"""
224+
Update a feature's details.
225+
226+
Only features that are not yet completed (passes=False) can be edited.
227+
This allows users to provide corrections or additional instructions
228+
when the agent is stuck or implementing a feature incorrectly.
229+
"""
230+
project_name = validate_project_name(project_name)
231+
project_dir = _get_project_path(project_name)
232+
233+
if not project_dir:
234+
raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")
235+
236+
if not project_dir.exists():
237+
raise HTTPException(status_code=404, detail="Project directory not found")
238+
239+
_, Feature = _get_db_classes()
240+
241+
try:
242+
with get_db_session(project_dir) as session:
243+
feature = session.query(Feature).filter(Feature.id == feature_id).first()
244+
245+
if not feature:
246+
raise HTTPException(status_code=404, detail=f"Feature {feature_id} not found")
247+
248+
# Prevent editing completed features
249+
if feature.passes:
250+
raise HTTPException(
251+
status_code=400,
252+
detail="Cannot edit a completed feature. Features marked as done are immutable."
253+
)
254+
255+
# Apply updates for non-None fields
256+
if update.category is not None:
257+
feature.category = update.category
258+
if update.name is not None:
259+
feature.name = update.name
260+
if update.description is not None:
261+
feature.description = update.description
262+
if update.steps is not None:
263+
feature.steps = update.steps
264+
if update.priority is not None:
265+
feature.priority = update.priority
266+
267+
session.commit()
268+
session.refresh(feature)
269+
270+
return feature_to_response(feature)
271+
except HTTPException:
272+
raise
273+
except Exception:
274+
logger.exception("Failed to update feature")
275+
raise HTTPException(status_code=500, detail="Failed to update feature")
276+
277+
220278
@router.delete("/{feature_id}")
221279
async def delete_feature(project_name: str, feature_id: int):
222280
"""Delete a feature."""

server/schemas.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,15 @@ class FeatureCreate(FeatureBase):
8787
priority: int | None = None
8888

8989

90+
class FeatureUpdate(BaseModel):
91+
"""Request schema for updating a feature (partial updates allowed)."""
92+
category: str | None = None
93+
name: str | None = None
94+
description: str | None = None
95+
steps: list[str] | None = None
96+
priority: int | None = None
97+
98+
9099
class FeatureResponse(FeatureBase):
91100
"""Response schema for a feature."""
92101
id: int
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
import { useState, useId } from 'react'
2+
import { X, Save, Plus, Trash2, Loader2, AlertCircle } from 'lucide-react'
3+
import { useUpdateFeature } from '../hooks/useProjects'
4+
import type { Feature } from '../lib/types'
5+
6+
interface Step {
7+
id: string
8+
value: string
9+
}
10+
11+
interface EditFeatureFormProps {
12+
feature: Feature
13+
projectName: string
14+
onClose: () => void
15+
onSaved: () => void
16+
}
17+
18+
export function EditFeatureForm({ feature, projectName, onClose, onSaved }: EditFeatureFormProps) {
19+
const formId = useId()
20+
const [category, setCategory] = useState(feature.category)
21+
const [name, setName] = useState(feature.name)
22+
const [description, setDescription] = useState(feature.description)
23+
const [priority, setPriority] = useState(String(feature.priority))
24+
const [steps, setSteps] = useState<Step[]>(() =>
25+
feature.steps.length > 0
26+
? feature.steps.map((step, i) => ({ id: `${formId}-step-${i}`, value: step }))
27+
: [{ id: `${formId}-step-0`, value: '' }]
28+
)
29+
const [error, setError] = useState<string | null>(null)
30+
const [stepCounter, setStepCounter] = useState(feature.steps.length || 1)
31+
32+
const updateFeature = useUpdateFeature(projectName)
33+
34+
const handleAddStep = () => {
35+
setSteps([...steps, { id: `${formId}-step-${stepCounter}`, value: '' }])
36+
setStepCounter(stepCounter + 1)
37+
}
38+
39+
const handleRemoveStep = (id: string) => {
40+
setSteps(steps.filter(step => step.id !== id))
41+
}
42+
43+
const handleStepChange = (id: string, value: string) => {
44+
setSteps(steps.map(step =>
45+
step.id === id ? { ...step, value } : step
46+
))
47+
}
48+
49+
const handleSubmit = async (e: React.FormEvent) => {
50+
e.preventDefault()
51+
setError(null)
52+
53+
const filteredSteps = steps
54+
.map(s => s.value.trim())
55+
.filter(s => s.length > 0)
56+
57+
try {
58+
await updateFeature.mutateAsync({
59+
featureId: feature.id,
60+
update: {
61+
category: category.trim(),
62+
name: name.trim(),
63+
description: description.trim(),
64+
steps: filteredSteps,
65+
priority: parseInt(priority, 10),
66+
},
67+
})
68+
onSaved()
69+
} catch (err) {
70+
setError(err instanceof Error ? err.message : 'Failed to update feature')
71+
}
72+
}
73+
74+
const isValid = category.trim() && name.trim() && description.trim()
75+
76+
// Check if any changes were made
77+
const currentSteps = steps.map(s => s.value.trim()).filter(s => s)
78+
const hasChanges =
79+
category.trim() !== feature.category ||
80+
name.trim() !== feature.name ||
81+
description.trim() !== feature.description ||
82+
parseInt(priority, 10) !== feature.priority ||
83+
JSON.stringify(currentSteps) !== JSON.stringify(feature.steps)
84+
85+
return (
86+
<div className="neo-modal-backdrop" onClick={onClose}>
87+
<div
88+
className="neo-modal w-full max-w-2xl"
89+
onClick={(e) => e.stopPropagation()}
90+
>
91+
{/* Header */}
92+
<div className="flex items-center justify-between p-6 border-b-3 border-[var(--color-neo-border)]">
93+
<h2 className="font-display text-2xl font-bold">
94+
Edit Feature
95+
</h2>
96+
<button
97+
onClick={onClose}
98+
className="neo-btn neo-btn-ghost p-2"
99+
>
100+
<X size={24} />
101+
</button>
102+
</div>
103+
104+
{/* Form */}
105+
<form onSubmit={handleSubmit} className="p-6 space-y-4">
106+
{/* Error Message */}
107+
{error && (
108+
<div className="flex items-center gap-3 p-4 bg-[var(--color-neo-danger)] text-white border-3 border-[var(--color-neo-border)]">
109+
<AlertCircle size={20} />
110+
<span>{error}</span>
111+
<button
112+
type="button"
113+
onClick={() => setError(null)}
114+
className="ml-auto"
115+
>
116+
<X size={16} />
117+
</button>
118+
</div>
119+
)}
120+
121+
{/* Category & Priority Row */}
122+
<div className="flex gap-4">
123+
<div className="flex-1">
124+
<label className="block font-display font-bold mb-2 uppercase text-sm">
125+
Category
126+
</label>
127+
<input
128+
type="text"
129+
value={category}
130+
onChange={(e) => setCategory(e.target.value)}
131+
placeholder="e.g., Authentication, UI, API"
132+
className="neo-input"
133+
required
134+
/>
135+
</div>
136+
<div className="w-32">
137+
<label className="block font-display font-bold mb-2 uppercase text-sm">
138+
Priority
139+
</label>
140+
<input
141+
type="number"
142+
value={priority}
143+
onChange={(e) => setPriority(e.target.value)}
144+
min="1"
145+
className="neo-input"
146+
required
147+
/>
148+
</div>
149+
</div>
150+
151+
{/* Name */}
152+
<div>
153+
<label className="block font-display font-bold mb-2 uppercase text-sm">
154+
Feature Name
155+
</label>
156+
<input
157+
type="text"
158+
value={name}
159+
onChange={(e) => setName(e.target.value)}
160+
placeholder="e.g., User login form"
161+
className="neo-input"
162+
required
163+
/>
164+
</div>
165+
166+
{/* Description */}
167+
<div>
168+
<label className="block font-display font-bold mb-2 uppercase text-sm">
169+
Description
170+
</label>
171+
<textarea
172+
value={description}
173+
onChange={(e) => setDescription(e.target.value)}
174+
placeholder="Describe what this feature should do..."
175+
className="neo-input min-h-[100px] resize-y"
176+
required
177+
/>
178+
</div>
179+
180+
{/* Steps */}
181+
<div>
182+
<label className="block font-display font-bold mb-2 uppercase text-sm">
183+
Test Steps
184+
</label>
185+
<div className="space-y-2">
186+
{steps.map((step, index) => (
187+
<div key={step.id} className="flex gap-2">
188+
<span className="neo-input w-12 text-center flex-shrink-0 flex items-center justify-center">
189+
{index + 1}
190+
</span>
191+
<input
192+
type="text"
193+
value={step.value}
194+
onChange={(e) => handleStepChange(step.id, e.target.value)}
195+
placeholder="Describe this step..."
196+
className="neo-input flex-1"
197+
/>
198+
{steps.length > 1 && (
199+
<button
200+
type="button"
201+
onClick={() => handleRemoveStep(step.id)}
202+
className="neo-btn neo-btn-ghost p-2"
203+
>
204+
<Trash2 size={18} />
205+
</button>
206+
)}
207+
</div>
208+
))}
209+
</div>
210+
<button
211+
type="button"
212+
onClick={handleAddStep}
213+
className="neo-btn neo-btn-ghost mt-2 text-sm"
214+
>
215+
<Plus size={16} />
216+
Add Step
217+
</button>
218+
</div>
219+
220+
{/* Actions */}
221+
<div className="flex gap-3 pt-4 border-t-3 border-[var(--color-neo-border)]">
222+
<button
223+
type="submit"
224+
disabled={!isValid || !hasChanges || updateFeature.isPending}
225+
className="neo-btn neo-btn-success flex-1"
226+
>
227+
{updateFeature.isPending ? (
228+
<Loader2 size={18} className="animate-spin" />
229+
) : (
230+
<>
231+
<Save size={18} />
232+
Save Changes
233+
</>
234+
)}
235+
</button>
236+
<button
237+
type="button"
238+
onClick={onClose}
239+
className="neo-btn neo-btn-ghost"
240+
>
241+
Cancel
242+
</button>
243+
</div>
244+
</form>
245+
</div>
246+
</div>
247+
)
248+
}

ui/src/components/FeatureModal.tsx

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useState } from 'react'
2-
import { X, CheckCircle2, Circle, SkipForward, Trash2, Loader2, AlertCircle } from 'lucide-react'
2+
import { X, CheckCircle2, Circle, SkipForward, Trash2, Loader2, AlertCircle, Pencil } from 'lucide-react'
33
import { useSkipFeature, useDeleteFeature } from '../hooks/useProjects'
4+
import { EditFeatureForm } from './EditFeatureForm'
45
import type { Feature } from '../lib/types'
56

67
interface FeatureModalProps {
@@ -12,6 +13,7 @@ interface FeatureModalProps {
1213
export function FeatureModal({ feature, projectName, onClose }: FeatureModalProps) {
1314
const [error, setError] = useState<string | null>(null)
1415
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
16+
const [showEdit, setShowEdit] = useState(false)
1517

1618
const skipFeature = useSkipFeature(projectName)
1719
const deleteFeature = useDeleteFeature(projectName)
@@ -36,6 +38,18 @@ export function FeatureModal({ feature, projectName, onClose }: FeatureModalProp
3638
}
3739
}
3840

41+
// Show edit form when in edit mode
42+
if (showEdit) {
43+
return (
44+
<EditFeatureForm
45+
feature={feature}
46+
projectName={projectName}
47+
onClose={() => setShowEdit(false)}
48+
onSaved={onClose}
49+
/>
50+
)
51+
}
52+
3953
return (
4054
<div className="neo-modal-backdrop" onClick={onClose}>
4155
<div
@@ -159,6 +173,14 @@ export function FeatureModal({ feature, projectName, onClose }: FeatureModalProp
159173
</div>
160174
) : (
161175
<div className="flex gap-3">
176+
<button
177+
onClick={() => setShowEdit(true)}
178+
disabled={skipFeature.isPending}
179+
className="neo-btn neo-btn-primary flex-1"
180+
>
181+
<Pencil size={18} />
182+
Edit
183+
</button>
162184
<button
163185
onClick={handleSkip}
164186
disabled={skipFeature.isPending}
@@ -169,7 +191,7 @@ export function FeatureModal({ feature, projectName, onClose }: FeatureModalProp
169191
) : (
170192
<>
171193
<SkipForward size={18} />
172-
Skip (Move to End)
194+
Skip
173195
</>
174196
)}
175197
</button>

0 commit comments

Comments
 (0)