diff --git a/src-tauri/src/commands/conversation_search.rs b/src-tauri/src/commands/conversation_search.rs new file mode 100644 index 0000000000..408b81e21f --- /dev/null +++ b/src-tauri/src/commands/conversation_search.rs @@ -0,0 +1,288 @@ +use std::collections::HashMap; + +use crate::app_error::AppCommandError; +use crate::db::service::{agent_setting_service, folder_service}; +use crate::models::{ + AgentType, ContentBlock, ConversationDetail, ConversationSearchMatch, ConversationSearchResult, + DbConversationSummary, MessageTurn, TurnRole, +}; +use crate::parsers::{build_agent_parser, path_eq_for_matching, AgentParser, ParseError}; + +/// 在已应用目录、状态和智能体筛选的候选会话中搜索,保持原有顺序。 +/// 只读解析器返回的正文,不调用会更新会话元数据的详情接口。 +pub(super) async fn filter_by_text( + conn: &sea_orm::DatabaseConnection, + rows: Vec, + search: &str, +) -> Result, AppCommandError> { + Ok(search_rows(conn, rows, search, false) + .await? + .into_iter() + .map(|result| result.summary) + .collect()) +} + +pub(super) async fn search_with_matches( + conn: &sea_orm::DatabaseConnection, + rows: Vec, + search: &str, +) -> Result, AppCommandError> { + search_rows(conn, rows, search, true).await +} + +async fn search_rows( + conn: &sea_orm::DatabaseConnection, + rows: Vec, + search: &str, + include_previews: bool, +) -> Result, AppCommandError> { + let needle = search.trim().to_lowercase(); + if needle.is_empty() || rows.is_empty() { + return Ok(rows + .into_iter() + .map(|summary| ConversationSearchResult { + summary, + matches: vec![], + }) + .collect()); + } + let envs = agent_setting_service::load_agent_envs(conn).await; + let folders: HashMap<_, _> = folder_service::list_all_folder_details(conn) + .await? + .into_iter() + .map(|folder| (folder.id, folder.path)) + .collect(); + tokio::task::spawn_blocking(move || { + let mut parsers: HashMap> = HashMap::new(); + rows.into_iter() + .filter_map(|row| { + let title_matches = row + .title + .as_ref() + .is_some_and(|title| title.to_lowercase().contains(&needle)); + if title_matches && !include_previews { + return Some(ConversationSearchResult { + summary: row, + matches: vec![], + }); + } + let Some(external_id) = row.external_id.as_deref() else { + return title_matches.then_some(ConversationSearchResult { + summary: row, + matches: vec![], + }); + }; + let parser = parsers.entry(row.agent_type).or_insert_with(|| { + build_agent_parser(row.agent_type, &envs.get(row.agent_type)) + }); + let cwd = row + .origin_cwd + .as_ref() + .or_else(|| folders.get(&row.folder_id)); + let detail = read_detail(parser.as_ref(), &row, external_id, cwd); + let matches = match detail { + Ok(detail) => preview_matches(&detail.turns, &needle), + Err(error) => { + tracing::debug!(conversation_id = row.id, %error, "搜索时无法读取会话正文"); + vec![] + } + }; + (title_matches || !matches.is_empty()).then_some(ConversationSearchResult { + summary: row, + matches, + }) + }) + .collect() + }) + .await + .map_err(|error| { + AppCommandError::task_execution_failed("Failed to search conversation content") + .with_detail(error.to_string()) + }) +} + +fn preview_matches(turns: &[MessageTurn], needle: &str) -> Vec { + turns + .iter() + .enumerate() + .filter_map(|(turn_index, turn)| { + if !matches!(turn.role, TurnRole::User | TurnRole::Assistant) { + return None; + } + let text = turn + .blocks + .iter() + .filter_map(|block| match block { + ContentBlock::Text { text } => Some(text.as_str()), + _ => None, + }) + .collect::>() + .join("\n"); + let (before, matched_text, after) = snippet(&text, needle)?; + Some(ConversationSearchMatch { + turn_id: turn.id.clone(), + turn_index, + role: turn.role.clone(), + before, + matched_text, + after, + }) + }) + .take(3) + .collect() +} + +// 小写转换可能扩展字符(如 İ),因此将归一化字节映射回原始 UTF-8 边界。 +fn snippet(text: &str, needle: &str) -> Option<(String, String, String)> { + if needle.is_empty() { + return None; + } + let lower = text.to_lowercase(); + let at = lower.find(needle)?; + let mut normalized_offset = 0; + let mut start = 0; + let mut end = text.len(); + for (index, character) in text.char_indices() { + let normalized_len: usize = character.to_lowercase().map(char::len_utf8).sum(); + let next = normalized_offset + normalized_len; + if normalized_offset <= at && at < next { + start = index; + } + if normalized_offset < at + needle.len() && at + needle.len() <= next { + end = index + character.len_utf8(); + break; + } + normalized_offset = next; + } + let context_start = text[..start] + .char_indices() + .rev() + .nth(47) + .map_or(0, |(i, _)| i); + let context_end = text[end..] + .char_indices() + .nth(96) + .map_or(text.len(), |(i, _)| end + i); + let before = format!( + "{}{}", + if context_start > 0 { "…" } else { "" }, + &text[context_start..start] + ); + let after = format!( + "{}{}", + &text[end..context_end], + if context_end < text.len() { "…" } else { "" } + ); + Some((before, text[start..end].to_owned(), after)) +} + +fn read_detail( + parser: &dyn AgentParser, + row: &DbConversationSummary, + external_id: &str, + cwd: Option<&String>, +) -> Result { + let result = parser.get_conversation(external_id); + // 与详情读取保持一致:部分 ACP id 需要按原始目录和创建时间解析。 + if !matches!(result, Err(ParseError::ConversationNotFound(_))) + || !matches!( + row.agent_type, + AgentType::OpenClaw | AgentType::Cline | AgentType::Gemini + ) + { + return result; + } + let matched = parser + .list_conversations()? + .into_iter() + .filter(|conversation| { + conversation + .folder_path + .as_ref() + .zip(cwd) + .is_some_and(|(a, b)| path_eq_for_matching(a, b)) + }) + .min_by_key(|conversation| { + (conversation.started_at - row.created_at) + .num_seconds() + .unsigned_abs() + }) + .filter(|conversation| { + (conversation.started_at - row.created_at) + .num_seconds() + .unsigned_abs() + < 300 + }); + match matched { + Some(conversation) => parser.get_conversation(&conversation.id), + None => result, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::MessageTurn; + + fn turn(id: &str, role: &str, text: &str) -> MessageTurn { + serde_json::from_value(serde_json::json!({ + "id": id, "role": role, + "blocks": [{"type":"text", "text":text}], + "timestamp":"2026-01-01T00:00:00Z" + })) + .unwrap() + } + + #[test] + fn previews_keep_original_text_and_global_turn_positions() { + let turns = vec![ + turn("system-0", "system", "timeout"), + turn("user-1", "user", "检查 CONNECTION Timeout 的原因"), + turn( + "assistant-2", + "assistant", + "Repair the connection timeout now", + ), + ]; + let matches = preview_matches(&turns, "connection timeout"); + assert_eq!(matches.len(), 2); + assert_eq!(matches[0].turn_id, "user-1"); + assert_eq!(matches[0].turn_index, 1); + assert_eq!(matches[0].before, "检查 "); + assert_eq!(matches[0].matched_text, "CONNECTION Timeout"); + assert_eq!(matches[0].after, " 的原因"); + assert_eq!(matches[1].turn_id, "assistant-2"); + assert_eq!(matches[1].turn_index, 2); + } + + #[test] + fn previews_are_limited_to_three_messages_and_bounded_context() { + let turns: Vec<_> = (0..8) + .map(|i| { + turn( + &format!("turn-{i}"), + "assistant", + &format!("{}命中{}", "前".repeat(1000), "后".repeat(1000)), + ) + }) + .collect(); + let matches = preview_matches(&turns, "命中"); + assert_eq!(matches.len(), 3); + for item in matches { + assert_eq!(item.matched_text, "命中"); + assert!(item.before.chars().count() <= 49); + assert!(item.after.chars().count() <= 97); + assert!(item.before.starts_with('…')); + assert!(item.after.ends_with('…')); + } + } + + #[test] + fn previews_handle_unicode_lowercase_expansion_and_emoji() { + let turns = vec![turn("turn-1", "assistant", "😀İ 前文连接超时后文")]; + let matches = preview_matches(&turns, "i\u{307}"); + assert_eq!(matches[0].before, "😀"); + assert_eq!(matches[0].matched_text, "İ"); + assert_eq!(matches[0].after, " 前文连接超时后文"); + } +} diff --git a/src-tauri/src/commands/conversations.rs b/src-tauri/src/commands/conversations.rs index 27c2764075..0bee7e9d04 100644 --- a/src-tauri/src/commands/conversations.rs +++ b/src-tauri/src/commands/conversations.rs @@ -92,17 +92,21 @@ async fn list_all_conversations_core_with_codex_titles( status, include_children, } = options; - conversation_service::list_all( + let rows = conversation_service::list_all( conn, folder_ids, agent_type, - search, + None, sort_by, status, include_children, ) .await - .map_err(AppCommandError::from) + .map_err(AppCommandError::from)?; + match search.as_deref().filter(|query| !query.trim().is_empty()) { + Some(query) => super::conversation_search::filter_by_text(conn, rows, query).await, + None => Ok(rows), + } } #[cfg(feature = "tauri-runtime")] @@ -136,6 +140,46 @@ pub async fn list_all_conversations( .await } +pub(crate) async fn search_conversations_core( + conn: &sea_orm::DatabaseConnection, + emitter: &EventEmitter, + chat_channel_manager: &crate::chat_channel::manager::ChatChannelManager, + mut options: ListAllConversationsOptions, +) -> Result, AppCommandError> { + let search = options.search.take().unwrap_or_default(); + let rows = list_all_conversations_core(conn, emitter, chat_channel_manager, options).await?; + super::conversation_search::search_with_matches(conn, rows, &search).await +} + +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +pub async fn search_conversations( + app: tauri::AppHandle, + folder_ids: Option>, + agent_type: Option, + search: Option, + sort_by: Option, + status: Option, + include_children: Option, +) -> Result, AppCommandError> { + let db = app.state::(); + let manager = app.state::(); + search_conversations_core( + &db.conn, + &EventEmitter::Tauri(app.clone()), + &manager, + ListAllConversationsOptions { + folder_ids, + agent_type, + search, + sort_by, + status, + include_children: include_children.unwrap_or(false), + }, + ) + .await +} + pub async fn list_child_conversations_core( conn: &sea_orm::DatabaseConnection, parent_conversation_id: i32, @@ -3039,6 +3083,145 @@ mod tests { use super::*; use crate::db::test_helpers::{fresh_in_memory_db, seed_folder}; + #[tokio::test] + async fn content_search_reads_configured_history_and_preserves_scope() { + let db = fresh_in_memory_db().await; + let home = tempfile::tempdir().unwrap(); + let sessions = home.path().join("sessions/2026/01/01"); + std::fs::create_dir_all(&sessions).unwrap(); + let session_id = "01900000-0000-7000-8000-000000000001"; + let records = [ + serde_json::json!({"timestamp":"2026-01-01T00:00:00Z","type":"session_meta","payload":{"id":session_id,"cwd":"/search-project"}}), + serde_json::json!({"timestamp":"2026-01-01T00:00:01Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"用户正文:连接超时"}]}}), + serde_json::json!({"timestamp":"2026-01-01T00:00:02Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Retry the CONNECTION timeout"}]}}), + serde_json::json!({"timestamp":"2026-01-01T00:00:03Z","type":"response_item","payload":{"type":"function_call_output","call_id":"call-1","output":"tool-only-secret"}}), + serde_json::json!({"timestamp":"2026-01-01T00:00:04Z","type":"response_item","payload":{"type":"reasoning","summary":[{"type":"summary_text","text":"thinking-only-secret"}]}}), + ]; + std::fs::write( + sessions.join(format!("rollout-2026-01-01T00-00-00-{session_id}.jsonl")), + records.iter().map(|r| format!("{r}\n")).collect::(), + ) + .unwrap(); + agent_setting_service::ensure_defaults( + &db.conn, + &[agent_setting_service::AgentDefaultInput { + agent_type: AgentType::Codex, + registry_id: "codex".into(), + default_sort_order: 0, + }], + ) + .await + .unwrap(); + agent_setting_service::update_preferences( + &db.conn, + AgentType::Codex, + true, + Some(serde_json::json!({"CODEX_HOME":home.path()}).to_string()), + ) + .await + .unwrap(); + let folder = seed_folder(&db, "/search-project").await; + let other_folder = seed_folder(&db, "/other-project").await; + let row = conversation_service::create( + &db.conn, + folder, + AgentType::Codex, + Some("Maintenance".into()), + None, + ) + .await + .unwrap(); + conversation_service::bind_external_id(&db.conn, row.id, session_id, &[]) + .await + .unwrap(); + let (_, emitter) = sync_test_emitter(); + let manager = crate::chat_channel::manager::ChatChannelManager::new(); + for (query, scope, agent, expected) in [ + ("连接超时", folder, AgentType::Codex, vec![row.id]), + ("connection TIMEOUT", folder, AgentType::Codex, vec![row.id]), + ("maintenance", folder, AgentType::Codex, vec![row.id]), + ("连接超时", other_folder, AgentType::Codex, vec![]), + ("连接超时", folder, AgentType::ClaudeCode, vec![]), + ("absent", folder, AgentType::Codex, vec![]), + ("tool-only-secret", folder, AgentType::Codex, vec![]), + ("thinking-only-secret", folder, AgentType::Codex, vec![]), + ("%", folder, AgentType::Codex, vec![]), + ] { + let rows = list_all_conversations_core_with_codex_titles( + &db.conn, + &emitter, + &manager, + ListAllConversationsOptions { + folder_ids: Some(vec![scope]), + agent_type: Some(agent), + search: Some(query.into()), + ..Default::default() + }, + &HashMap::new(), + ) + .await + .unwrap(); + assert_eq!( + rows.iter().map(|r| r.id).collect::>(), + expected, + "query={query}" + ); + } + let rows = list_all_conversations_core_with_codex_titles( + &db.conn, + &emitter, + &manager, + ListAllConversationsOptions { + search: Some("连接超时".into()), + status: Some("completed".into()), + ..Default::default() + }, + &HashMap::new(), + ) + .await + .unwrap(); + assert!(rows.is_empty(), "正文匹配不能绕过状态筛选"); + let previews = search_conversations_core( + &db.conn, + &emitter, + &manager, + ListAllConversationsOptions { + folder_ids: Some(vec![folder]), + search: Some("connection timeout".into()), + ..Default::default() + }, + ) + .await + .unwrap(); + let payload = serde_json::to_value(&previews).unwrap(); + assert_eq!(payload[0]["id"], row.id); + assert_eq!( + payload[0]["matches"][0]["matched_text"], + "CONNECTION timeout" + ); + assert_eq!(payload[0]["matches"][0]["turn_index"], 1); + assert_eq!(payload[0]["matches"][0]["role"], "assistant"); + assert!(payload[0]["matches"][0]["turn_id"] + .as_str() + .is_some_and(|id| !id.is_empty())); + conversation_service::soft_delete(&db.conn, row.id) + .await + .unwrap(); + let rows = list_all_conversations_core_with_codex_titles( + &db.conn, + &emitter, + &manager, + ListAllConversationsOptions { + search: Some("连接超时".into()), + ..Default::default() + }, + &HashMap::new(), + ) + .await + .unwrap(); + assert!(rows.is_empty(), "已删除会话不能通过正文重新出现"); + } + /// Serializes every test that touches the process-global [`IMPORT_GUARD`]. /// /// The harness runs `#[tokio::test]`s on parallel threads of one process, so diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 6ea8214887..0a7cd967bb 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,4 +1,5 @@ pub mod acp; +mod conversation_search; #[cfg(feature = "tauri-runtime")] pub mod app_update; pub mod assistants; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 25a96b83d1..1f1d1e4eed 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1839,6 +1839,7 @@ mod tauri_app { conversations::list_conversations, conversations::get_conversation, conversations::list_all_conversations, + conversations::search_conversations, conversations::list_child_conversations, conversations::list_opened_tabs, conversations::save_opened_tabs, diff --git a/src-tauri/src/models/conversation.rs b/src-tauri/src/models/conversation.rs index b1527d4326..b3481ef002 100644 --- a/src-tauri/src/models/conversation.rs +++ b/src-tauri/src/models/conversation.rs @@ -2,7 +2,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use super::agent::AgentType; -use super::message::{MessageTurn, TurnUsage}; +use super::message::{MessageTurn, TurnRole, TurnUsage}; use crate::db::entities::conversation::ConversationKind; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -25,6 +25,23 @@ pub struct ConversationSummary { pub delegation_call_id: Option, } +#[derive(Debug, Clone, Serialize)] +pub struct ConversationSearchMatch { + pub turn_id: String, + pub turn_index: usize, + pub role: TurnRole, + pub before: String, + pub matched_text: String, + pub after: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ConversationSearchResult { + #[serde(flatten)] + pub summary: DbConversationSummary, + pub matches: Vec, +} + #[derive(Debug, Clone, Serialize)] pub struct DbConversationSummary { pub id: i32, diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index 43960b7cd4..d5a2f53dc5 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -29,7 +29,8 @@ pub use canvas::{CanvasBoard, CanvasBoardSummary, CanvasMutation, CanvasNode, Ca pub use chat_channel::{ChannelStatusInfo, ChatChannelInfo, ChatChannelMessageLogInfo}; pub use conversation::{ AgentConversationCount, AgentStats, ConversationBranchTag, ConversationDetail, - ConversationOutline, ConversationSummary, ConversationTagDetail, ConversationTurnsPage, + ConversationOutline, ConversationSearchMatch, ConversationSearchResult, ConversationSummary, + ConversationTagDetail, ConversationTurnsPage, DbConversationDetail, DbConversationSummary, FolderInfo, ImportFolderOutcome, ImportResult, ImportSelectedResult, ScanFolder, ScanResult, ScanSession, ScanSessionStatus, SelectedSessionKey, SessionStats, SidebarData, diff --git a/src-tauri/src/web/handlers/conversation_search_tests.rs b/src-tauri/src/web/handlers/conversation_search_tests.rs new file mode 100644 index 0000000000..57e6d29e4e --- /dev/null +++ b/src-tauri/src/web/handlers/conversation_search_tests.rs @@ -0,0 +1,61 @@ +use super::*; +use crate::db::service::{agent_setting_service, conversation_service}; +use crate::db::test_helpers::{fresh_in_memory_db, seed_folder}; + +#[tokio::test] +async fn search_route_returns_flattened_conversations_with_matches() { + let db = fresh_in_memory_db().await; + let data = tempfile::tempdir().unwrap(); + agent_setting_service::ensure_defaults( + &db.conn, + &[agent_setting_service::AgentDefaultInput { + agent_type: AgentType::Codex, + registry_id: "codex".into(), + default_sort_order: 0, + }], + ) + .await + .unwrap(); + agent_setting_service::update_preferences( + &db.conn, + AgentType::Codex, + true, + Some(serde_json::json!({"CODEX_HOME":data.path()}).to_string()), + ) + .await + .unwrap(); + let folder_id = seed_folder(&db, "/search-http-test").await; + let row = conversation_service::create( + &db.conn, + folder_id, + AgentType::Codex, + Some("Search route title".into()), + None, + ) + .await + .unwrap(); + let state = Arc::new(AppState::new_for_test(db, data.path().to_path_buf())); + let router = crate::web::router::build_router( + state, + "search-test-token".into(), + data.path().to_path_buf(), + Arc::new(crate::web::shutdown::ShutdownSignal::new()), + ); + let server = axum_test::TestServer::new(router).unwrap(); + server + .post("/api/search_conversations") + .json(&serde_json::json!({})) + .await + .assert_status_unauthorized(); + let response = server + .post("/api/search_conversations") + .authorization_bearer("search-test-token") + .json(&serde_json::json!({"folderIds":[folder_id],"search":"route"})) + .await; + response.assert_status_ok(); + let results: serde_json::Value = response.json(); + assert_eq!(results.as_array().unwrap().len(), 1); + assert_eq!(results[0]["id"], row.id); + assert_eq!(results[0]["title"], "Search route title"); + assert_eq!(results[0]["matches"], serde_json::json!([])); +} diff --git a/src-tauri/src/web/handlers/conversations.rs b/src-tauri/src/web/handlers/conversations.rs index 94f9c46f79..de10d58280 100644 --- a/src-tauri/src/web/handlers/conversations.rs +++ b/src-tauri/src/web/handlers/conversations.rs @@ -47,6 +47,32 @@ pub struct ListChildConversationsParams { pub parent_conversation_id: i32, } +pub async fn search_conversations( + Extension(state): Extension>, + Json(params): Json, +) -> Result>, AppCommandError> { + Ok(Json( + conv_commands::search_conversations_core( + &state.db.conn, + &state.emitter, + &state.chat_channel_manager, + conv_commands::ListAllConversationsOptions { + folder_ids: params.folder_ids, + agent_type: params.agent_type, + search: params.search, + sort_by: params.sort_by, + status: params.status, + include_children: params.include_children.unwrap_or(false), + }, + ) + .await?, + )) +} + +#[cfg(test)] +#[path = "conversation_search_tests.rs"] +mod conversation_search_http_tests; + pub async fn list_child_conversations( Extension(state): Extension>, Json(params): Json, diff --git a/src-tauri/src/web/router.rs b/src-tauri/src/web/router.rs index c4eb63b67b..ee7b95dbe7 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -72,6 +72,10 @@ pub fn build_router_with_proxy_auth( "/list_all_conversations", post(handlers::conversations::list_all_conversations), ) + .route( + "/search_conversations", + post(handlers::conversations::search_conversations), + ) .route( "/list_child_conversations", post(handlers::conversations::list_child_conversations), diff --git a/src/components/conversations/search-command-dialog.test.tsx b/src/components/conversations/search-command-dialog.test.tsx index eccad10ee2..c0e913eb30 100644 --- a/src/components/conversations/search-command-dialog.test.tsx +++ b/src/components/conversations/search-command-dialog.test.tsx @@ -1,26 +1,30 @@ -import { act, render, screen } from "@testing-library/react" +import { act, render, screen, waitFor } from "@testing-library/react" import userEvent from "@testing-library/user-event" import { NextIntlClientProvider } from "next-intl" import { beforeEach, describe, expect, it, vi } from "vitest" import { SearchCommandDialog } from "./search-command-dialog" import enMessages from "@/i18n/messages/en.json" +import { searchConversations } from "@/lib/api" +import type { ConversationSearchResult } from "@/lib/types" +import { useConversationSearchStore } from "@/stores/conversation-search-store" // The active folder and its conversations, per test: the agent filter chips // only render once the folder holds more than one agent type. const workspace = vi.hoisted(() => ({ activeFolderId: null as number | null, conversations: [] as { folder_id: number; agent_type: string }[], + openTab: vi.fn(), })) vi.mock("@/components/agent-icon", () => ({ AgentIcon: () => null })) vi.mock("@/lib/api", () => ({ - listAllConversations: vi.fn(async () => []), + searchConversations: vi.fn(async () => []), })) vi.mock("@/contexts/tab-context", () => ({ - useTabActions: () => ({ openTab: vi.fn() }), + useTabActions: () => ({ openTab: workspace.openTab }), })) vi.mock("@/contexts/workbench-route-context", () => ({ @@ -61,10 +65,105 @@ function renderDialog() { describe("SearchCommandDialog focus", () => { beforeEach(() => { + workspace.openTab.mockClear() + useConversationSearchStore.setState({ target: null }) + vi.mocked(searchConversations).mockResolvedValue([]) workspace.activeFolderId = null workspace.conversations = [] }) + it("shows a body match even when its title does not contain the query", async () => { + vi.mocked(searchConversations).mockResolvedValue([ + { + id: 42, + folder_id: 1, + title: "Database maintenance", + agent_type: "codex", + status: "completed", + created_at: "2026-01-01T00:00:00Z", + } as ConversationSearchResult, + ]) + const user = userEvent.setup() + renderDialog() + await user.type(screen.getByRole("combobox"), "connection timeout") + expect(await screen.findByText("Database maintenance")).toBeVisible() + }) + + it("ignores a slow response after the query changes", async () => { + let resolveOld!: (rows: ConversationSearchResult[]) => void + vi.mocked(searchConversations).mockImplementation(async (options) => { + if (options?.search === "old") { + return new Promise((resolve) => { + resolveOld = resolve + }) + } + return [] + }) + const user = userEvent.setup() + renderDialog() + const input = screen.getByRole("combobox") + await user.type(input, "old") + await waitFor(() => expect(resolveOld).toBeTypeOf("function")) + await user.clear(input) + await user.type(input, "new") + await waitFor(() => + expect(searchConversations).toHaveBeenCalledWith( + expect.objectContaining({ search: "new" }) + ) + ) + await act(async () => { + resolveOld([ + { + id: 42, + folder_id: 1, + title: "Obsolete result", + agent_type: "codex", + status: "completed", + created_at: "2026-01-01T00:00:00Z", + } as ConversationSearchResult, + ]) + }) + expect(screen.queryByText("Obsolete result")).not.toBeInTheDocument() + }) + + it("previews and highlights a body match below its conversation title", async () => { + vi.mocked(searchConversations).mockResolvedValue([ + { + id: 42, + folder_id: 1, + title: "Database maintenance", + agent_type: "codex", + status: "completed", + created_at: "2026-01-01T00:00:00Z", + matches: [ + { + turn_id: "turn-12", + turn_index: 12, + role: "assistant", + before: "Retry the ", + matched_text: "CONNECTION timeout", + after: " after checking the network.", + }, + ], + } as ConversationSearchResult, + ]) + const user = userEvent.setup() + renderDialog() + await user.type(screen.getByRole("combobox"), "connection timeout") + const match = await screen.findByText("CONNECTION timeout") + expect(match.tagName).toBe("MARK") + expect(screen.getByText(/after checking the network/)).toBeVisible() + await user.click(match) + expect(workspace.openTab).toHaveBeenCalledWith(1, 42, "codex", true) + expect(useConversationSearchStore.getState().target).toEqual( + expect.objectContaining({ + conversationId: 42, + turnId: "turn-12", + turnIndex: 12, + }) + ) + }) + it("opens with the cursor in the search box, so typing searches", async () => { const user = userEvent.setup() renderDialog() diff --git a/src/components/conversations/search-command-dialog.tsx b/src/components/conversations/search-command-dialog.tsx index 6dbcd551b7..5560d81157 100644 --- a/src/components/conversations/search-command-dialog.tsx +++ b/src/components/conversations/search-command-dialog.tsx @@ -11,11 +11,14 @@ import { useAppWorkspaceStore } from "@/stores/app-workspace-store" import { useTabActions } from "@/contexts/tab-context" import { useWorkbenchRoute } from "@/contexts/workbench-route-context" import { useWorkspaceActions } from "@/contexts/workspace-context" -import { listAllConversations } from "@/lib/api" +import { searchConversations } from "@/lib/api" +import { requestConversationSearchJump } from "@/stores/conversation-search-store" import type { AgentType, ConversationStatus, DbConversationSummary, + ConversationSearchMatch, + ConversationSearchResult, } from "@/lib/types" import { useFileTree, type FlatFileEntry } from "@/hooks/use-file-tree" import { rankFileMatches } from "@/lib/file-search-match" @@ -75,9 +78,10 @@ export function SearchCommandDialog({ }, []) const [query, setQuery] = useState("") const [agentFilter, setAgentFilter] = useState(null) - const [results, setResults] = useState([]) + const [results, setResults] = useState([]) const [searching, setSearching] = useState(false) const debounceRef = useRef>(undefined) + const searchVersion = useRef(0) const folderPath = folder?.path ?? "" @@ -104,7 +108,7 @@ export function SearchCommandDialog({ ) const doSearch = useCallback( - async (q: string, agent: AgentType | null) => { + async (q: string, agent: AgentType | null, version: number) => { if (!q.trim() && !agent) { setResults([]) setSearching(false) @@ -112,16 +116,16 @@ export function SearchCommandDialog({ } setSearching(true) try { - const data = await listAllConversations({ + const data = await searchConversations({ folder_ids: folderId > 0 ? [folderId] : null, search: q.trim() || null, agent_type: agent, }) - setResults(data) + if (version === searchVersion.current) setResults(data) } catch { - setResults([]) + if (version === searchVersion.current) setResults([]) } finally { - setSearching(false) + if (version === searchVersion.current) setSearching(false) } }, [folderId] @@ -129,15 +133,18 @@ export function SearchCommandDialog({ // Debounced search on query change (conversations tab only) useEffect(() => { - if (activeTab !== "conversations") return + const version = ++searchVersion.current + if (!open || activeTab !== "conversations") return + setResults([]) if (debounceRef.current) clearTimeout(debounceRef.current) debounceRef.current = setTimeout(() => { - doSearch(query, agentFilter) + doSearch(query, agentFilter, version) }, 300) return () => { + searchVersion.current = version + 1 if (debounceRef.current) clearTimeout(debounceRef.current) } - }, [query, agentFilter, doSearch, activeTab]) + }, [query, agentFilter, doSearch, activeTab, open]) // Reset state when dialog closes useEffect(() => { @@ -151,11 +158,12 @@ export function SearchCommandDialog({ }, [open, resetFileTree]) const handleSelectConversation = useCallback( - (conv: DbConversationSummary) => { + (conv: DbConversationSummary, match?: ConversationSearchMatch) => { // Leave any workbench route (e.g. Automations) so the picked conversation // isn't stranded behind the route overlay — covers re-selecting the // already-active tab, which doesn't change activeTabId. openConversations() + if (match) requestConversationSearchJump(conv.id, match) openTab(conv.folder_id, conv.id, conv.agent_type, true) onOpenChange(false) }, @@ -191,7 +199,7 @@ export function SearchCommandDialog({ } open={open} onOpenChange={onOpenChange} - shouldFilter={activeTab === "conversations"} + shouldFilter={false} // Not `autoFocus`: an input focused while mounting takes focus before // the focus trap starts listening, so the trap never records it and has // nothing to pull focus back to when the chat composer behind the dialog @@ -304,32 +312,70 @@ export function SearchCommandDialog({ : t("noResults")} {results.length > 0 && ( - + <> {results.map((conv) => ( - handleSelectConversation(conv)} + className="mx-3 my-3 rounded-lg border border-border bg-background p-1 shadow-sm" > - - - {formatConversationTitle(conv.title) || - t("untitledConversation")} - - - {getAgentLabel(conv.agent_type)} - - - {formatDistanceToNow(new Date(conv.created_at), { - addSuffix: true, - locale: dateFnsLocale, - })} - - + + handleSelectConversation(conv, conv.matches?.[0]) + } + className="gap-2 rounded-md px-3 py-2.5" + > + + + {formatConversationTitle(conv.title) || + t("untitledConversation")} + + + {getAgentLabel(conv.agent_type)} + + + {formatDistanceToNow(new Date(conv.created_at), { + addSuffix: true, + locale: dateFnsLocale, + })} + + + {conv.matches?.slice(0, 3).map((match) => ( + handleSelectConversation(conv, match)} + className="mx-2 items-start gap-3 rounded-none border-t border-border/60 px-1 py-3 first:rounded-t-md last:rounded-b-md" + > + + {t( + match.role === "user" + ? "userMessage" + : "assistantReply" + )} + + + {match.before} + + {match.matched_text} + + {match.after} + + + ))} + ))} - + )} )} diff --git a/src/components/message/collapsible-user-message.test.tsx b/src/components/message/collapsible-user-message.test.tsx index 4d3f2c2210..0ebb8e1e84 100644 --- a/src/components/message/collapsible-user-message.test.tsx +++ b/src/components/message/collapsible-user-message.test.tsx @@ -1,5 +1,5 @@ import { type ReactElement } from "react" -import { fireEvent, render, screen } from "@testing-library/react" +import { fireEvent, render, screen, waitFor } from "@testing-library/react" import { NextIntlClientProvider } from "next-intl" import { afterEach, describe, expect, it } from "vitest" @@ -26,6 +26,17 @@ describe("CollapsibleUserMessage", () => { restoreMetrics = null }) + it("展开搜索定位的长消息,并允许用户再次收起", async () => { + restoreMetrics = mockClampLayout(600, 240) + renderWithIntl( + + ) + const toggle = screen.getByTestId("collapsible-user-message-toggle") + await waitFor(() => expect(toggle).toHaveAttribute("aria-expanded", "true")) + fireEvent.click(toggle) + expect(toggle).toHaveAttribute("aria-expanded", "false") + }) + it("renders short content with no toggle", () => { renderWithIntl() diff --git a/src/components/message/collapsible-user-message.tsx b/src/components/message/collapsible-user-message.tsx index f396038523..572b0c9220 100644 --- a/src/components/message/collapsible-user-message.tsx +++ b/src/components/message/collapsible-user-message.tsx @@ -19,12 +19,14 @@ import { ContentPartsRenderer } from "./content-parts-renderer" */ export const CollapsibleUserMessage = memo(function CollapsibleUserMessage({ parts, + revealToken, }: { parts: AdaptedContentPart[] + revealToken?: number }) { const t = useTranslations("Folder.chat.messageList") const { contentRef, contentId, isOverflowing, expanded, toggle } = - useCollapsibleOverflow(parts) + useCollapsibleOverflow(parts, revealToken) const clipped = !expanded diff --git a/src/components/message/completed-turn-content.test.tsx b/src/components/message/completed-turn-content.test.tsx index df1026a57f..39d711824a 100644 --- a/src/components/message/completed-turn-content.test.tsx +++ b/src/components/message/completed-turn-content.test.tsx @@ -50,6 +50,22 @@ const queryToggle = () => screen.queryByRole("button", { name: /^(Activity|Working)$/ }) describe("splitAssistantTurnParts", () => { + it("展开搜索定位到的过程正文,并允许用户再次收起", async () => { + renderWithIntl( + + ) + expect( + await screen.findByText("I found the relevant component.") + ).toBeVisible() + fireEvent.click(toggle()) + expect( + screen.queryByText("I found the relevant component.") + ).not.toBeInTheDocument() + }) it("keeps only the trailing final response outside progress", () => { const split = splitAssistantTurnParts(COMPLETED_PARTS) diff --git a/src/components/message/completed-turn-content.tsx b/src/components/message/completed-turn-content.tsx index e152dd590f..cf88fb7f45 100644 --- a/src/components/message/completed-turn-content.tsx +++ b/src/components/message/completed-turn-content.tsx @@ -1,6 +1,14 @@ "use client" -import { memo, useCallback, useId, useMemo, useRef, useState } from "react" +import { + memo, + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, +} from "react" import { useTranslations } from "next-intl" import { @@ -81,6 +89,7 @@ export const CompletedTurnContent = memo(function CompletedTurnContent({ roundOpen = true, onRoundOpenChange, foldEpoch = 0, + revealToken, }: { parts: AdaptedContentPart[] durationMs?: number | null @@ -94,6 +103,7 @@ export const CompletedTurnContent = memo(function CompletedTurnContent({ onRoundOpenChange?: (open: boolean) => void /** Bumped by `message-list-view` on every user send. */ foldEpoch?: number + revealToken?: number }) { const tElapsed = useTranslations("Folder.chat.liveTurnStats") const split = useMemo(() => splitAssistantTurnParts(parts), [parts]) @@ -156,6 +166,16 @@ export const CompletedTurnContent = memo(function CompletedTurnContent({ [currentRound, foldEpoch, onRoundOpenChange, parts] ) + const appliedReveal = useRef(undefined) + useEffect(() => { + if (revealToken == null || appliedReveal.current === revealToken) return + const frame = requestAnimationFrame(() => { + appliedReveal.current = revealToken + handleOpenChange(true) + }) + return () => cancelAnimationFrame(frame) + }, [revealToken, handleOpenChange]) + const elapsed = typeof durationMs === "number" && durationMs > 0 ? formatElapsedLabel(durationMs, tElapsed) diff --git a/src/components/message/message-list-view.tsx b/src/components/message/message-list-view.tsx index 9ae0acf0d1..26ec398d69 100644 --- a/src/components/message/message-list-view.tsx +++ b/src/components/message/message-list-view.tsx @@ -112,6 +112,8 @@ import { type LoadedMessageSubAgents, } from "@/components/message/message-nav-entries" import { useMessageNavJump } from "@/components/message/use-message-nav-jump" +import { useConversationSearchJump } from "./use-conversation-search-jump" +import { toast } from "sonner" import { useConversationOutline } from "@/hooks/use-conversation-outline" import { publishTranscriptInsights, @@ -1219,6 +1221,7 @@ const HistoricalMessageGroup = memo(function HistoricalMessageGroup({ onForkFromTurn, forkDisabled = false, isThreadTail = false, + revealToken, }: { group: ResolvedMessageGroup dimmed?: boolean @@ -1235,6 +1238,7 @@ const HistoricalMessageGroup = memo(function HistoricalMessageGroup({ /** Whether nothing follows this group in the thread — the one position where * a turn the backend cannot name still forks where the user pointed. */ isThreadTail?: boolean + revealToken?: number }) { if (group.role === "system") { // A failed turn's line is the agent's account of the failure, not a @@ -1263,7 +1267,10 @@ const HistoricalMessageGroup = memo(function HistoricalMessageGroup({ - + ) : ( @@ -1276,6 +1283,7 @@ const HistoricalMessageGroup = memo(function HistoricalMessageGroup({ roundOpen={roundOpen} onRoundOpenChange={onRoundOpenChange} foldEpoch={foldEpoch} + revealToken={revealToken} /> )} @@ -1701,17 +1709,50 @@ export function MessageListView({ // "not right now" instead of dropping its button and shifting the icon row. const forkBusy = connStatus === "prompting" + const scrollApiRef = useRef(null) + const tSearch = useTranslations("Folder.search") + const handleSearchUnavailable = useCallback(() => { + toast.error(tSearch("jumpUnavailable")) + }, [tSearch]) + const { highlightedTarget } = useConversationSearchJump({ + conversationId, + dbConversationId: session?.dbConversationId ?? conversationId, + enabled: isActive && !detailLoading && detail != null, + threadItems, + scrollApiRef, + loadOlderTurnsThrough, + onUnavailable: handleSearchUnavailable, + }) + const renderThreadItem = useCallback( (item: ThreadRenderItem) => { switch (item.kind) { case "turn": { const pt = item.isRoleTransition ? 16 : 0 + const searchHighlighted = + highlightedTarget != null && + (highlightedTarget.role == null || + highlightedTarget.role === item.group.role) && + (item.group.id === highlightedTarget.turnId || + item.sourceTurns.some( + (turn) => + turn.id === highlightedTarget.turnId || + turn.source_turn_id === highlightedTarget.turnId + )) const phaseLabel = item.group.role === "user" && userTurnHeader ? userTurnHeader(item.group) : null return ( -
0 ? { paddingTop: pt } : undefined}> +
0 ? { paddingTop: pt } : undefined} + data-search-highlighted={searchHighlighted || undefined} + className={ + searchHighlighted + ? "rounded-lg bg-yellow-100/40 ring-2 ring-yellow-400/60 dark:bg-yellow-900/20" + : undefined + } + > {phaseLabel ? (
) @@ -1764,6 +1808,7 @@ export function MessageListView({ handleRoundOpenChange, onForkFromTurn, forkBusy, + highlightedTarget, ] ) @@ -1833,7 +1878,6 @@ export function MessageListView({ // --- Message navigator ------------------------------------------------------ // Lifted scroll handle so the navigator — in the Session Details side panel, // outside the MessageScrollProvider subtree — can drive scrollToIndex. - const scrollApiRef = useRef(null) // The per-message rows are computed only while the side panel shows them: // grouping them parses every turn's diffs. const navDemanded = useTranscriptNavDemanded( diff --git a/src/components/message/use-conversation-search-jump.test.tsx b/src/components/message/use-conversation-search-jump.test.tsx new file mode 100644 index 0000000000..80b9d1cd67 --- /dev/null +++ b/src/components/message/use-conversation-search-jump.test.tsx @@ -0,0 +1,150 @@ +import { act, renderHook, waitFor } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" +import type { ThreadRenderItem } from "./message-list-view" +import { useConversationSearchJump } from "./use-conversation-search-jump" +import { + requestConversationSearchJump, + useConversationSearchStore, +} from "@/stores/conversation-search-store" + +function items(...ids: string[]): ThreadRenderItem[] { + return ids.map((id) => ({ + kind: "turn", + group: { id }, + sourceTurns: [{ id }], + })) as ThreadRenderItem[] +} + +function setup(initialItems: ThreadRenderItem[], enabled = true) { + const scrollToIndex = vi.fn() + let resolvePage!: (reached: boolean) => void + const loadOlderTurnsThrough = vi.fn( + () => + new Promise((resolve) => { + resolvePage = resolve + }) + ) + const onUnavailable = vi.fn() + const scrollApiRef = { + current: { scrollToIndex, revealFromAbove: vi.fn() }, + } + const hook = renderHook( + (props: { threadItems: ThreadRenderItem[]; enabled: boolean }) => + useConversationSearchJump({ + ...props, + conversationId: -42, + dbConversationId: 42, + scrollApiRef, + loadOlderTurnsThrough, + onUnavailable, + }), + { initialProps: { threadItems: initialItems, enabled } } + ) + return { + ...hook, + scrollToIndex, + loadOlderTurnsThrough, + onUnavailable, + resolvePage: (reached: boolean) => resolvePage(reached), + } +} + +beforeEach(() => useConversationSearchStore.setState({ target: null })) + +describe("搜索结果定位", () => { + it("等待会话加载后定位已加载消息,并消费请求", async () => { + requestConversationSearchJump(42, { turn_id: "turn-12", turn_index: 12 }) + const { result, rerender, scrollToIndex, loadOlderTurnsThrough } = setup( + items("turn-11", "turn-12"), + false + ) + expect(scrollToIndex).not.toHaveBeenCalled() + rerender({ threadItems: items("turn-11", "turn-12"), enabled: true }) + await waitFor(() => + expect(scrollToIndex).toHaveBeenCalledWith(1, { + align: "start", + smooth: false, + }) + ) + expect(loadOlderTurnsThrough).not.toHaveBeenCalled() + expect(result.current.highlightedTarget?.turnId).toBe("turn-12") + expect(useConversationSearchStore.getState().target).toBeNull() + }) + + it("将合并助手回复的子消息定位到其所在行", async () => { + requestConversationSearchJump(42, { turn_id: "turn-13", turn_index: 13 }) + const merged = items("turn-12") + merged[0] = { + ...merged[0], + sourceTurns: [{ id: "turn-12" }, { id: "turn-13" }], + } as ThreadRenderItem + const { scrollToIndex } = setup(merged) + await waitFor(() => + expect(scrollToIndex).toHaveBeenCalledWith(0, { + align: "start", + smooth: false, + }) + ) + }) + + it("先加载较早消息,再等待渲染后滚动", async () => { + requestConversationSearchJump(42, { turn_id: "turn-2", turn_index: 2 }) + const { rerender, scrollToIndex, loadOlderTurnsThrough, resolvePage } = + setup(items("turn-90")) + await waitFor(() => + expect(loadOlderTurnsThrough).toHaveBeenCalledWith(-42, 2) + ) + rerender({ threadItems: items("turn-2", "turn-90"), enabled: true }) + await act(async () => resolvePage(true)) + await waitFor(() => + expect(scrollToIndex).toHaveBeenCalledWith(0, { + align: "start", + smooth: false, + }) + ) + }) + + it("不会消费其他会话的定位请求", async () => { + requestConversationSearchJump(99, { turn_id: "turn-2", turn_index: 2 }) + const { scrollToIndex, loadOlderTurnsThrough } = setup(items("turn-2")) + await act(async () => {}) + expect(scrollToIndex).not.toHaveBeenCalled() + expect(loadOlderTurnsThrough).not.toHaveBeenCalled() + expect(useConversationSearchStore.getState().target?.conversationId).toBe( + 99 + ) + }) + + it("按消息角色区分相同 id,避免跳到另一方的消息", async () => { + requestConversationSearchJump(42, { + turn_id: "shared", + turn_index: 0, + role: "user", + }) + const shared = items("shared", "shared") + for (let i = 0; i < shared.length; i++) { + const item = shared[i] + if (item.kind === "turn") { + item.group.role = i === 0 ? "user" : "assistant" + } + } + const { scrollToIndex } = setup(shared) + await waitFor(() => + expect(scrollToIndex).toHaveBeenCalledWith(0, { + align: "start", + smooth: false, + }) + ) + }) + + it("历史消息无法加载时提示失败并消费请求", async () => { + requestConversationSearchJump(42, { turn_id: "missing", turn_index: 2 }) + const { resolvePage, onUnavailable, scrollToIndex } = setup( + items("turn-90") + ) + await act(async () => resolvePage(false)) + await waitFor(() => expect(onUnavailable).toHaveBeenCalledTimes(1)) + expect(scrollToIndex).not.toHaveBeenCalled() + expect(useConversationSearchStore.getState().target).toBeNull() + }) +}) diff --git a/src/components/message/use-conversation-search-jump.ts b/src/components/message/use-conversation-search-jump.ts new file mode 100644 index 0000000000..28c20e585f --- /dev/null +++ b/src/components/message/use-conversation-search-jump.ts @@ -0,0 +1,134 @@ +"use client" + +import { useEffect, useMemo, useRef, useState, type RefObject } from "react" +import type { ThreadRenderItem } from "./message-list-view" +import type { MessageScrollContextValue } from "./message-scroll-context" +import { + clearConversationSearchJump, + useConversationSearchStore, +} from "@/stores/conversation-search-store" + +interface Options { + conversationId: number + dbConversationId: number + enabled: boolean + threadItems: readonly ThreadRenderItem[] + scrollApiRef: RefObject + loadOlderTurnsThrough: (id: number, throughIndex: number) => Promise + onUnavailable: () => void +} + +const EMPTY_INDICES: ReadonlyMap = new Map() + +export function useConversationSearchJump({ + conversationId, + dbConversationId, + enabled, + threadItems, + scrollApiRef, + loadOlderTurnsThrough, + onUnavailable, +}: Options) { + const target = useConversationSearchStore((state) => + state.target?.conversationId === dbConversationId ? state.target : null + ) + const [highlightedTarget, setHighlightedTarget] = useState<{ + conversationId: number + turnId: string + token: number + role?: "user" | "assistant" + } | null>(null) + const [settledToken, setSettledToken] = useState(null) + const searchPending = target != null + const indices = useMemo(() => { + if (!searchPending) return EMPTY_INDICES + const result = new Map() + threadItems.forEach((item, index) => { + if (item.kind !== "turn") return + result.set(item.group.id, index) + result.set(`${item.group.role}:${item.group.id}`, index) + for (const turn of item.sourceTurns) { + result.set(turn.id, index) + result.set(`${item.group.role}:${turn.id}`, index) + if (turn.source_turn_id) { + result.set(turn.source_turn_id, index) + result.set(`${item.group.role}:${turn.source_turn_id}`, index) + } + } + }) + return result + }, [threadItems, searchPending]) + const indicesRef = useRef(indices) + const targetKey = target + ? target.role + ? `${target.role}:${target.turnId}` + : target.turnId + : null + useEffect(() => { + indicesRef.current = indices + }, [indices]) + + useEffect(() => { + if ( + !enabled || + !target || + (targetKey != null && indicesRef.current.has(targetKey)) + ) + return + let cancelled = false + void loadOlderTurnsThrough(conversationId, target.turnIndex).then( + () => { + if (!cancelled) setSettledToken(target.token) + }, + () => { + if (!cancelled) setSettledToken(target.token) + } + ) + return () => { + cancelled = true + } + }, [enabled, target, targetKey, conversationId, loadOlderTurnsThrough]) + + const index = targetKey != null ? indices.get(targetKey) : undefined + useEffect(() => { + if (!enabled || !target) return + if (index == null && settledToken !== target.token) return + const frame = requestAnimationFrame(() => { + if (useConversationSearchStore.getState().target?.token !== target.token) + return + if (index == null) { + clearConversationSearchJump(target.token) + onUnavailable() + return + } + if (!scrollApiRef.current) return + scrollApiRef.current.scrollToIndex(index, { + align: "start", + smooth: false, + }) + setHighlightedTarget({ + conversationId: dbConversationId, + turnId: target.turnId, + token: target.token, + role: target.role, + }) + clearConversationSearchJump(target.token) + }) + return () => cancelAnimationFrame(frame) + }, [ + enabled, + target, + index, + settledToken, + scrollApiRef, + onUnavailable, + dbConversationId, + ]) + + return { + highlightedTarget: + highlightedTarget?.conversationId === dbConversationId + ? highlightedTarget + : null, + } +} diff --git a/src/hooks/use-collapsible-overflow.ts b/src/hooks/use-collapsible-overflow.ts index 4b4d863e62..e8a91170a1 100644 --- a/src/hooks/use-collapsible-overflow.ts +++ b/src/hooks/use-collapsible-overflow.ts @@ -46,7 +46,8 @@ function remember(contentKey: unknown, overflowing: boolean) { * another. */ export function useCollapsibleOverflow( - contentKey: unknown + contentKey: unknown, + revealToken?: number ): { contentRef: React.RefObject contentId: string @@ -61,6 +62,19 @@ export function useCollapsibleOverflow( const [expanded, setExpanded] = useState(false) const contentId = useId() + useEffect(() => { + if (revealToken == null) return + const frame = requestAnimationFrame(() => { + const el = contentRef.current + if (el && el.scrollHeight > el.clientHeight + 1) { + remember(contentKey, true) + setIsOverflowing(true) + } + setExpanded(true) + }) + return () => cancelAnimationFrame(frame) + }, [revealToken, contentKey]) + useEffect(() => { // Nothing useful to redetect once expanded: the caller drops its clamp // class, so clientHeight === scrollHeight trivially and this would diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 75134bf6d7..c2a5081a6a 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -2406,7 +2406,10 @@ "typeToSearch": "اكتب للبحث في المحادثات", "typeToSearchFiles": "اكتب للبحث في الملفات أو المجلدات", "noResults": "لم يتم العثور على نتائج.", - "untitledConversation": "محادثة بدون عنوان" + "untitledConversation": "محادثة بدون عنوان", + "userMessage": "المستخدم", + "assistantReply": "المساعد", + "jumpUnavailable": "تعذر العثور على الرسالة. ربما تغير سجل المحادثة." }, "folderTitleBar": { "showSidebar": "إظهار الشريط الجانبي", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 61dccd3e36..f5af1af069 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -2406,7 +2406,10 @@ "typeToSearch": "Tippen, um Konversationen zu suchen", "typeToSearchFiles": "Tippen, um Dateien oder Verzeichnisse zu suchen", "noResults": "Keine Ergebnisse gefunden.", - "untitledConversation": "Unbenannte Konversation" + "untitledConversation": "Unbenannte Konversation", + "userMessage": "Benutzer", + "assistantReply": "Assistent", + "jumpUnavailable": "Die Nachricht wurde nicht gefunden. Der Verlauf wurde möglicherweise geändert." }, "folderTitleBar": { "showSidebar": "Seitenleiste anzeigen", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 968be548b1..761d0ff790 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -2406,7 +2406,10 @@ "typeToSearch": "Type to search conversations", "typeToSearchFiles": "Type to search files or directories", "noResults": "No results found.", - "untitledConversation": "Untitled conversation" + "untitledConversation": "Untitled conversation", + "userMessage": "User", + "assistantReply": "Assistant", + "jumpUnavailable": "Could not locate this message. Its history may have changed." }, "folderTitleBar": { "showSidebar": "Show Sidebar", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index beff5d5f2e..1c68b1872e 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -2406,7 +2406,10 @@ "typeToSearch": "Escribe para buscar conversaciones", "typeToSearchFiles": "Escribe para buscar archivos o directorios", "noResults": "No se encontraron resultados.", - "untitledConversation": "Conversación sin título" + "untitledConversation": "Conversación sin título", + "userMessage": "Usuario", + "assistantReply": "Asistente", + "jumpUnavailable": "No se pudo localizar el mensaje. El historial puede haber cambiado." }, "folderTitleBar": { "showSidebar": "Mostrar barra lateral", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 7df6a66a74..285b1d4eb1 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -2406,7 +2406,10 @@ "typeToSearch": "Tapez pour rechercher des conversations", "typeToSearchFiles": "Tapez pour rechercher des fichiers ou répertoires", "noResults": "Aucun résultat trouvé.", - "untitledConversation": "Conversation sans titre" + "untitledConversation": "Conversation sans titre", + "userMessage": "Utilisateur", + "assistantReply": "Assistant", + "jumpUnavailable": "Message introuvable. L’historique a peut-être changé." }, "folderTitleBar": { "showSidebar": "Afficher la barre latérale", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index ba88471939..c86d790a02 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -2406,7 +2406,10 @@ "typeToSearch": "入力して会話を検索", "typeToSearchFiles": "入力してファイルまたはディレクトリを検索", "noResults": "結果が見つかりません。", - "untitledConversation": "無題の会話" + "untitledConversation": "無題の会話", + "userMessage": "ユーザー", + "assistantReply": "アシスタント", + "jumpUnavailable": "メッセージが見つかりません。履歴が変更された可能性があります。" }, "folderTitleBar": { "showSidebar": "サイドバーを表示", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 1dcf62a808..1318bbac28 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -2406,7 +2406,10 @@ "typeToSearch": "입력하여 대화를 검색하세요", "typeToSearchFiles": "입력하여 파일 또는 디렉토리를 검색하세요", "noResults": "검색 결과가 없습니다.", - "untitledConversation": "제목 없는 대화" + "untitledConversation": "제목 없는 대화", + "userMessage": "사용자", + "assistantReply": "어시스턴트", + "jumpUnavailable": "메시지를 찾을 수 없습니다. 대화 기록이 변경되었을 수 있습니다." }, "folderTitleBar": { "showSidebar": "사이드바 표시", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 87de123d23..1eaa800d66 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -2406,7 +2406,10 @@ "typeToSearch": "Digite para buscar conversas", "typeToSearchFiles": "Digite para buscar arquivos ou diretórios", "noResults": "Nenhum resultado encontrado.", - "untitledConversation": "Conversa sem título" + "untitledConversation": "Conversa sem título", + "userMessage": "Usuário", + "assistantReply": "Assistente", + "jumpUnavailable": "Não foi possível localizar a mensagem. O histórico pode ter sido alterado." }, "folderTitleBar": { "showSidebar": "Mostrar barra lateral", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 68163419e0..b3df335fb6 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -2406,7 +2406,10 @@ "typeToSearch": "输入关键词搜索会话", "typeToSearchFiles": "输入关键词搜索文件或目录", "noResults": "未找到结果。", - "untitledConversation": "未命名会话" + "untitledConversation": "未命名会话", + "userMessage": "用户", + "assistantReply": "助手", + "jumpUnavailable": "无法定位这条消息,会话历史可能已发生变化。" }, "folderTitleBar": { "showSidebar": "显示侧边栏", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index f1770b52be..dcebfa32e4 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -2406,7 +2406,10 @@ "typeToSearch": "輸入關鍵字搜尋會話", "typeToSearchFiles": "輸入關鍵字搜尋檔案或目錄", "noResults": "找不到結果。", - "untitledConversation": "未命名會話" + "untitledConversation": "未命名會話", + "userMessage": "使用者", + "assistantReply": "助手", + "jumpUnavailable": "無法定位這則訊息,對話歷史可能已變更。" }, "folderTitleBar": { "showSidebar": "顯示側邊欄", diff --git a/src/lib/api.ts b/src/lib/api.ts index 14230b6c79..aa34d92fce 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -131,6 +131,7 @@ import type { WorktreeResolution, GitWorktreeRemoval, DbConversationSummary, + ConversationSearchResult, ImportResult, ImportSelectedResult, ScanResult, @@ -2317,6 +2318,19 @@ export async function listChildConversations( }) } +export async function searchConversations( + params?: Parameters[0] +): Promise { + return getTransport().call("search_conversations", { + folderIds: params?.folder_ids ?? null, + agentType: params?.agent_type ?? null, + search: params?.search ?? null, + sortBy: params?.sort_by ?? null, + status: params?.status ?? null, + includeChildren: params?.include_children ?? null, + }) +} + export async function listOpenedTabs(): Promise { return getTransport().call("list_opened_tabs") } diff --git a/src/lib/types.ts b/src/lib/types.ts index a00b0cbd98..117e022e34 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -635,6 +635,19 @@ export type ConversationChange = | { kind: "deleted"; id: number } | { kind: "status"; id: number; status: string } +export interface ConversationSearchMatch { + turn_id: string + turn_index: number + role: "user" | "assistant" + before: string + matched_text: string + after: string +} + +export interface ConversationSearchResult extends DbConversationSummary { + matches: ConversationSearchMatch[] +} + export const CONVERSATION_CHANGED_EVENT = "conversation://changed" /** Payload for the global `folder://changed` side-channel. A folder created or diff --git a/src/stores/conversation-search-store.ts b/src/stores/conversation-search-store.ts new file mode 100644 index 0000000000..be0a24a69a --- /dev/null +++ b/src/stores/conversation-search-store.ts @@ -0,0 +1,37 @@ +import { create } from "zustand" + +export interface ConversationSearchTarget { + conversationId: number + turnId: string + turnIndex: number + role?: "user" | "assistant" + token: number +} + +let nextToken = 0 + +// 请求需保留到目标会话完成加载,不能依赖组件挂载时序发送一次性事件。 +export const useConversationSearchStore = create<{ + target: ConversationSearchTarget | null +}>(() => ({ target: null })) + +export function requestConversationSearchJump( + conversationId: number, + match: { turn_id: string; turn_index: number; role?: "user" | "assistant" } +): void { + useConversationSearchStore.setState({ + target: { + conversationId, + turnId: match.turn_id, + turnIndex: match.turn_index, + role: match.role, + token: ++nextToken, + }, + }) +} + +export function clearConversationSearchJump(token: number): void { + useConversationSearchStore.setState((state) => + state.target?.token === token ? { target: null } : state + ) +}