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
)}
-
+
)}