Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 56 additions & 35 deletions src/ui/agent_panel/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<dyn Fn(bool)>,
Rc<dyn Fn(Vec<ChatMessage>)>,
);

#[allow(clippy::too_many_arguments)]
pub fn create_agent_panel(
on_open_file: Rc<dyn Fn(&str)>,
Expand All @@ -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<dyn Fn(bool)>) {
) -> AgentPanelParts {
let panel = gtk::Box::new(gtk::Orientation::Vertical, 0);
panel.set_width_request(AGENT_PANEL_MIN_WIDTH);

Expand Down Expand Up @@ -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(),
Expand All @@ -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(),
Expand All @@ -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<dyn Fn(Vec<ChatMessage>)> = {
let state = Rc::clone(&state);
Rc::new(move |history: Vec<ChatMessage>| {
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 ---
{
Expand Down Expand Up @@ -1001,7 +1022,7 @@ pub fn create_agent_panel(
})
};

(panel, input_view, on_theme_change)
(panel, input_view, on_theme_change, load_history)
}

// ---------------------------------------------------------------------------
Expand Down
42 changes: 35 additions & 7 deletions src/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<usize>,
/// Shared (`Rc`) so the off-thread history load can mark it as loaded.
pub last_saved_chat_len: Rc<Cell<usize>>,
pub run_panel: RunPanel,
/// Called on theme change to re-highlight the current file.
pub on_theme_rehighlight: Rc<dyn Fn(bool)>,
Expand Down Expand Up @@ -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<RefCell<Vec<session::ChatMessage>>> =
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.
Expand All @@ -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;
Expand Down Expand Up @@ -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::<Vec<session::ChatMessage>>();
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(&current_file);
Expand Down Expand Up @@ -355,8 +385,6 @@ impl Workspace {
})
};

let last_saved_chat_len = Cell::new(chat_history.borrow().len());

Workspace {
root,
config,
Expand Down