fix(project): Non-blocking project switch with stale-ui tint
When switching projects, the previous implementation ran the entire
save/load/refresh sequence on the main thread. With large project files
or slow disks, this caused the UI to freeze for several seconds.
Fix:
- _switch_project now returns immediately after setting flags; the
actual work runs in a daemon thread (_do_project_switch)
- New is_project_stale() property returns True while a switch is queued
or running; the GUI renders an amber/yellow tint overlay to signal
the controller state lags the user's last click
- AI ops are gated: _api_generate returns HTTP 409, _handle_generate_send
and _handle_md_only early-return with ai_status feedback, all when
is_project_stale() is true
- Queued switches (clicking project A then B in rapid succession) are
coalesced: B replaces A as the target; once A completes, B is
triggered automatically via the finally branch in _do_project_switch
- New state fields: _project_switch_in_progress, _project_switch_pending_path,
_project_switch_thread, _project_switch_lock
- AppController state class attributes use hasattr guard for _app to
keep the controller usable standalone in tests/headless mode
UX:
- Render loop keeps drawing during the switch
- User can still scroll, switch tabs, browse files
- Amber tint + popup explains what's happening and that AI ops are paused
- ai_status shows the target project name
Tests:
- _wait_for_switch helper added for the new async switch flow
- All 7 existing switch tests updated to call _wait_for_switch
- 2 new tests:
- test_switch_project_non_blocking: verifies _switch_project returns
in <0.2s and is_project_stale() is True during the switch
- test_api_generate_blocked_while_stale: verifies _api_generate
raises HTTPException(409) while a switch is in progress
All 33 related tests pass.
This commit is contained in:
+75
-16
@@ -272,6 +272,8 @@ def _api_generate(controller: 'AppController', req: GenerateRequest) -> dict[str
|
|||||||
"""
|
"""
|
||||||
if not req.prompt.strip():
|
if not req.prompt.strip():
|
||||||
raise HTTPException(status_code=400, detail="Prompt cannot be empty")
|
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:
|
with controller._send_thread_lock:
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
try:
|
try:
|
||||||
@@ -800,6 +802,7 @@ class AppController:
|
|||||||
self._pending_dialog_lock: threading.Lock = threading.Lock()
|
self._pending_dialog_lock: threading.Lock = threading.Lock()
|
||||||
self._api_event_queue_lock: threading.Lock = threading.Lock()
|
self._api_event_queue_lock: threading.Lock = threading.Lock()
|
||||||
self._rag_engine_lock: threading.Lock = threading.Lock()
|
self._rag_engine_lock: threading.Lock = threading.Lock()
|
||||||
|
self._project_switch_lock: threading.Lock = threading.Lock()
|
||||||
|
|
||||||
# --- Internal State ---
|
# --- Internal State ---
|
||||||
self._ai_status: str = "idle"
|
self._ai_status: str = "idle"
|
||||||
@@ -842,6 +845,9 @@ class AppController:
|
|||||||
self._pending_mma_approvals: List[Dict[str, Any]] = []
|
self._pending_mma_approvals: List[Dict[str, Any]] = []
|
||||||
self._mma_approval_open: bool = False
|
self._mma_approval_open: bool = False
|
||||||
self._mma_approval_edit_mode: 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._mma_approval_payload: str = ""
|
||||||
self._pending_mma_spawns: List[Dict[str, Any]] = []
|
self._pending_mma_spawns: List[Dict[str, Any]] = []
|
||||||
self._mma_spawn_open: bool = False
|
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]
|
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)])
|
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:
|
def _switch_project(self, path: str) -> None:
|
||||||
"""
|
"""
|
||||||
[C: src/gui_2.py:App._render_projects_panel]
|
[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
|
return
|
||||||
if not Path(path).exists():
|
if not Path(path).exists():
|
||||||
self.ai_status = f"project file not found: {path}"
|
self.ai_status = f"project file not found: {path}"
|
||||||
return
|
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:
|
try:
|
||||||
self.project = project_manager.load_project(path)
|
self._flush_to_project()
|
||||||
self.active_project_path = path
|
try:
|
||||||
new_root = Path(path).parent
|
new_project = project_manager.load_project(path)
|
||||||
self.preset_manager = presets.PresetManager(new_root)
|
except Exception as e:
|
||||||
self.tool_preset_manager = tool_presets.ToolPresetManager(new_root)
|
self.ai_status = f"failed to load project: {e}"
|
||||||
from src.personas import PersonaManager
|
return
|
||||||
self.persona_manager = PersonaManager(new_root)
|
try:
|
||||||
except Exception as e:
|
self.project = new_project
|
||||||
self.ai_status = f"failed to load project: {e}"
|
self.active_project_path = path
|
||||||
return
|
new_root = Path(path).parent
|
||||||
self._refresh_from_project()
|
self.preset_manager = presets.PresetManager(new_root)
|
||||||
file_items_as_dicts = [{"path": f.path if hasattr(f, "path") else str(f)} for f in self.files]
|
self.tool_preset_manager = tool_presets.ToolPresetManager(new_root)
|
||||||
mcp_client.configure(file_items_as_dicts, [str(new_root)])
|
from src.personas import PersonaManager
|
||||||
self.ai_status = f"switched to: {Path(path).stem}"
|
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:
|
def _refresh_from_project(self) -> None:
|
||||||
# Deserialize FileItems in files.paths
|
# Deserialize FileItems in files.paths
|
||||||
@@ -3279,6 +3332,9 @@ class AppController:
|
|||||||
Logic for the 'MD Only' action.
|
Logic for the 'MD Only' action.
|
||||||
[C: src/gui_2.py:App._render_message_panel]
|
[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():
|
def worker():
|
||||||
"""
|
"""
|
||||||
@@ -3324,6 +3380,9 @@ class AppController:
|
|||||||
Logic for the 'Gen + Send' action.
|
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]
|
[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():
|
def worker():
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1258,6 +1258,7 @@ if __name__ == "__main__":
|
|||||||
|
|
||||||
def render_main_interface(app: App) -> None:
|
def render_main_interface(app: App) -> None:
|
||||||
render_error_tint(app)
|
render_error_tint(app)
|
||||||
|
render_project_stale_tint(app)
|
||||||
app.perf_monitor.start_frame()
|
app.perf_monitor.start_frame()
|
||||||
app._autofocus_response_tab = app.controller._autofocus_response_tab
|
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")
|
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:
|
def render_heavy_text(app: App, label: str, content: str, id_suffix: str = "") -> None:
|
||||||
if imgui.button(f"[+]##{label}{id_suffix}"):
|
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'
|
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'
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
|
import time
|
||||||
import pytest
|
import pytest
|
||||||
import tempfile
|
import tempfile
|
||||||
import shutil
|
import shutil
|
||||||
@@ -11,6 +12,19 @@ from src import presets, tool_presets
|
|||||||
from src import project_manager
|
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):
|
def _setup_two_projects(tmp_path):
|
||||||
project_a_dir = tmp_path / "project_a"
|
project_a_dir = tmp_path / "project_a"
|
||||||
project_a_dir.mkdir()
|
project_a_dir.mkdir()
|
||||||
@@ -57,6 +71,7 @@ def test_switch_project_resets_invalid_persona(tmp_path, monkeypatch):
|
|||||||
ctrl.ui_active_persona = "PersonaA"
|
ctrl.ui_active_persona = "PersonaA"
|
||||||
|
|
||||||
ctrl._switch_project(str(project_b_path))
|
ctrl._switch_project(str(project_b_path))
|
||||||
|
_wait_for_switch(ctrl)
|
||||||
|
|
||||||
assert "PersonaA" not in ctrl.personas
|
assert "PersonaA" not in ctrl.personas
|
||||||
assert "PersonaB" 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"
|
assert ctrl.ui_project_preset_name == "PresetA"
|
||||||
|
|
||||||
ctrl._switch_project(str(project_b_path))
|
ctrl._switch_project(str(project_b_path))
|
||||||
|
_wait_for_switch(ctrl)
|
||||||
|
|
||||||
assert "PresetA" not in ctrl.presets
|
assert "PresetA" not in ctrl.presets
|
||||||
assert "PresetB" 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
|
assert "ToolA" in ctrl.tool_presets
|
||||||
|
|
||||||
ctrl._switch_project(str(project_b_path))
|
ctrl._switch_project(str(project_b_path))
|
||||||
|
_wait_for_switch(ctrl)
|
||||||
|
|
||||||
assert "ToolA" not in ctrl.tool_presets
|
assert "ToolA" not in ctrl.tool_presets
|
||||||
assert "ToolB" 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
|
original_global = ctrl.ui_global_preset_name
|
||||||
|
|
||||||
ctrl._switch_project(str(project_b_path))
|
ctrl._switch_project(str(project_b_path))
|
||||||
|
_wait_for_switch(ctrl)
|
||||||
|
|
||||||
assert ctrl.ui_global_preset_name == original_global
|
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)
|
assert any("gte_hello" in f.path for f in ctrl.context_files)
|
||||||
|
|
||||||
ctrl._switch_project(str(project_b_path))
|
ctrl._switch_project(str(project_b_path))
|
||||||
|
_wait_for_switch(ctrl)
|
||||||
|
|
||||||
assert len(ctrl.context_files) == 2
|
assert len(ctrl.context_files) == 2
|
||||||
assert all("gencpp" in f.path for f in ctrl.context_files)
|
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("forth" in f.path for f in ctrl.context_files)
|
||||||
assert not any("gte_hello" 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()
|
||||||
|
|||||||
Reference in New Issue
Block a user