From ea119059f393c59bceced3b10e513827c8fa4dec Mon Sep 17 00:00:00 2001 From: Adam Dalloul <47503782+Adam-Dalloul@users.noreply.github.com> Date: Sat, 15 Aug 2026 10:51:45 -0700 Subject: [PATCH 1/3] Show archived CLI sessions in the import picker Codex stores threads.archived in state_*.sqlite and Hermes already had an archived column but hid those rows. Import now keeps them, badges them, and hides them behind a Hide archived filter that is on by default. --- src-tauri/src/commands/conversations.rs | 11 +++ src-tauri/src/models/conversation.rs | 8 ++ src-tauri/src/parsers/acp_native.rs | 1 + src-tauri/src/parsers/claude.rs | 2 + src-tauri/src/parsers/cline.rs | 2 + src-tauri/src/parsers/codebuddy.rs | 2 + src-tauri/src/parsers/codex.rs | 88 ++++++++++++++++++- src-tauri/src/parsers/cursor.rs | 1 + src-tauri/src/parsers/gemini.rs | 1 + src-tauri/src/parsers/grok.rs | 1 + src-tauri/src/parsers/hermes.rs | 4 +- src-tauri/src/parsers/kimi_code.rs | 2 + src-tauri/src/parsers/openclaw.rs | 2 + src-tauri/src/parsers/opencode.rs | 1 + src-tauri/src/parsers/pi.rs | 2 + src-tauri/src/parsers/summary_cache.rs | 1 + .../import-sessions/import-sessions-rows.tsx | 5 ++ .../import-sessions-window.test.tsx | 29 ++++-- .../import-sessions-window.tsx | 14 ++- src/i18n/messages/ar.json | 2 + src/i18n/messages/de.json | 2 + src/i18n/messages/en.json | 2 + src/i18n/messages/es.json | 2 + src/i18n/messages/fr.json | 2 + src/i18n/messages/ja.json | 2 + src/i18n/messages/ko.json | 2 + src/i18n/messages/pt.json | 2 + src/i18n/messages/zh-CN.json | 2 + src/i18n/messages/zh-TW.json | 2 + src/lib/types.ts | 2 + 30 files changed, 190 insertions(+), 9 deletions(-) diff --git a/src-tauri/src/commands/conversations.rs b/src-tauri/src/commands/conversations.rs index 23ba28b1c..f8425bea9 100644 --- a/src-tauri/src/commands/conversations.rs +++ b/src-tauri/src/commands/conversations.rs @@ -558,6 +558,7 @@ fn build_scan_result( model: summary.model, git_branch: summary.git_branch, status, + archived: summary.archived, }); } @@ -3945,6 +3946,7 @@ mod tests { parent_id: None, parent_tool_use_id: None, delegation_call_id: None, + archived: false, }, ) } @@ -4007,6 +4009,15 @@ mod tests { assert_eq!(result.importable_count, 1, "only New counts as importable"); } + #[test] + fn scan_preserves_cli_archived_flag() { + let (agent, mut summary) = + scan_summary("arch", AgentType::Codex, Some("/tmp/p"), at(0)); + summary.archived = true; + let result = build_scan_result(vec![(agent, summary)], &HashMap::new(), &[]); + assert!(result.folders[0].sessions[0].archived); + } + #[test] fn scan_counts_sessions_without_folder_path_instead_of_listing_them() { let summaries = vec![ diff --git a/src-tauri/src/models/conversation.rs b/src-tauri/src/models/conversation.rs index ac1201496..c9f93a9d0 100644 --- a/src-tauri/src/models/conversation.rs +++ b/src-tauri/src/models/conversation.rs @@ -23,6 +23,11 @@ pub struct ConversationSummary { pub parent_tool_use_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub delegation_call_id: Option, + /// True when the originating CLI marked the session archived (Codex + /// `threads.archived`, Hermes `sessions.archived`, …). Import can hide + /// these behind a filter. Default false so older parsers stay live. + #[serde(default)] + pub archived: bool, } #[derive(Debug, Clone, Serialize)] @@ -231,6 +236,9 @@ pub struct ScanSession { pub model: Option, pub git_branch: Option, pub status: ScanSessionStatus, + /// Originating CLI archived this session. Default false for old scans. + #[serde(default)] + pub archived: bool, } /// One folder group in the import-picker scan: all sessions sharing a diff --git a/src-tauri/src/parsers/acp_native.rs b/src-tauri/src/parsers/acp_native.rs index 77339ff82..9d13f08e5 100644 --- a/src-tauri/src/parsers/acp_native.rs +++ b/src-tauri/src/parsers/acp_native.rs @@ -167,6 +167,7 @@ impl AcpNativeParser { parent_id: None, parent_tool_use_id: None, delegation_call_id: None, + archived: false, } } } diff --git a/src-tauri/src/parsers/claude.rs b/src-tauri/src/parsers/claude.rs index 8dfc36249..0e8b9219e 100644 --- a/src-tauri/src/parsers/claude.rs +++ b/src-tauri/src/parsers/claude.rs @@ -982,6 +982,7 @@ impl ClaudeParser { parent_id: None, parent_tool_use_id: None, delegation_call_id: None, + archived: false, })) } } @@ -1940,6 +1941,7 @@ impl ClaudeParser { parent_id: None, parent_tool_use_id: None, delegation_call_id: None, + archived: false, }; Ok(ConversationDetail { diff --git a/src-tauri/src/parsers/cline.rs b/src-tauri/src/parsers/cline.rs index 41b36344c..c5a4473f4 100644 --- a/src-tauri/src/parsers/cline.rs +++ b/src-tauri/src/parsers/cline.rs @@ -193,6 +193,7 @@ impl AgentParser for ClineParser { parent_id: None, parent_tool_use_id: None, delegation_call_id: None, + archived: false, }); } @@ -348,6 +349,7 @@ impl AgentParser for ClineParser { parent_id: None, parent_tool_use_id: None, delegation_call_id: None, + archived: false, }; Ok(ConversationDetail { diff --git a/src-tauri/src/parsers/codebuddy.rs b/src-tauri/src/parsers/codebuddy.rs index c731419ea..2935ac70e 100644 --- a/src-tauri/src/parsers/codebuddy.rs +++ b/src-tauri/src/parsers/codebuddy.rs @@ -143,6 +143,7 @@ impl CodeBuddyParser { parent_id: None, parent_tool_use_id: None, delegation_call_id: None, + archived: false, }) } @@ -333,6 +334,7 @@ impl CodeBuddyParser { parent_id: None, parent_tool_use_id: None, delegation_call_id: None, + archived: false, }; Ok(ConversationDetail { diff --git a/src-tauri/src/parsers/codex.rs b/src-tauri/src/parsers/codex.rs index 8fe892bb7..3479ac004 100644 --- a/src-tauri/src/parsers/codex.rs +++ b/src-tauri/src/parsers/codex.rs @@ -1,7 +1,7 @@ use std::collections::{HashMap, HashSet}; use std::fs; use std::io::{BufRead, BufReader}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::OnceLock; use chrono::{DateTime, Utc}; @@ -266,6 +266,7 @@ impl CodexParser { parent_id: None, parent_tool_use_id: None, delegation_call_id: None, + archived: false, })) } } @@ -284,6 +285,81 @@ fn resolve_codex_home_dir_from( .unwrap_or_else(|| home_dir.unwrap_or_default().join(".codex")) } +/// Codex Desktop / CLI persist `threads.archived` in `state_.sqlite`. +/// Newest schema file wins. Missing DB or column is "not archived". +fn load_codex_archived_ids(codex_home: &Path) -> HashSet { + let Some(db_path) = newest_codex_state_db(codex_home) else { + return HashSet::new(); + }; + let Ok(conn) = rusqlite::Connection::open_with_flags( + &db_path, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY, + ) else { + return HashSet::new(); + }; + let Ok(mut stmt) = conn.prepare( + "SELECT id FROM threads WHERE COALESCE(archived, 0) != 0", + ) else { + return HashSet::new(); + }; + let Ok(rows) = stmt.query_map([], |row| row.get::<_, String>(0)) else { + return HashSet::new(); + }; + rows.filter_map(Result::ok).collect() +} + +fn newest_codex_state_db(codex_home: &Path) -> Option { + let mut best: Option<(u32, PathBuf)> = None; + let entries = fs::read_dir(codex_home).ok()?; + for entry in entries.flatten() { + let name = entry.file_name(); + let name = name.to_string_lossy(); + let Some(rest) = name.strip_prefix("state_") else { + continue; + }; + let Some(num) = rest.strip_suffix(".sqlite") else { + continue; + }; + let Ok(version) = num.parse::() else { + continue; + }; + if best.as_ref().is_none_or(|(v, _)| version >= *v) { + best = Some((version, entry.path())); + } + } + best.map(|(_, path)| path) +} + +#[cfg(test)] +mod archived_flag_tests { + use super::*; + use std::fs; + + #[test] + fn load_codex_archived_ids_reads_newest_state_db() { + let dir = tempfile::tempdir().expect("tempdir"); + fs::write(dir.path().join("state_4.sqlite"), b"not a db").unwrap(); + let db5 = dir.path().join("state_5.sqlite"); + { + let conn = rusqlite::Connection::open(&db5).expect("create db"); + conn.execute( + "CREATE TABLE threads (id TEXT PRIMARY KEY, archived INTEGER)", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO threads (id, archived) VALUES ('live-1', 0), ('arch-1', 1)", + [], + ) + .unwrap(); + } + let ids = load_codex_archived_ids(dir.path()); + assert!(ids.contains("arch-1")); + assert!(!ids.contains("live-1")); + assert_eq!(newest_codex_state_db(dir.path()), Some(db5)); + } +} + impl AgentParser for CodexParser { fn list_conversations(&self) -> Result, ParseError> { let mut conversations = Vec::new(); @@ -313,6 +389,15 @@ impl AgentParser for CodexParser { } } + let archived = load_codex_archived_ids( + self.base_dir.parent().unwrap_or(self.base_dir.as_path()), + ); + for conversation in &mut conversations { + if archived.contains(&conversation.id) { + conversation.archived = true; + } + } + conversations.sort_by_key(|b| std::cmp::Reverse(b.started_at)); Ok(conversations) } @@ -3511,6 +3596,7 @@ impl CodexParser { parent_id: None, parent_tool_use_id: None, delegation_call_id: None, + archived: false, }; Ok(ConversationDetail { diff --git a/src-tauri/src/parsers/cursor.rs b/src-tauri/src/parsers/cursor.rs index 0e2cdad46..52d08ac14 100644 --- a/src-tauri/src/parsers/cursor.rs +++ b/src-tauri/src/parsers/cursor.rs @@ -325,6 +325,7 @@ fn summary_from( parent_id: None, parent_tool_use_id: None, delegation_call_id: None, + archived: false, } } diff --git a/src-tauri/src/parsers/gemini.rs b/src-tauri/src/parsers/gemini.rs index 619b71e88..0efa2b1b2 100644 --- a/src-tauri/src/parsers/gemini.rs +++ b/src-tauri/src/parsers/gemini.rs @@ -454,6 +454,7 @@ impl GeminiParser { parent_id: None, parent_tool_use_id: None, delegation_call_id: None, + archived: false, }) } diff --git a/src-tauri/src/parsers/grok.rs b/src-tauri/src/parsers/grok.rs index f43feab5d..503bb62d4 100644 --- a/src-tauri/src/parsers/grok.rs +++ b/src-tauri/src/parsers/grok.rs @@ -265,6 +265,7 @@ impl GrokParser { parent_id: None, parent_tool_use_id: None, delegation_call_id: None, + archived: false, } } diff --git a/src-tauri/src/parsers/hermes.rs b/src-tauri/src/parsers/hermes.rs index 4340bf2f5..fd2468505 100644 --- a/src-tauri/src/parsers/hermes.rs +++ b/src-tauri/src/parsers/hermes.rs @@ -116,6 +116,7 @@ impl HermesParser { s.started_at AS started_at, s.ended_at AS ended_at, s.parent_session_id AS parent_id, + COALESCE(s.archived, 0) AS archived, ( SELECT COUNT(*) FROM messages m WHERE m.session_id = s.id @@ -123,7 +124,6 @@ impl HermesParser { AND m.role <> 'system' ) AS message_count FROM sessions s - WHERE COALESCE(s.archived, 0) = 0 ORDER BY s.started_at DESC "# .to_string(), @@ -458,6 +458,7 @@ fn parse_sqlite_summary_row(row: &QueryResult) -> Result = row.try_get("", "title")?; let model: Option = row.try_get("", "model")?; let parent_id: Option = row.try_get("", "parent_id")?; + let archived_i64: i64 = row.try_get("", "archived").unwrap_or(0); let message_count_i64: i64 = row.try_get("", "message_count")?; let started_at = get_real(row, "started_at") @@ -488,6 +489,7 @@ fn parse_sqlite_summary_row(row: &QueryResult) -> Result )} + {session.archived && ( + + {t("statusArchived")} + + )} {t("messageCount", { count: session.message_count })} diff --git a/src/components/import-sessions/import-sessions-window.test.tsx b/src/components/import-sessions/import-sessions-window.test.tsx index 910595a03..895f79fe1 100644 --- a/src/components/import-sessions/import-sessions-window.test.tsx +++ b/src/components/import-sessions/import-sessions-window.test.tsx @@ -75,7 +75,8 @@ function session( externalId: string, agentType: string, title: string, - status: "new" | "imported" | "deleted" + status: "new" | "imported" | "deleted", + archived = false ) { return { external_id: externalId, @@ -87,6 +88,7 @@ function session( model: null, git_branch: null, status, + archived, } } @@ -103,6 +105,7 @@ function scanFixture(): ScanResult { session("a1", "claude_code", "Alpha one", "new"), session("a2", "codex", "Alpha two", "new"), session("a3", "codex", "Alpha old", "imported"), + session("a4", "codex", "Alpha archived", "new", true), ], }, { @@ -118,8 +121,8 @@ function scanFixture(): ScanResult { }, ], no_folder_count: 1, - total_sessions: 5, - importable_count: 3, + total_sessions: 6, + importable_count: 4, } as ScanResult } @@ -165,9 +168,9 @@ describe("ImportSessionsWindow", () => { expect(await screen.findByText("alpha")).toBeVisible() expect(screen.getByText("beta")).toBeVisible() - // All five sessions render as rows under their folders. expect(screen.getByText("Alpha one")).toBeVisible() expect(screen.getByText("Beta gone")).toBeVisible() + expect(screen.queryByText("Alpha archived")).toBeNull() // The not-in-codeg folder carries the "New" badge; the existing one not. const alphaHeader = screen .getByText("alpha") @@ -176,7 +179,7 @@ describe("ImportSessionsWindow", () => { expect(alphaHeader.textContent).toContain("New") // Summary footer counts come from the scan payload. expect( - screen.getByText("5 sessions · 3 importable · 2 folders") + screen.getByText("6 sessions · 4 importable · 2 folders") ).toBeVisible() expect(screen.getByText("1 without a project folder skipped")).toBeVisible() }) @@ -253,13 +256,27 @@ describe("ImportSessionsWindow", () => { renderWindow() await screen.findByText("alpha") - fireEvent.click(screen.getByRole("switch")) + const switches = screen.getAllByRole("switch") + fireEvent.click(switches[0]) expect(screen.queryByText("Alpha old")).toBeNull() expect(screen.queryByText("Beta gone")).toBeNull() expect(screen.getByText("Alpha one")).toBeVisible() expect(screen.getByText("Beta new")).toBeVisible() }) + it("hides archived sessions by default and shows them when the filter is off", async () => { + renderWindow() + await screen.findByText("alpha") + + expect(screen.queryByText("Alpha archived")).toBeNull() + expect(screen.getByText("Alpha one")).toBeVisible() + + const switches = screen.getAllByRole("switch") + fireEvent.click(switches[1]) + expect(screen.getByText("Alpha archived")).toBeVisible() + expect(screen.getByText("Archived")).toBeVisible() + }) + it("imports exactly the selected keys and shows the summary", async () => { importMock.mockResolvedValue({ imported: 2, diff --git a/src/components/import-sessions/import-sessions-window.tsx b/src/components/import-sessions/import-sessions-window.tsx index 76f19a0b1..2cdd12ae5 100644 --- a/src/components/import-sessions/import-sessions-window.tsx +++ b/src/components/import-sessions/import-sessions-window.tsx @@ -92,6 +92,7 @@ export function ImportSessionsWindow({ const [search, setSearch] = useState("") const [agentFilter, setAgentFilter] = useState("all") const [onlyImportable, setOnlyImportable] = useState(false) + const [hideArchived, setHideArchived] = useState(true) const [importResult, setImportResult] = useState( null ) @@ -224,6 +225,9 @@ export function ImportSessionsWindow({ if (onlyImportable) { sessions = sessions.filter((s) => s.status === "new") } + if (hideArchived) { + sessions = sessions.filter((s) => !s.archived) + } if (query) { const folderMatches = folder.path.toLowerCase().includes(query) || @@ -237,7 +241,7 @@ export function ImportSessionsWindow({ if (sessions.length > 0) entries.push({ folder, sessions }) } return entries - }, [scan, search, agentFilter, onlyImportable]) + }, [scan, search, agentFilter, onlyImportable, hideArchived]) const rows = useMemo(() => { const out: Row[] = [] @@ -531,6 +535,14 @@ export function ImportSessionsWindow({ /> {t("onlyImportable")} +