mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-01-30 06:12:06 +00:00
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:
@@ -17,6 +17,7 @@ from ..schemas import (
|
||||
FeatureCreate,
|
||||
FeatureListResponse,
|
||||
FeatureResponse,
|
||||
FeatureUpdate,
|
||||
)
|
||||
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")
|
||||
|
||||
|
||||
@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}")
|
||||
async def delete_feature(project_name: str, feature_id: int):
|
||||
"""Delete a feature."""
|
||||
|
||||
@@ -87,6 +87,15 @@ class FeatureCreate(FeatureBase):
|
||||
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):
|
||||
"""Response schema for a feature."""
|
||||
id: int
|
||||
|
||||
248
ui/src/components/EditFeatureForm.tsx
Normal file
248
ui/src/components/EditFeatureForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
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 { EditFeatureForm } from './EditFeatureForm'
|
||||
import type { Feature } from '../lib/types'
|
||||
|
||||
interface FeatureModalProps {
|
||||
@@ -12,6 +13,7 @@ interface FeatureModalProps {
|
||||
export function FeatureModal({ feature, projectName, onClose }: FeatureModalProps) {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [showEdit, setShowEdit] = useState(false)
|
||||
|
||||
const skipFeature = useSkipFeature(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 (
|
||||
<div className="neo-modal-backdrop" onClick={onClose}>
|
||||
<div
|
||||
@@ -159,6 +173,14 @@ export function FeatureModal({ feature, projectName, onClose }: FeatureModalProp
|
||||
</div>
|
||||
) : (
|
||||
<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
|
||||
onClick={handleSkip}
|
||||
disabled={skipFeature.isPending}
|
||||
@@ -169,7 +191,7 @@ export function FeatureModal({ feature, projectName, onClose }: FeatureModalProp
|
||||
) : (
|
||||
<>
|
||||
<SkipForward size={18} />
|
||||
Skip (Move to End)
|
||||
Skip
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
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
|
||||
@@ -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
|
||||
// ============================================================================
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
FeatureListResponse,
|
||||
Feature,
|
||||
FeatureCreate,
|
||||
FeatureUpdate,
|
||||
FeatureBulkCreate,
|
||||
FeatureBulkCreateResponse,
|
||||
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(
|
||||
projectName: string,
|
||||
bulk: FeatureBulkCreate
|
||||
|
||||
@@ -82,6 +82,14 @@ export interface FeatureCreate {
|
||||
priority?: number
|
||||
}
|
||||
|
||||
export interface FeatureUpdate {
|
||||
category?: string
|
||||
name?: string
|
||||
description?: string
|
||||
steps?: string[]
|
||||
priority?: number
|
||||
}
|
||||
|
||||
// Agent types
|
||||
export type AgentStatus = 'stopped' | 'running' | 'paused' | 'crashed'
|
||||
|
||||
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user