From 10e97236757feb6a28420eb52367e6759132e05a Mon Sep 17 00:00:00 2001 From: Tryanks Date: Thu, 1 Oct 2026 23:35:12 +0800 Subject: [PATCH 1/2] fix(sidebar): put the path row above recent activity in Add Project The recents list loads asynchronously above the path row, so the input and Browse button moved down under the pointer once the scan finished and a click meant for Browse landed on a recent project. The path row now comes first and the list loads below it. Each recent project lists its conversations per provider in words, and the section states that choosing a project also imports those conversations. --- crates/ui/src/add_project_dialog.rs | 41 ++++++++++++++++++++--------- locales/en.yml | 5 +++- locales/zh-CN.yml | 5 +++- 3 files changed, 36 insertions(+), 15 deletions(-) diff --git a/crates/ui/src/add_project_dialog.rs b/crates/ui/src/add_project_dialog.rs index b529d37ca..66c8c05f8 100644 --- a/crates/ui/src/add_project_dialog.rs +++ b/crates/ui/src/add_project_dialog.rs @@ -31,10 +31,10 @@ use tcode_protocol::{CommandResponse, ExternalImportState, ExternalThread, Recen const RECENT_LIMIT: usize = 15; const RECENT_ROW_HEIGHT_ESTIMATE: f32 = 64.; const RECENT_VIEWPORT_MAX_HEIGHT: f32 = 390.; -/// Everything above the recents viewport inside the dialog: title, the path row -/// and the footer. Subtracted so the list scrolls instead of pushing the Open -/// button off a short window. -const RECENT_VIEWPORT_CHROME: f32 = 260.; +/// Everything around the recents viewport inside the dialog: title, the path +/// row, the recents heading and the footer. Subtracted so the list scrolls +/// instead of pushing the Open button off a short window. +const RECENT_VIEWPORT_CHROME: f32 = 280.; enum RecentState { Loading, @@ -364,12 +364,6 @@ impl Render for AddProjectDialog { let can_browse = self.can_browse(cx); v_flex() .gap_4() - .child( - v_flex() - .gap_2() - .child(div().text_size(px(13.)).font_semibold().child(recent_label)) - .child(self.render_recent(window, cx)), - ) .child( v_flex() .gap_1() @@ -410,6 +404,22 @@ impl Render for AddProjectDialog { ) }), ) + .child( + v_flex() + .gap_2() + .child( + v_flex() + .gap_0p5() + .child(div().text_size(px(13.)).font_semibold().child(recent_label)) + .child( + div() + .text_size(px(11.)) + .text_color(cx.theme().muted_foreground) + .child(crate::tr!("sidebar.recent_import_hint")), + ), + ) + .child(self.render_recent(window, cx)), + ) } } @@ -583,9 +593,14 @@ fn tool_counts(threads: &[ExternalThread]) -> String { ] .into_iter() .filter_map(|source| { - counts - .get(&source) - .map(|count| format!("{} ×{count}", source.display_name())) + counts.get(&source).map(|&count| { + let tool = source.display_name(); + if count == 1 { + crate::tr!("sidebar.recent_tool_threads_one", tool = tool).into_owned() + } else { + crate::tr!("sidebar.recent_tool_threads", tool = tool, count = count).into_owned() + } + }) }) .collect::>() .join(" · ") diff --git a/locales/en.yml b/locales/en.yml index 6f31c7768..c44587e68 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -126,9 +126,12 @@ sidebar: add_project: "Add project" select_project: "Select project directory" recent_activity: "Recently active" - open_recent: "Open recent project %{name}" + open_recent: "Open recent project %{name} and import its conversations" recent_loading: "Looking for recent activity…" recent_empty: "No recent activity found" + recent_import_hint: "Choosing a project here also imports the conversations listed under it." + recent_tool_threads_one: "%{tool}: 1 conversation" + recent_tool_threads: "%{tool}: %{count} conversations" path_placeholder: "Enter an absolute directory path" host_path_placeholder: "Absolute path on %{host}" host_path_hint: "This path is on %{host}, not on this device." diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 735ce779d..a9eb8eefc 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -126,9 +126,12 @@ sidebar: add_project: "添加项目" select_project: "选择项目目录" recent_activity: "最近活跃" - open_recent: "打开最近项目 %{name}" + open_recent: "打开最近项目 %{name} 并导入其对话" recent_loading: "正在查找最近活跃的目录…" recent_empty: "未发现最近活跃的目录" + recent_import_hint: "点击此处的项目会同时导入其下列出的对话。" + recent_tool_threads_one: "%{tool}:1 条对话" + recent_tool_threads: "%{tool}:%{count} 条对话" path_placeholder: "输入绝对目录路径" host_path_placeholder: "%{host} 上的绝对路径" host_path_hint: "该路径在 %{host} 上解析,而不是这台设备。" From ca745e70bb8ba2ffd9dcab0a4a1eeed096628646 Mon Sep 17 00:00:00 2001 From: Tryanks Date: Thu, 1 Oct 2026 23:49:07 +0800 Subject: [PATCH 2/2] fix(import): keep the recent-activity scan bounded The session walk followed directory symlinks, so a link back to an ancestor in the Codex sessions tree branched without end: with three such links the scan never finished and Add Project stayed on its loading row. Symlinked directories are no longer walked. Directory existence and project exclusion were checked per thread, with every project root canonicalized again for every thread; the scan grew by about 4 ms per project on 850 threads. They now run once per directory, with project roots canonicalized once. --- crates/services/src/import/mod.rs | 4 +++- crates/services/src/import/scan.rs | 26 ++++++++++++-------------- crates/services/src/import/tests.rs | 8 ++++++++ 3 files changed, 23 insertions(+), 15 deletions(-) diff --git a/crates/services/src/import/mod.rs b/crates/services/src/import/mod.rs index 820f4610b..59a33c448 100644 --- a/crates/services/src/import/mod.rs +++ b/crates/services/src/import/mod.rs @@ -318,7 +318,9 @@ fn collect_files( }; for entry in entries.flatten() { let path = entry.path(); - if path.is_dir() { + // Not followed through symlinks: a link back to an ancestor would + // make the walk branch without end. + if entry.file_type().is_ok_and(|kind| kind.is_dir()) { visit(&path, predicate, files); } else if predicate(&path) { files.push(path); diff --git a/crates/services/src/import/scan.rs b/crates/services/src/import/scan.rs index a621f6b5f..832761581 100644 --- a/crates/services/src/import/scan.rs +++ b/crates/services/src/import/scan.rs @@ -130,17 +130,22 @@ pub fn scan_recent_dirs(roots: &ExternalRoots, exclude_roots: &[PathBuf]) -> Vec let mut groups = HashMap::>::new(); for (cwd, thread) in found { - if !cwd.is_dir() - || exclude_roots - .iter() - .any(|excluded| same_path(&cwd, excluded)) - { - continue; - } groups.entry(cwd).or_default().push(thread); } + // Filesystem checks run once per directory, not per thread: each one can + // block on a slow or unreachable mount. + let excluded: Vec<_> = exclude_roots + .iter() + .map(|root| root.canonicalize().unwrap_or_else(|_| root.clone())) + .collect(); let mut dirs: Vec<_> = groups .into_iter() + .filter(|(cwd, _)| { + cwd.is_dir() && { + let cwd = cwd.canonicalize().unwrap_or_else(|_| cwd.clone()); + !excluded.contains(&cwd) + } + }) .map(|(path, mut threads)| { threads.sort_by_key(|thread| std::cmp::Reverse(thread.last_active_ms)); RecentDir { @@ -209,10 +214,3 @@ fn desktop_metadata(root: &Path) -> HashMap { } metadata } - -fn same_path(left: &Path, right: &Path) -> bool { - match (left.canonicalize(), right.canonicalize()) { - (Ok(left), Ok(right)) => left == right, - _ => left == right, - } -} diff --git a/crates/services/src/import/tests.rs b/crates/services/src/import/tests.rs index 2e9e0b86e..6530aa636 100644 --- a/crates/services/src/import/tests.rs +++ b/crates/services/src/import/tests.rs @@ -196,6 +196,14 @@ fn scanner_groups_attributes_orders_and_excludes() { })], ); + // A link back to an ancestor must not list the sessions below it again. + #[cfg(unix)] + std::os::unix::fs::symlink( + roots.codex_session_roots[0].join("2026"), + roots.codex_session_roots[0].join("2026/01/loop"), + ) + .unwrap(); + let recent = scan_recent_dirs(&roots, std::slice::from_ref(&excluded_cwd)); assert_eq!(recent.len(), 2); assert_eq!(recent[0].path, desktop_cwd);