Private
Public Access
0
0

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:
2026-06-04 21:29:12 -04:00
parent 36f3292249
commit 8d1fa18785
3 changed files with 172 additions and 16 deletions
@@ -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()