From 77cbd9a6904fa459bfeedcead8c1fba70781d086 Mon Sep 17 00:00:00 2001 From: John Fitzpatrick Date: Fri, 9 Jan 2026 08:44:04 -0700 Subject: [PATCH 1/8] feat: Enable LAN access for Web UI server Changes: - Update CORS to allow all origins for LAN accessibility - Disable localhost-only security middleware - Bind uvicorn to 0.0.0.0 instead of 127.0.0.1 in server/main.py - Update start_ui.py to use 0.0.0.0 for socket binding - Update server startup messages to reflect new binding address This allows the AutoCoder Web UI to be accessed from other machines on the local network, enabling remote development workflows. Note: This reduces security - only use on trusted networks. Co-Authored-By: Claude Opus 4.5 --- server/main.py | 34 +++++++++++++++------------------- start_ui.py | 12 ++++++------ ui/package-lock.json | 16 ++++++++++++++-- ui/tsconfig.tsbuildinfo | 2 +- 4 files changed, 36 insertions(+), 28 deletions(-) diff --git a/server/main.py b/server/main.py index 72c7e730..53e09e2d 100644 --- a/server/main.py +++ b/server/main.py @@ -53,15 +53,10 @@ async def lifespan(app: FastAPI): lifespan=lifespan, ) -# CORS - allow only localhost origins for security +# CORS - allow all origins for LAN access app.add_middleware( CORSMiddleware, - allow_origins=[ - "http://localhost:5173", # Vite dev server - "http://127.0.0.1:5173", - "http://localhost:8888", # Production - "http://127.0.0.1:8888", - ], + allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], @@ -69,19 +64,20 @@ async def lifespan(app: FastAPI): # ============================================================================ -# Security Middleware +# Security Middleware (disabled for LAN access) # ============================================================================ -@app.middleware("http") -async def require_localhost(request: Request, call_next): - """Only allow requests from localhost.""" - client_host = request.client.host if request.client else None - - # Allow localhost connections - if client_host not in ("127.0.0.1", "::1", "localhost", None): - raise HTTPException(status_code=403, detail="Localhost access only") - - return await call_next(request) +# NOTE: Localhost restriction removed to allow LAN access +# @app.middleware("http") +# async def require_localhost(request: Request, call_next): +# """Only allow requests from localhost.""" +# client_host = request.client.host if request.client else None +# +# # Allow localhost connections +# if client_host not in ("127.0.0.1", "::1", "localhost", None): +# raise HTTPException(status_code=403, detail="Localhost access only") +# +# return await call_next(request) # ============================================================================ @@ -179,7 +175,7 @@ async def serve_spa(path: str): import uvicorn uvicorn.run( "server.main:app", - host="127.0.0.1", # Localhost only for security + host="0.0.0.0", # LAN accessible port=8888, reload=True, ) diff --git a/start_ui.py b/start_ui.py index 267ae12d..8adddf9e 100644 --- a/start_ui.py +++ b/start_ui.py @@ -44,7 +44,7 @@ def find_available_port(start: int = 8888, max_attempts: int = 10) -> int: for port in range(start, start + max_attempts): try: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.bind(("127.0.0.1", port)) + s.bind(("0.0.0.0", port)) return port except OSError: continue @@ -158,14 +158,14 @@ def start_dev_server(port: int) -> tuple: venv_python = get_venv_python() print("\n Starting development servers...") - print(f" - FastAPI backend: http://127.0.0.1:{port}") + print(f" - FastAPI backend: http://0.0.0.0:{port}") print(" - Vite frontend: http://127.0.0.1:5173") # Start FastAPI backend = subprocess.Popen([ str(venv_python), "-m", "uvicorn", "server.main:app", - "--host", "127.0.0.1", + "--host", "0.0.0.0", "--port", str(port), "--reload" ], cwd=str(ROOT)) @@ -185,12 +185,12 @@ def start_production_server(port: int): """Start FastAPI server in production mode.""" venv_python = get_venv_python() - print(f"\n Starting server at http://127.0.0.1:{port}") + print(f"\n Starting server at http://0.0.0.0:{port}") return subprocess.Popen([ str(venv_python), "-m", "uvicorn", "server.main:app", - "--host", "127.0.0.1", + "--host", "0.0.0.0", "--port", str(port) ], cwd=str(ROOT)) @@ -280,7 +280,7 @@ def main() -> None: webbrowser.open(f"http://127.0.0.1:{port}") print("\n" + "=" * 50) - print(f" Server running at http://127.0.0.1:{port}") + print(f" Server running at http://0.0.0.0:{port}") print(" Press Ctrl+C to stop") print("=" * 50) diff --git a/ui/package-lock.json b/ui/package-lock.json index e3df4462..f98aa50b 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -1,11 +1,11 @@ { - "name": "autonomous-coding-ui", + "name": "autocoder", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "autonomous-coding-ui", + "name": "autocoder", "version": "1.0.0", "dependencies": { "@radix-ui/react-dialog": "^1.1.2", @@ -66,6 +66,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -2323,6 +2324,7 @@ "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -2334,6 +2336,7 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -2383,6 +2386,7 @@ "integrity": "sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.51.0", "@typescript-eslint/types": "8.51.0", @@ -2634,6 +2638,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2751,6 +2756,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3018,6 +3024,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3997,6 +4004,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -4058,6 +4066,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -4070,6 +4079,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -4363,6 +4373,7 @@ "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4485,6 +4496,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/ui/tsconfig.tsbuildinfo b/ui/tsconfig.tsbuildinfo index fd98d1fc..973c5f0f 100644 --- a/ui/tsconfig.tsbuildinfo +++ b/ui/tsconfig.tsbuildinfo @@ -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/debuglogviewer.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/typingindicator.tsx","./src/hooks/useassistantchat.ts","./src/hooks/usecelebration.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"} \ No newline at end of file +{"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/DebugLogViewer.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/SetupWizard.tsx","./src/components/SpecCreationChat.tsx","./src/components/TypingIndicator.tsx","./src/hooks/useAssistantChat.ts","./src/hooks/useCelebration.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"} \ No newline at end of file From 517921987416ab0e444564cd5d98c2eb5b67bf24 Mon Sep 17 00:00:00 2001 From: John Fitzpatrick Date: Fri, 9 Jan 2026 14:31:34 -0700 Subject: [PATCH 2/8] fix: Improve stability and add database repair tools - Add safe_print() with retry logic to handle EAGAIN errors when stdout pipe buffer is full, preventing session crashes - Add duplicate detection to feature_create_bulk to skip existing features - Add feature_db_repair MCP tool and /api/projects/{name}/features/repair endpoint to remove duplicates and compact IDs - Fix notification flicker in AgentThought component by stabilizing visibility state transitions - Fix React key warning in KanbanColumn by using composite key Co-Authored-By: Claude Opus 4.5 --- agent.py | 45 +++++++++--- mcp_server/feature_mcp.py | 110 ++++++++++++++++++++++++++++- server/routers/features.py | 86 ++++++++++++++++++++++ ui/src/components/AgentThought.tsx | 17 +++-- ui/src/components/KanbanColumn.tsx | 2 +- 5 files changed, 242 insertions(+), 18 deletions(-) diff --git a/agent.py b/agent.py index e4d0de49..df8692ee 100644 --- a/agent.py +++ b/agent.py @@ -8,6 +8,7 @@ import asyncio import io import sys +import time from pathlib import Path from typing import Optional @@ -19,6 +20,30 @@ sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace') + +def safe_print(*args, **kwargs) -> None: + """ + Print with retry logic to handle EAGAIN errors. + + When stdout is a pipe (subprocess), the buffer can fill up causing + BlockingIOError (errno 11). This function retries with backoff. + """ + max_retries = 5 + for attempt in range(max_retries): + try: + print(*args, **kwargs) + return + except BlockingIOError: + if attempt < max_retries - 1: + time.sleep(0.1 * (attempt + 1)) # Backoff: 0.1s, 0.2s, 0.3s... + else: + # Last resort: try without flush + kwargs.pop('flush', None) + try: + print(*args, **kwargs) + except Exception: + pass # Give up silently + from client import create_client from progress import has_features, print_progress_summary, print_session_header from prompts import ( @@ -50,7 +75,7 @@ async def run_agent_session( - "continue" if agent should continue working - "error" if an error occurred """ - print("Sending prompt to Claude Agent SDK...\n") + safe_print("Sending prompt to Claude Agent SDK...\n") try: # Send the query @@ -68,15 +93,15 @@ async def run_agent_session( if block_type == "TextBlock" and hasattr(block, "text"): response_text += block.text - print(block.text, end="", flush=True) + safe_print(block.text, end="", flush=True) elif block_type == "ToolUseBlock" and hasattr(block, "name"): - print(f"\n[Tool: {block.name}]", flush=True) + safe_print(f"\n[Tool: {block.name}]", flush=True) if hasattr(block, "input"): input_str = str(block.input) if len(input_str) > 200: - print(f" Input: {input_str[:200]}...", flush=True) + safe_print(f" Input: {input_str[:200]}...", flush=True) else: - print(f" Input: {input_str}", flush=True) + safe_print(f" Input: {input_str}", flush=True) # Handle UserMessage (tool results) elif msg_type == "UserMessage" and hasattr(msg, "content"): @@ -89,20 +114,20 @@ async def run_agent_session( # Check if command was blocked by security hook if "blocked" in str(result_content).lower(): - print(f" [BLOCKED] {result_content}", flush=True) + safe_print(f" [BLOCKED] {result_content}", flush=True) elif is_error: # Show errors (truncated) error_str = str(result_content)[:500] - print(f" [Error] {error_str}", flush=True) + safe_print(f" [Error] {error_str}", flush=True) else: # Tool succeeded - just show brief confirmation - print(" [Done]", flush=True) + safe_print(" [Done]", flush=True) - print("\n" + "-" * 70 + "\n") + safe_print("\n" + "-" * 70 + "\n") return "continue", response_text except Exception as e: - print(f"Error during agent session: {e}") + safe_print(f"Error during agent session: {e}") return "error", str(e) diff --git a/mcp_server/feature_mcp.py b/mcp_server/feature_mcp.py index 8c5f3c83..a76590b5 100644 --- a/mcp_server/feature_mcp.py +++ b/mcp_server/feature_mcp.py @@ -365,6 +365,9 @@ def feature_create_bulk( Features are assigned sequential priorities based on their order. All features start with passes=false. + Duplicate detection: Features with the same name as existing features + are skipped to prevent duplicates. + This is typically used by the initializer agent to set up the initial feature list from the app specification. @@ -376,15 +379,23 @@ def feature_create_bulk( - steps (list[str]): Implementation/test steps Returns: - JSON with: created (int) - number of features created + JSON with: created (int), skipped (int), skipped_names (list) """ session = get_session() try: + # Get existing feature names for duplicate detection + existing_names = set( + name[0] for name in session.query(Feature.name).all() + ) + # Get the starting priority max_priority_result = session.query(Feature.priority).order_by(Feature.priority.desc()).first() start_priority = (max_priority_result[0] + 1) if max_priority_result else 1 created_count = 0 + skipped_count = 0 + skipped_names = [] + for i, feature_data in enumerate(features): # Validate required fields if not all(key in feature_data for key in ["category", "name", "description", "steps"]): @@ -392,8 +403,17 @@ def feature_create_bulk( "error": f"Feature at index {i} missing required fields (category, name, description, steps)" }) + # Skip duplicates + if feature_data["name"] in existing_names: + skipped_count += 1 + skipped_names.append(feature_data["name"]) + continue + + # Add to existing names to catch duplicates within this batch + existing_names.add(feature_data["name"]) + db_feature = Feature( - priority=start_priority + i, + priority=start_priority + created_count, category=feature_data["category"], name=feature_data["name"], description=feature_data["description"], @@ -405,7 +425,91 @@ def feature_create_bulk( session.commit() - return json.dumps({"created": created_count}, indent=2) + result = {"created": created_count, "skipped": skipped_count} + if skipped_names: + result["skipped_names"] = skipped_names[:10] # Limit to first 10 + if len(skipped_names) > 10: + result["skipped_names"].append(f"... and {len(skipped_names) - 10} more") + + return json.dumps(result, indent=2) + except Exception as e: + session.rollback() + return json.dumps({"error": str(e)}) + finally: + session.close() + + +@mcp.tool() +def feature_db_repair() -> str: + """Repair the feature database by removing duplicates and compacting IDs. + + This tool performs the following repairs: + 1. Removes duplicate features (keeping the one with lowest ID) + 2. Compacts IDs to be sequential (1, 2, 3, ...) with no gaps + 3. Resets priorities to match the new sequential IDs + + Use this if the database has inconsistencies like duplicate IDs or gaps. + + Returns: + JSON with: duplicates_removed (int), ids_compacted (bool), + old_max_id (int), new_max_id (int), total_features (int) + """ + session = get_session() + try: + from sqlalchemy import text + + # Step 1: Find and remove duplicates (keep lowest ID for each name) + duplicates_query = """ + SELECT id FROM features + WHERE id NOT IN ( + SELECT MIN(id) FROM features GROUP BY name + ) + """ + result = session.execute(text(duplicates_query)) + duplicate_ids = [row[0] for row in result.fetchall()] + duplicates_removed = len(duplicate_ids) + + if duplicate_ids: + session.execute( + text(f"DELETE FROM features WHERE id IN ({','.join(map(str, duplicate_ids))})") + ) + session.commit() + + # Step 2: Get current state + all_features = session.query(Feature).order_by(Feature.priority.asc(), Feature.id.asc()).all() + old_max_id = max(f.id for f in all_features) if all_features else 0 + total_features = len(all_features) + + # Step 3: Check if compaction is needed + expected_ids = set(range(1, total_features + 1)) + actual_ids = set(f.id for f in all_features) + needs_compaction = expected_ids != actual_ids + + new_max_id = old_max_id + if needs_compaction and all_features: + # Create a mapping from old ID to new ID + # We need to use raw SQL to avoid SQLAlchemy's identity map issues + + # First, shift all IDs to negative to avoid conflicts + session.execute(text("UPDATE features SET id = -id")) + session.commit() + + # Then assign new sequential IDs + for new_id, feature in enumerate(all_features, start=1): + session.execute( + text(f"UPDATE features SET id = {new_id}, priority = {new_id} WHERE id = {-feature.id}") + ) + session.commit() + + new_max_id = total_features + + return json.dumps({ + "duplicates_removed": duplicates_removed, + "ids_compacted": needs_compaction, + "old_max_id": old_max_id, + "new_max_id": new_max_id, + "total_features": total_features + }, indent=2) except Exception as e: session.rollback() return json.dumps({"error": str(e)}) diff --git a/server/routers/features.py b/server/routers/features.py index 3329a68f..e18c5e46 100644 --- a/server/routers/features.py +++ b/server/routers/features.py @@ -257,6 +257,92 @@ async def delete_feature(project_name: str, feature_id: int): raise HTTPException(status_code=500, detail="Failed to delete feature") +@router.post("/repair") +async def repair_database(project_name: str): + """ + Repair the feature database by removing duplicates and compacting IDs. + + Performs the following repairs: + 1. Removes duplicate features (keeping the one with lowest ID) + 2. Compacts IDs to be sequential (1, 2, 3, ...) with no gaps + 3. Resets priorities to match the new sequential IDs + """ + from sqlalchemy import text + + 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") + + db_file = project_dir / "features.db" + if not db_file.exists(): + return {"duplicates_removed": 0, "ids_compacted": False, "total_features": 0} + + _, Feature = _get_db_classes() + + try: + with get_db_session(project_dir) as session: + # Step 1: Find and remove duplicates (keep lowest ID for each name) + duplicates_query = """ + SELECT id FROM features + WHERE id NOT IN ( + SELECT MIN(id) FROM features GROUP BY name + ) + """ + result = session.execute(text(duplicates_query)) + duplicate_ids = [row[0] for row in result.fetchall()] + duplicates_removed = len(duplicate_ids) + + if duplicate_ids: + session.execute( + text(f"DELETE FROM features WHERE id IN ({','.join(map(str, duplicate_ids))})") + ) + session.commit() + + # Step 2: Get current state + all_features = session.query(Feature).order_by(Feature.priority.asc(), Feature.id.asc()).all() + old_max_id = max(f.id for f in all_features) if all_features else 0 + total_features = len(all_features) + + # Step 3: Check if compaction is needed + expected_ids = set(range(1, total_features + 1)) + actual_ids = set(f.id for f in all_features) + needs_compaction = expected_ids != actual_ids + + new_max_id = old_max_id + if needs_compaction and all_features: + # First, shift all IDs to negative to avoid conflicts + session.execute(text("UPDATE features SET id = -id")) + session.commit() + + # Then assign new sequential IDs + for new_id, feature in enumerate(all_features, start=1): + session.execute( + text(f"UPDATE features SET id = {new_id}, priority = {new_id} WHERE id = {-feature.id}") + ) + session.commit() + + new_max_id = total_features + + return { + "success": True, + "duplicates_removed": duplicates_removed, + "ids_compacted": needs_compaction, + "old_max_id": old_max_id, + "new_max_id": new_max_id, + "total_features": total_features + } + except HTTPException: + raise + except Exception: + logger.exception("Failed to repair database") + raise HTTPException(status_code=500, detail="Failed to repair database") + + @router.patch("/{feature_id}/skip") async def skip_feature(project_name: str, feature_id: int): """ diff --git a/ui/src/components/AgentThought.tsx b/ui/src/components/AgentThought.tsx index 8cc85084..bb763cc2 100644 --- a/ui/src/components/AgentThought.tsx +++ b/ui/src/components/AgentThought.tsx @@ -60,18 +60,22 @@ export function AgentThought({ logs, agentStatus }: AgentThoughtProps) { : 0 // Determine if component should be visible + // Use displayedThought for visibility check to prevent flickering when + // new logs come in without a valid thought const shouldShow = useMemo(() => { - if (!thought) return false + const hasContent = thought || displayedThought + if (!hasContent) return false if (agentStatus === 'running') return true if (agentStatus === 'paused') { return Date.now() - lastLogTimestamp < IDLE_TIMEOUT } return false - }, [thought, agentStatus, lastLogTimestamp]) + }, [thought, displayedThought, agentStatus, lastLogTimestamp]) // Animate text changes using CSS transitions + // Only update displayedThought when we have a new valid thought useEffect(() => { - if (thought !== displayedThought && thought) { + if (thought && thought !== displayedThought) { // Fade out setTextVisible(false) // After fade out, update text and fade in @@ -89,11 +93,16 @@ export function AgentThought({ logs, agentStatus }: AgentThoughtProps) { setIsVisible(true) } else { // Delay hiding to allow exit animation - const timeout = setTimeout(() => setIsVisible(false), 300) + const timeout = setTimeout(() => { + setIsVisible(false) + // Clear displayed thought only after fully hidden + setDisplayedThought(null) + }, 300) return () => clearTimeout(timeout) } }, [shouldShow]) + // Don't render if not visible or no content to display if (!isVisible || !displayedThought) return null const isRunning = agentStatus === 'running' diff --git a/ui/src/components/KanbanColumn.tsx b/ui/src/components/KanbanColumn.tsx index b1486d5f..4e5a8197 100644 --- a/ui/src/components/KanbanColumn.tsx +++ b/ui/src/components/KanbanColumn.tsx @@ -47,7 +47,7 @@ export function KanbanColumn({ ) : ( features.map((feature, index) => (
From ef5531145194effbbf9edf87c5aef431e9d2b948 Mon Sep 17 00:00:00 2001 From: John Fitzpatrick Date: Fri, 9 Jan 2026 22:21:06 -0700 Subject: [PATCH 3/8] feat: Stop agent when all features are complete Add completion check to prevent wasting API credits when no work remains: - Add all_features_complete() function to progress.py - Check at start of each iteration if all features are passing - Print friendly message and exit gracefully when complete - Skip check on first run (initializer hasn't created features yet) Co-Authored-By: Claude Opus 4.5 --- agent.py | 13 ++++++++++++- progress.py | 15 +++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/agent.py b/agent.py index df8692ee..f45ad13c 100644 --- a/agent.py +++ b/agent.py @@ -45,7 +45,7 @@ def safe_print(*args, **kwargs) -> None: pass # Give up silently from client import create_client -from progress import has_features, print_progress_summary, print_session_header +from progress import all_features_complete, has_features, print_progress_summary, print_session_header from prompts import ( copy_spec_to_project, get_coding_prompt, @@ -196,6 +196,17 @@ async def run_autonomous_agent( print("To continue, run the script again without --max-iterations") break + # Check if all features are complete (skip on first run - initializer hasn't created features yet) + if not is_first_run and all_features_complete(project_dir): + print("\n" + "=" * 70) + print(" ALL FEATURES COMPLETE!") + print("=" * 70) + print("\nAll features have been implemented and are passing.") + print("The agent will now stop to save API credits.") + print("\nTo add more features, use the UI or add them to the database,") + print("then restart the agent.") + break + # Print session header print_session_header(iteration, is_first_run) diff --git a/progress.py b/progress.py index dfb700b4..fbea8768 100644 --- a/progress.py +++ b/progress.py @@ -17,6 +17,21 @@ PROGRESS_CACHE_FILE = ".progress_cache" +def all_features_complete(project_dir: Path) -> bool: + """ + Check if all features in the project are complete (passing). + + Returns True if: + - There are features AND all of them are passing + + Returns False if: + - No features exist, OR + - There are pending/failing features + """ + passing, in_progress, total = count_passing_tests(project_dir) + return total > 0 and passing == total + + def has_features(project_dir: Path) -> bool: """ Check if the project has features in the database. From cb47623faa9ef414a8e8ca5a076ec98d8a86658c Mon Sep 17 00:00:00 2001 From: John Fitzpatrick Date: Sun, 11 Jan 2026 14:05:27 -0700 Subject: [PATCH 4/8] fix: Register projects created via /create-spec and clear cache on switch Two fixes for UI project management: 1. Projects created via /create-spec now automatically register in the autocoder registry so they appear in the UI dropdown immediately. Added register_project.py script and updated create-spec command. 2. Fixed stale data showing when switching projects. React Query cache is now cleared for the previous project before switching, preventing progress bar and features from showing wrong project's data. Co-Authored-By: Claude Opus 4.5 --- .claude/commands/create-spec.md | 19 ++++++++++ register_project.py | 65 +++++++++++++++++++++++++++++++++ ui/src/App.tsx | 8 +++- ui/tsconfig.tsbuildinfo | 2 +- 4 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 register_project.py diff --git a/.claude/commands/create-spec.md b/.claude/commands/create-spec.md index f8cae28e..a992c493 100644 --- a/.claude/commands/create-spec.md +++ b/.claude/commands/create-spec.md @@ -518,6 +518,25 @@ Write this JSON file: - The UI polls this file to detect completion and show the Continue button - If the user asks for additional changes after you've written this, you may update it again when the new changes are complete +## 4. Register the Project (REQUIRED) + +**After writing the status file**, register the project so it appears in the AutoCoder UI. + +Run this command using Bash: + +```bash +python /home/john/autocoder/register_project.py "" "$ARGUMENTS" +``` + +Where `` is derived from the project path (the last directory component, e.g., `my-app` from `~/projects/my-app`). + +**Example:** +```bash +python /home/john/autocoder/register_project.py "my-app" "/home/john/projects/my-app" +``` + +**Note:** If the project is already registered, this will succeed silently. This ensures projects created via `/create-spec` appear in the UI dropdown. + --- # AFTER FILE GENERATION: NEXT STEPS diff --git a/register_project.py b/register_project.py new file mode 100644 index 00000000..c023e09e --- /dev/null +++ b/register_project.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +""" +Register Project Script +======================== + +Simple CLI script to register a project in the autocoder registry. +Called by the /create-spec command after generating spec files. + +Usage: + python register_project.py + +Example: + python register_project.py my-app ~/projects/my-app +""" + +import sys +from pathlib import Path + +# Add parent directory to path so we can import registry +sys.path.insert(0, str(Path(__file__).parent)) + +from registry import register_project, get_project_path, RegistryError + + +def main(): + if len(sys.argv) != 3: + print("Usage: python register_project.py ", file=sys.stderr) + sys.exit(1) + + name = sys.argv[1] + path = Path(sys.argv[2]).expanduser().resolve() + + # Check if already registered + existing_path = get_project_path(name) + if existing_path: + if existing_path.resolve() == path: + print(f"Project '{name}' is already registered at {path}") + sys.exit(0) + else: + print(f"Project '{name}' is already registered at a different path: {existing_path}", file=sys.stderr) + sys.exit(1) + + # Validate path exists + if not path.exists(): + print(f"Error: Path does not exist: {path}", file=sys.stderr) + sys.exit(1) + + if not path.is_dir(): + print(f"Error: Path is not a directory: {path}", file=sys.stderr) + sys.exit(1) + + # Register the project + try: + register_project(name, path) + print(f"Registered project '{name}' at {path}") + except RegistryError as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + except ValueError as e: + print(f"Invalid project name: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 328a31b7..98458a3c 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -55,6 +55,12 @@ function App() { // Persist selected project to localStorage const handleSelectProject = useCallback((project: string | null) => { + // Invalidate old project's cached data to prevent stale data showing + if (selectedProject && selectedProject !== project) { + queryClient.removeQueries({ queryKey: ['features', selectedProject] }) + queryClient.removeQueries({ queryKey: ['agent-status', selectedProject] }) + } + setSelectedProject(project) try { if (project) { @@ -65,7 +71,7 @@ function App() { } catch { // localStorage not available } - }, []) + }, [selectedProject, queryClient]) // Validate stored project exists (clear if project was deleted) useEffect(() => { diff --git a/ui/tsconfig.tsbuildinfo b/ui/tsconfig.tsbuildinfo index b2e71fb3..0d2d7a29 100644 --- a/ui/tsconfig.tsbuildinfo +++ b/ui/tsconfig.tsbuildinfo @@ -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/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/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"} \ No newline at end of file +{"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/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/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"} \ No newline at end of file From 4223fc037985b8cf3f1d3c0f6ff5b55f7346b366 Mon Sep 17 00:00:00 2001 From: John Fitzpatrick Date: Sun, 11 Jan 2026 22:53:12 -0700 Subject: [PATCH 5/8] feat: Improve headless server support and expand command allowlist - Configure Playwright MCP to use bundled Chromium with --headless and --no-sandbox - Auto-detect Playwright's Chromium path for server environments - Add pgrep, cd, jq to allowed bash commands - Allow pkill for playwright, chrome, chromium processes Co-Authored-By: Claude Opus 4.5 --- client.py | 15 +++++++++++---- security.py | 7 +++++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/client.py b/client.py index c0582767..f55c296f 100644 --- a/client.py +++ b/client.py @@ -212,10 +212,17 @@ def create_client(project_dir: Path, model: str, yolo_mode: bool = False): } if not yolo_mode: # Include Playwright MCP server for browser automation (standard mode only) - # Headless mode is configurable via PLAYWRIGHT_HEADLESS environment variable - playwright_args = ["@playwright/mcp@latest", "--viewport-size", "1280x720"] - if get_playwright_headless(): - playwright_args.append("--headless") + # Uses Playwright's bundled Chromium in headless mode for remote/server environments + chromium_path = os.path.expanduser("~/.cache/ms-playwright/chromium-1200/chrome-linux64/chrome") + playwright_args = [ + "@playwright/mcp@latest", + "--viewport-size", "1280x720", + "--headless", # Always headless for server environments + "--no-sandbox", # Required for some Linux environments + ] + # Use Playwright's Chromium if available (works on headless servers) + if os.path.exists(chromium_path): + playwright_args.extend(["--executable-path", chromium_path]) mcp_servers["playwright"] = { "command": "npx", "args": playwright_args, diff --git a/security.py b/security.py index 4e03117e..ed247793 100644 --- a/security.py +++ b/security.py @@ -42,8 +42,12 @@ "sleep", "kill", # Kill by PID "pkill", # For killing dev servers; validated separately + "pgrep", # For checking running processes + # Directory navigation + "cd", # Change directory (used in compound commands) # Network/API testing "curl", + "jq", # JSON parsing # File operations "mv", "rm", # Use with caution @@ -189,6 +193,9 @@ def validate_pkill_command(command_string: str) -> tuple[bool, str]: "npx", "vite", "next", + "playwright", + "chrome", + "chromium", } try: From 2b6a4aad3dc490e0352591c900fb6a69146a63d0 Mon Sep 17 00:00:00 2001 From: John Fitzpatrick Date: Mon, 12 Jan 2026 09:23:54 -0700 Subject: [PATCH 6/8] feat: Add dark mode and expand chat bash permission - Add dark mode toggle in Settings with localStorage persistence - Support system color scheme preference detection - Update all neo-brutalism components to use CSS variables - Add sqlite3 to allowed bash commands - Enable Bash tool for expand chat to query features database Co-Authored-By: Claude Opus 4.5 --- security.py | 2 + server/services/expand_chat_session.py | 2 + ui/src/App.tsx | 11 ++++ ui/src/components/SettingsModal.tsx | 73 +++++++++++++++++++++-- ui/src/styles/globals.css | 80 ++++++++++++++++---------- 5 files changed, 133 insertions(+), 35 deletions(-) diff --git a/security.py b/security.py index ed247793..35065394 100644 --- a/security.py +++ b/security.py @@ -48,6 +48,8 @@ # Network/API testing "curl", "jq", # JSON parsing + # Database + "sqlite3", # SQLite database queries # File operations "mv", "rm", # Use with caution diff --git a/server/services/expand_chat_session.py b/server/services/expand_chat_session.py index 659c7766..c8f45109 100644 --- a/server/services/expand_chat_session.py +++ b/server/services/expand_chat_session.py @@ -154,6 +154,7 @@ async def start(self) -> AsyncGenerator[dict, None]: "allow": [ "Read(./**)", "Glob(./**)", + "Bash(*)", # Allow bash for sqlite3 queries ], }, } @@ -176,6 +177,7 @@ async def start(self) -> AsyncGenerator[dict, None]: allowed_tools=[ "Read", "Glob", + "Bash", # For sqlite3 queries ], permission_mode="acceptEdits", max_turns=100, diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 98458a3c..931e8ad1 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -22,6 +22,17 @@ import { SettingsModal } from './components/SettingsModal' import { Loader2, Settings } from 'lucide-react' import type { Feature } from './lib/types' +// Apply dark mode on initial load (before React renders) +function initDarkMode() { + const saved = localStorage.getItem('darkMode') + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches + const isDark = saved !== null ? saved === 'true' : prefersDark + if (isDark) { + document.documentElement.classList.add('dark') + } +} +initDarkMode() + function App() { // Initialize selected project from localStorage const [selectedProject, setSelectedProject] = useState(() => { diff --git a/ui/src/components/SettingsModal.tsx b/ui/src/components/SettingsModal.tsx index 11608a73..37f2ccfd 100644 --- a/ui/src/components/SettingsModal.tsx +++ b/ui/src/components/SettingsModal.tsx @@ -1,17 +1,46 @@ -import { useEffect, useRef } from 'react' -import { X, Loader2, AlertCircle } from 'lucide-react' +import { useEffect, useRef, useState } from 'react' +import { X, Loader2, AlertCircle, Moon, Sun } from 'lucide-react' import { useSettings, useUpdateSettings, useAvailableModels } from '../hooks/useProjects' interface SettingsModalProps { onClose: () => void } +// Dark mode helper functions +function getInitialDarkMode(): boolean { + if (typeof window === 'undefined') return false + const saved = localStorage.getItem('darkMode') + if (saved !== null) return saved === 'true' + return window.matchMedia('(prefers-color-scheme: dark)').matches +} + +function applyDarkMode(isDark: boolean): void { + if (isDark) { + document.documentElement.classList.add('dark') + } else { + document.documentElement.classList.remove('dark') + } + localStorage.setItem('darkMode', String(isDark)) +} + export function SettingsModal({ onClose }: SettingsModalProps) { const { data: settings, isLoading, isError, refetch } = useSettings() const { data: modelsData } = useAvailableModels() const updateSettings = useUpdateSettings() const modalRef = useRef(null) const closeButtonRef = useRef(null) + const [darkMode, setDarkMode] = useState(getInitialDarkMode) + + // Apply dark mode on initial load + useEffect(() => { + applyDarkMode(darkMode) + }, []) + + const handleDarkModeToggle = () => { + const newValue = !darkMode + setDarkMode(newValue) + applyDarkMode(newValue) + } // Focus trap - keep focus within modal useEffect(() => { @@ -132,6 +161,42 @@ export function SettingsModal({ onClose }: SettingsModalProps) { {/* Settings Content */} {settings && !isLoading && (
+ {/* Dark Mode Toggle */} +
+
+
+ +

+ Switch to dark theme +

+
+ +
+
+ {/* YOLO Mode Toggle */}
@@ -152,7 +217,7 @@ export function SettingsModal({ onClose }: SettingsModalProps) { className={`relative w-14 h-8 rounded-none border-3 border-[var(--color-neo-border)] transition-colors ${ settings.yolo_mode ? 'bg-[var(--color-neo-pending)]' - : 'bg-white' + : 'bg-[var(--color-neo-card)]' } ${isSaving ? 'opacity-50 cursor-not-allowed' : ''}`} role="switch" aria-checked={settings.yolo_mode} @@ -190,7 +255,7 @@ export function SettingsModal({ onClose }: SettingsModalProps) { className={`flex-1 py-3 px-4 font-display font-bold text-sm transition-colors ${ settings.model === model.id ? 'bg-[var(--color-neo-accent)] text-white' - : 'bg-white text-[var(--color-neo-text)] hover:bg-gray-100' + : 'bg-[var(--color-neo-card)] text-[var(--color-neo-text)] hover:bg-[var(--color-neo-bg)]' } ${isSaving ? 'opacity-50 cursor-not-allowed' : ''}`} > {model.name} diff --git a/ui/src/styles/globals.css b/ui/src/styles/globals.css index 274cb2f3..700abcf4 100644 --- a/ui/src/styles/globals.css +++ b/ui/src/styles/globals.css @@ -59,6 +59,24 @@ --shadow-neo-xl: 8px 8px 0px rgba(0, 0, 0, 1); } + /* Dark mode theme */ + :root.dark { + --color-neo-bg: #0d0d0d; + --color-neo-card: #1a1a1a; + --color-neo-pending: #ffd60a; + --color-neo-progress: #00b4d8; + --color-neo-done: #70e000; + --color-neo-accent: #ff006e; + --color-neo-danger: #ff5400; + --color-neo-border: #e0e0e0; + --color-neo-text: #f0f0f0; + --color-neo-text-secondary: #a0a0a0; + --shadow-neo-sm: 2px 2px 0px rgba(255, 255, 255, 0.3); + --shadow-neo-md: 4px 4px 0px rgba(255, 255, 255, 0.3); + --shadow-neo-lg: 6px 6px 0px rgba(255, 255, 255, 0.3); + --shadow-neo-xl: 8px 8px 0px rgba(255, 255, 255, 0.3); + } + body { font-family: var(--font-neo-sans); background-color: var(--color-neo-bg); @@ -105,9 +123,9 @@ font-size: 0.875rem; text-transform: uppercase; letter-spacing: 0.025em; - color: #1a1a1a; - background-color: #ffffff; - border: 3px solid #1a1a1a; + color: var(--color-neo-text); + background-color: var(--color-neo-card); + border: 3px solid var(--color-neo-border); box-shadow: var(--shadow-neo-md); transition: transform var(--transition-neo-fast), box-shadow var(--transition-neo-fast); cursor: pointer; @@ -152,13 +170,13 @@ .neo-btn-ghost { background-color: transparent; - color: #1a1a1a; + color: var(--color-neo-text); box-shadow: none; } .neo-btn-ghost:hover { - background-color: rgba(0, 0, 0, 0.05); - color: #1a1a1a; + background-color: var(--color-neo-card); + color: var(--color-neo-text); box-shadow: none; transform: none; } @@ -194,15 +212,15 @@ padding: 0.75rem 1rem; font-family: var(--font-neo-sans); font-size: 1rem; - color: #1a1a1a; - background-color: #ffffff; - border: 3px solid #1a1a1a; + color: var(--color-neo-text); + background-color: var(--color-neo-card); + border: 3px solid var(--color-neo-border); box-shadow: 3px 3px 0px rgba(0, 0, 0, 0.1); transition: transform var(--transition-neo-fast), box-shadow var(--transition-neo-fast); } .neo-input::placeholder { - color: #4a4a4a; + color: var(--color-neo-text-secondary); opacity: 0.7; } @@ -210,7 +228,7 @@ outline: none; transform: translate(-1px, -1px); box-shadow: var(--shadow-neo-md); - border-color: #ff006e; + border-color: var(--color-neo-accent); } /* Badge */ @@ -222,23 +240,23 @@ font-size: 0.75rem; font-weight: 700; text-transform: uppercase; - color: #1a1a1a; - border: 2px solid #1a1a1a; + color: var(--color-neo-text); + border: 2px solid var(--color-neo-border); } /* Progress Bar */ .neo-progress { width: 100%; height: 2rem; - background-color: #ffffff; - border: 3px solid #1a1a1a; + background-color: var(--color-neo-card); + border: 3px solid var(--color-neo-border); box-shadow: var(--shadow-neo-sm); overflow: hidden; } .neo-progress-fill { height: 100%; - background-color: #70e000; + background-color: var(--color-neo-done); transition: width 0.5s ease-out; } @@ -255,8 +273,8 @@ } .neo-modal { - background-color: #ffffff; - border: 4px solid #1a1a1a; + background-color: var(--color-neo-card); + border: 4px solid var(--color-neo-border); box-shadow: var(--shadow-neo-xl); animation: popIn 0.3s var(--transition-neo-fast); max-width: 90vw; @@ -266,8 +284,8 @@ /* Dropdown */ .neo-dropdown { - background-color: #ffffff; - border: 3px solid #1a1a1a; + background-color: var(--color-neo-card); + border: 3px solid var(--color-neo-border); box-shadow: var(--shadow-neo-lg); } @@ -276,7 +294,7 @@ width: 100%; padding: 0.75rem 1rem; cursor: pointer; - color: #1a1a1a; + color: var(--color-neo-text); background-color: transparent; text-align: left; border: none; @@ -285,19 +303,19 @@ } .neo-dropdown-item:hover { - background-color: #ffd60a; - color: #1a1a1a; + background-color: var(--color-neo-pending); + color: var(--color-neo-text); } /* Tooltip */ .neo-tooltip { - background-color: #1a1a1a; - color: #ffffff; + background-color: var(--color-neo-border); + color: var(--color-neo-bg); padding: 0.5rem 0.75rem; font-size: 0.75rem; font-weight: 700; text-transform: uppercase; - border: 2px solid #1a1a1a; + border: 2px solid var(--color-neo-border); box-shadow: var(--shadow-neo-sm); } @@ -479,15 +497,15 @@ } ::-webkit-scrollbar-track { - background: #fffef5; - border: 2px solid #1a1a1a; + background: var(--color-neo-bg); + border: 2px solid var(--color-neo-border); } ::-webkit-scrollbar-thumb { - background: #1a1a1a; - border: 2px solid #1a1a1a; + background: var(--color-neo-border); + border: 2px solid var(--color-neo-border); } ::-webkit-scrollbar-thumb:hover { - background: #4a4a4a; + background: var(--color-neo-text-secondary); } From 5dbed5bdffd0fa4fe237bd4869f44e805ae33859 Mon Sep 17 00:00:00 2001 From: John Fitzpatrick Date: Mon, 12 Jan 2026 09:48:18 -0700 Subject: [PATCH 7/8] feat: Improve dark mode support and debug panel Dark mode improvements: - Replace hardcoded #1a1a1a colors with CSS variable --color-neo-text - Fix text readability in ExpandProjectChat, SpecCreationChat, ChatMessage - Fix FolderBrowser, ConfirmDialog, NewProjectModal text colors - Add explicit text color to TypingIndicator - Fix ProjectSelector selected item text (text-black on yellow) Debug panel: - Remove fixed 600px max height limit - Allow panel to expand up to header (window.innerHeight - 64px) Co-Authored-By: Claude Opus 4.5 --- ui/src/App.tsx | 2 +- ui/src/components/AssistantChat.tsx | 2 +- ui/src/components/AssistantPanel.tsx | 2 +- ui/src/components/ChatMessage.tsx | 6 +++--- ui/src/components/ConfirmDialog.tsx | 2 +- ui/src/components/DebugLogViewer.tsx | 8 +++++--- ui/src/components/ExpandProjectChat.tsx | 14 +++++++------- ui/src/components/FolderBrowser.tsx | 16 ++++++++-------- ui/src/components/KanbanColumn.tsx | 2 +- ui/src/components/NewProjectModal.tsx | 16 ++++++++-------- ui/src/components/ProjectSelector.tsx | 8 +++++--- ui/src/components/QuestionOptions.tsx | 10 +++++----- ui/src/components/SpecCreationChat.tsx | 16 ++++++++-------- ui/src/components/TypingIndicator.tsx | 2 +- ui/src/styles/globals.css | 2 ++ ui/tsconfig.tsbuildinfo | 2 +- 16 files changed, 58 insertions(+), 52 deletions(-) diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 21de9fb2..582ffd5a 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -189,7 +189,7 @@ function App() { return (
{/* Header */} -
+
{/* Logo and Title */} diff --git a/ui/src/components/AssistantChat.tsx b/ui/src/components/AssistantChat.tsx index ef8aeb32..422a40d3 100644 --- a/ui/src/components/AssistantChat.tsx +++ b/ui/src/components/AssistantChat.tsx @@ -131,7 +131,7 @@ export function AssistantChat({ projectName }: AssistantChatProps) { )} {/* Input area */} -
+