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>
This commit is contained in:
Auto
2026-01-14 14:54:53 +02:00
parent 07c2010d32
commit d1b8eb5f99
8 changed files with 373 additions and 4 deletions

View File

@@ -17,6 +17,7 @@ from ..schemas import (
FeatureCreate, FeatureCreate,
FeatureListResponse, FeatureListResponse,
FeatureResponse, FeatureResponse,
FeatureUpdate,
) )
from ..utils.validation import validate_project_name from ..utils.validation import validate_project_name
@@ -217,6 +218,63 @@ async def get_feature(project_name: str, feature_id: int):
raise HTTPException(status_code=500, detail="Database error occurred") raise HTTPException(status_code=500, detail="Database error occurred")
@router.patch("/{feature_id}", response_model=FeatureResponse)
async def update_feature(project_name: str, feature_id: int, update: FeatureUpdate):
"""
Update a feature's details.
Only features that are not yet completed (passes=False) can be edited.
This allows users to provide corrections or additional instructions
when the agent is stuck or implementing a feature incorrectly.
"""
project_name = validate_project_name(project_name)
project_dir = _get_project_path(project_name)
if not project_dir:
raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")
if not project_dir.exists():
raise HTTPException(status_code=404, detail="Project directory not found")
_, Feature = _get_db_classes()
try:
with get_db_session(project_dir) as session:
feature = session.query(Feature).filter(Feature.id == feature_id).first()
if not feature:
raise HTTPException(status_code=404, detail=f"Feature {feature_id} not found")
# Prevent editing completed features
if feature.passes:
raise HTTPException(
status_code=400,
detail="Cannot edit a completed feature. Features marked as done are immutable."
)
# Apply updates for non-None fields
if update.category is not None:
feature.category = update.category
if update.name is not None:
feature.name = update.name
if update.description is not None:
feature.description = update.description
if update.steps is not None:
feature.steps = update.steps
if update.priority is not None:
feature.priority = update.priority
session.commit()
session.refresh(feature)
return feature_to_response(feature)
except HTTPException:
raise
except Exception:
logger.exception("Failed to update feature")
raise HTTPException(status_code=500, detail="Failed to update feature")
@router.delete("/{feature_id}") @router.delete("/{feature_id}")
async def delete_feature(project_name: str, feature_id: int): async def delete_feature(project_name: str, feature_id: int):
"""Delete a feature.""" """Delete a feature."""

View File

@@ -87,6 +87,15 @@ class FeatureCreate(FeatureBase):
priority: int | None = None priority: int | None = None
class FeatureUpdate(BaseModel):
"""Request schema for updating a feature (partial updates allowed)."""
category: str | None = None
name: str | None = None
description: str | None = None
steps: list[str] | None = None
priority: int | None = None
class FeatureResponse(FeatureBase): class FeatureResponse(FeatureBase):
"""Response schema for a feature.""" """Response schema for a feature."""
id: int id: int

View File

@@ -0,0 +1,248 @@
import { useState, useId } from 'react'
import { X, Save, Plus, Trash2, Loader2, AlertCircle } from 'lucide-react'
import { useUpdateFeature } from '../hooks/useProjects'
import type { Feature } from '../lib/types'
interface Step {
id: string
value: string
}
interface EditFeatureFormProps {
feature: Feature
projectName: string
onClose: () => void
onSaved: () => void
}
export function EditFeatureForm({ feature, projectName, onClose, onSaved }: EditFeatureFormProps) {
const formId = useId()
const [category, setCategory] = useState(feature.category)
const [name, setName] = useState(feature.name)
const [description, setDescription] = useState(feature.description)
const [priority, setPriority] = useState(String(feature.priority))
const [steps, setSteps] = useState<Step[]>(() =>
feature.steps.length > 0
? feature.steps.map((step, i) => ({ id: `${formId}-step-${i}`, value: step }))
: [{ id: `${formId}-step-0`, value: '' }]
)
const [error, setError] = useState<string | null>(null)
const [stepCounter, setStepCounter] = useState(feature.steps.length || 1)
const updateFeature = useUpdateFeature(projectName)
const handleAddStep = () => {
setSteps([...steps, { id: `${formId}-step-${stepCounter}`, value: '' }])
setStepCounter(stepCounter + 1)
}
const handleRemoveStep = (id: string) => {
setSteps(steps.filter(step => step.id !== id))
}
const handleStepChange = (id: string, value: string) => {
setSteps(steps.map(step =>
step.id === id ? { ...step, value } : step
))
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
const filteredSteps = steps
.map(s => s.value.trim())
.filter(s => s.length > 0)
try {
await updateFeature.mutateAsync({
featureId: feature.id,
update: {
category: category.trim(),
name: name.trim(),
description: description.trim(),
steps: filteredSteps,
priority: parseInt(priority, 10),
},
})
onSaved()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update feature')
}
}
const isValid = category.trim() && name.trim() && description.trim()
// Check if any changes were made
const currentSteps = steps.map(s => s.value.trim()).filter(s => s)
const hasChanges =
category.trim() !== feature.category ||
name.trim() !== feature.name ||
description.trim() !== feature.description ||
parseInt(priority, 10) !== feature.priority ||
JSON.stringify(currentSteps) !== JSON.stringify(feature.steps)
return (
<div className="neo-modal-backdrop" onClick={onClose}>
<div
className="neo-modal w-full max-w-2xl"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between p-6 border-b-3 border-[var(--color-neo-border)]">
<h2 className="font-display text-2xl font-bold">
Edit Feature
</h2>
<button
onClick={onClose}
className="neo-btn neo-btn-ghost p-2"
>
<X size={24} />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{/* Error Message */}
{error && (
<div className="flex items-center gap-3 p-4 bg-[var(--color-neo-danger)] text-white border-3 border-[var(--color-neo-border)]">
<AlertCircle size={20} />
<span>{error}</span>
<button
type="button"
onClick={() => setError(null)}
className="ml-auto"
>
<X size={16} />
</button>
</div>
)}
{/* Category & Priority Row */}
<div className="flex gap-4">
<div className="flex-1">
<label className="block font-display font-bold mb-2 uppercase text-sm">
Category
</label>
<input
type="text"
value={category}
onChange={(e) => setCategory(e.target.value)}
placeholder="e.g., Authentication, UI, API"
className="neo-input"
required
/>
</div>
<div className="w-32">
<label className="block font-display font-bold mb-2 uppercase text-sm">
Priority
</label>
<input
type="number"
value={priority}
onChange={(e) => setPriority(e.target.value)}
min="1"
className="neo-input"
required
/>
</div>
</div>
{/* Name */}
<div>
<label className="block font-display font-bold mb-2 uppercase text-sm">
Feature Name
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., User login form"
className="neo-input"
required
/>
</div>
{/* Description */}
<div>
<label className="block font-display font-bold mb-2 uppercase text-sm">
Description
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Describe what this feature should do..."
className="neo-input min-h-[100px] resize-y"
required
/>
</div>
{/* Steps */}
<div>
<label className="block font-display font-bold mb-2 uppercase text-sm">
Test Steps
</label>
<div className="space-y-2">
{steps.map((step, index) => (
<div key={step.id} className="flex gap-2">
<span className="neo-input w-12 text-center flex-shrink-0 flex items-center justify-center">
{index + 1}
</span>
<input
type="text"
value={step.value}
onChange={(e) => handleStepChange(step.id, e.target.value)}
placeholder="Describe this step..."
className="neo-input flex-1"
/>
{steps.length > 1 && (
<button
type="button"
onClick={() => handleRemoveStep(step.id)}
className="neo-btn neo-btn-ghost p-2"
>
<Trash2 size={18} />
</button>
)}
</div>
))}
</div>
<button
type="button"
onClick={handleAddStep}
className="neo-btn neo-btn-ghost mt-2 text-sm"
>
<Plus size={16} />
Add Step
</button>
</div>
{/* Actions */}
<div className="flex gap-3 pt-4 border-t-3 border-[var(--color-neo-border)]">
<button
type="submit"
disabled={!isValid || !hasChanges || updateFeature.isPending}
className="neo-btn neo-btn-success flex-1"
>
{updateFeature.isPending ? (
<Loader2 size={18} className="animate-spin" />
) : (
<>
<Save size={18} />
Save Changes
</>
)}
</button>
<button
type="button"
onClick={onClose}
className="neo-btn neo-btn-ghost"
>
Cancel
</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -1,6 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import { X, CheckCircle2, Circle, SkipForward, Trash2, Loader2, AlertCircle } from 'lucide-react' import { X, CheckCircle2, Circle, SkipForward, Trash2, Loader2, AlertCircle, Pencil } from 'lucide-react'
import { useSkipFeature, useDeleteFeature } from '../hooks/useProjects' import { useSkipFeature, useDeleteFeature } from '../hooks/useProjects'
import { EditFeatureForm } from './EditFeatureForm'
import type { Feature } from '../lib/types' import type { Feature } from '../lib/types'
interface FeatureModalProps { interface FeatureModalProps {
@@ -12,6 +13,7 @@ interface FeatureModalProps {
export function FeatureModal({ feature, projectName, onClose }: FeatureModalProps) { export function FeatureModal({ feature, projectName, onClose }: FeatureModalProps) {
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [showEdit, setShowEdit] = useState(false)
const skipFeature = useSkipFeature(projectName) const skipFeature = useSkipFeature(projectName)
const deleteFeature = useDeleteFeature(projectName) const deleteFeature = useDeleteFeature(projectName)
@@ -36,6 +38,18 @@ export function FeatureModal({ feature, projectName, onClose }: FeatureModalProp
} }
} }
// Show edit form when in edit mode
if (showEdit) {
return (
<EditFeatureForm
feature={feature}
projectName={projectName}
onClose={() => setShowEdit(false)}
onSaved={onClose}
/>
)
}
return ( return (
<div className="neo-modal-backdrop" onClick={onClose}> <div className="neo-modal-backdrop" onClick={onClose}>
<div <div
@@ -159,6 +173,14 @@ export function FeatureModal({ feature, projectName, onClose }: FeatureModalProp
</div> </div>
) : ( ) : (
<div className="flex gap-3"> <div className="flex gap-3">
<button
onClick={() => setShowEdit(true)}
disabled={skipFeature.isPending}
className="neo-btn neo-btn-primary flex-1"
>
<Pencil size={18} />
Edit
</button>
<button <button
onClick={handleSkip} onClick={handleSkip}
disabled={skipFeature.isPending} disabled={skipFeature.isPending}
@@ -169,7 +191,7 @@ export function FeatureModal({ feature, projectName, onClose }: FeatureModalProp
) : ( ) : (
<> <>
<SkipForward size={18} /> <SkipForward size={18} />
Skip (Move to End) Skip
</> </>
)} )}
</button> </button>

View File

@@ -4,7 +4,7 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import * as api from '../lib/api' import * as api from '../lib/api'
import type { FeatureCreate, ModelsResponse, Settings, SettingsUpdate } from '../lib/types' import type { FeatureCreate, FeatureUpdate, ModelsResponse, Settings, SettingsUpdate } from '../lib/types'
// ============================================================================ // ============================================================================
// Projects // Projects
@@ -94,6 +94,18 @@ export function useSkipFeature(projectName: string) {
}) })
} }
export function useUpdateFeature(projectName: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ featureId, update }: { featureId: number; update: FeatureUpdate }) =>
api.updateFeature(projectName, featureId, update),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['features', projectName] })
},
})
}
// ============================================================================ // ============================================================================
// Agent // Agent
// ============================================================================ // ============================================================================

View File

@@ -9,6 +9,7 @@ import type {
FeatureListResponse, FeatureListResponse,
Feature, Feature,
FeatureCreate, FeatureCreate,
FeatureUpdate,
FeatureBulkCreate, FeatureBulkCreate,
FeatureBulkCreateResponse, FeatureBulkCreateResponse,
AgentStatusResponse, AgentStatusResponse,
@@ -119,6 +120,17 @@ export async function skipFeature(projectName: string, featureId: number): Promi
}) })
} }
export async function updateFeature(
projectName: string,
featureId: number,
update: FeatureUpdate
): Promise<Feature> {
return fetchJSON(`/projects/${encodeURIComponent(projectName)}/features/${featureId}`, {
method: 'PATCH',
body: JSON.stringify(update),
})
}
export async function createFeaturesBulk( export async function createFeaturesBulk(
projectName: string, projectName: string,
bulk: FeatureBulkCreate bulk: FeatureBulkCreate

View File

@@ -82,6 +82,14 @@ export interface FeatureCreate {
priority?: number priority?: number
} }
export interface FeatureUpdate {
category?: string
name?: string
description?: string
steps?: string[]
priority?: number
}
// Agent types // Agent types
export type AgentStatus = 'stopped' | 'running' | 'paused' | 'crashed' export type AgentStatus = 'stopped' | 'running' | 'paused' | 'crashed'

View File

@@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/addfeatureform.tsx","./src/components/agentcontrol.tsx","./src/components/agentthought.tsx","./src/components/assistantchat.tsx","./src/components/assistantfab.tsx","./src/components/assistantpanel.tsx","./src/components/chatmessage.tsx","./src/components/confirmdialog.tsx","./src/components/debuglogviewer.tsx","./src/components/devservercontrol.tsx","./src/components/expandprojectchat.tsx","./src/components/expandprojectmodal.tsx","./src/components/featurecard.tsx","./src/components/featuremodal.tsx","./src/components/folderbrowser.tsx","./src/components/kanbanboard.tsx","./src/components/kanbancolumn.tsx","./src/components/newprojectmodal.tsx","./src/components/progressdashboard.tsx","./src/components/projectselector.tsx","./src/components/questionoptions.tsx","./src/components/settingsmodal.tsx","./src/components/setupwizard.tsx","./src/components/speccreationchat.tsx","./src/components/terminal.tsx","./src/components/terminaltabs.tsx","./src/components/typingindicator.tsx","./src/hooks/useassistantchat.ts","./src/hooks/usecelebration.ts","./src/hooks/useexpandchat.ts","./src/hooks/usefeaturesound.ts","./src/hooks/useprojects.ts","./src/hooks/usespecchat.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/lib/types.ts"],"version":"5.6.3"} {"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/addfeatureform.tsx","./src/components/agentcontrol.tsx","./src/components/agentthought.tsx","./src/components/assistantchat.tsx","./src/components/assistantfab.tsx","./src/components/assistantpanel.tsx","./src/components/chatmessage.tsx","./src/components/confirmdialog.tsx","./src/components/debuglogviewer.tsx","./src/components/devservercontrol.tsx","./src/components/editfeatureform.tsx","./src/components/expandprojectchat.tsx","./src/components/expandprojectmodal.tsx","./src/components/featurecard.tsx","./src/components/featuremodal.tsx","./src/components/folderbrowser.tsx","./src/components/kanbanboard.tsx","./src/components/kanbancolumn.tsx","./src/components/newprojectmodal.tsx","./src/components/progressdashboard.tsx","./src/components/projectselector.tsx","./src/components/questionoptions.tsx","./src/components/settingsmodal.tsx","./src/components/setupwizard.tsx","./src/components/speccreationchat.tsx","./src/components/terminal.tsx","./src/components/terminaltabs.tsx","./src/components/typingindicator.tsx","./src/hooks/useassistantchat.ts","./src/hooks/usecelebration.ts","./src/hooks/useexpandchat.ts","./src/hooks/usefeaturesound.ts","./src/hooks/useprojects.ts","./src/hooks/usespecchat.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/lib/types.ts"],"version":"5.6.3"}