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:
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user