From bbccaf103340cd61c9c16a23dbb0aefab5c89ad7 Mon Sep 17 00:00:00 2001 From: Connor Tyndall Date: Fri, 9 Jan 2026 07:57:12 -0600 Subject: [PATCH 1/7] feat: Add feature editing and deletion capabilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses leonvanzyl/autocoder#25 - Edit Existing Features in The UI ## Backend - Add FeatureUpdate schema for partial updates (PATCH) - Add PATCH endpoint for updating features - Add feature_create, feature_update, feature_delete MCP tools - Enable feature management tools in assistant chat ## Frontend - Add Edit mode to FeatureModal with form for category, name, description, steps - Allow Edit/Delete on completed features (not just pending) - Add useUpdateFeature mutation hook - Add friendly tool descriptions in assistant chat ## Workflow - UI: Click feature → Edit/Delete buttons for all features - Assistant: Commands like "Update feature 25 description to..." or "Delete feature 123" - For completed features, deletion removes from tracking only; suggests creating a "removal" feature if code deletion is also needed Co-Authored-By: Claude Opus 4.5 --- mcp_server/feature_mcp.py | 167 +++++++ server/routers/features.py | 48 ++ server/schemas.py | 8 + server/services/assistant_chat_session.py | 93 +++- ui/src/components/FeatureModal.tsx | 515 ++++++++++++++++------ ui/src/hooks/useAssistantChat.ts | 360 ++++++++------- ui/src/hooks/useProjects.ts | 189 +++++--- ui/src/lib/api.ts | 265 ++++++----- ui/src/lib/types.ts | 298 +++++++------ 9 files changed, 1341 insertions(+), 602 deletions(-) diff --git a/mcp_server/feature_mcp.py b/mcp_server/feature_mcp.py index 8c5f3c83..cc402dd6 100644 --- a/mcp_server/feature_mcp.py +++ b/mcp_server/feature_mcp.py @@ -15,6 +15,8 @@ - feature_mark_in_progress: Mark a feature as in-progress - feature_clear_in_progress: Clear in-progress status - feature_create_bulk: Create multiple features at once +- feature_create: Create a single feature +- feature_update: Update a feature's editable fields """ import json @@ -413,5 +415,170 @@ def feature_create_bulk( session.close() +@mcp.tool() +def feature_create( + category: Annotated[str, Field(min_length=1, max_length=100, description="Feature category (e.g., 'Authentication', 'API', 'UI')")], + name: Annotated[str, Field(min_length=1, max_length=255, description="Feature name")], + description: Annotated[str, Field(min_length=1, description="Detailed description of the feature")], + steps: Annotated[list[str], Field(min_length=1, description="List of implementation/verification steps")] +) -> str: + """Create a single feature in the project backlog. + + Use this when the user asks to add a new feature, capability, or test case. + The feature will be added with the next available priority number. + + Args: + category: Feature category for grouping (e.g., 'Authentication', 'API', 'UI') + name: Descriptive name for the feature + description: Detailed description of what this feature should do + steps: List of steps to implement or verify the feature + + Returns: + JSON with the created feature details including its ID + """ + session = get_session() + try: + # Get the next priority + max_priority_result = session.query(Feature.priority).order_by(Feature.priority.desc()).first() + next_priority = (max_priority_result[0] + 1) if max_priority_result else 1 + + db_feature = Feature( + priority=next_priority, + category=category, + name=name, + description=description, + steps=steps, + passes=False, + ) + session.add(db_feature) + session.commit() + session.refresh(db_feature) + + return json.dumps({ + "success": True, + "message": f"Created feature: {name}", + "feature": db_feature.to_dict() + }, indent=2) + except Exception as e: + session.rollback() + return json.dumps({"error": str(e)}) + finally: + session.close() + + +@mcp.tool() +def feature_update( + feature_id: Annotated[int, Field(description="The ID of the feature to update", ge=1)], + category: Annotated[str | None, Field(default=None, min_length=1, max_length=100, description="New category (optional)")] = None, + name: Annotated[str | None, Field(default=None, min_length=1, max_length=255, description="New name (optional)")] = None, + description: Annotated[str | None, Field(default=None, min_length=1, description="New description (optional)")] = None, + steps: Annotated[list[str] | None, Field(default=None, min_length=1, description="New steps list (optional)")] = None, +) -> str: + """Update an existing feature's editable fields. + + Use this when the user asks to modify, update, edit, or change a feature. + Only the provided fields will be updated; others remain unchanged. + + Cannot update: id, priority (use feature_skip), passes, in_progress (agent-controlled) + + Args: + feature_id: The ID of the feature to update + category: New category (optional) + name: New name (optional) + description: New description (optional) + steps: New steps list (optional) + + Returns: + JSON with the updated feature details, or error if not found. + """ + session = get_session() + try: + feature = session.query(Feature).filter(Feature.id == feature_id).first() + + if feature is None: + return json.dumps({"error": f"Feature with ID {feature_id} not found"}) + + # Collect updates + updates = {} + if category is not None: + updates["category"] = category + if name is not None: + updates["name"] = name + if description is not None: + updates["description"] = description + if steps is not None: + updates["steps"] = steps + + if not updates: + return json.dumps({"error": "No fields to update. Provide at least one of: category, name, description, steps"}) + + # Apply updates + for field, value in updates.items(): + setattr(feature, field, value) + + session.commit() + session.refresh(feature) + + return json.dumps({ + "success": True, + "message": f"Updated feature: {feature.name}", + "feature": feature.to_dict() + }, indent=2) + except Exception as e: + session.rollback() + return json.dumps({"error": str(e)}) + finally: + session.close() + + +@mcp.tool() +def feature_delete( + feature_id: Annotated[int, Field(description="The ID of the feature to delete", ge=1)] +) -> str: + """Delete a feature from the backlog. + + Use this when the user asks to remove, delete, or drop a feature. + This removes the feature from tracking only - any implemented code remains. + + For completed features, consider suggesting the user create a new "removal" + feature if they also want the code removed. + + Args: + feature_id: The ID of the feature to delete + + Returns: + JSON with success message and deleted feature details, or error if not found. + """ + session = get_session() + try: + feature = session.query(Feature).filter(Feature.id == feature_id).first() + + if feature is None: + return json.dumps({"error": f"Feature with ID {feature_id} not found"}) + + feature_info = feature.to_dict() + was_passing = feature.passes + + session.delete(feature) + session.commit() + + result = { + "success": True, + "message": f"Deleted feature: {feature_info['name']}", + "deleted_feature": feature_info, + } + + # Add hint for completed features + if was_passing: + result["note"] = "This feature was completed. The implemented code remains in the codebase. If you want the code removed, create a new feature describing what to remove." + + return json.dumps(result, indent=2) + except Exception as e: + session.rollback() + return json.dumps({"error": str(e)}) + finally: + session.close() + + if __name__ == "__main__": mcp.run() diff --git a/server/routers/features.py b/server/routers/features.py index 3329a68f..94a82196 100644 --- a/server/routers/features.py +++ b/server/routers/features.py @@ -16,6 +16,7 @@ FeatureCreate, FeatureListResponse, FeatureResponse, + FeatureUpdate, ) # Lazy imports to avoid circular dependencies @@ -295,3 +296,50 @@ async def skip_feature(project_name: str, feature_id: int): except Exception: logger.exception("Failed to skip feature") raise HTTPException(status_code=500, detail="Failed to skip feature") + + +@router.patch("/{feature_id}", response_model=FeatureResponse) +async def update_feature(project_name: str, feature_id: int, update: FeatureUpdate): + """ + Update a feature's editable fields (category, name, description, steps). + + Only provided fields are updated; others remain unchanged. + Cannot update: id, priority (use skip), passes, in_progress (agent-controlled). + """ + 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") + + # Get only the fields that were provided (exclude unset) + update_data = update.model_dump(exclude_unset=True) + + if not update_data: + raise HTTPException(status_code=400, detail="No fields to update") + + # Apply updates + for field, value in update_data.items(): + setattr(feature, field, value) + + 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") diff --git a/server/schemas.py b/server/schemas.py index 842906a8..9ff6a21c 100644 --- a/server/schemas.py +++ b/server/schemas.py @@ -87,6 +87,14 @@ class FeatureCreate(FeatureBase): priority: int | None = None +class FeatureUpdate(BaseModel): + """Request schema for updating a feature. All fields optional for partial updates.""" + category: str | None = Field(None, min_length=1, max_length=100) + name: str | None = Field(None, min_length=1, max_length=255) + description: str | None = Field(None, min_length=1) + steps: list[str] | None = Field(None, min_length=1) + + class FeatureResponse(FeatureBase): """Response schema for a feature.""" id: int diff --git a/server/services/assistant_chat_session.py b/server/services/assistant_chat_session.py index a9b556a8..f973cf84 100644 --- a/server/services/assistant_chat_session.py +++ b/server/services/assistant_chat_session.py @@ -29,13 +29,25 @@ # Root directory of the project ROOT_DIR = Path(__file__).parent.parent.parent -# Read-only feature MCP tools (no mark_passing, skip, create_bulk) +# Read-only feature MCP tools READONLY_FEATURE_MCP_TOOLS = [ "mcp__features__feature_get_stats", "mcp__features__feature_get_next", "mcp__features__feature_get_for_regression", ] +# Feature management tools (create/skip/update/delete but not mark_passing) +FEATURE_MANAGEMENT_TOOLS = [ + "mcp__features__feature_create", + "mcp__features__feature_create_bulk", + "mcp__features__feature_skip", + "mcp__features__feature_update", + "mcp__features__feature_delete", +] + +# Combined list for assistant +ASSISTANT_FEATURE_TOOLS = READONLY_FEATURE_MCP_TOOLS + FEATURE_MANAGEMENT_TOOLS + # Read-only built-in tools (no Write, Edit, Bash) READONLY_BUILTIN_TOOLS = [ "Read", @@ -60,17 +72,32 @@ def get_system_prompt(project_name: str, project_dir: Path) -> str: except Exception as e: logger.warning(f"Failed to read app_spec.txt: {e}") - return f"""You are a helpful project assistant for the "{project_name}" project. + return f"""You are a helpful project assistant and backlog manager for the "{project_name}" project. -Your role is to help users understand the codebase, answer questions about features, and explain how code works. You have READ-ONLY access to the project files. +Your role is to help users understand the codebase, answer questions about features, and manage the project backlog. You can READ files and CREATE/MANAGE features, but you cannot modify source code. -IMPORTANT: You CANNOT modify any files. You can only: +## What You CAN Do + +**Codebase Analysis (Read-Only):** - Read and analyze source code files - Search for patterns in the codebase - Look up documentation online - Check feature progress and status -If the user asks you to make changes, politely explain that you're a read-only assistant and they should use the main coding agent for modifications. +**Feature Management:** +- Create new features/test cases in the backlog +- Update existing features (name, description, category, steps) +- Skip features to deprioritize them (move to end of queue) +- Delete features from the backlog (removes tracking only, code remains) +- View feature statistics and progress + +## What You CANNOT Do + +- Modify, create, or delete source code files +- Mark features as passing (that requires actual implementation by the coding agent) +- Run bash commands or execute code + +If the user asks you to modify code, explain that you're a project assistant and they should use the main coding agent for implementation. ## Project Specification @@ -78,14 +105,57 @@ def get_system_prompt(project_name: str, project_dir: Path) -> str: ## Available Tools -You have access to these read-only tools: +**Code Analysis:** - **Read**: Read file contents - **Glob**: Find files by pattern (e.g., "**/*.tsx") - **Grep**: Search file contents with regex - **WebFetch/WebSearch**: Look up documentation online + +**Feature Management:** - **feature_get_stats**: Get feature completion progress - **feature_get_next**: See the next pending feature -- **feature_get_for_regression**: See passing features +- **feature_get_for_regression**: See passing features for testing +- **feature_create**: Create a single feature in the backlog +- **feature_create_bulk**: Create multiple features at once +- **feature_skip**: Move a feature to the end of the queue +- **feature_update**: Update a feature's category, name, description, or steps +- **feature_delete**: Remove a feature from the backlog (code remains) + +## Creating Features + +When a user asks to add a feature, gather the following information: +1. **Category**: A grouping like "Authentication", "API", "UI", "Database" +2. **Name**: A concise, descriptive name +3. **Description**: What the feature should do +4. **Steps**: How to verify/implement the feature (as a list) + +You can ask clarifying questions if the user's request is vague, or make reasonable assumptions for simple requests. + +## Updating Features + +When a user asks to update, modify, edit, or change a feature, use `feature_update`. +You can update any combination of: category, name, description, steps. +Only the fields you provide will be changed; others remain as-is. + +**Example interaction:** +User: "Update feature 25 to have a better description" +You: I'll update that feature's description. What should the new description be? +User: "It should be 'Implement OAuth2 authentication with Google and GitHub providers'" +You: [calls feature_update with feature_id=25 and new description] +You: Done! I've updated the description for feature 25. + +## Deleting Features + +When a user asks to remove, delete, or drop a feature, use `feature_delete`. +This removes the feature from backlog tracking only - any implemented code remains in the codebase. + +**Important:** For completed features, after deleting, suggest creating a new "removal" feature +if the user also wants the code removed. Example: +User: "Delete feature 123 and remove the implementation" +You: [calls feature_delete with feature_id=123] +You: Done! I've removed feature 123 from the backlog. Since this feature was already implemented, +the code still exists. Would you like me to create a new feature for the coding agent to remove +that implementation? ## Guidelines @@ -93,7 +163,8 @@ def get_system_prompt(project_name: str, project_dir: Path) -> str: 2. When explaining code, reference specific file paths and line numbers 3. Use the feature tools to answer questions about project progress 4. Search the codebase to find relevant information before answering -5. If you're unsure, say so rather than guessing""" +5. When creating or updating features, confirm what was done +6. If you're unsure about details, ask for clarification""" class AssistantChatSession: @@ -144,14 +215,14 @@ async def start(self) -> AsyncGenerator[dict, None]: self.conversation_id = conv.id yield {"type": "conversation_created", "conversation_id": self.conversation_id} - # Build permissions list for read-only access + # Build permissions list for assistant access (read + feature management) permissions_list = [ "Read(./**)", "Glob(./**)", "Grep(./**)", "WebFetch", "WebSearch", - *READONLY_FEATURE_MCP_TOOLS, + *ASSISTANT_FEATURE_TOOLS, ] # Create security settings file @@ -191,7 +262,7 @@ async def start(self) -> AsyncGenerator[dict, None]: model="claude-opus-4-5-20251101", cli_path=system_cli, system_prompt=system_prompt, - allowed_tools=[*READONLY_BUILTIN_TOOLS, *READONLY_FEATURE_MCP_TOOLS], + allowed_tools=[*READONLY_BUILTIN_TOOLS, *ASSISTANT_FEATURE_TOOLS], mcp_servers=mcp_servers, permission_mode="bypassPermissions", max_turns=100, diff --git a/ui/src/components/FeatureModal.tsx b/ui/src/components/FeatureModal.tsx index 6daede12..2fe6b6a0 100644 --- a/ui/src/components/FeatureModal.tsx +++ b/ui/src/components/FeatureModal.tsx @@ -1,40 +1,147 @@ -import { useState } from 'react' -import { X, CheckCircle2, Circle, SkipForward, Trash2, Loader2, AlertCircle } from 'lucide-react' -import { useSkipFeature, useDeleteFeature } from '../hooks/useProjects' -import type { Feature } from '../lib/types' +import { useState, useId, useEffect } from "react"; +import { + X, + CheckCircle2, + Circle, + SkipForward, + Trash2, + Loader2, + AlertCircle, + Pencil, + Plus, + Save, +} from "lucide-react"; +import { + useSkipFeature, + useDeleteFeature, + useUpdateFeature, +} from "../hooks/useProjects"; +import type { Feature } from "../lib/types"; + +interface Step { + id: string; + value: string; +} interface FeatureModalProps { - feature: Feature - projectName: string - onClose: () => void + feature: Feature; + projectName: string; + onClose: () => void; } -export function FeatureModal({ feature, projectName, onClose }: FeatureModalProps) { - const [error, setError] = useState(null) - const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) +export function FeatureModal({ + feature, + projectName, + onClose, +}: FeatureModalProps) { + const formId = useId(); + const [error, setError] = useState(null); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + + // Edit mode state + const [isEditing, setIsEditing] = useState(false); + const [editCategory, setEditCategory] = useState(feature.category); + const [editName, setEditName] = useState(feature.name); + const [editDescription, setEditDescription] = useState(feature.description); + const [editSteps, setEditSteps] = useState( + feature.steps.length > 0 + ? feature.steps.map((s, i) => ({ id: `${formId}-step-${i}`, value: s })) + : [{ id: `${formId}-step-0`, value: "" }], + ); + const [stepCounter, setStepCounter] = useState(feature.steps.length || 1); - const skipFeature = useSkipFeature(projectName) - const deleteFeature = useDeleteFeature(projectName) + const skipFeature = useSkipFeature(projectName); + const deleteFeature = useDeleteFeature(projectName); + const updateFeature = useUpdateFeature(projectName); + + // Reset edit form when feature changes or edit mode is exited + useEffect(() => { + if (!isEditing) { + setEditCategory(feature.category); + setEditName(feature.name); + setEditDescription(feature.description); + setEditSteps( + feature.steps.length > 0 + ? feature.steps.map((s, i) => ({ + id: `${formId}-step-${i}`, + value: s, + })) + : [{ id: `${formId}-step-0`, value: "" }], + ); + setStepCounter(feature.steps.length || 1); + } + }, [feature, isEditing, formId]); const handleSkip = async () => { - setError(null) + setError(null); try { - await skipFeature.mutateAsync(feature.id) - onClose() + await skipFeature.mutateAsync(feature.id); + onClose(); } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to skip feature') + setError(err instanceof Error ? err.message : "Failed to skip feature"); } - } + }; const handleDelete = async () => { - setError(null) + setError(null); try { - await deleteFeature.mutateAsync(feature.id) - onClose() + await deleteFeature.mutateAsync(feature.id); + onClose(); } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to delete feature') + setError(err instanceof Error ? err.message : "Failed to delete feature"); } - } + }; + + // Edit mode step management + const handleAddStep = () => { + setEditSteps([ + ...editSteps, + { id: `${formId}-step-${stepCounter}`, value: "" }, + ]); + setStepCounter(stepCounter + 1); + }; + + const handleRemoveStep = (id: string) => { + setEditSteps(editSteps.filter((step) => step.id !== id)); + }; + + const handleStepChange = (id: string, value: string) => { + setEditSteps( + editSteps.map((step) => (step.id === id ? { ...step, value } : step)), + ); + }; + + const handleSaveEdit = async () => { + setError(null); + + // Filter out empty steps + const filteredSteps = editSteps + .map((s) => s.value.trim()) + .filter((s) => s.length > 0); + + try { + await updateFeature.mutateAsync({ + featureId: feature.id, + update: { + category: editCategory.trim(), + name: editName.trim(), + description: editDescription.trim(), + steps: filteredSteps.length > 0 ? filteredSteps : undefined, + }, + }); + setIsEditing(false); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to update feature"); + } + }; + + const handleCancelEdit = () => { + setIsEditing(false); + setError(null); + }; + + const isEditValid = + editCategory.trim() && editName.trim() && editDescription.trim(); return (
@@ -45,17 +152,20 @@ export function FeatureModal({ feature, projectName, onClose }: FeatureModalProp {/* Header */}
- - {feature.category} - -

- {feature.name} -

+ {isEditing ? ( +

Edit Feature

+ ) : ( + <> + + {feature.category} + +

+ {feature.name} +

+ + )}
-
@@ -67,124 +177,275 @@ export function FeatureModal({ feature, projectName, onClose }: FeatureModalProp
{error} -
)} - {/* Status */} -
- {feature.passes ? ( - <> - - - COMPLETE - - - ) : ( - <> - - - PENDING + {isEditing ? ( + /* Edit Form */ + <> + {/* Category */} +
+ + setEditCategory(e.target.value)} + placeholder="e.g., Authentication, UI, API" + className="neo-input" + required + /> +
+ + {/* Name */} +
+ + setEditName(e.target.value)} + placeholder="e.g., User login form" + className="neo-input" + required + /> +
+ + {/* Description */} +
+ +