From 6cc06d355db69096383d36a878dcb025c21586e0 Mon Sep 17 00:00:00 2001 From: Adam Dalloul <47503782+Adam-Dalloul@users.noreply.github.com> Date: Sat, 15 Aug 2026 09:46:00 -0700 Subject: [PATCH 1/2] Read Codex Desktop thread names from session_index.jsonl --- src-tauri/src/parsers/codex.rs | 86 ++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/src-tauri/src/parsers/codex.rs b/src-tauri/src/parsers/codex.rs index 8fe892bb7..41b319c15 100644 --- a/src-tauri/src/parsers/codex.rs +++ b/src-tauri/src/parsers/codex.rs @@ -284,6 +284,48 @@ fn resolve_codex_home_dir_from( .unwrap_or_else(|| home_dir.unwrap_or_default().join(".codex")) } +/// Codex Desktop stores the current thread name in `session_index.jsonl` +/// (sibling of `sessions/`). Rollouts often have no `thread_name_updated`, +/// so the list/import path would otherwise keep the first user prompt. +/// Last non-empty `thread_name` for an id wins. +fn load_session_index_titles(codex_home: &std::path::Path) -> HashMap { + let path = codex_home.join("session_index.jsonl"); + let Ok(file) = fs::File::open(&path) else { + return HashMap::new(); + }; + let mut titles = HashMap::new(); + for line in BufReader::new(file).lines() { + let Ok(line) = line else { continue }; + if line.trim().is_empty() { + continue; + } + let Ok(value) = serde_json::from_str::(&line) else { + continue; + }; + let Some(id) = value + .get("id") + .or_else(|| value.get("session_id")) + .or_else(|| value.get("sessionId")) + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + else { + continue; + }; + let Some(name) = value + .get("thread_name") + .or_else(|| value.get("threadName")) + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + else { + continue; + }; + titles.insert(id.to_string(), truncate_str(name, 100)); + } + titles +} + impl AgentParser for CodexParser { fn list_conversations(&self) -> Result, ParseError> { let mut conversations = Vec::new(); @@ -313,6 +355,17 @@ impl AgentParser for CodexParser { } } + let index_titles = load_session_index_titles( + self.base_dir + .parent() + .unwrap_or(self.base_dir.as_path()), + ); + for conversation in &mut conversations { + if let Some(name) = index_titles.get(&conversation.id) { + conversation.title = Some(name.clone()); + } + } + conversations.sort_by_key(|b| std::cmp::Reverse(b.started_at)); Ok(conversations) } @@ -5251,6 +5304,39 @@ mod tests { } #[test] + fn list_conversations_prefers_session_index_thread_name() { + // Codex Desktop often renames a thread in session_index.jsonl without + // writing thread_name_updated into the rollout. Import must use that + // name instead of the first user prompt. See issue #457. + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time ok") + .as_nanos(); + let home = env::temp_dir().join(format!("codeg-codex-idx-{nanos}")); + let sessions = home.join("sessions"); + fs::create_dir_all(&sessions).expect("sessions dir"); + let rollout = sessions.join("rollout-abc.jsonl"); + fs::write( + &rollout, + concat!( + "{\"timestamp\":\"2026-03-01T10:00:00Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"abc-session\",\"cwd\":\"/tmp/demo\"}}\n", + "{\"timestamp\":\"2026-03-01T10:00:01Z\",\"type\":\"event_msg\",\"payload\":{\"type\":\"user_message\",\"message\":\"please implement a very long thing that should not become the sidebar title\"}}\n", + ), + ) + .expect("write rollout"); + fs::write( + home.join("session_index.jsonl"), + "{\"id\":\"abc-session\",\"thread_name\":\"Auth refactor\"}\n{\"id\":\"abc-session\",\"thread_name\":\"Readable title\"}\n", + ) + .expect("write index"); + + let parser = CodexParser::with_base_dir(sessions); + let list = parser.list_conversations().expect("list ok"); + assert_eq!(list.len(), 1); + assert_eq!(list[0].title.as_deref(), Some("Readable title")); + let _ = fs::remove_dir_all(home); + } + fn parse_summary_prefers_native_thread_name_over_goal_objective() { // A native `thread_name_updated` wins over the goal-objective fallback // (newest non-empty), matching the detail parser. From 79e161cede137cdd8589c8d550142ffd06aae3d1 Mon Sep 17 00:00:00 2001 From: Adam Dalloul <47503782+Adam-Dalloul@users.noreply.github.com> Date: Sat, 15 Aug 2026 10:18:28 -0700 Subject: [PATCH 2/2] Keep Codex session_index titles when opening a conversation List/import already preferred the last session_index.jsonl thread_name. Opening the same conversation used the rollout parser only, so the first prompt replaced the Desktop title. Apply the same overlay on detail. --- src-tauri/src/parsers/codex.rs | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/src-tauri/src/parsers/codex.rs b/src-tauri/src/parsers/codex.rs index 41b319c15..1666d7a55 100644 --- a/src-tauri/src/parsers/codex.rs +++ b/src-tauri/src/parsers/codex.rs @@ -355,11 +355,7 @@ impl AgentParser for CodexParser { } } - let index_titles = load_session_index_titles( - self.base_dir - .parent() - .unwrap_or(self.base_dir.as_path()), - ); + let index_titles = self.session_index_titles(); for conversation in &mut conversations { if let Some(name) = index_titles.get(&conversation.id) { conversation.title = Some(name.clone()); @@ -388,7 +384,11 @@ impl AgentParser for CodexParser { } let fname = path.file_name().unwrap_or_default().to_string_lossy(); if fname.contains(conversation_id) { - return self.parse_conversation_detail(&path, conversation_id); + let mut detail = self.parse_conversation_detail(&path, conversation_id)?; + if let Some(name) = self.session_index_titles().get(conversation_id) { + detail.summary.title = Some(name.clone()); + } + return Ok(detail); } } @@ -398,6 +398,12 @@ impl AgentParser for CodexParser { } } +impl CodexParser { + fn session_index_titles(&self) -> HashMap { + load_session_index_titles(self.base_dir.parent().unwrap_or(self.base_dir.as_path())) + } +} + fn parse_codex_json_arg(payload: &serde_json::Value) -> Option { let args = payload.get("arguments").or_else(|| payload.get("input"))?; if let Some(s) = args.as_str() { @@ -5315,7 +5321,7 @@ mod tests { let home = env::temp_dir().join(format!("codeg-codex-idx-{nanos}")); let sessions = home.join("sessions"); fs::create_dir_all(&sessions).expect("sessions dir"); - let rollout = sessions.join("rollout-abc.jsonl"); + let rollout = sessions.join("rollout-abc-session.jsonl"); fs::write( &rollout, concat!( @@ -5334,6 +5340,14 @@ mod tests { let list = parser.list_conversations().expect("list ok"); assert_eq!(list.len(), 1); assert_eq!(list[0].title.as_deref(), Some("Readable title")); + let detail = parser + .get_conversation("abc-session") + .expect("detail ok"); + assert_eq!( + detail.summary.title.as_deref(), + Some("Readable title"), + "opening a conversation must keep the session_index title" + ); let _ = fs::remove_dir_all(home); }