feat(sessions): add session browsing for OpenCode and OpenClaw

Add two new session providers following the existing convention-based
pattern. OpenCode reads three-layer JSON storage, OpenClaw parses JSONL
event streams. Wire up backend dispatch, frontend filter dropdown, icon
mappings, toolbar button for OpenClaw, and i18n subtitle updates.
This commit is contained in:
Jason
2026-02-21 18:10:57 +08:00
parent 8b9c09d994
commit 165af5eec4
10 changed files with 533 additions and 10 deletions
+5 -1
View File
@@ -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<SessionMeta> {
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<Vec<Session
match provider_id {
"codex" => 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}")),
}
}
@@ -1,3 +1,5 @@
pub mod claude;
pub mod codex;
pub mod openclaw;
pub mod opencode;
mod utils;
@@ -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<SessionMeta> {
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<Vec<SessionMessage>, 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<SessionMeta> {
let file = File::open(path).ok()?;
let reader = BufReader::new(file);
let mut session_id: Option<String> = None;
let mut cwd: Option<String> = None;
let mut created_at: Option<i64> = None;
let mut last_active_at: Option<i64> = None;
let mut summary: Option<String> = 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
})
}
@@ -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<SessionMeta> {
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<Vec<SessionMessage>, 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<SessionMeta> {
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<String> {
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<PathBuf>) {
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);
}
}
}