diff --git a/src-tauri/src/commands/session_manager.rs b/src-tauri/src/commands/session_manager.rs index 8a9f910d8..8f3caee1b 100644 --- a/src-tauri/src/commands/session_manager.rs +++ b/src-tauri/src/commands/session_manager.rs @@ -57,3 +57,20 @@ pub async fn launch_session_terminal( Ok(true) } + +#[tauri::command] +pub async fn delete_session( + providerId: String, + sessionId: String, + sourcePath: String, +) -> Result { + let provider_id = providerId.clone(); + let session_id = sessionId.clone(); + let source_path = sourcePath.clone(); + + tauri::async_runtime::spawn_blocking(move || { + session_manager::delete_session(&provider_id, &session_id, &source_path) + }) + .await + .map_err(|e| format!("Failed to delete session: {e}"))? +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 29254fb63..94c5e6364 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1038,6 +1038,7 @@ pub fn run() { // Session manager commands::list_sessions, commands::get_session_messages, + commands::delete_session, commands::launch_session_terminal, commands::get_tool_versions, // Provider terminal diff --git a/src-tauri/src/session_manager/mod.rs b/src-tauri/src/session_manager/mod.rs index f7ab61e98..d2a4926e7 100644 --- a/src-tauri/src/session_manager/mod.rs +++ b/src-tauri/src/session_manager/mod.rs @@ -2,7 +2,7 @@ pub mod providers; pub mod terminal; use serde::Serialize; -use std::path::Path; +use std::path::{Path, PathBuf}; use providers::{claude, codex, gemini, openclaw, opencode}; @@ -79,3 +79,90 @@ pub fn load_messages(provider_id: &str, source_path: &str) -> Result Err(format!("Unsupported provider: {provider_id}")), } } + +pub fn delete_session( + provider_id: &str, + session_id: &str, + source_path: &str, +) -> Result { + let root = provider_root(provider_id)?; + delete_session_with_root(provider_id, session_id, Path::new(source_path), &root) +} + +fn delete_session_with_root( + provider_id: &str, + session_id: &str, + source_path: &Path, + root: &Path, +) -> Result { + let validated_root = canonicalize_existing_path(root, "session root")?; + let validated_source = canonicalize_existing_path(source_path, "session source")?; + + if !validated_source.starts_with(&validated_root) { + return Err(format!( + "Session source path is outside provider root: {}", + source_path.display() + )); + } + + match provider_id { + "codex" => codex::delete_session(&validated_root, &validated_source, session_id), + "claude" => claude::delete_session(&validated_root, &validated_source, session_id), + "opencode" => opencode::delete_session(&validated_root, &validated_source, session_id), + "openclaw" => openclaw::delete_session(&validated_root, &validated_source, session_id), + "gemini" => gemini::delete_session(&validated_root, &validated_source, session_id), + _ => Err(format!("Unsupported provider: {provider_id}")), + } +} + +fn provider_root(provider_id: &str) -> Result { + let root = match provider_id { + "codex" => crate::codex_config::get_codex_config_dir().join("sessions"), + "claude" => crate::config::get_claude_config_dir().join("projects"), + "opencode" => opencode::get_opencode_data_dir(), + "openclaw" => crate::openclaw_config::get_openclaw_dir().join("agents"), + "gemini" => crate::gemini_config::get_gemini_dir().join("tmp"), + _ => return Err(format!("Unsupported provider: {provider_id}")), + }; + + Ok(root) +} + +fn canonicalize_existing_path(path: &Path, label: &str) -> Result { + if !path.exists() { + return Err(format!("{label} not found: {}", path.display())); + } + + path.canonicalize() + .map_err(|e| format!("Failed to resolve {label} {}: {e}", path.display())) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn rejects_source_path_outside_provider_root() { + let root = tempdir().expect("tempdir"); + let outside = tempdir().expect("tempdir"); + let source = outside.path().join("session.jsonl"); + std::fs::write(&source, "{}").expect("write source"); + + let err = delete_session_with_root("codex", "session-1", &source, root.path()) + .expect_err("expected outside-root path to be rejected"); + + assert!(err.contains("outside provider root")); + } + + #[test] + fn rejects_missing_source_path() { + let root = tempdir().expect("tempdir"); + let missing = root.path().join("missing.jsonl"); + + let err = delete_session_with_root("codex", "session-1", &missing, root.path()) + .expect_err("expected missing source path to fail"); + + assert!(err.contains("session source not found")); + } +} diff --git a/src-tauri/src/session_manager/providers/claude.rs b/src-tauri/src/session_manager/providers/claude.rs index 6f1d8f20a..7eeed5233 100644 --- a/src-tauri/src/session_manager/providers/claude.rs +++ b/src-tauri/src/session_manager/providers/claude.rs @@ -70,6 +70,41 @@ pub fn load_messages(path: &Path) -> Result, String> { Ok(messages) } +pub fn delete_session(_root: &Path, path: &Path, session_id: &str) -> Result { + let meta = parse_session(path).ok_or_else(|| { + format!( + "Failed to parse Claude session metadata: {}", + path.display() + ) + })?; + + if meta.session_id != session_id { + return Err(format!( + "Claude session ID mismatch: expected {session_id}, found {}", + meta.session_id + )); + } + + if let Some(stem) = path.file_stem() { + let sibling = path.parent().unwrap_or_else(|| Path::new("")).join(stem); + remove_path_if_exists(&sibling).map_err(|e| { + format!( + "Failed to delete Claude session sidecar {}: {e}", + sibling.display() + ) + })?; + } + + std::fs::remove_file(path).map_err(|e| { + format!( + "Failed to delete Claude session file {}: {e}", + path.display() + ) + })?; + + Ok(true) +} + fn parse_session(path: &Path) -> Option { if is_agent_session(path) { return None; @@ -187,3 +222,50 @@ fn collect_jsonl_files(root: &Path, files: &mut Vec) { } } } + +fn remove_path_if_exists(path: &Path) -> std::io::Result<()> { + match std::fs::metadata(path) { + Ok(meta) => { + if meta.is_dir() { + std::fs::remove_dir_all(path) + } else { + std::fs::remove_file(path) + } + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(err) => Err(err), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn delete_session_removes_main_file_and_sidecar_directory() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("abc123-session.jsonl"); + let sidecar = temp.path().join("abc123-session"); + let subagents = sidecar.join("subagents"); + let tool_results = sidecar.join("tool-results"); + + std::fs::create_dir_all(&subagents).expect("create subagents"); + std::fs::create_dir_all(&tool_results).expect("create tool-results"); + std::fs::write(subagents.join("agent-1.jsonl"), "{}").expect("write subagent"); + std::fs::write(tool_results.join("tool-1.txt"), "result").expect("write tool result"); + std::fs::write( + &path, + concat!( + "{\"sessionId\":\"session-123\",\"cwd\":\"/tmp/project\",\"timestamp\":\"2026-03-06T10:00:00Z\"}\n", + "{\"message\":{\"role\":\"user\",\"content\":\"hello\"},\"timestamp\":\"2026-03-06T10:01:00Z\"}\n" + ), + ) + .expect("write session"); + + delete_session(temp.path(), &path, "session-123").expect("delete session"); + + assert!(!path.exists()); + assert!(!sidecar.exists()); + } +} diff --git a/src-tauri/src/session_manager/providers/codex.rs b/src-tauri/src/session_manager/providers/codex.rs index 0fd1e9656..ff3a335ab 100644 --- a/src-tauri/src/session_manager/providers/codex.rs +++ b/src-tauri/src/session_manager/providers/codex.rs @@ -81,6 +81,27 @@ pub fn load_messages(path: &Path) -> Result, String> { Ok(messages) } +pub fn delete_session(_root: &Path, path: &Path, session_id: &str) -> Result { + let meta = parse_session(path) + .ok_or_else(|| format!("Failed to parse Codex session metadata: {}", path.display()))?; + + if meta.session_id != session_id { + return Err(format!( + "Codex session ID mismatch: expected {session_id}, found {}", + meta.session_id + )); + } + + std::fs::remove_file(path).map_err(|e| { + format!( + "Failed to delete Codex session file {}: {e}", + path.display() + ) + })?; + + Ok(true) +} + fn parse_session(path: &Path) -> Option { let (head, tail) = read_head_tail_lines(path, 10, 30).ok()?; @@ -192,3 +213,30 @@ fn collect_jsonl_files(root: &Path, files: &mut Vec) { } } } + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn delete_session_removes_jsonl_file() { + let temp = tempdir().expect("tempdir"); + let path = temp + .path() + .join("rollout-2026-03-06T21-50-12-019cc369-bd7c-7891-b371-7b20b4fe0b18.jsonl"); + std::fs::write( + &path, + concat!( + "{\"timestamp\":\"2026-03-06T21:50:12Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"019cc369-bd7c-7891-b371-7b20b4fe0b18\",\"cwd\":\"/tmp/project\"}}\n", + "{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"hello\"}}\n" + ), + ) + .expect("write session"); + + delete_session(temp.path(), &path, "019cc369-bd7c-7891-b371-7b20b4fe0b18") + .expect("delete session"); + + assert!(!path.exists()); + } +} diff --git a/src-tauri/src/session_manager/providers/gemini.rs b/src-tauri/src/session_manager/providers/gemini.rs index 9487ec874..88720f595 100644 --- a/src-tauri/src/session_manager/providers/gemini.rs +++ b/src-tauri/src/session_manager/providers/gemini.rs @@ -80,6 +80,31 @@ pub fn load_messages(path: &Path) -> Result, String> { Ok(result) } +pub fn delete_session(_root: &Path, path: &Path, session_id: &str) -> Result { + let meta = parse_session(path).ok_or_else(|| { + format!( + "Failed to parse Gemini session metadata: {}", + path.display() + ) + })?; + + if meta.session_id != session_id { + return Err(format!( + "Gemini session ID mismatch: expected {session_id}, found {}", + meta.session_id + )); + } + + std::fs::remove_file(path).map_err(|e| { + format!( + "Failed to delete Gemini session file {}: {e}", + path.display() + ) + })?; + + Ok(true) +} + fn parse_session(path: &Path) -> Option { let data = std::fs::read_to_string(path).ok()?; let value: Value = serde_json::from_str(&data).ok()?; @@ -115,3 +140,36 @@ fn parse_session(path: &Path) -> Option { resume_command: Some(format!("gemini --resume {session_id}")), }) } + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn delete_session_removes_json_file() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("session-2026-03-06T10-17-test.json"); + std::fs::write( + &path, + r#"{ + "sessionId": "gemini-session-123", + "startTime": "2026-03-06T10:17:58.000Z", + "lastUpdated": "2026-03-06T10:20:00.000Z", + "messages": [ + { + "id": "msg-1", + "timestamp": "2026-03-06T10:17:58.000Z", + "type": "user", + "content": "hello" + } + ] + }"#, + ) + .expect("write session"); + + delete_session(temp.path(), &path, "gemini-session-123").expect("delete session"); + + assert!(!path.exists()); + } +} diff --git a/src-tauri/src/session_manager/providers/openclaw.rs b/src-tauri/src/session_manager/providers/openclaw.rs index 25b911514..a56b56389 100644 --- a/src-tauri/src/session_manager/providers/openclaw.rs +++ b/src-tauri/src/session_manager/providers/openclaw.rs @@ -5,7 +5,10 @@ use std::path::Path; use serde_json::Value; use crate::openclaw_config::get_openclaw_dir; -use crate::session_manager::{SessionMessage, SessionMeta}; +use crate::{ + config::write_json_file, + session_manager::{SessionMessage, SessionMeta}, +}; use super::utils::{ extract_text, parse_timestamp_to_ms, path_basename, read_head_tail_lines, truncate_summary, @@ -115,6 +118,37 @@ pub fn load_messages(path: &Path) -> Result, String> { Ok(messages) } +pub fn delete_session(_root: &Path, path: &Path, session_id: &str) -> Result { + let meta = parse_session(path).ok_or_else(|| { + format!( + "Failed to parse OpenClaw session metadata: {}", + path.display() + ) + })?; + + if meta.session_id != session_id { + return Err(format!( + "OpenClaw session ID mismatch: expected {session_id}, found {}", + meta.session_id + )); + } + + let index_path = path + .parent() + .unwrap_or_else(|| Path::new("")) + .join("sessions.json"); + prune_sessions_index(&index_path, session_id, path)?; + + std::fs::remove_file(path).map_err(|e| { + format!( + "Failed to delete OpenClaw session file {}: {e}", + path.display() + ) + })?; + + Ok(true) +} + fn parse_session(path: &Path) -> Option { let (head, tail) = read_head_tail_lines(path, 10, 30).ok()?; @@ -206,3 +240,92 @@ fn parse_session(path: &Path) -> Option { resume_command: None, // OpenClaw sessions are gateway-managed, no CLI resume }) } + +fn prune_sessions_index( + index_path: &Path, + session_id: &str, + source_path: &Path, +) -> Result<(), String> { + if !index_path.exists() { + return Ok(()); + } + + let content = std::fs::read_to_string(index_path).map_err(|e| { + format!( + "Failed to read OpenClaw sessions index {}: {e}", + index_path.display() + ) + })?; + let mut index: serde_json::Map = + serde_json::from_str(&content).map_err(|e| { + format!( + "Failed to parse OpenClaw sessions index {}: {e}", + index_path.display() + ) + })?; + + let source = source_path.to_string_lossy(); + index.retain(|_, entry| { + let same_id = entry.get("sessionId").and_then(Value::as_str) == Some(session_id); + let same_file = entry.get("sessionFile").and_then(Value::as_str) == Some(source.as_ref()); + !(same_id || same_file) + }); + + write_json_file(index_path, &index).map_err(|e| { + format!( + "Failed to update OpenClaw sessions index {}: {e}", + index_path.display() + ) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn delete_session_updates_index_and_removes_jsonl() { + let temp = tempdir().expect("tempdir"); + let sessions_dir = temp.path().join("main").join("sessions"); + std::fs::create_dir_all(&sessions_dir).expect("create sessions dir"); + + let session_path = sessions_dir.join("session-123.jsonl"); + std::fs::write( + &session_path, + concat!( + "{\"type\":\"session\",\"id\":\"session-123\",\"cwd\":\"/tmp/project\",\"timestamp\":\"2026-03-06T10:00:00Z\"}\n", + "{\"type\":\"message\",\"message\":{\"role\":\"user\",\"content\":\"hello\"},\"timestamp\":\"2026-03-06T10:01:00Z\"}\n" + ), + ) + .expect("write session"); + std::fs::write( + sessions_dir.join("sessions.json"), + format!( + r#"{{ + "agent:main:main": {{ + "sessionId": "session-123", + "sessionFile": "{}" + }}, + "agent:main:other": {{ + "sessionId": "session-456", + "sessionFile": "{}/session-456.jsonl" + }} + }}"#, + session_path.display(), + sessions_dir.display() + ), + ) + .expect("write index"); + + delete_session(temp.path(), &session_path, "session-123").expect("delete session"); + + assert!(!session_path.exists()); + let updated: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(sessions_dir.join("sessions.json")).expect("read index"), + ) + .expect("parse index"); + assert!(updated.get("agent:main:main").is_none()); + assert!(updated.get("agent:main:other").is_some()); + } +} diff --git a/src-tauri/src/session_manager/providers/opencode.rs b/src-tauri/src/session_manager/providers/opencode.rs index 7cb5d115b..09115518d 100644 --- a/src-tauri/src/session_manager/providers/opencode.rs +++ b/src-tauri/src/session_manager/providers/opencode.rs @@ -12,7 +12,7 @@ const PROVIDER_ID: &str = "opencode"; /// /// Respects `XDG_DATA_HOME` on all platforms; falls back to /// `~/.local/share/opencode/storage/`. -fn get_opencode_data_dir() -> PathBuf { +pub(crate) 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"); @@ -111,6 +111,71 @@ pub fn load_messages(path: &Path) -> Result, String> { Ok(messages) } +pub fn delete_session(storage: &Path, path: &Path, session_id: &str) -> Result { + if path.file_name().and_then(|name| name.to_str()) != Some(session_id) { + return Err(format!( + "OpenCode session path does not match session ID: expected {session_id}, found {}", + path.display() + )); + } + + let mut message_files = Vec::new(); + collect_json_files(path, &mut message_files); + + let mut message_ids = Vec::new(); + for message_path in &message_files { + let data = match std::fs::read_to_string(message_path) { + Ok(data) => data, + Err(_) => continue, + }; + let value: Value = match serde_json::from_str(&data) { + Ok(value) => value, + Err(_) => continue, + }; + if let Some(message_id) = value.get("id").and_then(Value::as_str) { + message_ids.push(message_id.to_string()); + } + } + + for message_id in &message_ids { + let part_dir = storage.join("part").join(message_id); + remove_dir_all_if_exists(&part_dir).map_err(|e| { + format!( + "Failed to delete OpenCode part directory {}: {e}", + part_dir.display() + ) + })?; + } + + let session_diff_path = storage + .join("session_diff") + .join(format!("{session_id}.json")); + remove_file_if_exists(&session_diff_path).map_err(|e| { + format!( + "Failed to delete OpenCode session diff {}: {e}", + session_diff_path.display() + ) + })?; + + remove_dir_all_if_exists(path).map_err(|e| { + format!( + "Failed to delete OpenCode message directory {}: {e}", + path.display() + ) + })?; + + if let Some(session_file) = find_session_file(storage, session_id) { + remove_file_if_exists(&session_file).map_err(|e| { + format!( + "Failed to delete OpenCode session file {}: {e}", + session_file.display() + ) + })?; + } + + Ok(true) +} + 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()?; @@ -274,3 +339,97 @@ fn collect_json_files(root: &Path, files: &mut Vec) { } } } + +fn find_session_file(storage: &Path, session_id: &str) -> Option { + let session_root = storage.join("session"); + let mut files = Vec::new(); + collect_json_files(&session_root, &mut files); + let expected = format!("{session_id}.json"); + + files + .into_iter() + .find(|path| path.file_name().and_then(|name| name.to_str()) == Some(expected.as_str())) +} + +fn remove_file_if_exists(path: &Path) -> std::io::Result<()> { + match std::fs::remove_file(path) { + Ok(()) => Ok(()), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(err) => Err(err), + } +} + +fn remove_dir_all_if_exists(path: &Path) -> std::io::Result<()> { + match std::fs::remove_dir_all(path) { + Ok(()) => Ok(()), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(err) => Err(err), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn delete_session_removes_session_diff_messages_and_parts() { + let temp = tempdir().expect("tempdir"); + let storage = temp.path(); + let project_id = "project-123"; + let session_id = "ses_123"; + let session_dir = storage.join("session").join(project_id); + let message_dir = storage.join("message").join(session_id); + let session_diff = storage + .join("session_diff") + .join(format!("{session_id}.json")); + let part_dir = storage.join("part").join("msg_1"); + let session_file = session_dir.join(format!("{session_id}.json")); + + std::fs::create_dir_all(&session_dir).expect("create session dir"); + std::fs::create_dir_all(&message_dir).expect("create message dir"); + std::fs::create_dir_all(&part_dir).expect("create part dir"); + std::fs::create_dir_all(storage.join("project")).expect("create project dir"); + std::fs::create_dir_all(storage.join("session_diff")).expect("create session diff dir"); + + std::fs::write( + &session_file, + format!( + r#"{{ + "id": "{session_id}", + "projectID": "{project_id}", + "directory": "/tmp/project", + "time": {{ "created": 1, "updated": 2 }} + }}"# + ), + ) + .expect("write session file"); + std::fs::write( + message_dir.join("msg_1.json"), + format!(r#"{{"id":"msg_1","sessionID":"{session_id}","role":"user"}}"#), + ) + .expect("write message file"); + std::fs::write( + part_dir.join("prt_1.json"), + r#"{"id":"prt_1","messageID":"msg_1"}"#, + ) + .expect("write part file"); + std::fs::write(&session_diff, "[]").expect("write session diff"); + std::fs::write( + storage.join("project").join(format!("{project_id}.json")), + r#"{"id":"project-123"}"#, + ) + .expect("write project file"); + + delete_session(storage, &message_dir, session_id).expect("delete session"); + + assert!(!session_file.exists()); + assert!(!message_dir.exists()); + assert!(!session_diff.exists()); + assert!(!part_dir.exists()); + assert!(storage + .join("project") + .join(format!("{project_id}.json")) + .exists()); + } +} diff --git a/src/components/sessions/SessionManagerPage.tsx b/src/components/sessions/SessionManagerPage.tsx index f7041a398..9db70d5fb 100644 --- a/src/components/sessions/SessionManagerPage.tsx +++ b/src/components/sessions/SessionManagerPage.tsx @@ -7,13 +7,19 @@ import { RefreshCw, Search, Play, + Trash2, MessageSquare, Clock, FolderOpen, X, } from "lucide-react"; -import { useSessionMessagesQuery, useSessionsQuery } from "@/lib/query"; +import { + useDeleteSessionMutation, + useSessionMessagesQuery, + useSessionsQuery, +} from "@/lib/query"; import { sessionsApi } from "@/lib/api"; +import type { SessionMeta } from "@/types"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; @@ -25,6 +31,7 @@ import { } from "@/components/ui/select"; import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; import { ScrollArea } from "@/components/ui/scroll-area"; +import { ConfirmDialog } from "@/components/ConfirmDialog"; import { Tooltip, TooltipContent, @@ -66,6 +73,7 @@ export function SessionManagerPage({ appId }: { appId: string }) { ); const [tocDialogOpen, setTocDialogOpen] = useState(false); const [isSearchOpen, setIsSearchOpen] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); const searchInputRef = useRef(null); const [search, setSearch] = useState(""); @@ -113,6 +121,7 @@ export function SessionManagerPage({ appId }: { appId: string }) { selectedSession?.providerId, selectedSession?.sourcePath, ); + const deleteSessionMutation = useDeleteSessionMutation(); // 提取用户消息用于目录 const userMessagesToc = useMemo(() => { @@ -184,6 +193,19 @@ export function SessionManagerPage({ appId }: { appId: string }) { } }; + const handleDeleteConfirm = async () => { + if (!deleteTarget?.sourcePath || deleteSessionMutation.isPending) { + return; + } + + setDeleteTarget(null); + await deleteSessionMutation.mutateAsync({ + providerId: deleteTarget.providerId, + sessionId: deleteTarget.sessionId, + sourcePath: deleteTarget.sourcePath, + }); + }; + return (
@@ -517,6 +539,36 @@ export function SessionManagerPage({ appId }: { appId: string }) { )} + + + + + + {t("sessionManager.deleteTooltip", { + defaultValue: "永久删除此本地会话记录", + })} + +
@@ -629,6 +681,33 @@ export function SessionManagerPage({ appId }: { appId: string }) { + void handleDeleteConfirm()} + onCancel={() => { + if (!deleteSessionMutation.isPending) { + setDeleteTarget(null); + } + }} + />
); } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index e9dee9fd4..137fbc7a2 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -604,6 +604,14 @@ "projectDirCopied": "Directory copied", "copySourcePath": "Copy source file", "sourcePathCopied": "Source file copied", + "delete": "Delete session", + "deleting": "Deleting...", + "deleteTooltip": "Permanently delete this local session record", + "deleteConfirmTitle": "Delete session", + "deleteConfirmMessage": "This will permanently delete the local session \"{{title}}\"\nSession ID: {{sessionId}}\n\nThis action cannot be undone.", + "deleteConfirmAction": "Delete session", + "sessionDeleted": "Session deleted", + "deleteFailed": "Failed to delete session: {{error}}", "loadingMessages": "Loading transcript...", "emptySession": "No messages available", "clickToCopyPath": "Click to copy path", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 4d3defa7d..2809dca9a 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -604,6 +604,14 @@ "projectDirCopied": "ディレクトリをコピーしました", "copySourcePath": "元ファイルをコピー", "sourcePathCopied": "元ファイルをコピーしました", + "delete": "セッションを削除", + "deleting": "削除中...", + "deleteTooltip": "このローカルセッション記録を完全に削除します", + "deleteConfirmTitle": "セッションを削除", + "deleteConfirmMessage": "ローカルセッション「{{title}}」を完全に削除します\nSession ID: {{sessionId}}\n\nこの操作は元に戻せません。", + "deleteConfirmAction": "セッションを削除", + "sessionDeleted": "セッションを削除しました", + "deleteFailed": "セッションの削除に失敗しました: {{error}}", "loadingMessages": "内容を読み込み中...", "emptySession": "表示できる内容がありません", "clickToCopyPath": "クリックしてパスをコピー", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 7acdc881b..9d664d7fb 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -604,6 +604,14 @@ "projectDirCopied": "目录已复制", "copySourcePath": "复制原始文件", "sourcePathCopied": "原始文件已复制", + "delete": "删除会话", + "deleting": "删除中...", + "deleteTooltip": "永久删除此本地会话记录", + "deleteConfirmTitle": "删除会话", + "deleteConfirmMessage": "将永久删除本地会话“{{title}}”\nSession ID: {{sessionId}}\n\n此操作不可恢复。", + "deleteConfirmAction": "删除会话", + "sessionDeleted": "会话已删除", + "deleteFailed": "删除会话失败: {{error}}", "loadingMessages": "加载会话内容中...", "emptySession": "该会话暂无可展示内容", "clickToCopyPath": "点击复制路径", diff --git a/src/lib/api/sessions.ts b/src/lib/api/sessions.ts index 4c621bce6..d9a4f61dc 100644 --- a/src/lib/api/sessions.ts +++ b/src/lib/api/sessions.ts @@ -1,6 +1,12 @@ import { invoke } from "@tauri-apps/api/core"; import type { SessionMessage, SessionMeta } from "@/types"; +export interface DeleteSessionOptions { + providerId: string; + sessionId: string; + sourcePath: string; +} + export const sessionsApi = { async list(): Promise { return await invoke("list_sessions"); @@ -13,6 +19,15 @@ export const sessionsApi = { return await invoke("get_session_messages", { providerId, sourcePath }); }, + async delete(options: DeleteSessionOptions): Promise { + const { providerId, sessionId, sourcePath } = options; + return await invoke("delete_session", { + providerId, + sessionId, + sourcePath, + }); + }, + async launchTerminal(options: { command: string; cwd?: string | null; diff --git a/src/lib/query/mutations.ts b/src/lib/query/mutations.ts index ddcaf4f75..e87ab5c13 100644 --- a/src/lib/query/mutations.ts +++ b/src/lib/query/mutations.ts @@ -1,9 +1,10 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; -import { providersApi, settingsApi, type AppId } from "@/lib/api"; +import { providersApi, sessionsApi, settingsApi, type AppId } from "@/lib/api"; +import type { DeleteSessionOptions } from "@/lib/api/sessions"; import type { SwitchResult } from "@/lib/api/providers"; -import type { Provider, Settings } from "@/types"; +import type { Provider, SessionMeta, Settings } from "@/types"; import { extractErrorMessage } from "@/utils/errorUtils"; import { generateUUID } from "@/utils/uuid"; import { openclawKeys } from "@/hooks/useOpenClaw"; @@ -267,6 +268,50 @@ export const useSwitchProviderMutation = (appId: AppId) => { }); }; +export const useDeleteSessionMutation = () => { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: async (input: DeleteSessionOptions) => { + await sessionsApi.delete(input); + return input; + }, + onSuccess: async (input) => { + queryClient.setQueryData(["sessions"], (current) => + (current ?? []).filter( + (session) => + !( + session.providerId === input.providerId && + session.sessionId === input.sessionId && + session.sourcePath === input.sourcePath + ), + ), + ); + queryClient.removeQueries({ + queryKey: ["sessionMessages", input.providerId, input.sourcePath], + }); + + await queryClient.invalidateQueries({ queryKey: ["sessions"] }); + + toast.success( + t("sessionManager.sessionDeleted", { + defaultValue: "会话已删除", + }), + ); + }, + onError: (error: Error) => { + const detail = extractErrorMessage(error) || t("common.unknown"); + toast.error( + t("sessionManager.deleteFailed", { + defaultValue: "删除会话失败: {{error}}", + error: detail, + }), + ); + }, + }); +}; + export const useSaveSettingsMutation = () => { const queryClient = useQueryClient(); diff --git a/tests/components/SessionManagerPage.test.tsx b/tests/components/SessionManagerPage.test.tsx new file mode 100644 index 000000000..b5195d368 --- /dev/null +++ b/tests/components/SessionManagerPage.test.tsx @@ -0,0 +1,140 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SessionManagerPage } from "@/components/sessions/SessionManagerPage"; +import type { SessionMessage, SessionMeta } from "@/types"; +import { setSessionFixtures } from "../msw/state"; + +const toastSuccessMock = vi.fn(); +const toastErrorMock = vi.fn(); + +vi.mock("sonner", () => ({ + toast: { + success: (...args: unknown[]) => toastSuccessMock(...args), + error: (...args: unknown[]) => toastErrorMock(...args), + }, +})); + +vi.mock("@/components/sessions/SessionToc", () => ({ + SessionTocSidebar: () => null, + SessionTocDialog: () => null, +})); + +vi.mock("@/components/ConfirmDialog", () => ({ + ConfirmDialog: ({ + isOpen, + title, + message, + confirmText, + cancelText, + onConfirm, + onCancel, + }: { + isOpen: boolean; + title: string; + message: string; + confirmText: string; + cancelText: string; + onConfirm: () => void; + onCancel: () => void; + }) => + isOpen ? ( +
+
{title}
+
{message}
+ + +
+ ) : null, +})); + +const renderPage = () => { + const client = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + return render( + + + , + ); +}; + +describe("SessionManagerPage", () => { + beforeEach(() => { + toastSuccessMock.mockReset(); + toastErrorMock.mockReset(); + + const sessions: SessionMeta[] = [ + { + providerId: "codex", + sessionId: "codex-session-1", + title: "Alpha Session", + summary: "Alpha summary", + projectDir: "/mock/codex", + createdAt: 2, + lastActiveAt: 20, + sourcePath: "/mock/codex/session-1.jsonl", + resumeCommand: "codex resume codex-session-1", + }, + { + providerId: "codex", + sessionId: "codex-session-2", + title: "Beta Session", + summary: "Beta summary", + projectDir: "/mock/codex", + createdAt: 1, + lastActiveAt: 10, + sourcePath: "/mock/codex/session-2.jsonl", + resumeCommand: "codex resume codex-session-2", + }, + ]; + const messages: Record = { + "codex:/mock/codex/session-1.jsonl": [ + { role: "user", content: "alpha", ts: 20 }, + ], + "codex:/mock/codex/session-2.jsonl": [ + { role: "user", content: "beta", ts: 10 }, + ], + }; + + setSessionFixtures(sessions, messages); + }); + + it("deletes the selected session and selects the next visible session", async () => { + renderPage(); + + await waitFor(() => + expect( + screen.getByRole("heading", { name: "Alpha Session" }), + ).toBeInTheDocument(), + ); + + fireEvent.click(screen.getByRole("button", { name: /删除会话/i })); + + const dialog = screen.getByTestId("confirm-dialog"); + expect(dialog).toBeInTheDocument(); + expect(within(dialog).getByText(/Alpha Session/)).toBeInTheDocument(); + + fireEvent.click(within(dialog).getByRole("button", { name: /删除会话/i })); + + await waitFor(() => + expect( + screen.getByRole("heading", { name: "Beta Session" }), + ).toBeInTheDocument(), + ); + + expect(screen.queryByText("Alpha Session")).not.toBeInTheDocument(); + expect(toastErrorMock).not.toHaveBeenCalled(); + expect(toastSuccessMock).toHaveBeenCalled(); + }); +}); diff --git a/tests/msw/handlers.ts b/tests/msw/handlers.ts index ae145c001..5867b1edf 100644 --- a/tests/msw/handlers.ts +++ b/tests/msw/handlers.ts @@ -4,9 +4,12 @@ import type { McpServer, Provider, Settings } from "@/types"; import { addProvider, deleteProvider, + deleteSession, getCurrentProviderId, + getSessionMessages, getProviders, listProviders, + listSessions, resetProviderState, setCurrentProviderId, updateProvider, @@ -37,7 +40,9 @@ const success = (payload: T) => HttpResponse.json(payload as any); export const handlers = [ http.post(`${TAURI_ENDPOINT}/get_migration_result`, () => success(false)), - http.post(`${TAURI_ENDPOINT}/get_skills_migration_result`, () => success(null)), + http.post(`${TAURI_ENDPOINT}/get_skills_migration_result`, () => + success(null), + ), http.post(`${TAURI_ENDPOINT}/get_providers`, async ({ request }) => { const { app } = await withJson<{ app: AppId }>(request); return success(getProviders(app)); @@ -105,6 +110,25 @@ export const handlers = [ http.post(`${TAURI_ENDPOINT}/open_external`, () => success(true)), + http.post(`${TAURI_ENDPOINT}/list_sessions`, () => success(listSessions())), + + http.post(`${TAURI_ENDPOINT}/get_session_messages`, async ({ request }) => { + const { providerId, sourcePath } = await withJson<{ + providerId: string; + sourcePath: string; + }>(request); + return success(getSessionMessages(providerId, sourcePath)); + }), + + http.post(`${TAURI_ENDPOINT}/delete_session`, async ({ request }) => { + const { providerId, sessionId, sourcePath } = await withJson<{ + providerId: string; + sessionId: string; + sourcePath: string; + }>(request); + return success(deleteSession(providerId, sessionId, sourcePath)); + }), + // MCP APIs http.post(`${TAURI_ENDPOINT}/get_mcp_config`, async ({ request }) => { const { app } = await withJson<{ app: AppId }>(request); @@ -178,9 +202,13 @@ export const handlers = [ }, ), - http.post(`${TAURI_ENDPOINT}/apply_claude_onboarding_skip`, () => success(true)), + http.post(`${TAURI_ENDPOINT}/apply_claude_onboarding_skip`, () => + success(true), + ), - http.post(`${TAURI_ENDPOINT}/clear_claude_onboarding_skip`, () => success(true)), + http.post(`${TAURI_ENDPOINT}/clear_claude_onboarding_skip`, () => + success(true), + ), http.post(`${TAURI_ENDPOINT}/get_config_dir`, async ({ request }) => { const { app } = await withJson<{ app: AppId }>(request); @@ -280,7 +308,9 @@ export const handlers = [ success([]), ), http.post(`${TAURI_ENDPOINT}/add_to_failover_queue`, () => success(true)), - http.post(`${TAURI_ENDPOINT}/remove_from_failover_queue`, () => success(true)), + http.post(`${TAURI_ENDPOINT}/remove_from_failover_queue`, () => + success(true), + ), http.post(`${TAURI_ENDPOINT}/reorder_failover_queue`, () => success(true)), http.post(`${TAURI_ENDPOINT}/set_failover_item_enabled`, () => success(true)), diff --git a/tests/msw/state.ts b/tests/msw/state.ts index 2db234840..291d5201b 100644 --- a/tests/msw/state.ts +++ b/tests/msw/state.ts @@ -1,5 +1,11 @@ import type { AppId } from "@/lib/api/types"; -import type { McpServer, Provider, Settings } from "@/types"; +import type { + McpServer, + Provider, + SessionMessage, + SessionMeta, + Settings, +} from "@/types"; type ProvidersByApp = Record>; type CurrentProviderState = Record; @@ -80,13 +86,69 @@ let settingsState: Settings = { language: "zh", }; let appConfigDirOverride: string | null = null; +const sessionMessageKey = (providerId: string, sourcePath: string) => + `${providerId}:${sourcePath}`; + +const createDefaultSessions = (): SessionMeta[] => { + const now = Date.now(); + return [ + { + providerId: "codex", + sessionId: "codex-session-1", + title: "Codex Session One", + summary: "Codex summary", + projectDir: "/mock/codex", + createdAt: now - 2000, + lastActiveAt: now - 1000, + sourcePath: "/mock/codex/session-1.jsonl", + resumeCommand: "codex resume codex-session-1", + }, + { + providerId: "claude", + sessionId: "claude-session-1", + title: "Claude Session One", + summary: "Claude summary", + projectDir: "/mock/claude", + createdAt: now - 4000, + lastActiveAt: now - 3000, + sourcePath: "/mock/claude/session-1.jsonl", + resumeCommand: "claude --resume claude-session-1", + }, + ]; +}; + +const createDefaultSessionMessages = (): Record => ({ + [sessionMessageKey("codex", "/mock/codex/session-1.jsonl")]: [ + { + role: "user", + content: "First codex message", + ts: Date.now() - 1000, + }, + ], + [sessionMessageKey("claude", "/mock/claude/session-1.jsonl")]: [ + { + role: "user", + content: "First claude message", + ts: Date.now() - 3000, + }, + ], +}); + +let sessionsState = createDefaultSessions(); +let sessionMessagesState = createDefaultSessionMessages(); let mcpConfigs: McpConfigState = { claude: { sample: { id: "sample", name: "Sample Claude Server", enabled: true, - apps: { claude: true, codex: false, gemini: false, opencode: false, openclaw: false }, + apps: { + claude: true, + codex: false, + gemini: false, + opencode: false, + openclaw: false, + }, server: { type: "stdio", command: "claude-server", @@ -98,7 +160,13 @@ let mcpConfigs: McpConfigState = { id: "httpServer", name: "HTTP Codex Server", enabled: false, - apps: { claude: false, codex: true, gemini: false, opencode: false, openclaw: false }, + apps: { + claude: false, + codex: true, + gemini: false, + opencode: false, + openclaw: false, + }, server: { type: "http", url: "http://localhost:3000", @@ -116,6 +184,8 @@ const cloneProviders = (value: ProvidersByApp) => export const resetProviderState = () => { providers = createDefaultProviders(); current = createDefaultCurrent(); + sessionsState = createDefaultSessions(); + sessionMessagesState = createDefaultSessionMessages(); settingsState = { showInTray: true, minimizeToTrayOnClose: true, @@ -131,7 +201,13 @@ export const resetProviderState = () => { id: "sample", name: "Sample Claude Server", enabled: true, - apps: { claude: true, codex: false, gemini: false, opencode: false, openclaw: false }, + apps: { + claude: true, + codex: false, + gemini: false, + opencode: false, + openclaw: false, + }, server: { type: "stdio", command: "claude-server", @@ -143,7 +219,13 @@ export const resetProviderState = () => { id: "httpServer", name: "HTTP Codex Server", enabled: false, - apps: { claude: false, codex: true, gemini: false, opencode: false, openclaw: false }, + apps: { + claude: false, + codex: true, + gemini: false, + opencode: false, + openclaw: false, + }, server: { type: "http", url: "http://localhost:3000", @@ -285,3 +367,41 @@ export const deleteMcpServer = (appType: AppId, id: string) => { if (!mcpConfigs[appType]) return; delete mcpConfigs[appType][id]; }; + +export const listSessions = () => + JSON.parse(JSON.stringify(sessionsState)) as SessionMeta[]; + +export const getSessionMessages = (providerId: string, sourcePath: string) => + JSON.parse( + JSON.stringify( + sessionMessagesState[sessionMessageKey(providerId, sourcePath)] ?? [], + ), + ) as SessionMessage[]; + +export const deleteSession = ( + providerId: string, + sessionId: string, + sourcePath: string, +) => { + sessionsState = sessionsState.filter( + (session) => + !( + session.providerId === providerId && + session.sessionId === sessionId && + session.sourcePath === sourcePath + ), + ); + delete sessionMessagesState[sessionMessageKey(providerId, sourcePath)]; + return true; +}; + +export const setSessionFixtures = ( + sessions: SessionMeta[], + messages: Record, +) => { + sessionsState = JSON.parse(JSON.stringify(sessions)) as SessionMeta[]; + sessionMessagesState = JSON.parse(JSON.stringify(messages)) as Record< + string, + SessionMessage[] + >; +};