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);
}
}
}
+17 -2
View File
@@ -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() {
>
<Cpu className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("sessions")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("sessionManager.title")}
>
<History className="w-4 h-4" />
</Button>
</>
) : (
<>
+22 -4
View File
@@ -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() {
<span>Claude Code</span>
</div>
</SelectItem>
<SelectItem value="opencode">
<div className="flex items-center gap-2">
<ProviderIcon
icon="opencode"
name="opencode"
size={14}
/>
<span>OpenCode</span>
</div>
</SelectItem>
<SelectItem value="openclaw">
<div className="flex items-center gap-2">
<ProviderIcon
icon="openclaw"
name="openclaw"
size={14}
/>
<span>OpenClaw</span>
</div>
</SelectItem>
</SelectContent>
</Select>
+2
View File
@@ -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;
};
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -515,7 +515,7 @@
},
"sessionManager": {
"title": "セッション管理",
"subtitle": "Codex / Claude Code のセッションを管理",
"subtitle": "Claude Code / Codex / OpenCode / OpenClaw のセッションを管理",
"searchPlaceholder": "内容・ディレクトリ・ID で検索",
"searchSessions": "セッションを検索",
"providerFilterAll": "すべて",
+1 -1
View File
@@ -515,7 +515,7 @@
},
"sessionManager": {
"title": "会话管理",
"subtitle": "管理 Codex 与 Claude Code 会话记录",
"subtitle": "管理 Claude Code、Codex、OpenCode 与 OpenClaw 会话记录",
"searchPlaceholder": "搜索会话内容、目录或 ID",
"searchSessions": "搜索会话",
"providerFilterAll": "全部",