diff --git a/src/app_controller.py b/src/app_controller.py index f21db854..97c07e7d 100644 --- a/src/app_controller.py +++ b/src/app_controller.py @@ -272,6 +272,8 @@ def _api_generate(controller: 'AppController', req: GenerateRequest) -> dict[str """ if not req.prompt.strip(): raise HTTPException(status_code=400, detail="Prompt cannot be empty") + if controller.is_project_stale(): + raise HTTPException(status_code=409, detail="Project switch in progress; AI ops are disabled until it completes.") with controller._send_thread_lock: start_time = time.time() try: @@ -800,6 +802,7 @@ class AppController: self._pending_dialog_lock: threading.Lock = threading.Lock() self._api_event_queue_lock: threading.Lock = threading.Lock() self._rag_engine_lock: threading.Lock = threading.Lock() + self._project_switch_lock: threading.Lock = threading.Lock() # --- Internal State --- self._ai_status: str = "idle" @@ -842,6 +845,9 @@ class AppController: self._pending_mma_approvals: List[Dict[str, Any]] = [] self._mma_approval_open: bool = False self._mma_approval_edit_mode: bool = False + self._project_switch_in_progress: bool = False + self._project_switch_pending_path: Optional[str] = None + self._project_switch_thread: Optional[threading.Thread] = None self._mma_approval_payload: str = "" self._pending_mma_spawns: List[Dict[str, Any]] = [] self._mma_spawn_open: bool = False @@ -2683,31 +2689,78 @@ class AppController: file_items_as_dicts = [{"path": f.path if hasattr(f, "path") else str(f)} for f in self.files] mcp_client.configure(file_items_as_dicts, [str(project_root)]) + def is_project_stale(self) -> bool: + """True when a project switch is queued or running; UI should tint + to signal the controller state lags the user's last click.""" + with self._project_switch_lock: + if self._project_switch_in_progress: + return True + pending = self._project_switch_pending_path + if pending and pending != self.active_project_path: + return True + return False + def _switch_project(self, path: str) -> None: """ [C: src/gui_2.py:App._render_projects_panel] + + Non-blocking: returns immediately, marks the controller as stale, + and runs the actual save/load work in a background thread so the + render loop keeps drawing and lightweight UI interactions (scrolling, + selecting tabs) remain responsive. """ - if path == self.active_project_path: + if path == self.active_project_path and not self.is_project_stale(): return if not Path(path).exists(): self.ai_status = f"project file not found: {path}" return - self._flush_to_project() + with self._project_switch_lock: + if self._project_switch_in_progress: + if self._project_switch_pending_path == path: + return + self._project_switch_pending_path = path + self.ai_status = f"switch queued: {Path(path).stem} (waiting on {Path(self._project_switch_pending_path or '').stem})" + return + self._project_switch_in_progress = True + self._project_switch_pending_path = path + self.ai_status = f"switching to: {Path(path).stem} (stale ui - ops disabled)" + self._project_switch_thread = threading.Thread( + target=self._do_project_switch, + args=(path,), + daemon=True, + ) + self._project_switch_thread.start() + + def _do_project_switch(self, path: str) -> None: try: - self.project = project_manager.load_project(path) - self.active_project_path = path - new_root = Path(path).parent - self.preset_manager = presets.PresetManager(new_root) - self.tool_preset_manager = tool_presets.ToolPresetManager(new_root) - from src.personas import PersonaManager - self.persona_manager = PersonaManager(new_root) - except Exception as e: - self.ai_status = f"failed to load project: {e}" - return - self._refresh_from_project() - file_items_as_dicts = [{"path": f.path if hasattr(f, "path") else str(f)} for f in self.files] - mcp_client.configure(file_items_as_dicts, [str(new_root)]) - self.ai_status = f"switched to: {Path(path).stem}" + self._flush_to_project() + try: + new_project = project_manager.load_project(path) + except Exception as e: + self.ai_status = f"failed to load project: {e}" + return + try: + self.project = new_project + self.active_project_path = path + new_root = Path(path).parent + self.preset_manager = presets.PresetManager(new_root) + self.tool_preset_manager = tool_presets.ToolPresetManager(new_root) + from src.personas import PersonaManager + self.persona_manager = PersonaManager(new_root) + except Exception as e: + self.ai_status = f"failed to init managers: {e}" + return + self._refresh_from_project() + file_items_as_dicts = [{"path": f.path if hasattr(f, "path") else str(f)} for f in self.files] + mcp_client.configure(file_items_as_dicts, [str(new_root)]) + self.ai_status = f"switched to: {Path(path).stem}" + finally: + with self._project_switch_lock: + pending = self._project_switch_pending_path + self._project_switch_in_progress = False + self._project_switch_pending_path = None + if pending and pending != self.active_project_path and Path(pending).exists(): + self._switch_project(pending) def _refresh_from_project(self) -> None: # Deserialize FileItems in files.paths @@ -3279,6 +3332,9 @@ class AppController: Logic for the 'MD Only' action. [C: src/gui_2.py:App._render_message_panel] """ + if self.is_project_stale(): + self.ai_status = "project switch in progress; MD generation disabled" + return def worker(): """ @@ -3324,6 +3380,9 @@ class AppController: Logic for the 'Gen + Send' action. [C: src/gui_2.py:App._render_message_panel, src/gui_2.py:App._render_synthesis_panel, src/gui_2.py:App._render_takes_panel, tests/test_gui_events_v2.py:test_handle_generate_send_pushes_event, tests/test_symbol_parsing.py:test_handle_generate_send_appends_definitions, tests/test_symbol_parsing.py:test_handle_generate_send_no_symbols] """ + if self.is_project_stale(): + self.ai_status = "project switch in progress; AI ops disabled" + return def worker(): """ diff --git a/src/gui_2.py b/src/gui_2.py index 19e1800e..917137a6 100644 --- a/src/gui_2.py +++ b/src/gui_2.py @@ -1258,6 +1258,7 @@ if __name__ == "__main__": def render_main_interface(app: App) -> None: render_error_tint(app) + render_project_stale_tint(app) app.perf_monitor.start_frame() app._autofocus_response_tab = app.controller._autofocus_response_tab @@ -4620,6 +4621,27 @@ def render_error_tint(app: App) -> None: imgui.text_wrapped(HotReloader.last_error or "Unknown error") +def render_project_stale_tint(app: App) -> None: + """Renders a yellow/amber tint overlay when the project is mid-switch. + + UI remains responsive (scroll, tab browse) but AI / MD ops are gated + on the controller's is_project_stale() returning False. + """ + if not app.controller.is_project_stale(): return + draw_list = imgui.get_background_draw_list() + display_size = imgui.get_io().display_size + tint_col = imgui.get_color_u32(imgui.ImVec4(1.0, 0.85, 0.2, 0.15)) + draw_list.add_rect_filled(imgui.ImVec2(0, 0), display_size, tint_col) + pending = app.controller._project_switch_pending_path or app.controller.active_project_path + imgui.set_next_window_pos(imgui.ImVec2(10, 50)) + with imscope.window("Project Stale", None, + imgui.WindowFlags_.always_auto_resize | imgui.WindowFlags_.no_title_bar | + imgui.WindowFlags_.no_resize | imgui.WindowFlags_.no_move): + imgui.text_colored(imgui.ImVec4(1.0, 0.85, 0.2, 1.0), "PROJECT SWITCHING") + imgui.text_wrapped(f"Loading: {Path(pending).stem if pending else '?'}") + imgui.text_wrapped("UI is read-only until the switch completes. You can still browse tabs.") + + def render_heavy_text(app: App, label: str, content: str, id_suffix: str = "") -> None: if imgui.button(f"[+]##{label}{id_suffix}"): app.text_viewer_type = 'markdown' if label in ('message', 'text', 'content', 'system') else 'json' if label in ('tool_calls', 'data') else 'powershell' if label == 'script' else 'text' diff --git a/tests/test_project_switch_persona_preset.py b/tests/test_project_switch_persona_preset.py index 1c042806..7a9711f4 100644 --- a/tests/test_project_switch_persona_preset.py +++ b/tests/test_project_switch_persona_preset.py @@ -1,5 +1,6 @@ import os import json +import time import pytest import tempfile import shutil @@ -11,6 +12,19 @@ from src import presets, tool_presets from src import project_manager +def _wait_for_switch(ctrl, timeout: float = 2.0) -> None: + """Polls until any background project switch thread completes.""" + deadline = time.time() + timeout + while time.time() < deadline: + with ctrl._project_switch_lock: + in_progress = ctrl._project_switch_in_progress + thread = ctrl._project_switch_thread + if not in_progress and (thread is None or not thread.is_alive()): + return + time.sleep(0.02) + raise AssertionError("Project switch did not complete within timeout") + + def _setup_two_projects(tmp_path): project_a_dir = tmp_path / "project_a" project_a_dir.mkdir() @@ -57,6 +71,7 @@ def test_switch_project_resets_invalid_persona(tmp_path, monkeypatch): ctrl.ui_active_persona = "PersonaA" ctrl._switch_project(str(project_b_path)) + _wait_for_switch(ctrl) assert "PersonaA" not in ctrl.personas assert "PersonaB" in ctrl.personas @@ -80,6 +95,7 @@ def test_switch_project_resets_invalid_preset(tmp_path, monkeypatch): assert ctrl.ui_project_preset_name == "PresetA" ctrl._switch_project(str(project_b_path)) + _wait_for_switch(ctrl) assert "PresetA" not in ctrl.presets assert "PresetB" in ctrl.presets @@ -111,6 +127,7 @@ def test_switch_project_resets_invalid_tool_preset(tmp_path, monkeypatch): assert "ToolA" in ctrl.tool_presets ctrl._switch_project(str(project_b_path)) + _wait_for_switch(ctrl) assert "ToolA" not in ctrl.tool_presets assert "ToolB" in ctrl.tool_presets @@ -135,6 +152,7 @@ def test_switch_project_preserves_global_preset(tmp_path, monkeypatch): original_global = ctrl.ui_global_preset_name ctrl._switch_project(str(project_b_path)) + _wait_for_switch(ctrl) assert ctrl.ui_global_preset_name == original_global @@ -213,8 +231,65 @@ paths = [ assert any("gte_hello" in f.path for f in ctrl.context_files) ctrl._switch_project(str(project_b_path)) + _wait_for_switch(ctrl) assert len(ctrl.context_files) == 2 assert all("gencpp" in f.path for f in ctrl.context_files) assert not any("forth" in f.path for f in ctrl.context_files) assert not any("gte_hello" in f.path for f in ctrl.context_files) + + +def test_switch_project_non_blocking(tmp_path, monkeypatch): + """The switch returns immediately; the heavy work runs in a background + thread. UI calls see is_project_stale() == True while it's running.""" + project_a_path, project_b_path = _setup_two_projects(tmp_path) + + ctrl = AppController() + monkeypatch.setattr(ctrl, "_rebuild_rag_index", lambda: None) + monkeypatch.setattr(ctrl, "_flush_to_project", lambda: None) + + ctrl.active_project_path = str(project_a_path) + ctrl.project = project_manager.load_project(str(project_a_path)) + ctrl.preset_manager = presets.PresetManager(Path(project_a_path).parent) + ctrl.tool_preset_manager = tool_presets.ToolPresetManager(Path(project_a_path).parent) + ctrl.persona_manager = PersonaManager(Path(project_a_path).parent) + ctrl._refresh_from_project() + + started = time.time() + ctrl._switch_project(str(project_b_path)) + elapsed = time.time() - started + + assert elapsed < 0.2, f"_switch_project blocked the caller for {elapsed:.2f}s" + assert ctrl.is_project_stale() + _wait_for_switch(ctrl) + assert not ctrl.is_project_stale() + assert ctrl.active_project_path == str(project_b_path) + + +def test_api_generate_blocked_while_stale(tmp_path, monkeypatch): + """_api_generate returns 409 when the project switch is in progress.""" + project_a_path, project_b_path = _setup_two_projects(tmp_path) + + ctrl = AppController() + monkeypatch.setattr(ctrl, "_rebuild_rag_index", lambda: None) + monkeypatch.setattr(ctrl, "_flush_to_project", lambda: None) + + ctrl.active_project_path = str(project_a_path) + ctrl.project = project_manager.load_project(str(project_a_path)) + ctrl.preset_manager = presets.PresetManager(Path(project_a_path).parent) + ctrl.tool_preset_manager = tool_presets.ToolPresetManager(Path(project_a_path).parent) + ctrl.persona_manager = PersonaManager(Path(project_a_path).parent) + ctrl._refresh_from_project() + + ctrl._switch_project(str(project_b_path)) + assert ctrl.is_project_stale() + from fastapi import HTTPException + from src.app_controller import _api_generate + from src.models import GenerateRequest + req = GenerateRequest(prompt="hello") + with pytest.raises(HTTPException) as exc_info: + _api_generate(ctrl, req) + assert exc_info.value.status_code == 409 + + _wait_for_switch(ctrl) + assert not ctrl.is_project_stale()