diff --git a/src-tauri/src/session_manager/providers/claude.rs b/src-tauri/src/session_manager/providers/claude.rs index 226613d13..d02eaba22 100644 --- a/src-tauri/src/session_manager/providers/claude.rs +++ b/src-tauri/src/session_manager/providers/claude.rs @@ -9,10 +9,10 @@ use crate::session_manager::{SessionMessage, SessionMeta}; use super::utils::{ extract_text, parse_timestamp_to_ms, path_basename, read_head_tail_lines, truncate_summary, + TITLE_MAX_CHARS, }; const PROVIDER_ID: &str = "claude"; -const TITLE_MAX_CHARS: usize = 80; pub fn scan_sessions() -> Vec { let root = get_claude_config_dir().join("projects"); diff --git a/src-tauri/src/session_manager/providers/codex.rs b/src-tauri/src/session_manager/providers/codex.rs index ce40e31f7..5d3c7d364 100644 --- a/src-tauri/src/session_manager/providers/codex.rs +++ b/src-tauri/src/session_manager/providers/codex.rs @@ -11,6 +11,7 @@ use crate::session_manager::{SessionMessage, SessionMeta}; use super::utils::{ extract_text, parse_timestamp_to_ms, path_basename, read_head_tail_lines, truncate_summary, + TITLE_MAX_CHARS, }; const PROVIDER_ID: &str = "codex"; @@ -129,8 +130,9 @@ fn parse_session(path: &Path) -> Option { let mut session_id: Option = None; let mut project_dir: Option = None; let mut created_at: Option = None; + let mut first_user_message: Option = None; - // Extract metadata from head lines + // Extract metadata and first user message from head lines for line in &head { let value: Value = match serde_json::from_str(line) { Ok(parsed) => parsed, @@ -158,6 +160,29 @@ fn parse_session(path: &Path) -> Option { } } } + // Extract first user message as title candidate + if first_user_message.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") + && payload.get("role").and_then(Value::as_str) == Some("user") + { + let text = payload.get("content").map(extract_text).unwrap_or_default(); + let trimmed = text.trim(); + if !trimmed.is_empty() && !trimmed.starts_with("# AGENTS.md") { + first_user_message = Some(trimmed.to_string()); + } + } + } + } + if session_id.is_some() + && project_dir.is_some() + && created_at.is_some() + && first_user_message.is_some() + { + break; + } } // Extract last_active_at and summary from tail lines (reverse order) @@ -190,10 +215,14 @@ fn parse_session(path: &Path) -> Option { let session_id = session_id.or_else(|| infer_session_id_from_filename(path)); let session_id = session_id?; - let title = project_dir - .as_deref() - .and_then(path_basename) - .map(|value| value.to_string()); + let title = first_user_message + .map(|t| truncate_summary(&t, TITLE_MAX_CHARS)) + .or_else(|| { + project_dir + .as_deref() + .and_then(path_basename) + .map(|v| v.to_string()) + }); let summary = summary.map(|text| truncate_summary(&text, 160)); @@ -261,6 +290,82 @@ mod tests { assert!(!path.exists()); } + #[test] + fn parse_session_uses_first_user_message_as_title() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("session.jsonl"); + std::fs::write( + &path, + concat!( + "{\"timestamp\":\"2026-03-06T21:50:12Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"test-id\",\"cwd\":\"/tmp/project\"}}\n", + "{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"How do I deploy?\"}}\n", + "{\"timestamp\":\"2026-03-06T21:50:14Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":\"Here is how...\"}}\n" + ), + ) + .expect("write"); + + let meta = parse_session(&path).unwrap(); + assert_eq!(meta.title.as_deref(), Some("How do I deploy?")); + } + + #[test] + fn parse_session_skips_agents_md_injection() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("session.jsonl"); + std::fs::write( + &path, + concat!( + "{\"timestamp\":\"2026-03-06T21:50:12Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"test-id\",\"cwd\":\"/tmp/project\"}}\n", + "{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"developer\",\"content\":\"\"}}\n", + "{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"# AGENTS.md instructions for /tmp/project\\nDo stuff\"}}\n", + "{\"timestamp\":\"2026-03-06T21:50:14Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"Fix the login bug\"}}\n" + ), + ) + .expect("write"); + + let meta = parse_session(&path).unwrap(); + // Should skip AGENTS.md injection and use the real user message + assert_eq!(meta.title.as_deref(), Some("Fix the login bug")); + } + + #[test] + fn parse_session_falls_back_to_dir_basename() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("session.jsonl"); + std::fs::write( + &path, + concat!( + "{\"timestamp\":\"2026-03-06T21:50:12Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"test-id\",\"cwd\":\"/tmp/my-project\"}}\n", + "{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":\"Hello\"}}\n" + ), + ) + .expect("write"); + + let meta = parse_session(&path).unwrap(); + // No user message → falls back to dir basename + assert_eq!(meta.title.as_deref(), Some("my-project")); + } + + #[test] + fn parse_session_truncates_long_title() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("session.jsonl"); + let long_msg = "a".repeat(200); + std::fs::write( + &path, + format!( + "{{\"timestamp\":\"2026-03-06T21:50:12Z\",\"type\":\"session_meta\",\"payload\":{{\"id\":\"test-id\",\"cwd\":\"/tmp/p\"}}}}\n\ + {{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{{\"type\":\"message\",\"role\":\"user\",\"content\":\"{long_msg}\"}}}}\n", + ), + ) + .expect("write"); + + let meta = parse_session(&path).unwrap(); + let title = meta.title.unwrap(); + assert!(title.len() <= TITLE_MAX_CHARS + 3); // +3 for "..." + assert!(title.ends_with("...")); + } + #[test] fn load_messages_includes_function_call_and_output() { let temp = tempdir().expect("tempdir"); diff --git a/src-tauri/src/session_manager/providers/openclaw.rs b/src-tauri/src/session_manager/providers/openclaw.rs index a56b56389..875a20ee4 100644 --- a/src-tauri/src/session_manager/providers/openclaw.rs +++ b/src-tauri/src/session_manager/providers/openclaw.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::fs::File; use std::io::{BufRead, BufReader}; use std::path::Path; @@ -12,6 +13,7 @@ use crate::{ use super::utils::{ extract_text, parse_timestamp_to_ms, path_basename, read_head_tail_lines, truncate_summary, + TITLE_MAX_CHARS, }; const PROVIDER_ID: &str = "openclaw"; @@ -46,22 +48,15 @@ pub fn scan_sessions() -> Vec { Err(_) => continue, }; + let display_names = load_display_names(&sessions_dir); + for entry in session_entries.flatten() { let path = entry.path(); if path.extension().and_then(|ext| ext.to_str()) != Some("jsonl") { continue; } - // Skip sessions.json index file - if path - .file_name() - .and_then(|n| n.to_str()) - .map(|n| n == "sessions.json") - .unwrap_or(false) - { - continue; - } - if let Some(meta) = parse_session(&path) { + if let Some(meta) = parse_session(&path, Some(&display_names)) { sessions.push(meta); } } @@ -119,7 +114,7 @@ pub fn load_messages(path: &Path) -> Result, String> { } pub fn delete_session(_root: &Path, path: &Path, session_id: &str) -> Result { - let meta = parse_session(path).ok_or_else(|| { + let meta = parse_session(path, None).ok_or_else(|| { format!( "Failed to parse OpenClaw session metadata: {}", path.display() @@ -149,15 +144,43 @@ pub fn delete_session(_root: &Path, path: &Path, session_id: &str) -> Result Option { +/// Read `sessions.json` index and build a sessionId → displayName lookup map. +/// Returns an empty map if the file does not exist or cannot be parsed. +fn load_display_names(sessions_dir: &Path) -> HashMap { + let index_path = sessions_dir.join("sessions.json"); + let content = match std::fs::read_to_string(&index_path) { + Ok(c) => c, + Err(_) => return HashMap::new(), + }; + let index: serde_json::Map = match serde_json::from_str(&content) { + Ok(m) => m, + Err(_) => return HashMap::new(), + }; + + let mut map = HashMap::new(); + for (_key, entry) in &index { + if let (Some(id), Some(name)) = ( + entry.get("sessionId").and_then(Value::as_str), + entry.get("displayName").and_then(Value::as_str), + ) { + if !name.is_empty() { + map.insert(id.to_string(), name.to_string()); + } + } + } + map +} + +fn parse_session(path: &Path, display_names: Option<&HashMap>) -> Option { 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 summary: Option = None; + let mut first_user_message: Option = None; - // Extract metadata and first message summary from head lines + // Extract metadata, summary, and first user message from head lines for line in &head { let value: Value = match serde_json::from_str(line) { Ok(parsed) => parsed, @@ -189,15 +212,30 @@ fn parse_session(path: &Path) -> Option { continue; } - // OpenClaw summary is the first message content - if event_type == "message" && summary.is_none() { + if event_type == "message" { 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 first_user_message.is_none() + && message.get("role").and_then(Value::as_str) == Some("user") + { + first_user_message = Some(text.trim().to_string()); + } + if summary.is_none() { + summary = Some(text); + } } } } + + if session_id.is_some() + && cwd.is_some() + && created_at.is_some() + && summary.is_some() + && first_user_message.is_some() + { + break; + } } // Extract last_active_at from tail lines (reverse order) @@ -221,10 +259,17 @@ fn parse_session(path: &Path) -> Option { }); let session_id = session_id?; - let title = cwd - .as_deref() - .and_then(path_basename) - .map(|s| s.to_string()); + // Title priority: displayName (from sessions.json) > first user message > dir basename + let title = display_names + .and_then(|m| m.get(&session_id)) + .filter(|s| !s.is_empty()) + .map(|t| truncate_summary(t, TITLE_MAX_CHARS)) + .or_else(|| first_user_message.map(|t| truncate_summary(&t, TITLE_MAX_CHARS))) + .or_else(|| { + cwd.as_deref() + .and_then(path_basename) + .map(|s| s.to_string()) + }); let summary = summary.map(|text| truncate_summary(&text, 160)); @@ -284,6 +329,93 @@ mod tests { use super::*; use tempfile::tempdir; + #[test] + fn parse_session_uses_first_user_message_as_title() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("session-abc.jsonl"); + std::fs::write( + &path, + concat!( + "{\"type\":\"session\",\"id\":\"session-abc\",\"cwd\":\"/tmp/project\",\"timestamp\":\"2026-03-06T10:00:00Z\"}\n", + "{\"type\":\"message\",\"message\":{\"role\":\"user\",\"content\":\"How do I deploy?\"},\"timestamp\":\"2026-03-06T10:01:00Z\"}\n", + "{\"type\":\"message\",\"message\":{\"role\":\"assistant\",\"content\":\"Here is how...\"},\"timestamp\":\"2026-03-06T10:02:00Z\"}\n" + ), + ) + .expect("write"); + + let meta = parse_session(&path, None).unwrap(); + assert_eq!(meta.title.as_deref(), Some("How do I deploy?")); + } + + #[test] + fn parse_session_display_name_overrides_user_message() { + let temp = tempdir().expect("tempdir"); + let sessions_dir = temp.path(); + + let path = sessions_dir.join("session-abc.jsonl"); + std::fs::write( + &path, + concat!( + "{\"type\":\"session\",\"id\":\"session-abc\",\"cwd\":\"/tmp/project\",\"timestamp\":\"2026-03-06T10:00:00Z\"}\n", + "{\"type\":\"message\",\"message\":{\"role\":\"user\",\"content\":\"fix something\"},\"timestamp\":\"2026-03-06T10:01:00Z\"}\n" + ), + ) + .expect("write session"); + + std::fs::write( + sessions_dir.join("sessions.json"), + r#"{ + "agent:main:main": { + "sessionId": "session-abc", + "displayName": "重构登录模块" + } + }"#, + ) + .expect("write index"); + + let display_names = load_display_names(sessions_dir); + let meta = parse_session(&path, Some(&display_names)).unwrap(); + assert_eq!(meta.title.as_deref(), Some("重构登录模块")); + } + + #[test] + fn parse_session_falls_back_to_dir_basename() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("session-def.jsonl"); + std::fs::write( + &path, + concat!( + "{\"type\":\"session\",\"id\":\"session-def\",\"cwd\":\"/tmp/my-project\",\"timestamp\":\"2026-03-06T10:00:00Z\"}\n", + "{\"type\":\"message\",\"message\":{\"role\":\"assistant\",\"content\":\"Hello\"},\"timestamp\":\"2026-03-06T10:01:00Z\"}\n" + ), + ) + .expect("write"); + + let meta = parse_session(&path, None).unwrap(); + // No user message and no displayName → falls back to dir basename + assert_eq!(meta.title.as_deref(), Some("my-project")); + } + + #[test] + fn parse_session_truncates_long_title() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("session-trunc.jsonl"); + let long_msg = "a".repeat(200); + std::fs::write( + &path, + format!( + "{{\"type\":\"session\",\"id\":\"session-trunc\",\"cwd\":\"/tmp/p\",\"timestamp\":\"2026-03-06T10:00:00Z\"}}\n\ + {{\"type\":\"message\",\"message\":{{\"role\":\"user\",\"content\":\"{long_msg}\"}},\"timestamp\":\"2026-03-06T10:01:00Z\"}}\n", + ), + ) + .expect("write"); + + let meta = parse_session(&path, None).unwrap(); + let title = meta.title.unwrap(); + assert!(title.len() <= TITLE_MAX_CHARS + 3); // +3 for "..." + assert!(title.ends_with("...")); + } + #[test] fn delete_session_updates_index_and_removes_jsonl() { let temp = tempdir().expect("tempdir"); diff --git a/src-tauri/src/session_manager/providers/utils.rs b/src-tauri/src/session_manager/providers/utils.rs index dbbaa001d..4339ae3e7 100644 --- a/src-tauri/src/session_manager/providers/utils.rs +++ b/src-tauri/src/session_manager/providers/utils.rs @@ -5,6 +5,9 @@ use std::path::Path; use chrono::{DateTime, FixedOffset}; use serde_json::Value; +/// Maximum number of characters for session titles (shared across providers). +pub const TITLE_MAX_CHARS: usize = 80; + /// 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(