From 87d7c3dd09e0e68d6e39668d2428f456f63dcd55 Mon Sep 17 00:00:00 2001 From: Daniel Kurzynski Date: Thu, 8 Oct 2026 18:01:27 +0200 Subject: [PATCH] fix(gpui): show project folders before the first session exists The main screen hands the session listing to the sidebar only when it differs from the last one it saw. Its initial value is an empty list, so an empty listing (a fresh install, or a new data directory) never reached the sidebar. The sidebar then never counted its sessions as loaded and never laid out the folders of the projects saved in projects.json: the Projects view stayed empty. Gpui now records when the first listing arrived, and the main screen hands that first listing over even when it is empty. --- crates/ui_gpui/src/app/commands.rs | 2 +- crates/ui_gpui/src/app/event_loop.rs | 2 +- crates/ui_gpui/src/lib.rs | 16 ++++++++++++++ crates/ui_gpui/src/main_screen/mod.rs | 11 ++++++++++ crates/ui_gpui/src/main_screen/tests/mod.rs | 1 + .../ui_gpui/src/main_screen/tests/sidebar.rs | 22 +++++++++++++++++++ crates/ui_gpui/src/sidebar/projects_view.rs | 9 ++++++++ 7 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 crates/ui_gpui/src/main_screen/tests/sidebar.rs diff --git a/crates/ui_gpui/src/app/commands.rs b/crates/ui_gpui/src/app/commands.rs index b6dcc7dc..fbbca071 100644 --- a/crates/ui_gpui/src/app/commands.rs +++ b/crates/ui_gpui/src/app/commands.rs @@ -76,7 +76,7 @@ impl Gpui { match service.list_sessions().await { Ok(sessions) => { *gpui.session_lifecycles.lock().unwrap() = lifecycles; - *gpui.chat_sessions.lock().unwrap() = sessions.clone(); + gpui.set_chat_sessions(sessions.clone()); gpui.push_event(UiEvent::UpdateChatList { sessions }); } Err(e) => gpui.display_error(format!("Failed to list sessions: {e:#}")), diff --git a/crates/ui_gpui/src/app/event_loop.rs b/crates/ui_gpui/src/app/event_loop.rs index 86fd51a8..2e975feb 100644 --- a/crates/ui_gpui/src/app/event_loop.rs +++ b/crates/ui_gpui/src/app/event_loop.rs @@ -555,7 +555,7 @@ impl Gpui { sessions.len() ); // Update local cache - *self.chat_sessions.lock().unwrap() = sessions.clone(); + self.set_chat_sessions(sessions.clone()); let _current_session_id = self.current_session_id.lock().unwrap().clone(); // Refresh all windows to trigger re-render with new chat data diff --git a/crates/ui_gpui/src/lib.rs b/crates/ui_gpui/src/lib.rs index fff3c082..1ea9e0b0 100644 --- a/crates/ui_gpui/src/lib.rs +++ b/crates/ui_gpui/src/lib.rs @@ -226,6 +226,9 @@ pub struct Gpui { // Current chat state current_session_id: Arc>>, chat_sessions: Arc>>, + /// Set once the first session listing arrived, so an empty `chat_sessions` + /// can be told apart from one not loaded yet. + chat_sessions_loaded: Arc, /// Visits and settlement per session, mirrored from the core for the /// sidebar. Sessions absent here have the default lifecycle. session_lifecycles: @@ -591,6 +594,7 @@ impl Gpui { current_session_id: Arc::new(Mutex::new(None)), chat_sessions: Arc::new(Mutex::new(Vec::new())), + chat_sessions_loaded: Arc::new(std::sync::atomic::AtomicBool::new(false)), session_lifecycles: Arc::new(Mutex::new(HashMap::new())), current_session_activity_state: Arc::new(Mutex::new(None)), session_stop_requests: Arc::new(Mutex::new(std::collections::HashSet::new())), @@ -889,6 +893,18 @@ impl Gpui { self.chat_sessions.lock().unwrap().clone() } + /// Whether the session listing arrived at least once (it may be empty). + pub fn chat_sessions_loaded(&self) -> bool { + self.chat_sessions_loaded + .load(std::sync::atomic::Ordering::Acquire) + } + + fn set_chat_sessions(&self, sessions: Vec) { + *self.chat_sessions.lock().unwrap() = sessions; + self.chat_sessions_loaded + .store(true, std::sync::atomic::Ordering::Release); + } + pub fn get_session_lifecycles( &self, ) -> HashMap { diff --git a/crates/ui_gpui/src/main_screen/mod.rs b/crates/ui_gpui/src/main_screen/mod.rs index cc090a5d..016506ce 100644 --- a/crates/ui_gpui/src/main_screen/mod.rs +++ b/crates/ui_gpui/src/main_screen/mod.rs @@ -208,6 +208,11 @@ pub struct MainScreen { /// Form for the viewed session's oldest open `ask_question` request. question_prompt: Option>, + /// Whether the sidebar got the first session listing. Without it, an + /// empty listing equals the initial `sessions` and never reaches the + /// sidebar, which then shows no project folders. + sidebar_sessions_synced: bool, + // Sidebar animation left_animator: SidebarAnimator, right_animator: SidebarAnimator, @@ -345,6 +350,7 @@ impl MainScreen { ui_scale: initial_scale, right_sidebar_width: px(initial_sidebar_width), right_sidebar_resizing: false, + sidebar_sessions_synced: false, resize_start_x: 0.0, resize_start_width: 0.0, context_limit_cache: None, @@ -1697,11 +1703,16 @@ impl Render for MainScreen { sidebar.set_persisted_projects(persisted_projects, cx); }); + let sessions_loaded = cx + .try_global::() + .is_some_and(|g| g.chat_sessions_loaded()); // Update project sidebar if needed if self.sessions != sessions || self.session_lifecycles != session_lifecycles || self.current_session_id != current_session_id + || (sessions_loaded && !self.sidebar_sessions_synced) { + self.sidebar_sessions_synced = sessions_loaded; let previous_session_id = self.current_session_id.clone(); self.sessions = sessions.clone(); self.session_lifecycles = session_lifecycles.clone(); diff --git a/crates/ui_gpui/src/main_screen/tests/mod.rs b/crates/ui_gpui/src/main_screen/tests/mod.rs index 5f49e9d5..f1290f7d 100644 --- a/crates/ui_gpui/src/main_screen/tests/mod.rs +++ b/crates/ui_gpui/src/main_screen/tests/mod.rs @@ -4,4 +4,5 @@ mod comments; mod composer; mod end_to_end; +mod sidebar; mod stored_outputs; diff --git a/crates/ui_gpui/src/main_screen/tests/sidebar.rs b/crates/ui_gpui/src/main_screen/tests/sidebar.rs new file mode 100644 index 00000000..fe52f791 --- /dev/null +++ b/crates/ui_gpui/src/main_screen/tests/sidebar.rs @@ -0,0 +1,22 @@ +//! The session sidebar as the main screen feeds it. + +use crate::test_support::MainScreenTest; +use code_assistant_core::ui::UiEvent; +use gpui_kit::TestAppContext; + +#[gpui_kit::test] +fn saved_projects_show_before_the_first_session_exists(cx: &mut TestAppContext) { + let mut test = MainScreenTest::new(cx); + // Replace what the machine's projects.json holds. + *test.gpui.persisted_projects.lock().unwrap() = ["proj".to_string()].into(); + + // A fresh store: the listing arrives, and it is empty. + test.push(UiEvent::UpdateChatList { + sessions: Vec::new(), + }); + + let folders = test.main_screen.read_with(&test.cx, |screen, cx| { + screen.project_sidebar.read(cx).folder_projects() + }); + assert_eq!(folders, vec![Some("proj".to_string())]); +} diff --git a/crates/ui_gpui/src/sidebar/projects_view.rs b/crates/ui_gpui/src/sidebar/projects_view.rs index 494fb050..5d66f702 100644 --- a/crates/ui_gpui/src/sidebar/projects_view.rs +++ b/crates/ui_gpui/src/sidebar/projects_view.rs @@ -161,6 +161,15 @@ impl SessionSidebar { .max() } + /// The projects of the displayed folders, in order (`None`: no project). + #[cfg(test)] + pub(crate) fn folder_projects(&self) -> Vec> { + self.folders + .iter() + .map(|folder| folder.project.clone()) + .collect() + } + pub(super) fn render_folders(&self, cx: &mut Context) -> Vec { if self.folders.is_empty() { return vec![render_hint("No projects yet", cx).into_any_element()];