mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-05-19 03:29:45 +08:00
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:
@@ -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
@@ -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>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -515,7 +515,7 @@
|
||||
},
|
||||
"sessionManager": {
|
||||
"title": "セッション管理",
|
||||
"subtitle": "Codex / Claude Code のセッションを管理",
|
||||
"subtitle": "Claude Code / Codex / OpenCode / OpenClaw のセッションを管理",
|
||||
"searchPlaceholder": "内容・ディレクトリ・ID で検索",
|
||||
"searchSessions": "セッションを検索",
|
||||
"providerFilterAll": "すべて",
|
||||
|
||||
@@ -515,7 +515,7 @@
|
||||
},
|
||||
"sessionManager": {
|
||||
"title": "会话管理",
|
||||
"subtitle": "管理 Codex 与 Claude Code 会话记录",
|
||||
"subtitle": "管理 Claude Code、Codex、OpenCode 与 OpenClaw 会话记录",
|
||||
"searchPlaceholder": "搜索会话内容、目录或 ID",
|
||||
"searchSessions": "搜索会话",
|
||||
"providerFilterAll": "全部",
|
||||
|
||||
Reference in New Issue
Block a user