diff --git a/src-tauri/src/session_manager/mod.rs b/src-tauri/src/session_manager/mod.rs index a91ba278..db96e424 100644 --- a/src-tauri/src/session_manager/mod.rs +++ b/src-tauri/src/session_manager/mod.rs @@ -4,7 +4,7 @@ pub mod terminal; use serde::Serialize; use std::path::Path; -use providers::{claude, codex}; +use providers::{claude, codex, openclaw, opencode}; #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -40,6 +40,8 @@ pub fn scan_sessions() -> Vec { 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.sort_by(|a, b| { let a_ts = a.last_active_at.or(a.created_at).unwrap_or(0); @@ -55,6 +57,8 @@ pub fn load_messages(provider_id: &str, source_path: &str) -> Result codex::load_messages(path), "claude" => claude::load_messages(path), + "opencode" => opencode::load_messages(path), + "openclaw" => openclaw::load_messages(path), _ => Err(format!("Unsupported provider: {provider_id}")), } } diff --git a/src-tauri/src/session_manager/providers/mod.rs b/src-tauri/src/session_manager/providers/mod.rs index 2837bb91..0d824c39 100644 --- a/src-tauri/src/session_manager/providers/mod.rs +++ b/src-tauri/src/session_manager/providers/mod.rs @@ -1,3 +1,5 @@ pub mod claude; pub mod codex; +pub mod openclaw; +pub mod opencode; mod utils; diff --git a/src-tauri/src/session_manager/providers/openclaw.rs b/src-tauri/src/session_manager/providers/openclaw.rs new file mode 100644 index 00000000..e8283bf0 --- /dev/null +++ b/src-tauri/src/session_manager/providers/openclaw.rs @@ -0,0 +1,211 @@ +use std::fs::File; +use std::io::{BufRead, BufReader}; +use std::path::Path; + +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}; + +const PROVIDER_ID: &str = "openclaw"; + +pub fn scan_sessions() -> Vec { + let agents_dir = get_openclaw_dir().join("agents"); + if !agents_dir.exists() { + return Vec::new(); + } + + let mut sessions = Vec::new(); + + // Traverse each agent directory + let agent_entries = match std::fs::read_dir(&agents_dir) { + Ok(entries) => entries, + Err(_) => return sessions, + }; + + for agent_entry in agent_entries.flatten() { + let agent_path = agent_entry.path(); + if !agent_path.is_dir() { + continue; + } + + let sessions_dir = agent_path.join("sessions"); + if !sessions_dir.is_dir() { + continue; + } + + let session_entries = match std::fs::read_dir(&sessions_dir) { + Ok(entries) => entries, + Err(_) => continue, + }; + + 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) { + sessions.push(meta); + } + } + } + + sessions +} + +pub fn load_messages(path: &Path) -> Result, String> { + let file = File::open(path).map_err(|e| format!("Failed to open session file: {e}"))?; + let reader = BufReader::new(file); + let mut messages = Vec::new(); + + for line in reader.lines() { + let line = match line { + Ok(value) => value, + Err(_) => continue, + }; + let value: Value = match serde_json::from_str(&line) { + Ok(parsed) => parsed, + Err(_) => continue, + }; + + if value.get("type").and_then(Value::as_str) != Some("message") { + continue; + } + + let message = match value.get("message") { + Some(msg) => msg, + None => continue, + }; + + let raw_role = message + .get("role") + .and_then(Value::as_str) + .unwrap_or("unknown"); + + // Map OpenClaw roles to our standard roles + let role = match raw_role { + "toolResult" => "tool".to_string(), + other => other.to_string(), + }; + + let content = message.get("content").map(extract_text).unwrap_or_default(); + if content.trim().is_empty() { + continue; + } + + let ts = value.get("timestamp").and_then(parse_timestamp_to_ms); + + messages.push(SessionMessage { role, content, ts }); + } + + Ok(messages) +} + +fn parse_session(path: &Path) -> Option { + let file = File::open(path).ok()?; + let reader = BufReader::new(file); + + 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) { + 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); + } + + let event_type = value.get("type").and_then(Value::as_str).unwrap_or(""); + + if event_type == "session" { + if session_id.is_none() { + session_id = value + .get("id") + .and_then(Value::as_str) + .map(|s| s.to_string()); + } + if cwd.is_none() { + cwd = 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) { + created_at.get_or_insert(ts); + } + continue; + } + + if event_type != "message" { + continue; + } + + // Extract first message content for summary + if summary.is_some() { + continue; + } + + let message = match value.get("message") { + Some(msg) => msg, + None => continue, + }; + + let text = message.get("content").map(extract_text).unwrap_or_default(); + if text.trim().is_empty() { + continue; + } + summary = Some(text); + } + + // Fall back to filename as session ID + let session_id = session_id.or_else(|| { + path.file_stem() + .and_then(|s| s.to_str()) + .map(|s| s.to_string()) + }); + let session_id = session_id?; + + let title = cwd + .as_deref() + .and_then(path_basename) + .map(|s| s.to_string()); + + let summary = summary.map(|text| truncate_summary(&text, 160)); + + Some(SessionMeta { + provider_id: PROVIDER_ID.to_string(), + session_id: session_id.clone(), + title, + summary, + project_dir: cwd, + created_at, + last_active_at, + source_path: Some(path.to_string_lossy().to_string()), + resume_command: None, // OpenClaw sessions are gateway-managed, no CLI resume + }) +} diff --git a/src-tauri/src/session_manager/providers/opencode.rs b/src-tauri/src/session_manager/providers/opencode.rs new file mode 100644 index 00000000..4428bc57 --- /dev/null +++ b/src-tauri/src/session_manager/providers/opencode.rs @@ -0,0 +1,271 @@ +use std::path::{Path, PathBuf}; + +use serde_json::Value; + +use crate::session_manager::{SessionMessage, SessionMeta}; + +use super::utils::{parse_timestamp_to_ms, path_basename, truncate_summary}; + +const PROVIDER_ID: &str = "opencode"; + +/// Return the OpenCode data directory. +/// +/// Respects `XDG_DATA_HOME` on all platforms; falls back to +/// `~/.local/share/opencode/storage/`. +fn get_opencode_data_dir() -> PathBuf { + if let Ok(xdg) = std::env::var("XDG_DATA_HOME") { + if !xdg.is_empty() { + return PathBuf::from(xdg).join("opencode").join("storage"); + } + } + dirs::home_dir() + .map(|h| h.join(".local/share/opencode/storage")) + .unwrap_or_else(|| PathBuf::from(".local/share/opencode/storage")) +} + +pub fn scan_sessions() -> Vec { + let storage = get_opencode_data_dir(); + let session_dir = storage.join("session"); + if !session_dir.exists() { + return Vec::new(); + } + + let mut json_files = Vec::new(); + collect_json_files(&session_dir, &mut json_files); + + let mut sessions = Vec::new(); + for path in json_files { + if let Some(meta) = parse_session(&storage, &path) { + sessions.push(meta); + } + } + sessions +} + +pub fn load_messages(path: &Path) -> Result, String> { + // `path` is the message directory: storage/message/{sessionID}/ + if !path.is_dir() { + return Err(format!("Message directory not found: {}", path.display())); + } + + let storage = path + .parent() + .and_then(|p| p.parent()) + .ok_or_else(|| "Cannot determine storage root from message path".to_string())?; + + let mut msg_files = Vec::new(); + collect_json_files(path, &mut msg_files); + + // Parse all messages and collect (created_ts, message_id, role, parts_text) + let mut entries: Vec<(i64, String, String, String)> = Vec::new(); + + for msg_path in &msg_files { + let data = match std::fs::read_to_string(msg_path) { + Ok(d) => d, + Err(_) => continue, + }; + let value: Value = match serde_json::from_str(&data) { + Ok(v) => v, + Err(_) => continue, + }; + + let msg_id = match value.get("id").and_then(Value::as_str) { + Some(id) => id.to_string(), + None => continue, + }; + + let role = value + .get("role") + .and_then(Value::as_str) + .unwrap_or("unknown") + .to_string(); + + let created_ts = value + .get("time") + .and_then(|t| t.get("created")) + .and_then(parse_timestamp_to_ms) + .unwrap_or(0); + + // Collect text parts from storage/part/{messageID}/ + let part_dir = storage.join("part").join(&msg_id); + let text = collect_parts_text(&part_dir); + if text.trim().is_empty() { + continue; + } + + entries.push((created_ts, msg_id, role, text)); + } + + // Sort by created timestamp + entries.sort_by_key(|(ts, _, _, _)| *ts); + + let messages = entries + .into_iter() + .map(|(ts, _, role, content)| SessionMessage { + role, + content, + ts: if ts > 0 { Some(ts) } else { None }, + }) + .collect(); + + Ok(messages) +} + +fn parse_session(storage: &Path, path: &Path) -> Option { + let data = std::fs::read_to_string(path).ok()?; + let value: Value = serde_json::from_str(&data).ok()?; + + let session_id = value.get("id").and_then(Value::as_str)?.to_string(); + let title = value + .get("title") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + let directory = value + .get("directory") + .and_then(Value::as_str) + .map(|s| s.to_string()); + + let created_at = value + .get("time") + .and_then(|t| t.get("created")) + .and_then(parse_timestamp_to_ms); + let updated_at = value + .get("time") + .and_then(|t| t.get("updated")) + .and_then(parse_timestamp_to_ms); + + // Derive title from directory basename if no explicit title + let display_title = title.or_else(|| { + directory + .as_deref() + .and_then(path_basename) + .map(|s| s.to_string()) + }); + + // Build source_path = message directory for this session + 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); + + Some(SessionMeta { + provider_id: PROVIDER_ID.to_string(), + session_id: session_id.clone(), + title: display_title, + summary, + project_dir: directory, + created_at, + last_active_at: updated_at.or(created_at), + source_path: Some(source_path), + resume_command: Some(format!("opencode session resume {session_id}")), + }) +} + +/// Read the first user message's first text part to use as summary. +fn get_first_user_summary(storage: &Path, session_id: &str) -> Option { + let msg_dir = storage.join("message").join(session_id); + if !msg_dir.is_dir() { + return None; + } + + let mut msg_files = Vec::new(); + collect_json_files(&msg_dir, &mut msg_files); + + // Collect user messages with timestamps for ordering + let mut user_msgs: Vec<(i64, String)> = Vec::new(); + for msg_path in &msg_files { + let data = match std::fs::read_to_string(msg_path) { + Ok(d) => d, + Err(_) => continue, + }; + let value: Value = match serde_json::from_str(&data) { + Ok(v) => v, + Err(_) => continue, + }; + + if value.get("role").and_then(Value::as_str) != Some("user") { + continue; + } + + let msg_id = match value.get("id").and_then(Value::as_str) { + Some(id) => id.to_string(), + None => continue, + }; + + let ts = value + .get("time") + .and_then(|t| t.get("created")) + .and_then(parse_timestamp_to_ms) + .unwrap_or(0); + + user_msgs.push((ts, msg_id)); + } + + user_msgs.sort_by_key(|(ts, _)| *ts); + + // Take first user message and get its parts + let (_, first_id) = user_msgs.first()?; + let part_dir = storage.join("part").join(first_id); + let text = collect_parts_text(&part_dir); + if text.trim().is_empty() { + return None; + } + Some(truncate_summary(&text, 160)) +} + +/// Collect text content from all parts in a part directory. +fn collect_parts_text(part_dir: &Path) -> String { + if !part_dir.is_dir() { + return String::new(); + } + + let mut parts = Vec::new(); + collect_json_files(part_dir, &mut parts); + + let mut texts = Vec::new(); + for part_path in &parts { + let data = match std::fs::read_to_string(part_path) { + Ok(d) => d, + Err(_) => continue, + }; + let value: Value = match serde_json::from_str(&data) { + Ok(v) => v, + Err(_) => continue, + }; + + // Only include text-type parts + if value.get("type").and_then(Value::as_str) != Some("text") { + continue; + } + + if let Some(text) = value.get("text").and_then(Value::as_str) { + if !text.trim().is_empty() { + texts.push(text.to_string()); + } + } + } + + texts.join("\n") +} + +fn collect_json_files(root: &Path, files: &mut Vec) { + if !root.exists() { + return; + } + + let entries = match std::fs::read_dir(root) { + Ok(entries) => entries, + Err(_) => return, + }; + + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_json_files(&path, files); + } else if path.extension().and_then(|ext| ext.to_str()) == Some("json") { + files.push(path); + } + } +} diff --git a/src/App.tsx b/src/App.tsx index 0b6f0f64..ae119b62 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -179,7 +179,9 @@ function App() { if ( currentView === "sessions" && activeApp !== "claude" && - activeApp !== "codex" + activeApp !== "codex" && + activeApp !== "opencode" && + activeApp !== "openclaw" ) { setCurrentView("providers"); } @@ -223,7 +225,11 @@ function App() { const providers = useMemo(() => data?.providers ?? {}, [data]); const currentProviderId = data?.currentProviderId ?? ""; const hasSkillsSupport = true; - const hasSessionSupport = activeApp === "claude" || activeApp === "codex"; + const hasSessionSupport = + activeApp === "claude" || + activeApp === "codex" || + activeApp === "opencode" || + activeApp === "openclaw"; const { addProvider, @@ -1122,6 +1128,15 @@ function App() { > + ) : ( <> diff --git a/src/components/sessions/SessionManagerPage.tsx b/src/components/sessions/SessionManagerPage.tsx index ad0825e5..588cad13 100644 --- a/src/components/sessions/SessionManagerPage.tsx +++ b/src/components/sessions/SessionManagerPage.tsx @@ -46,7 +46,7 @@ import { getSessionKey, } from "./utils"; -type ProviderFilter = "all" | "codex" | "claude"; +type ProviderFilter = "all" | "codex" | "claude" | "opencode" | "openclaw"; export function SessionManagerPage() { const { t } = useTranslation(); @@ -265,9 +265,7 @@ export function SessionManagerPage() { icon={ providerFilter === "all" ? "apps" - : providerFilter === "codex" - ? "openai" - : "claude" + : getProviderIconName(providerFilter) } name={providerFilter} size={14} @@ -309,6 +307,26 @@ export function SessionManagerPage() { Claude Code + +
+ + OpenCode +
+
+ +
+ + OpenClaw +
+
diff --git a/src/components/sessions/utils.ts b/src/components/sessions/utils.ts index d6dd9f32..633d7136 100644 --- a/src/components/sessions/utils.ts +++ b/src/components/sessions/utils.ts @@ -48,6 +48,8 @@ export const getProviderLabel = ( export const getProviderIconName = (providerId: string) => { if (providerId === "codex") return "openai"; if (providerId === "claude") return "claude"; + if (providerId === "opencode") return "opencode"; + if (providerId === "openclaw") return "openclaw"; return providerId; }; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index f1b2436c..670bd272 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -515,7 +515,7 @@ }, "sessionManager": { "title": "Session Manager", - "subtitle": "Manage Codex and Claude Code sessions", + "subtitle": "Manage Claude Code, Codex, OpenCode and OpenClaw sessions", "searchPlaceholder": "Search by content, directory, or ID", "searchSessions": "Search sessions", "providerFilterAll": "All", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 03ba8c02..c2068765 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -515,7 +515,7 @@ }, "sessionManager": { "title": "セッション管理", - "subtitle": "Codex / Claude Code のセッションを管理", + "subtitle": "Claude Code / Codex / OpenCode / OpenClaw のセッションを管理", "searchPlaceholder": "内容・ディレクトリ・ID で検索", "searchSessions": "セッションを検索", "providerFilterAll": "すべて", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 2c90a137..e2aa15ad 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -515,7 +515,7 @@ }, "sessionManager": { "title": "会话管理", - "subtitle": "管理 Codex 与 Claude Code 会话记录", + "subtitle": "管理 Claude Code、Codex、OpenCode 与 OpenClaw 会话记录", "searchPlaceholder": "搜索会话内容、目录或 ID", "searchSessions": "搜索会话", "providerFilterAll": "全部",