diff --git a/src-tauri/src/session_manager/mod.rs b/src-tauri/src/session_manager/mod.rs index 8d833636f..f7ab61e98 100644 --- a/src-tauri/src/session_manager/mod.rs +++ b/src-tauri/src/session_manager/mod.rs @@ -37,12 +37,27 @@ pub struct SessionMessage { } pub fn scan_sessions() -> Vec { + let (r1, r2, r3, r4, r5) = std::thread::scope(|s| { + let h1 = s.spawn(codex::scan_sessions); + let h2 = s.spawn(claude::scan_sessions); + let h3 = s.spawn(opencode::scan_sessions); + let h4 = s.spawn(openclaw::scan_sessions); + let h5 = s.spawn(gemini::scan_sessions); + ( + h1.join().unwrap_or_default(), + h2.join().unwrap_or_default(), + h3.join().unwrap_or_default(), + h4.join().unwrap_or_default(), + h5.join().unwrap_or_default(), + ) + }); + let mut sessions = Vec::new(); - sessions.extend(codex::scan_sessions()); - sessions.extend(claude::scan_sessions()); - sessions.extend(opencode::scan_sessions()); - sessions.extend(openclaw::scan_sessions()); - sessions.extend(gemini::scan_sessions()); + sessions.extend(r1); + sessions.extend(r2); + sessions.extend(r3); + sessions.extend(r4); + sessions.extend(r5); sessions.sort_by(|a, b| { let a_ts = a.last_active_at.or(a.created_at).unwrap_or(0); diff --git a/src-tauri/src/session_manager/providers/claude.rs b/src-tauri/src/session_manager/providers/claude.rs index b953c015d..6f1d8f20a 100644 --- a/src-tauri/src/session_manager/providers/claude.rs +++ b/src-tauri/src/session_manager/providers/claude.rs @@ -7,7 +7,9 @@ use serde_json::Value; use crate::config::get_claude_config_dir; use crate::session_manager::{SessionMessage, SessionMeta}; -use super::utils::{extract_text, parse_timestamp_to_ms, path_basename, truncate_summary}; +use super::utils::{ + extract_text, parse_timestamp_to_ms, path_basename, read_head_tail_lines, truncate_summary, +}; const PROVIDER_ID: &str = "claude"; @@ -73,60 +75,61 @@ fn parse_session(path: &Path) -> Option { return None; } - let file = File::open(path).ok()?; - let reader = BufReader::new(file); + let (head, tail) = read_head_tail_lines(path, 10, 30).ok()?; let mut session_id: Option = None; let mut project_dir: Option = None; let mut created_at: Option = None; - let mut last_active_at: Option = None; - let mut summary: Option = None; - for line in reader.lines() { - let line = match line { - Ok(value) => value, - Err(_) => continue, - }; - let value: Value = match serde_json::from_str(&line) { + // Extract metadata from head lines + for line in &head { + let value: Value = match serde_json::from_str(line) { Ok(parsed) => parsed, Err(_) => continue, }; - if session_id.is_none() { session_id = value .get("sessionId") .and_then(Value::as_str) .map(|s| s.to_string()); } - if project_dir.is_none() { project_dir = value .get("cwd") .and_then(Value::as_str) .map(|s| s.to_string()); } - - if let Some(ts) = value.get("timestamp").and_then(parse_timestamp_to_ms) { - if created_at.is_none() { - created_at = Some(ts); - } - last_active_at = Some(ts); + if created_at.is_none() { + created_at = value.get("timestamp").and_then(parse_timestamp_to_ms); } + } - if value.get("isMeta").and_then(Value::as_bool) == Some(true) { - continue; - } + // Extract last_active_at and summary from tail lines (reverse order) + let mut last_active_at: Option = None; + let mut summary: Option = None; - let message = match value.get("message") { - Some(message) => message, - None => continue, + for line in tail.iter().rev() { + let value: Value = match serde_json::from_str(line) { + Ok(parsed) => parsed, + Err(_) => continue, }; - - let text = message.get("content").map(extract_text).unwrap_or_default(); - if text.trim().is_empty() { - continue; + if last_active_at.is_none() { + last_active_at = value.get("timestamp").and_then(parse_timestamp_to_ms); + } + if summary.is_none() { + if value.get("isMeta").and_then(Value::as_bool) == Some(true) { + continue; + } + if let Some(message) = value.get("message") { + let text = message.get("content").map(extract_text).unwrap_or_default(); + if !text.trim().is_empty() { + summary = Some(text); + } + } + } + if last_active_at.is_some() && summary.is_some() { + break; } - summary = Some(text); } let session_id = session_id.or_else(|| infer_session_id_from_filename(path)); diff --git a/src-tauri/src/session_manager/providers/codex.rs b/src-tauri/src/session_manager/providers/codex.rs index 6c2b62b58..56a4a3cf4 100644 --- a/src-tauri/src/session_manager/providers/codex.rs +++ b/src-tauri/src/session_manager/providers/codex.rs @@ -1,6 +1,7 @@ use std::fs::File; use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; +use std::sync::LazyLock; use regex::Regex; use serde_json::Value; @@ -8,10 +9,19 @@ use serde_json::Value; use crate::codex_config::get_codex_config_dir; use crate::session_manager::{SessionMessage, SessionMeta}; -use super::utils::{extract_text, parse_timestamp_to_ms, path_basename, truncate_summary}; +use super::utils::{ + extract_text, parse_timestamp_to_ms, path_basename, read_head_tail_lines, truncate_summary, +}; const PROVIDER_ID: &str = "codex"; +static UUID_RE: LazyLock = LazyLock::new(|| { + Regex::new( + r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", + ) + .unwrap() +}); + pub fn scan_sessions() -> Vec { let root = get_codex_config_dir().join("sessions"); let mut files = Vec::new(); @@ -74,32 +84,21 @@ pub fn load_messages(path: &Path) -> Result, String> { } fn parse_session(path: &Path) -> Option { - let file = File::open(path).ok()?; - let reader = BufReader::new(file); + let (head, tail) = read_head_tail_lines(path, 10, 30).ok()?; let mut session_id: Option = None; let mut project_dir: Option = None; let mut created_at: Option = None; - let mut last_active_at: Option = None; - let mut summary: Option = None; - for line in reader.lines() { - let line = match line { - Ok(value) => value, - Err(_) => continue, - }; - let value: Value = match serde_json::from_str(&line) { + // Extract metadata from head lines + for line in &head { + let value: Value = match serde_json::from_str(line) { Ok(parsed) => parsed, Err(_) => continue, }; - - if let Some(ts) = value.get("timestamp").and_then(parse_timestamp_to_ms) { - if created_at.is_none() { - created_at = Some(ts); - } - last_active_at = Some(ts); + if created_at.is_none() { + created_at = value.get("timestamp").and_then(parse_timestamp_to_ms); } - if value.get("type").and_then(Value::as_str) == Some("session_meta") { if let Some(payload) = value.get("payload") { if session_id.is_none() { @@ -118,27 +117,37 @@ fn parse_session(path: &Path) -> Option { created_at.get_or_insert(ts); } } - continue; } + } - if value.get("type").and_then(Value::as_str) != Some("response_item") { - continue; - } + // Extract last_active_at and summary from tail lines (reverse order) + let mut last_active_at: Option = None; + let mut summary: Option = None; - let payload = match value.get("payload") { - Some(payload) => payload, - None => continue, + for line in tail.iter().rev() { + let value: Value = match serde_json::from_str(line) { + Ok(parsed) => parsed, + Err(_) => continue, }; - - if payload.get("type").and_then(Value::as_str) != Some("message") { - continue; + if last_active_at.is_none() { + last_active_at = value.get("timestamp").and_then(parse_timestamp_to_ms); } - - let text = payload.get("content").map(extract_text).unwrap_or_default(); - if text.trim().is_empty() { - continue; + if summary.is_none() + && value.get("type").and_then(Value::as_str) == Some("response_item") + { + if let Some(payload) = value.get("payload") { + if payload.get("type").and_then(Value::as_str) == Some("message") { + let text = + payload.get("content").map(extract_text).unwrap_or_default(); + if !text.trim().is_empty() { + summary = Some(text); + } + } + } + } + if last_active_at.is_some() && summary.is_some() { + break; } - summary = Some(text); } let session_id = session_id.or_else(|| infer_session_id_from_filename(path)); @@ -166,10 +175,7 @@ fn parse_session(path: &Path) -> Option { fn infer_session_id_from_filename(path: &Path) -> Option { let file_name = path.file_name()?.to_string_lossy(); - let re = - Regex::new(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}") - .ok()?; - re.find(&file_name).map(|mat| mat.as_str().to_string()) + UUID_RE.find(&file_name).map(|mat| mat.as_str().to_string()) } fn collect_jsonl_files(root: &Path, files: &mut Vec) { diff --git a/src-tauri/src/session_manager/providers/openclaw.rs b/src-tauri/src/session_manager/providers/openclaw.rs index e8283bf0f..25b911514 100644 --- a/src-tauri/src/session_manager/providers/openclaw.rs +++ b/src-tauri/src/session_manager/providers/openclaw.rs @@ -7,7 +7,9 @@ use serde_json::Value; use crate::openclaw_config::get_openclaw_dir; use crate::session_manager::{SessionMessage, SessionMeta}; -use super::utils::{extract_text, parse_timestamp_to_ms, path_basename, truncate_summary}; +use super::utils::{ + extract_text, parse_timestamp_to_ms, path_basename, read_head_tail_lines, truncate_summary, +}; const PROVIDER_ID: &str = "openclaw"; @@ -114,30 +116,22 @@ pub fn load_messages(path: &Path) -> Result, String> { } fn parse_session(path: &Path) -> Option { - let file = File::open(path).ok()?; - let reader = BufReader::new(file); + let (head, tail) = read_head_tail_lines(path, 10, 30).ok()?; let mut session_id: Option = None; let mut cwd: Option = None; let mut created_at: Option = None; - let mut last_active_at: Option = None; let mut summary: Option = None; - for line in reader.lines() { - let line = match line { - Ok(value) => value, - Err(_) => continue, - }; - let value: Value = match serde_json::from_str(&line) { + // Extract metadata and first message summary from head lines + for line in &head { + let value: Value = match serde_json::from_str(line) { Ok(parsed) => parsed, Err(_) => continue, }; - if let Some(ts) = value.get("timestamp").and_then(parse_timestamp_to_ms) { - if created_at.is_none() { - created_at = Some(ts); - } - last_active_at = Some(ts); + if created_at.is_none() { + created_at = value.get("timestamp").and_then(parse_timestamp_to_ms); } let event_type = value.get("type").and_then(Value::as_str).unwrap_or(""); @@ -161,25 +155,28 @@ fn parse_session(path: &Path) -> Option { continue; } - if event_type != "message" { - continue; + // OpenClaw summary is the first message content + if event_type == "message" && summary.is_none() { + if let Some(message) = value.get("message") { + let text = message.get("content").map(extract_text).unwrap_or_default(); + if !text.trim().is_empty() { + summary = Some(text); + } + } } + } - // Extract first message content for summary - if summary.is_some() { - continue; - } - - let message = match value.get("message") { - Some(msg) => msg, - None => continue, + // Extract last_active_at from tail lines (reverse order) + let mut last_active_at: Option = None; + for line in tail.iter().rev() { + let value: Value = match serde_json::from_str(line) { + Ok(parsed) => parsed, + Err(_) => continue, }; - - let text = message.get("content").map(extract_text).unwrap_or_default(); - if text.trim().is_empty() { - continue; + if let Some(ts) = value.get("timestamp").and_then(parse_timestamp_to_ms) { + last_active_at = Some(ts); + break; } - summary = Some(text); } // Fall back to filename as session ID diff --git a/src-tauri/src/session_manager/providers/opencode.rs b/src-tauri/src/session_manager/providers/opencode.rs index 4428bc57f..7cb5d115b 100644 --- a/src-tauri/src/session_manager/providers/opencode.rs +++ b/src-tauri/src/session_manager/providers/opencode.rs @@ -136,6 +136,7 @@ fn parse_session(storage: &Path, path: &Path) -> Option { .and_then(parse_timestamp_to_ms); // Derive title from directory basename if no explicit title + let has_title = title.is_some(); let display_title = title.or_else(|| { directory .as_deref() @@ -147,8 +148,12 @@ fn parse_session(storage: &Path, path: &Path) -> Option { let msg_dir = storage.join("message").join(&session_id); let source_path = msg_dir.to_string_lossy().to_string(); - // Get summary from first user message - let summary = get_first_user_summary(storage, &session_id); + // Skip expensive I/O if title already available from session JSON + let summary = if has_title { + display_title.clone() + } else { + get_first_user_summary(storage, &session_id) + }; Some(SessionMeta { provider_id: PROVIDER_ID.to_string(), diff --git a/src-tauri/src/session_manager/providers/utils.rs b/src-tauri/src/session_manager/providers/utils.rs index 76408ea35..9ac700b63 100644 --- a/src-tauri/src/session_manager/providers/utils.rs +++ b/src-tauri/src/session_manager/providers/utils.rs @@ -1,6 +1,54 @@ +use std::fs::File; +use std::io::{self, BufRead, BufReader, Seek, SeekFrom}; +use std::path::Path; + use chrono::{DateTime, FixedOffset}; use serde_json::Value; +/// Read the first `head_n` lines and last `tail_n` lines from a file. +/// For small files (< 16 KB), reads all lines once to avoid unnecessary seeking. +pub fn read_head_tail_lines( + path: &Path, + head_n: usize, + tail_n: usize, +) -> io::Result<(Vec, Vec)> { + let file = File::open(path)?; + let file_len = file.metadata()?.len(); + + // For small files, read all lines once and split + if file_len < 16_384 { + let reader = BufReader::new(file); + let all: Vec = reader.lines().map_while(Result::ok).collect(); + let head = all.iter().take(head_n).cloned().collect(); + let skip = all.len().saturating_sub(tail_n); + let tail = all.into_iter().skip(skip).collect(); + return Ok((head, tail)); + } + + // Read head lines from the beginning + let reader = BufReader::new(file); + let head: Vec = reader + .lines() + .take(head_n) + .map_while(Result::ok) + .collect(); + + // Seek to last ~16 KB for tail lines + let seek_pos = file_len.saturating_sub(16_384); + let mut file2 = File::open(path)?; + file2.seek(SeekFrom::Start(seek_pos))?; + let tail_reader = BufReader::new(file2); + let all_tail: Vec = tail_reader.lines().map_while(Result::ok).collect(); + + // Skip first partial line if we seeked into the middle of a line + let skip_first = if seek_pos > 0 { 1 } else { 0 }; + let usable: Vec = all_tail.into_iter().skip(skip_first).collect(); + let skip = usable.len().saturating_sub(tail_n); + let tail = usable.into_iter().skip(skip).collect(); + + Ok((head, tail)) +} + pub fn parse_timestamp_to_ms(value: &Value) -> Option { let raw = value.as_str()?; DateTime::parse_from_rfc3339(raw)