From a84c036cf003899822771c05bb8ffd5008f78c95 Mon Sep 17 00:00:00 2001 From: xprilion Date: Wed, 29 Apr 2026 16:46:36 +0530 Subject: [PATCH 1/2] Sonarqube cleanup --- backend/benchmark_small_models.py | 210 ++++++++++--------- backend/openmlr/agent/loop.py | 6 +- backend/openmlr/routes/agent.py | 9 +- backend/openmlr/services/session_manager.py | 48 ++--- backend/openmlr/tools/huggingface.py | 44 ++-- backend/openmlr/tools/plan.py | 24 ++- backend/tests/test_agent_loop.py | 1 - backend/tests/test_tools_huggingface.py | 2 +- backend/tests/test_tools_local.py | 4 +- backend/tests/test_tools_research.py | 2 +- frontend/src/components/CollapsiblePanel.tsx | 8 +- frontend/src/components/EditorPanel.tsx | 8 +- frontend/src/components/FileTree.tsx | 35 ++-- frontend/src/components/OnboardingModal.tsx | 21 +- frontend/src/components/ProjectSelector.tsx | 10 +- frontend/src/components/RightPanel.tsx | 30 ++- frontend/src/components/Sidebar.tsx | 28 +-- frontend/src/components/Terminal.tsx | 10 +- frontend/src/components/TodoReviewDrawer.tsx | 13 +- 19 files changed, 276 insertions(+), 237 deletions(-) diff --git a/backend/benchmark_small_models.py b/backend/benchmark_small_models.py index da93ddf..56d7428 100644 --- a/backend/benchmark_small_models.py +++ b/backend/benchmark_small_models.py @@ -61,113 +61,119 @@ def benchmark_model(model_info: dict) -> dict: } try: - # Clear memory - gc.collect() - if torch.backends.mps.is_available(): - torch.mps.empty_cache() - - initial_memory = get_memory_usage() - - # Load model - start_time = time.time() - print(" Loading tokenizer and model...") - - tokenizer = AutoTokenizer.from_pretrained(model_info["model_id"]) - if tokenizer.pad_token is None: - tokenizer.pad_token = tokenizer.eos_token - - model = AutoModelForCausalLM.from_pretrained( - model_info["model_id"], - torch_dtype=torch.float16 if torch.backends.mps.is_available() else torch.float32, - device_map="auto" if torch.backends.mps.is_available() else None, - trust_remote_code=True, - ) - - # Move to MPS if available - if torch.backends.mps.is_available(): - model = model.to("mps") - - load_time = time.time() - start_time - results["load_time"] = load_time - results["model_size_mb"] = get_model_size(model) - results["memory_usage_gb"] = get_memory_usage() - initial_memory - - print(f" ✅ Loaded in {load_time:.2f}s") - print(f" 📦 Model size: {results['model_size_mb']:.1f} MB") - print(f" 🧠 Memory usage: {results['memory_usage_gb']:.2f} GB") - - # Test inference - print(" 🚀 Running inference tests...") - - for i, prompt in enumerate(TEST_PROMPTS): - try: - # Tokenize - inputs = tokenizer(prompt, return_tensors="pt", padding=True) - if torch.backends.mps.is_available(): - inputs = {k: v.to("mps") for k, v in inputs.items()} - - # Generate - start_time = time.time() - - with torch.no_grad(): - outputs = model.generate( - **inputs, - max_new_tokens=50, - do_sample=True, - temperature=0.7, - pad_token_id=tokenizer.eos_token_id, - ) - - inference_time = time.time() - start_time - - # Decode output - generated_text = tokenizer.decode(outputs[0], skip_special_tokens=True) - - # Calculate tokens/second - new_tokens = len(outputs[0]) - len(inputs["input_ids"][0]) - tokens_per_sec = new_tokens / inference_time if inference_time > 0 else 0 - - results["inference_times"].append(inference_time) - results["tokens_per_second"].append(tokens_per_sec) - results["outputs"][f"prompt_{i}"] = { - "prompt": prompt, - "output": generated_text, - "inference_time": inference_time, - "tokens_per_sec": tokens_per_sec, - } - - print(f" Prompt {i + 1}: {tokens_per_sec:.1f} tokens/sec") - - except Exception as e: - error_msg = f"Error on prompt {i}: {str(e)}" - results["errors"].append(error_msg) - print(f" ❌ {error_msg}") - - # Calculate averages - if results["inference_times"]: - results["avg_inference_time"] = sum(results["inference_times"]) / len( - results["inference_times"] - ) - results["avg_tokens_per_second"] = sum(results["tokens_per_second"]) / len( - results["tokens_per_second"] - ) - - print(f" 📊 Average: {results.get('avg_tokens_per_second', 0):.1f} tokens/sec") - + results = _run_benchmark(model_info, results) except Exception as e: error_msg = f"Failed to load {model_info['name']}: {str(e)}" results["errors"].append(error_msg) print(f" ❌ {error_msg}") - finally: - # Cleanup - if "model" in locals(): - del model - if "tokenizer" in locals(): - del tokenizer - gc.collect() - if torch.backends.mps.is_available(): - torch.mps.empty_cache() + return results + + +def _run_benchmark(model_info: dict, results: dict) -> dict: + """Run the actual benchmark logic.""" + gc.collect() + if torch.backends.mps.is_available(): + torch.mps.empty_cache() + + initial_memory = get_memory_usage() + + # Load model + start_time = time.time() + print(" Loading tokenizer and model...") + + tokenizer = AutoTokenizer.from_pretrained(model_info["model_id"]) + if tokenizer.pad_token is None: + tokenizer.pad_token = tokenizer.eos_token + + model = AutoModelForCausalLM.from_pretrained( + model_info["model_id"], + torch_dtype=torch.float16 if torch.backends.mps.is_available() else torch.float32, + device_map="auto" if torch.backends.mps.is_available() else None, + trust_remote_code=True, + ) + + if torch.backends.mps.is_available(): + model = model.to("mps") + + load_time = time.time() - start_time + results["load_time"] = load_time + results["model_size_mb"] = get_model_size(model) + results["memory_usage_gb"] = get_memory_usage() - initial_memory + + print(f" ✅ Loaded in {load_time:.2f}s") + print(f" 📦 Model size: {results['model_size_mb']:.1f} MB") + print(f" 🧠 Memory usage: {results['memory_usage_gb']:.2f} GB") + + # Test inference + print(" 🚀 Running inference tests...") + + results = _run_inference_tests(model, tokenizer, results) + + # Calculate averages + if results["inference_times"]: + results["avg_inference_time"] = sum(results["inference_times"]) / len( + results["inference_times"] + ) + results["avg_tokens_per_second"] = sum(results["tokens_per_second"]) / len( + results["tokens_per_second"] + ) + + print(f" 📊 Average: {results.get('avg_tokens_per_second', 0):.1f} tokens/sec") + + # Cleanup + if "model" in locals(): + del model + if "tokenizer" in locals(): + del tokenizer + gc.collect() + if torch.backends.mps.is_available(): + torch.mps.empty_cache() + + return results + + +def _run_inference_tests(model, tokenizer, results: dict) -> dict: + """Run inference tests on the model.""" + for i, prompt in enumerate(TEST_PROMPTS): + try: + inputs = tokenizer(prompt, return_tensors="pt", padding=True) + if torch.backends.mps.is_available(): + inputs = {k: v.to("mps") for k, v in inputs.items()} + + start_time = time.time() + + with torch.no_grad(): + outputs = model.generate( + **inputs, + max_new_tokens=50, + do_sample=True, + temperature=0.7, + pad_token_id=tokenizer.eos_token_id, + ) + + inference_time = time.time() - start_time + + generated_text = tokenizer.decode(outputs[0], skip_special_tokens=True) + + new_tokens = len(outputs[0]) - len(inputs["input_ids"][0]) + tokens_per_sec = new_tokens / inference_time if inference_time > 0 else 0 + + results["inference_times"].append(inference_time) + results["tokens_per_second"].append(tokens_per_sec) + results["outputs"][f"prompt_{i}"] = { + "prompt": prompt, + "output": generated_text, + "inference_time": inference_time, + "tokens_per_sec": tokens_per_sec, + } + + print(f" Prompt {i + 1}: {tokens_per_sec:.1f} tokens/sec") + + except Exception as e: + error_msg = f"Error on prompt {i}: {str(e)}" + results["errors"].append(error_msg) + print(f" ❌ {error_msg}") return results diff --git a/backend/openmlr/agent/loop.py b/backend/openmlr/agent/loop.py index 2ae3850..8c42d1b 100644 --- a/backend/openmlr/agent/loop.py +++ b/backend/openmlr/agent/loop.py @@ -34,13 +34,15 @@ async def submission_loop(session: Session, tool_router) -> None: async def run_agent_turn( - session: Session, tool_router, user_message: str, mode: str = None + session: Session, tool_router, user_message: str, mode: str | None = None ) -> None: """Direct entry point: run one agent turn.""" await _run_agent(session, tool_router, user_message, mode) -async def _run_agent(session: Session, tool_router, user_message: str, mode: str = None) -> None: +async def _run_agent( + session: Session, tool_router, user_message: str, mode: str | None = None +) -> None: """Execute the agentic loop for a user message.""" session.clear_cancel() diff --git a/backend/openmlr/routes/agent.py b/backend/openmlr/routes/agent.py index bafd95b..e8d14da 100644 --- a/backend/openmlr/routes/agent.py +++ b/backend/openmlr/routes/agent.py @@ -2,6 +2,7 @@ import asyncio import logging +from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import StreamingResponse @@ -607,7 +608,7 @@ async def submit_approval( @router.post("/todo-approval") async def submit_todo_approval( request: Request, - user: User = Depends(get_current_user), + user: Annotated[User, Depends(get_current_user)], ): """Submit approval/rejection for proposed TODO list changes.""" body = await request.json() @@ -622,10 +623,10 @@ async def submit_todo_approval( active and hasattr(active.session, "pending_todo_approval") and active.session.pending_todo_approval + and not active.session.pending_todo_approval.done() ): - if not active.session.pending_todo_approval.done(): - active.session.pending_todo_approval.set_result(result) - return {"ok": True} + active.session.pending_todo_approval.set_result(result) + return {"ok": True} # Publish to Redis for background job workers try: diff --git a/backend/openmlr/services/session_manager.py b/backend/openmlr/services/session_manager.py index cb73799..e3eb967 100644 --- a/backend/openmlr/services/session_manager.py +++ b/backend/openmlr/services/session_manager.py @@ -275,36 +275,36 @@ async def _broadcast(event: AgentEvent): self.sessions[conversation_id] = active return active - async def remove_session(self, conversation_id: int) -> None: - active = self.sessions.pop(conversation_id, None) - if active: - # Cancel any running agent turn - active.session.cancel() - # Resolve any pending question/approval futures to unblock the loop - if hasattr(active.session, "pending_answers") and active.session.pending_answers: - try: - if not active.session.pending_answers.done(): - active.session.pending_answers.cancel() - except Exception: - pass - if ( - hasattr(active.session, "pending_todo_approval") - and active.session.pending_todo_approval - ): - try: - if not active.session.pending_todo_approval.done(): - active.session.pending_todo_approval.cancel() - except Exception: - pass + async def _cleanup_session(self, active) -> None: + active.session.cancel() + if hasattr(active.session, "pending_answers") and active.session.pending_answers: try: - await active.sandbox_manager.destroy() + if not active.session.pending_answers.done(): + active.session.pending_answers.cancel() except Exception: pass - # Disconnect MCP servers + if ( + hasattr(active.session, "pending_todo_approval") + and active.session.pending_todo_approval + ): try: - await active.mcp_manager.disconnect_all() + if not active.session.pending_todo_approval.done(): + active.session.pending_todo_approval.cancel() except Exception: pass + try: + await active.sandbox_manager.destroy() + except Exception: + pass + try: + await active.mcp_manager.disconnect_all() + except Exception: + pass + + async def remove_session(self, conversation_id: int) -> None: + active = self.sessions.pop(conversation_id, None) + if active: + await self._cleanup_session(active) if self.current_conversation_id == conversation_id: self.current_conversation_id = None diff --git a/backend/openmlr/tools/huggingface.py b/backend/openmlr/tools/huggingface.py index 381768f..e758b2c 100644 --- a/backend/openmlr/tools/huggingface.py +++ b/backend/openmlr/tools/huggingface.py @@ -9,6 +9,8 @@ log = logging.getLogger(__name__) HF_API = "https://huggingface.co" +HF_RATE_LIMIT_MSG = "HF_RATE_LIMIT_MSG" +HF_TRUNCATED_MSG = "HF_TRUNCATED_MSG" def _headers() -> dict: @@ -211,7 +213,7 @@ async def _handle_search_models( url, headers=_headers(), params=params, timeout=30, max_retries=3 ) except RateLimitError: - return "Hugging Face rate limit reached. Try again later or add HF_TOKEN.", False + return "HF_RATE_LIMIT_MSG", False except Exception as e: log.warning(f"HF search models error: {e}") return f"Hugging Face API error: {str(e)[:200]}", False @@ -269,7 +271,7 @@ async def _handle_model_info( try: resp = await fetch_with_retry(url, headers=_headers(), timeout=30, max_retries=3) except RateLimitError: - return "Hugging Face rate limit reached. Try again later or add HF_TOKEN.", False + return HF_RATE_LIMIT_MSG, False except Exception as e: log.warning(f"HF model info error: {e}") return f"Hugging Face API error: {str(e)[:200]}", False @@ -280,8 +282,21 @@ async def _handle_model_info( return f"Hugging Face API error {resp.status_code}: {resp.text[:500]}", False data = resp.json() + lines = _build_model_info_lines(data, repo_id) - # Build metadata summary + if include_readme: + readme_content = await _fetch_readme(repo_id, "model") + if readme_content: + lines.append(f"\n---\n\n## Model Card\n\n{readme_content}") + + output = "\n".join(lines) + if len(output) > 50000: + output = output[:50000] + HF_TRUNCATED_MSG + return output, True + + +def _build_model_info_lines(data: dict, repo_id: str) -> list[str]: + """Build model info lines from API response.""" lines = [f"# Model: {repo_id}\n"] lines.append(f"- **URL**: https://huggingface.co/{repo_id}") if data.get("pipeline_tag"): @@ -306,16 +321,7 @@ async def _handle_model_info( if len(siblings) > 30: lines.append(f" ... and {len(siblings) - 30} more") - # Fetch README / model card - if include_readme: - readme_content = await _fetch_readme(repo_id, "model") - if readme_content: - lines.append(f"\n---\n\n## Model Card\n\n{readme_content}") - - output = "\n".join(lines) - if len(output) > 50000: - output = output[:50000] + "\n\n...[truncated]" - return output, True + return lines async def _handle_search_datasets( @@ -342,7 +348,7 @@ async def _handle_search_datasets( url, headers=_headers(), params=params, timeout=30, max_retries=3 ) except RateLimitError: - return "Hugging Face rate limit reached. Try again later or add HF_TOKEN.", False + return "HF_RATE_LIMIT_MSG", False except Exception as e: log.warning(f"HF search datasets error: {e}") return f"Hugging Face API error: {str(e)[:200]}", False @@ -381,7 +387,7 @@ async def _handle_dataset_info( try: resp = await fetch_with_retry(url, headers=_headers(), timeout=30, max_retries=3) except RateLimitError: - return "Hugging Face rate limit reached. Try again later or add HF_TOKEN.", False + return "HF_RATE_LIMIT_MSG", False except Exception as e: log.warning(f"HF dataset info error: {e}") return f"Hugging Face API error: {str(e)[:200]}", False @@ -425,7 +431,7 @@ async def _handle_dataset_info( output = "\n".join(lines) if len(output) > 50000: - output = output[:50000] + "\n\n...[truncated]" + output = output[:50000] + HF_TRUNCATED_MSG return output, True @@ -448,7 +454,7 @@ async def _handle_read_file( try: resp = await fetch_with_retry(url, headers=_headers(), timeout=30, max_retries=3) except RateLimitError: - return "Hugging Face rate limit reached. Try again later or add HF_TOKEN.", False + return "HF_RATE_LIMIT_MSG", False except Exception as e: log.warning(f"HF read file error: {e}") return f"Hugging Face API error: {str(e)[:200]}", False @@ -495,7 +501,7 @@ async def _handle_read_file( output = text if len(output) > 50000: - output = output[:50000] + "\n\n...[truncated]" + output = output[:50000] + HF_TRUNCATED_MSG return f"# {repo_id}/{path}\n\n{output}", True @@ -528,6 +534,6 @@ async def _fetch_readme(repo_id: str, repo_type: str = "model") -> str | None: content = content[end + 3 :].strip() if len(content) > 30000: - content = content[:30000] + "\n\n...[truncated]" + content = content[:30000] + HF_TRUNCATED_MSG return content diff --git a/backend/openmlr/tools/plan.py b/backend/openmlr/tools/plan.py index fda8961..99e97c2 100644 --- a/backend/openmlr/tools/plan.py +++ b/backend/openmlr/tools/plan.py @@ -14,6 +14,10 @@ logger = logging.getLogger("openmlr.tools.plan") +PLAN_DIR = ".project-meta/plans" +PLAN_FILE = "PLAN.md" +REPORT_DIR = ".project-meta/reports" + def _get_session_factory(): """Get the correct async session factory for the current context (web or worker).""" @@ -153,8 +157,8 @@ async def _handle_plan( await _emit_resources(session, conv_id, db) # Write PLAN.md to workspace filesystem - await _write_to_workspace(conv_id, "PLAN.md", plan_md, ".project-meta/plans") - await _emit_files_changed(session, ".project-meta/plans") + await _write_to_workspace(conv_id, PLAN_FILE, plan_md, PLAN_DIR) + await _emit_files_changed(session, PLAN_DIR) return await _format_plan(db, conv_id), True @@ -194,8 +198,8 @@ async def _handle_plan( await _emit_resources(session, conv_id, db) # Write PLAN.md to workspace filesystem - await _write_to_workspace(conv_id, "PLAN.md", plan_md, ".project-meta/plans") - await _emit_files_changed(session, ".project-meta/plans") + await _write_to_workspace(conv_id, PLAN_FILE, plan_md, PLAN_DIR) + await _emit_files_changed(session, PLAN_DIR) return await _format_plan(db, conv_id), True @@ -262,7 +266,7 @@ async def _handle_plan( await ops.upsert_plan_resource(db, conv_id, plan_md) # Write PLAN.md to workspace filesystem - await _write_to_workspace(conv_id, "PLAN.md", plan_md, ".project-meta/plans") + await _write_to_workspace(conv_id, PLAN_FILE, plan_md, PLAN_DIR) # ── POST-UPDATE: Generate completion report if task was completed ── @@ -284,9 +288,7 @@ async def _handle_plan( from ..workspace.persistence import WorkspacePersistence safe_title = WorkspacePersistence._sanitize_filename(task.title) - await _write_to_workspace( - conv_id, f"{safe_title}.md", report, ".project-meta/reports" - ) + await _write_to_workspace(conv_id, f"{safe_title}.md", report, REPORT_DIR) await _emit_files_changed(session, ".project-meta/reports") result = await _format_plan(db, conv_id) @@ -296,7 +298,7 @@ async def _handle_plan( return result, True await _emit_resources(session, conv_id, db) - await _emit_files_changed(session, ".project-meta/plans") + await _emit_files_changed(session, PLAN_DIR) return await _format_plan(db, conv_id), True elif operation == "get": @@ -472,8 +474,8 @@ async def _emit_resources(session, conv_id: int, db): async def _request_todo_approval( session, - conv_id: int, - db, + conv_id: int, # unused, kept for API compatibility + db, # unused, kept for API compatibility change_type: str, proposed_tasks: list[dict], current_tasks: list[dict] | None = None, diff --git a/backend/tests/test_agent_loop.py b/backend/tests/test_agent_loop.py index 860ea94..526dc5c 100644 --- a/backend/tests/test_agent_loop.py +++ b/backend/tests/test_agent_loop.py @@ -240,7 +240,6 @@ async def test_emits_assistant_message_with_tool_calls(self, config): tool_call = ToolCall(id="tc1", name="web_search", arguments={"query": "test"}) emitted = [] - original_emit = session.emit async def capture_emit(event): emitted.append(event) diff --git a/backend/tests/test_tools_huggingface.py b/backend/tests/test_tools_huggingface.py index 5ca1a59..ead52d3 100644 --- a/backend/tests/test_tools_huggingface.py +++ b/backend/tests/test_tools_huggingface.py @@ -520,7 +520,7 @@ async def test_fetches_model_readme(self, mock_fetch): @patch("openmlr.tools.huggingface.fetch_with_retry") async def test_fetches_dataset_readme(self, mock_fetch): mock_fetch.return_value = _mock_response(text="# My Dataset") - result = await _fetch_readme("org/dataset", "dataset") + await _fetch_readme("org/dataset", "dataset") url = mock_fetch.call_args.args[0] assert url == "https://huggingface.co/datasets/org/dataset/resolve/main/README.md" diff --git a/backend/tests/test_tools_local.py b/backend/tests/test_tools_local.py index e8ea219..ca16272 100644 --- a/backend/tests/test_tools_local.py +++ b/backend/tests/test_tools_local.py @@ -132,7 +132,7 @@ def test_validate_path_allows_project_workspace(self, tmp_path): set_project_workspace(str(tmp_path)) try: path = tmp_path / "code" / "train.py" - resolved, error = _validate_path(path) + _, error = _validate_path(path) assert error is None finally: set_project_workspace(None) @@ -147,7 +147,7 @@ def test_validate_path_blocks_outside_project_workspace(self, tmp_path, monkeypa monkeypatch.chdir(project_dir) try: path = other_dir / "secret.txt" - resolved, error = _validate_path(path) + _, error = _validate_path(path) assert error is not None assert "outside workspace" in error finally: diff --git a/backend/tests/test_tools_research.py b/backend/tests/test_tools_research.py index f7e698c..af859a2 100644 --- a/backend/tests/test_tools_research.py +++ b/backend/tests/test_tools_research.py @@ -94,7 +94,7 @@ async def test_hf_read_file_dispatches(self): ) # This will try to actually call the handler which will fail network-wise, # but it should NOT return "not available" - result, success = await _execute_research_tool(tc) + result, _ = await _execute_research_tool(tc) assert "not available" not in result def test_system_prompt_not_empty(self): diff --git a/frontend/src/components/CollapsiblePanel.tsx b/frontend/src/components/CollapsiblePanel.tsx index 3bbd3d5..00b4cad 100644 --- a/frontend/src/components/CollapsiblePanel.tsx +++ b/frontend/src/components/CollapsiblePanel.tsx @@ -2,10 +2,10 @@ import { useState, type ReactNode } from 'react'; import { ChevronDown, ChevronRight } from 'lucide-react'; interface CollapsiblePanelProps { - title: string; - icon?: ReactNode; - badge?: string | number; - defaultExpanded?: boolean; + readonly title: string; + readonly icon?: ReactNode; + readonly badge?: string | number; + readonly defaultExpanded?: boolean; children: ReactNode; } diff --git a/frontend/src/components/EditorPanel.tsx b/frontend/src/components/EditorPanel.tsx index 2b7ee09..b73a203 100644 --- a/frontend/src/components/EditorPanel.tsx +++ b/frontend/src/components/EditorPanel.tsx @@ -3,10 +3,10 @@ import Editor from '@monaco-editor/react'; import type { OpenFile } from '../types'; interface Props { - openFiles: OpenFile[]; - activeFilePath: string | null; - onActivateFile: (path: string) => void; - onCloseFile: (path: string) => void; + readonly openFiles: readonly OpenFile[]; + readonly activeFilePath: string | null; + readonly onActivateFile: (path: string) => void; + readonly onCloseFile: (path: string) => void; } /** Extract just the filename from a full path. */ diff --git a/frontend/src/components/FileTree.tsx b/frontend/src/components/FileTree.tsx index a64c2d0..df49f94 100644 --- a/frontend/src/components/FileTree.tsx +++ b/frontend/src/components/FileTree.tsx @@ -18,9 +18,9 @@ import { api } from '../api'; import type { FileNode } from '../types'; interface Props { - projectUuid: string; - refreshKey?: number; - onFileSelect?: (path: string, content: string) => void; + readonly projectUuid: string; + readonly refreshKey?: number; + readonly onFileSelect?: (path: string, content: string) => void; } interface TreeNode extends FileNode { @@ -182,24 +182,27 @@ export function FileTree({ projectUuid, refreshKey, onFileSelect }: Props) { }); }, [projectUuid, loadDirectory]); + const mergeWithPreviousState = useCallback( + (entries: FileNode[], prev: TreeNode[]): TreeNode[] => { + const prevMap = new Map(prev.map((n) => [n.path, n])); + return entries.map((entry) => { + const existing = prevMap.get(entry.path); + if (existing && existing.expanded && existing.children) { + return { ...entry, expanded: true, children: existing.children }; + } + return entry; + }); + }, + [] + ); + // Auto-refresh when refreshKey changes (triggered by workspace_files_changed SSE event) useEffect(() => { if (refreshKey === undefined || refreshKey === 0) return; - // Refresh the root directory listing without showing full loading state loadDirectory('').then((entries) => { - setNodes((prev) => { - // Merge: preserve expanded state of existing nodes - const prevMap = new Map(prev.map((n) => [n.path, n])); - return entries.map((entry) => { - const existing = prevMap.get(entry.path); - if (existing && existing.expanded && existing.children) { - return { ...entry, expanded: true, children: existing.children }; - } - return entry; - }); - }); + setNodes((prev) => mergeWithPreviousState(entries, prev)); }); - }, [refreshKey, loadDirectory]); + }, [refreshKey, loadDirectory, mergeWithPreviousState]); const handleToggle = useCallback(async (path: string) => { setNodes((prev) => { diff --git a/frontend/src/components/OnboardingModal.tsx b/frontend/src/components/OnboardingModal.tsx index 9616b74..66844f1 100644 --- a/frontend/src/components/OnboardingModal.tsx +++ b/frontend/src/components/OnboardingModal.tsx @@ -143,14 +143,19 @@ export function OnboardingModal({ onComplete }: Props) {

{/* Step indicator */}
- {['providers', 'model', 'project'].map((s, i) => ( -
- ))} + {['providers', 'model', 'project'].map((s, i) => { + const getStepColor = (current: string, idx: number, stepVal: string): string => { + if (current === stepVal) return 'bg-primary'; + if (idx < ['providers', 'model', 'project'].indexOf(stepVal)) return 'bg-success'; + return 'bg-border'; + }; + return ( +
+ ); + })}
diff --git a/frontend/src/components/ProjectSelector.tsx b/frontend/src/components/ProjectSelector.tsx index 9bb3590..ee9fa31 100644 --- a/frontend/src/components/ProjectSelector.tsx +++ b/frontend/src/components/ProjectSelector.tsx @@ -3,11 +3,11 @@ import { FolderOpen, ChevronDown, Plus, SlidersHorizontal } from 'lucide-react'; import type { Project } from '../types'; interface Props { - projects: Project[]; - activeProject: Project | null; - onSelectProject: (project: Project) => void; - onNewProject: () => void; - onManageProjects: () => void; + readonly projects: readonly Project[]; + readonly activeProject: Project | null; + readonly onSelectProject: (project: Project) => void; + readonly onNewProject: () => void; + readonly onManageProjects: () => void; } export function ProjectSelector({ projects, activeProject, onSelectProject, onNewProject, onManageProjects }: Props) { diff --git a/frontend/src/components/RightPanel.tsx b/frontend/src/components/RightPanel.tsx index 8e0eefd..c4579b1 100644 --- a/frontend/src/components/RightPanel.tsx +++ b/frontend/src/components/RightPanel.tsx @@ -16,13 +16,13 @@ import { CollapsiblePanel } from './CollapsiblePanel'; import type { PlanTask, Resource, ContextUsage, SearchBudget } from '../types'; interface Props { - tasks: PlanTask[]; - resources: Resource[]; - contextUsage: ContextUsage | null; - searchBudget: SearchBudget | null; - visible: boolean; - projectUuid: string | null; - fileTreeRefreshKey?: number; + readonly tasks: readonly PlanTask[]; + readonly resources: readonly Resource[]; + readonly contextUsage: ContextUsage | null; + readonly searchBudget: SearchBudget | null; + readonly visible: boolean; + readonly projectUuid: string | null; + readonly fileTreeRefreshKey?: number; onToggle: () => void; onViewReport: (resource: Resource) => void; onFileOpen?: (path: string, content: string) => void; @@ -56,8 +56,19 @@ function SearchBudgetDialog({ currentMax, onSave, onClose }: { currentMax: numbe }; return ( -
-
e.stopPropagation()}> +
e.key === 'Escape' && onClose()} + role="dialog" + tabIndex={-1} + > +
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + role="document" + >

Search Budget

Set the maximum number of paper searches allowed per session. @@ -97,7 +108,6 @@ export function RightPanel({ tasks, resources: _resources, contextUsage, searchB const done = tasks.filter((t) => t.status === 'completed').length; const ctxPct = contextUsage ? Math.round(contextUsage.ratio * 100) : 0; - const ctxColor = ctxPct > 80 ? 'bg-error' : ctxPct > 60 ? 'bg-warning' : 'bg-success'; const budgetUsed = searchBudget?.used ?? 0; const budgetMax = searchBudget?.max ?? 25; const budgetPct = budgetMax > 0 ? Math.round((budgetUsed / budgetMax) * 100) : 0; diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 5bd7707..a3f9d5a 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -18,17 +18,17 @@ import { type ConvStatus = 'idle' | 'processing' | 'waiting_approval' | 'waiting_input'; interface Props { - conversations: Conversation[]; - currentUuid: string | null; - user: User | null; - convStatuses: Record; - terminalOpen: boolean; - terminalConnected: boolean; - terminalSessionCount: number; - onSwitch: (uuid: string) => void; - onNew: (mode?: string) => void; - onDelete: (uuid: string) => void; - onTerminalToggle: () => void; + readonly conversations: readonly Conversation[]; + readonly currentUuid: string | null; + readonly user: User | null; + readonly convStatuses: readonly Record; + readonly terminalOpen: boolean; + readonly terminalConnected: boolean; + readonly terminalSessionCount: number; + readonly onSwitch: (uuid: string) => void; + readonly onNew: (mode?: string) => void; + readonly onDelete: (uuid: string) => void; + readonly onTerminalToggle: () => void; } function groupByDate(conversations: Conversation[]) { @@ -90,7 +90,11 @@ export function Sidebar({ conversations, currentUuid, user, convStatuses, termin const groups = useMemo(() => groupByDate(filtered), [filtered]); // Terminal status dot color for collapsed rail - const termDotColor = !terminalOpen ? 'bg-text-dim' : terminalConnected ? 'bg-success' : 'bg-error'; + const getTermDotColor = (open: boolean, connected: boolean): string => { + if (!open) return 'bg-text-dim'; + return connected ? 'bg-success' : 'bg-error'; + }; + const termDotColor = getTermDotColor(terminalOpen, terminalConnected); if (collapsed) { return ( diff --git a/frontend/src/components/Terminal.tsx b/frontend/src/components/Terminal.tsx index ac0b69b..2bfb8df 100644 --- a/frontend/src/components/Terminal.tsx +++ b/frontend/src/components/Terminal.tsx @@ -11,11 +11,11 @@ import { WebLinksAddon } from '@xterm/addon-web-links'; import '@xterm/xterm/css/xterm.css'; interface Props { - projectUuid: string | null; - visible: boolean; - onToggle: () => void; - onConnectionChange?: (connected: boolean) => void; - rightOffset?: number; + readonly projectUuid: string | null; + readonly visible: boolean; + readonly onToggle: () => void; + readonly onConnectionChange?: (connected: boolean) => void; + readonly rightOffset?: number; } export function Terminal({ projectUuid, visible, onToggle, onConnectionChange, rightOffset = 0 }: Props) { diff --git a/frontend/src/components/TodoReviewDrawer.tsx b/frontend/src/components/TodoReviewDrawer.tsx index bc9cf0d..b511c84 100644 --- a/frontend/src/components/TodoReviewDrawer.tsx +++ b/frontend/src/components/TodoReviewDrawer.tsx @@ -9,9 +9,9 @@ interface TodoApprovalPayload { } interface Props { - payload: TodoApprovalPayload; - onDone: () => void; - onClose: () => void; + readonly payload: TodoApprovalPayload; + readonly onDone: () => void; + readonly onClose: () => void; } const statusIcon = (status: string) => { @@ -171,11 +171,12 @@ export function TodoReviewDrawer({ payload, onDone, onClose }: Props) { autoFocus /> ) : ( - handleEditStart(i)} + onKeyDown={(e) => e.key === 'Enter' && handleEditStart(i)} title="Click to edit" > {task.title} @@ -184,7 +185,7 @@ export function TodoReviewDrawer({ payload, onDone, onClose }: Props) { new )} - + )}