From 5917dee1b088ce7df2d1fcee240e923f0518d570 Mon Sep 17 00:00:00 2001 From: Sergii Kamenskyi Date: Thu, 11 Jun 2026 02:44:56 +0200 Subject: [PATCH] perf: parse chat history off the main thread on tab open The remaining tab-open stall was the synchronous parse of the persisted chat history (a multi-megabyte JSON via serde) inside `Workspace::new`, on the GTK main thread. Now the history starts empty and is parsed on a worker thread; a short poll delivers the parsed messages back to the UI thread and hands them to the panel's new `load_history` callback, which populates the shared buffer and renders the first page in idle-chunked batches (as before). The autosave dirty counter is seeded to the loaded length so the first autosave doesn't rewrite what we just read. `create_agent_panel` no longer reads history at construction; it returns a `load_history` hydration callback instead. --- src/ui/agent_panel/mod.rs | 91 ++++++++++++++++++++++++--------------- src/workspace.rs | 42 +++++++++++++++--- 2 files changed, 91 insertions(+), 42 deletions(-) diff --git a/src/ui/agent_panel/mod.rs b/src/ui/agent_panel/mod.rs index c94aed4..2663dbb 100644 --- a/src/ui/agent_panel/mod.rs +++ b/src/ui/agent_panel/mod.rs @@ -39,6 +39,16 @@ struct AttachedImage { texture: gtk::gdk::Texture, } +/// What [`create_agent_panel`] hands back: the panel widget, the input text view, +/// a theme-change callback, and a history-hydration callback (called once the +/// persisted chat history has been parsed off-thread). +type AgentPanelParts = ( + gtk::Box, + gtk::TextView, + Rc, + Rc)>, +); + #[allow(clippy::too_many_arguments)] pub fn create_agent_panel( on_open_file: Rc, @@ -62,7 +72,7 @@ pub fn create_agent_panel( token_label: gtk::Label, cost_label: gtk::Label, agent_name_label: gtk::Label, -) -> (gtk::Box, gtk::TextView, Rc) { +) -> AgentPanelParts { let panel = gtk::Box::new(gtk::Orientation::Vertical, 0); panel.set_width_request(AGENT_PANEL_MIN_WIDTH); @@ -239,11 +249,9 @@ pub fn create_agent_panel( panel.append(&input_frame); panel.append(&toolbar); - // --- Compute first page boundaries --- - let total_history = chat_history.borrow().len(); - let page_start = total_history.saturating_sub(PAGE_SIZE); - // --- State --- + // History starts empty; it is parsed off-thread by the caller and handed to + // `load_history` (returned below) once ready, so the panel appears instantly. let state = Rc::new(RefCell::new(PanelState { process: AgentProcessState { process: ClaudeBackend::new(), @@ -260,7 +268,7 @@ pub fn create_agent_panel( }, chat: ChatState { webview, - oldest_rendered_idx: total_history, // nothing rendered yet + oldest_rendered_idx: 0, // populated by load_history once parsed current_streaming: false, current_stream_id: None, current_text: String::new(), @@ -286,37 +294,50 @@ pub fn create_agent_panel( pending_background_tasks: std::collections::HashSet::new(), })); - // Show "Load previous" link if there are older entries - if page_start > 0 { - state.borrow().chat.webview.show_load_prev_button(); - } - - // --- Deferred first-page load (chunked across idle ticks) --- - { - let state_load = Rc::clone(&state); - // We commit to rendering page_start..total, so the oldest rendered index - // is page_start from the outset (keeps "Load previous" correct mid-load). - state.borrow_mut().chat.oldest_rendered_idx = page_start; - let mut cursor = page_start; - glib::idle_add_local(move || { - let s = state_load.borrow(); - let history = s.chat.chat_history.borrow(); + // --- Asynchronous history hydration --- + // The persisted chat history is parsed off the main thread by the caller and + // handed to this closure, which populates the shared buffer and renders the + // first page in chunks across idle ticks (so a long history streams in + // instead of freezing the panel on tab open). + let load_history: Rc)> = { + let state = Rc::clone(&state); + Rc::new(move |history: Vec| { let total = history.len(); - let dark = s.config.theme.get().is_dark(); - - let end = (cursor + HISTORY_RENDER_CHUNK).min(total); - for i in cursor..end { - chat_factory::render_history_message(&s.chat.webview, &history[i], dark); + let page_start = total.saturating_sub(PAGE_SIZE); + { + let mut s = state.borrow_mut(); + *s.chat.chat_history.borrow_mut() = history; + // We commit to rendering page_start..total, so the oldest rendered + // index is page_start from the outset (keeps "Load previous" + // correct even while the first page is still streaming in). + s.chat.oldest_rendered_idx = page_start; + if page_start > 0 { + s.chat.webview.show_load_prev_button(); + } } - cursor = end; - if cursor >= total { - glib::ControlFlow::Break - } else { - glib::ControlFlow::Continue - } - }); - } + let state_render = Rc::clone(&state); + let mut cursor = page_start; + glib::idle_add_local(move || { + let s = state_render.borrow(); + let history = s.chat.chat_history.borrow(); + let total = history.len(); + let dark = s.config.theme.get().is_dark(); + + let end = (cursor + HISTORY_RENDER_CHUNK).min(total); + for i in cursor..end { + chat_factory::render_history_message(&s.chat.webview, &history[i], dark); + } + cursor = end; + + if cursor >= total { + glib::ControlFlow::Break + } else { + glib::ControlFlow::Continue + } + }); + }) + }; // --- "Load previous" via flycrys://load-prev --- { @@ -1001,7 +1022,7 @@ pub fn create_agent_panel( }) }; - (panel, input_view, on_theme_change) + (panel, input_view, on_theme_change, load_history) } // --------------------------------------------------------------------------- diff --git a/src/workspace.rs b/src/workspace.rs index d5084c1..0b11c60 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -27,7 +27,8 @@ pub struct Workspace { /// Length of `chat_history` at the last autosave. Chat history is /// append-only, so a length change is a faithful "dirty" signal — the /// autosave skips the (potentially multi-MB) write when nothing was added. - pub last_saved_chat_len: Cell, + /// Shared (`Rc`) so the off-thread history load can mark it as loaded. + pub last_saved_chat_len: Rc>, pub run_panel: RunPanel, /// Called on theme change to re-highlight the current file. pub on_theme_rehighlight: Rc, @@ -147,9 +148,12 @@ impl Workspace { // Agent setup let agent_configs = session::list_agent_configs(); - let chat_history = Rc::new(RefCell::new(session::load_chat_history( - &config.borrow().id, - ))); + // History starts empty and is parsed off the main thread below, then + // injected once ready — so building the tab never blocks on a + // multi-megabyte chat-history JSON parse. + let chat_history: Rc>> = + Rc::new(RefCell::new(Vec::new())); + let last_saved_chat_len = Rc::new(Cell::new(0usize)); // After each tool result, refresh git status (off-thread) so edits the // agent makes appear in the tree/panel without waiting for the backstop. @@ -171,7 +175,7 @@ impl Workspace { config.borrow().agent_1_session_id.as_deref(), ); - let (agent_panel_1, agent_input_1, agent_on_theme_change) = { + let (agent_panel_1, agent_input_1, agent_on_theme_change, load_history) = { let profile = config.borrow().agent_1_profile.clone(); let session_id = config.borrow().agent_1_session_id.clone(); let fork_session = config.borrow().fork_session; @@ -245,6 +249,32 @@ impl Workspace { // Fill the late-binding slot so "Add Selected to Chat" can reach the agent input *agent_input_slot.borrow_mut() = Some(agent_input_1.clone()); + // Parse the persisted chat history off the main thread, then hand it to + // the panel once ready. Keeps the (multi-MB) JSON parse off the tab-build + // critical path; a short poll delivers the result back to the UI thread. + { + let id = config.borrow().id.clone(); + let (tx, rx) = std::sync::mpsc::channel::>(); + std::thread::spawn(move || { + let _ = tx.send(session::load_chat_history(&id)); + }); + let load_history = Rc::clone(&load_history); + let last_saved = Rc::clone(&last_saved_chat_len); + glib::timeout_add_local(std::time::Duration::from_millis(30), move || { + match rx.try_recv() { + Ok(history) => { + // Mark as already-persisted so the first autosave doesn't + // rewrite the history we just loaded. + last_saved.set(history.len()); + load_history(history); + glib::ControlFlow::Break + } + Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue, + Err(std::sync::mpsc::TryRecvError::Disconnected) => glib::ControlFlow::Break, + } + }); + } + // Wire chat button { let cf = Rc::clone(¤t_file); @@ -355,8 +385,6 @@ impl Workspace { }) }; - let last_saved_chat_len = Cell::new(chat_history.borrow().len()); - Workspace { root, config,