From 1686f7e4d017feef3ac4d4e02674b343565a1d6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=AC=E9=A3=8E=E5=90=B9=E9=9B=A8?= <2686367411@qq.com> Date: Tue, 27 Oct 2026 21:46:46 +0800 Subject: [PATCH 1/2] feat(chat): in-conversation message search (Ctrl+F) --- src-tauri/src/commands/conversations.rs | 125 +++++ src-tauri/src/lib.rs | 1 + src-tauri/src/models/conversation.rs | 13 +- src-tauri/src/models/mod.rs | 2 +- src-tauri/src/web/handlers/conversations.rs | 22 + src-tauri/src/web/router.rs | 4 + src/app/globals.css | 10 + .../chat/conversation-find-bar.test.tsx | 267 ++++++++++ src/components/chat/conversation-find-bar.tsx | 504 ++++++++++++++++++ src/components/message/message-list-view.tsx | 36 +- src/i18n/messages/ar.json | 23 +- src/i18n/messages/de.json | 13 + src/i18n/messages/en.json | 13 + src/i18n/messages/es.json | 13 + src/i18n/messages/fr.json | 13 + src/i18n/messages/ja.json | 13 + src/i18n/messages/ko.json | 13 + src/i18n/messages/pt.json | 13 + src/i18n/messages/zh-CN.json | 13 + src/i18n/messages/zh-TW.json | 13 + src/lib/api.ts | 12 + src/lib/keyboard-shortcuts.test.ts | 20 + src/lib/keyboard-shortcuts.ts | 5 + src/lib/types.ts | 9 + 24 files changed, 1162 insertions(+), 8 deletions(-) create mode 100644 src/components/chat/conversation-find-bar.test.tsx create mode 100644 src/components/chat/conversation-find-bar.tsx diff --git a/src-tauri/src/commands/conversations.rs b/src-tauri/src/commands/conversations.rs index 27c2764075..4ba7d662d5 100644 --- a/src-tauri/src/commands/conversations.rs +++ b/src-tauri/src/commands/conversations.rs @@ -2225,6 +2225,123 @@ pub async fn get_folder_conversation_outline( get_folder_conversation_outline_core(&db.conn, conversation_id, before_index).await } + +fn char_boundary_snippet(text: &str, byte_pos: usize, query_len: usize) -> String { + let mut start = byte_pos.saturating_sub(30); + while start > 0 && !text.is_char_boundary(start) { + start -= 1; + } + let mut end = (byte_pos + query_len + 40).min(text.len()); + while end < text.len() && !text.is_char_boundary(end) { + end += 1; + } + let mut s = String::new(); + if start > 0 { + s.push_str("... "); + } + if let Some(slice) = text.get(start..end) { + s.push_str(slice); + } + if end < text.len() { + s.push_str(" ..."); + } + s.replace('\n', " ").trim().to_string() +} + +/// In-conversation search for message content across all turns. +pub async fn search_conversation_messages_core( + conn: &sea_orm::DatabaseConnection, + conversation_id: i32, + query: &str, +) -> Result, AppCommandError> { + let query_trimmed = query.trim(); + if query_trimmed.is_empty() { + return Ok(Vec::new()); + } + + let (detail, _parsed_title) = get_folder_conversation_core(conn, conversation_id).await?; + let query_lower = query_trimmed.to_lowercase(); + let mut matches = Vec::new(); + + for (turn_index, turn) in detail.turns.into_iter().enumerate() { + let mut turn_text = String::new(); + for block in &turn.blocks { + match block { + ContentBlock::Text { text } => { + turn_text.push_str(text); + turn_text.push('\n'); + } + ContentBlock::ToolUse { + tool_name, + input_preview, + .. + } => { + turn_text.push_str(tool_name); + turn_text.push(' '); + if let Some(input) = input_preview { + turn_text.push_str(input); + turn_text.push('\n'); + } + } + ContentBlock::ToolResult { + output_preview: Some(out), + .. + } => { + turn_text.push_str(out); + turn_text.push('\n'); + } + ContentBlock::Thinking { text } => { + turn_text.push_str(text); + turn_text.push('\n'); + } + ContentBlock::TurnError { message } => { + turn_text.push_str(message); + turn_text.push('\n'); + } + ContentBlock::ImageGeneration { + revised_prompt: Some(prompt), + .. + } => { + turn_text.push_str(prompt); + turn_text.push('\n'); + } + _ => {} + } + } + + let text_lower = turn_text.to_lowercase(); + if text_lower.contains(&query_lower) { + let count = text_lower.match_indices(&query_lower).count(); + let snippet = if let Some(pos) = text_lower.find(&query_lower) { + char_boundary_snippet(&turn_text, pos, query_lower.len()) + } else { + String::new() + }; + + matches.push(ConversationMessageMatch { + turn_index, + turn_id: turn.id, + role: turn.role, + match_count: count, + snippet, + }); + } + } + + Ok(matches) +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn search_conversation_messages( + db: tauri::State<'_, AppDatabase>, + conversation_id: i32, + query: String, +) -> Result, AppCommandError> { + search_conversation_messages_core(&db.conn, conversation_id, &query).await +} + + /// Emit a `conversation://changed` Upsert for `conversation_id` so every /// client's sidebar inserts-or-replaces the row in real time. Re-fetches the /// fresh summary via `get_by_id`, which filters out soft-deleted rows — so an @@ -7458,6 +7575,14 @@ mod tests { ); } + #[test] + fn char_boundary_snippet_handles_multibyte_and_boundaries() { + let text = "你好世界,这是一个测试文本,用于测试对话内查找功能是否正常工作。"; + let snippet = char_boundary_snippet(text, 15, 6); + assert!(!snippet.is_empty()); + assert!(snippet.contains("测试")); + } + #[test] fn apply_turn_window_from_index_is_exact_and_full_coverage_is_marked() { let mut detail = windowless_detail(four_turns()); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 25a96b83d1..f627671039 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1848,6 +1848,7 @@ mod tauri_app { conversations::get_folder_conversation, conversations::get_folder_conversation_turns, conversations::get_folder_conversation_outline, + conversations::search_conversation_messages, conversations::list_folders, conversations::get_stats, conversations::get_sidebar_data, diff --git a/src-tauri/src/models/conversation.rs b/src-tauri/src/models/conversation.rs index b1527d4326..e163a3c234 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)] @@ -233,6 +233,17 @@ pub struct ConversationOutline { pub prefix_hash: String, } +/// A search match for in-conversation search. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConversationMessageMatch { + pub turn_index: usize, + pub turn_id: String, + pub role: TurnRole, + pub match_count: usize, + pub snippet: String, +} + + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FolderInfo { pub path: String, diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index 43960b7cd4..9607999967 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -29,7 +29,7 @@ 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, + ConversationMessageMatch, ConversationOutline, ConversationSummary, ConversationTagDetail, ConversationTurnsPage, DbConversationDetail, DbConversationSummary, FolderInfo, ImportFolderOutcome, ImportResult, ImportSelectedResult, ScanFolder, ScanResult, ScanSession, ScanSessionStatus, SelectedSessionKey, SessionStats, SidebarData, diff --git a/src-tauri/src/web/handlers/conversations.rs b/src-tauri/src/web/handlers/conversations.rs index 94f9c46f79..8bfb41a576 100644 --- a/src-tauri/src/web/handlers/conversations.rs +++ b/src-tauri/src/web/handlers/conversations.rs @@ -205,6 +205,28 @@ pub async fn get_folder_conversation_outline( Ok(Json(result)) } + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SearchConversationMessagesParams { + pub conversation_id: i32, + pub query: String, +} + +pub async fn search_conversation_messages( + Extension(state): Extension>, + Json(params): Json, +) -> Result>, AppCommandError> { + let result = conv_commands::search_conversation_messages_core( + &state.db.conn, + params.conversation_id, + ¶ms.query, + ) + .await?; + Ok(Json(result)) +} + + pub async fn list_folders( Extension(state): Extension>, ) -> Result>, AppCommandError> { diff --git a/src-tauri/src/web/router.rs b/src-tauri/src/web/router.rs index c4eb63b67b..c57e9bb80e 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -239,6 +239,10 @@ pub fn build_router_with_proxy_auth( "/get_folder_conversation_outline", post(handlers::conversations::get_folder_conversation_outline), ) + .route( + "/search_conversation_messages", + post(handlers::conversations::search_conversation_messages), + ) .route( "/list_opened_tabs", post(handlers::conversations::list_opened_tabs), diff --git a/src/app/globals.css b/src/app/globals.css index 836f24e1b5..414acd9dc9 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -3982,3 +3982,13 @@ div.monaco-diff-editor.side-by-side .editor.modified { opacity: 0; } } + +/* In-conversation find highlights (CSS Custom Highlight API - web standard find appearance) */ +::highlight(search-match) { + background-color: #fde047; + color: #000000; +} +::highlight(search-match-active) { + background-color: #f97316; + color: #ffffff; +} diff --git a/src/components/chat/conversation-find-bar.test.tsx b/src/components/chat/conversation-find-bar.test.tsx new file mode 100644 index 0000000000..97baa5869d --- /dev/null +++ b/src/components/chat/conversation-find-bar.test.tsx @@ -0,0 +1,267 @@ +import { cleanup, render, screen, waitFor } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +import { ConversationFindBar } from "./conversation-find-bar" +import type { ThreadRenderItem } from "@/components/message/message-list-view" +import type { MessageScrollContextValue } from "@/components/message/message-scroll-context" +import * as api from "@/lib/api" +import type { ConversationMessageMatch } from "@/lib/types" + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string, values?: Record) => { + if (values) { + return `${key}:${JSON.stringify(values)}` + } + return key + }, +})) + +vi.mock("@/lib/api", () => ({ + searchConversationMessages: vi.fn(), +})) + +function createFakeTurnItem(id: string, text: string): ThreadRenderItem { + return { + key: `persisted-${id}`, + kind: "turn", + group: { + id, + role: "assistant", + parts: [{ type: "text", text }], + resources: [], + images: [], + }, + phase: "persisted", + isResponseComplete: true, + showStats: false, + isRoleTransition: false, + previousUserIndex: null, + isLastAssistantRun: false, + isThreadTail: false, + sourceTurns: [], + } +} + +describe("ConversationFindBar", () => { + let scrollApi: MessageScrollContextValue + let scrollApiRef: { current: MessageScrollContextValue | null } + + beforeEach(() => { + vi.clearAllMocks() + scrollApi = { + scrollToIndex: vi.fn(), + revealFromAbove: vi.fn(), + } + scrollApiRef = { current: scrollApi } + }) + + afterEach(() => { + cleanup() + }) + + it("renders nothing when closed", () => { + render( + + ) + expect(screen.queryByRole("search")).toBeNull() + }) + + it("renders input and controls when open", () => { + render( + + ) + expect(screen.getByRole("search")).toBeDefined() + expect(screen.getByPlaceholderText("placeholder")).toBeDefined() + expect(screen.getByLabelText("previous")).toBeDefined() + expect(screen.getByLabelText("next")).toBeDefined() + expect(screen.getByLabelText("close")).toBeDefined() + }) + + it("calls onClose when close button is clicked or Escape pressed", async () => { + const onClose = vi.fn() + const user = userEvent.setup() + render( + + ) + + await user.click(screen.getByLabelText("close")) + expect(onClose).toHaveBeenCalledTimes(1) + + const input = screen.getByPlaceholderText("placeholder") + await user.type(input, "{Escape}") + expect(onClose).toHaveBeenCalledTimes(2) + }) + + it("searches via backend API and navigates matches", async () => { + const mockMatches: ConversationMessageMatch[] = [ + { + turn_index: 2, + turn_id: "turn-2", + role: "user", + match_count: 1, + snippet: "find me", + }, + { + turn_index: 5, + turn_id: "turn-5", + role: "assistant", + match_count: 2, + snippet: "another find", + }, + ] + vi.mocked(api.searchConversationMessages).mockResolvedValue(mockMatches) + + const threadItems = [ + createFakeTurnItem("turn-2", "find me"), + createFakeTurnItem("turn-5", "another find"), + ] + const onActiveTurnChange = vi.fn() + const user = userEvent.setup() + + render( + + ) + + const input = screen.getByPlaceholderText("placeholder") + await user.type(input, "find") + + await waitFor(() => { + expect(api.searchConversationMessages).toHaveBeenCalledWith(100, "find") + }) + + // First match active + await waitFor(() => { + expect(onActiveTurnChange).toHaveBeenCalledWith("turn-2") + expect(scrollApi.scrollToIndex).toHaveBeenCalledWith(0, { + align: "center", + smooth: true, + }) + }) + + // Press next button + await user.click(screen.getByLabelText("next")) + expect(onActiveTurnChange).toHaveBeenCalledWith("turn-5") + expect(scrollApi.scrollToIndex).toHaveBeenCalledWith(1, { + align: "center", + smooth: true, + }) + + // Press Enter inside input navigates next (wraps around to turn-2) + await user.type(input, "{Enter}") + expect(onActiveTurnChange).toHaveBeenCalledWith("turn-2") + + // Shift+Enter navigates previous (wraps backwards to turn-5) + await user.type(input, "{Shift>}{Enter}{/Shift}") + expect(onActiveTurnChange).toHaveBeenCalledWith("turn-5") + }) + + it("calls loadOlderTurnsThrough when match is an unloaded older turn", async () => { + const mockMatches: ConversationMessageMatch[] = [ + { + turn_index: 1, + turn_id: "turn-1", + role: "user", + match_count: 1, + snippet: "older match", + }, + ] + vi.mocked(api.searchConversationMessages).mockResolvedValue(mockMatches) + + const loadOlderTurnsThrough = vi.fn().mockResolvedValue(true) + const onActiveTurnChange = vi.fn() + const user = userEvent.setup() + + render( + + ) + + const input = screen.getByPlaceholderText("placeholder") + await user.type(input, "older") + + await waitFor(() => { + expect(loadOlderTurnsThrough).toHaveBeenCalledWith(100, 1) + expect(onActiveTurnChange).toHaveBeenCalledWith("turn-1") + }) + }) + + it("searches in-memory thread items for draft conversation (id=0)", async () => { + const threadItems = [ + createFakeTurnItem("draft-1", "Hello World"), + createFakeTurnItem("draft-2", "Something else"), + createFakeTurnItem("draft-3", "Hello again"), + ] + const onActiveTurnChange = vi.fn() + const user = userEvent.setup() + + render( + + ) + + const input = screen.getByPlaceholderText("placeholder") + await user.type(input, "Hello") + + await waitFor(() => { + expect(api.searchConversationMessages).not.toHaveBeenCalled() + expect(onActiveTurnChange).toHaveBeenCalledWith("draft-1") + }) + + await user.click(screen.getByLabelText("next")) + expect(onActiveTurnChange).toHaveBeenCalledWith("draft-3") + }) +}) diff --git a/src/components/chat/conversation-find-bar.tsx b/src/components/chat/conversation-find-bar.tsx new file mode 100644 index 0000000000..f36be6dc5e --- /dev/null +++ b/src/components/chat/conversation-find-bar.tsx @@ -0,0 +1,504 @@ +"use client" + +import { + memo, + useCallback, + useEffect, + useRef, + useState, + type RefObject, +} from "react" +import { ChevronDown, ChevronUp, Loader2, Search, X } from "lucide-react" +import { useTranslations } from "next-intl" + +import type { ThreadRenderItem } from "@/components/message/message-list-view" +import type { MessageScrollContextValue } from "@/components/message/message-scroll-context" +import { searchConversationMessages } from "@/lib/api" +import type { + AdaptedContentPart, + AdaptedMessage, +} from "@/lib/adapters/ai-elements-adapter" +import type { ConversationMessageMatch } from "@/lib/types" + +export interface ConversationFindBarProps { + conversationId: number + open: boolean + focusToken: number + onClose: () => void + threadItems: readonly ThreadRenderItem[] + liveMessage?: { id: string; content?: unknown } | null + scrollApiRef: RefObject + containerRef?: RefObject + loadOlderTurnsThrough: ( + conversationId: number, + throughIndex: number + ) => Promise + onActiveTurnChange?: (turnId: string | null) => void + turnsOffset?: number +} + +function extractText(parts: AdaptedContentPart[]): string { + return parts + .flatMap((p): string[] => { + if (p.type === "text") return [p.text] + if (p.type === "goal-run") return [extractText(p.items)] + if (p.type === "tool-call") { + const textParts = [p.toolName] + if (p.input && typeof p.input === "object") { + try { + textParts.push(JSON.stringify(p.input)) + } catch { + // ignore + } + } + if (p.output && typeof p.output === "string") { + textParts.push(p.output) + } + return textParts + } + return [] + }) + .filter((text) => text.length > 0) + .join("\n") +} + +function findTextRanges( + root: HTMLElement, + query: string, + excludeSelector?: string +): Range[] { + if (!query) return [] + const qLower = query.toLowerCase() + const ranges: Range[] = [] + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { + acceptNode(node) { + if ( + !node.textContent || + !node.textContent.toLowerCase().includes(qLower) + ) { + return NodeFilter.FILTER_REJECT + } + const parent = node.parentElement + if (!parent) return NodeFilter.FILTER_REJECT + const tag = parent.tagName.toLowerCase() + if (tag === "script" || tag === "style" || tag === "textarea") { + return NodeFilter.FILTER_REJECT + } + if (excludeSelector && parent.closest(excludeSelector)) { + return NodeFilter.FILTER_REJECT + } + return NodeFilter.FILTER_ACCEPT + }, + }) + + let currentNode = walker.nextNode() + while (currentNode) { + const text = currentNode.textContent ?? "" + const textLower = text.toLowerCase() + let start = 0 + while ((start = textLower.indexOf(qLower, start)) !== -1) { + try { + const range = new Range() + range.setStart(currentNode, start) + range.setEnd(currentNode, start + query.length) + ranges.push(range) + } catch { + // ignore + } + start += qLower.length + } + currentNode = walker.nextNode() + } + return ranges +} + +const ICON_BTN = + "flex h-6 w-6 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-30" + +export const ConversationFindBar = memo(function ConversationFindBar({ + conversationId, + open, + focusToken, + onClose, + threadItems, + liveMessage, + scrollApiRef, + containerRef, + loadOlderTurnsThrough, + onActiveTurnChange, + turnsOffset = 0, +}: ConversationFindBarProps) { + const t = useTranslations("ConversationFind") + const [query, setQuery] = useState("") + const [matches, setMatches] = useState([]) + const [matchIndex, setMatchIndex] = useState(0) + const [searching, setSearching] = useState(false) + const [loadingOlder, setLoadingOlder] = useState(false) + + const inputRef = useRef(null) + const debounceTimer = useRef | null>(null) + const searchSeq = useRef(0) + + // Focus & select input on open or focusToken change + useEffect(() => { + if (!open) return + inputRef.current?.focus() + inputRef.current?.select() + }, [open, focusToken]) + + // Clear state when closed + useEffect(() => { + if (!open) { + setQuery("") + setMatches([]) + setMatchIndex(0) + onActiveTurnChange?.(null) + } + }, [open, onActiveTurnChange]) + + // Jump to a specific match item + const jumpToMatch = useCallback( + async (match: ConversationMessageMatch) => { + onActiveTurnChange?.(match.turn_id) + + // Check if turn is currently in threadItems + const itemIndex = threadItems.findIndex( + (it) => it.kind === "turn" && it.group.id === match.turn_id + ) + + if (itemIndex !== -1) { + scrollApiRef.current?.scrollToIndex(itemIndex, { + align: "center", + smooth: true, + }) + return + } + + // If not loaded and in older turns + if (conversationId > 0 && match.turn_index < turnsOffset) { + setLoadingOlder(true) + try { + const success = await loadOlderTurnsThrough( + conversationId, + match.turn_index + ) + if (success) { + // Give virtua a frame to incorporate prepended items + requestAnimationFrame(() => { + const updatedIndex = threadItems.findIndex( + (it) => it.kind === "turn" && it.group.id === match.turn_id + ) + if (updatedIndex !== -1) { + scrollApiRef.current?.scrollToIndex(updatedIndex, { + align: "center", + smooth: false, + }) + } + }) + } + } finally { + setLoadingOlder(false) + } + } + }, + [ + conversationId, + loadOlderTurnsThrough, + onActiveTurnChange, + scrollApiRef, + threadItems, + turnsOffset, + ] + ) + + // Web-standard text highlighting via CSS Custom Highlight API + useEffect(() => { + const cssWithHighlights = + typeof CSS !== "undefined" + ? (CSS as unknown as { + highlights?: { + set(name: string, highlight: unknown): void + delete(name: string): void + clear(): void + } + }) + : null + const windowWithHighlight = + typeof window !== "undefined" + ? (window as unknown as { + Highlight?: new (...ranges: Range[]) => unknown + }) + : null + + if (!cssWithHighlights?.highlights || !windowWithHighlight?.Highlight) { + return + } + + const trimmed = query.trim() + const container = containerRef?.current + + if (!open || !trimmed || !container) { + cssWithHighlights.highlights.delete("search-match") + cssWithHighlights.highlights.delete("search-match-active") + return + } + + const rafId = requestAnimationFrame(() => { + if (!containerRef?.current) return + const ranges = findTextRanges( + containerRef.current, + trimmed, + "[role='search']" + ) + if (ranges.length === 0) { + cssWithHighlights.highlights?.delete("search-match") + cssWithHighlights.highlights?.delete("search-match-active") + return + } + + const activeMatch = matches[matchIndex] + let activeRangeIndex = -1 + if (activeMatch) { + activeRangeIndex = ranges.findIndex((r) => { + const el = r.startContainer.parentElement + return el?.closest(`[data-turn-id="${activeMatch.turn_id}"]`) !== null + }) + } + if (activeRangeIndex === -1 && ranges.length > 0) { + activeRangeIndex = 0 + } + + try { + const allHighlight = new windowWithHighlight.Highlight!(...ranges) + cssWithHighlights.highlights?.set("search-match", allHighlight) + + if (activeRangeIndex !== -1 && ranges[activeRangeIndex]) { + const activeRange = ranges[activeRangeIndex] + const activeHighlight = new windowWithHighlight.Highlight!( + activeRange + ) + cssWithHighlights.highlights?.set( + "search-match-active", + activeHighlight + ) + + activeRange.startContainer.parentElement?.scrollIntoView({ + block: "nearest", + inline: "nearest", + behavior: "smooth", + }) + } else { + cssWithHighlights.highlights?.delete("search-match-active") + } + } catch { + // ignore highlight registration errors + } + }) + + return () => { + cancelAnimationFrame(rafId) + cssWithHighlights.highlights?.delete("search-match") + cssWithHighlights.highlights?.delete("search-match-active") + } + }, [open, query, matchIndex, matches, containerRef, threadItems]) + + // Search execution + const executeSearch = useCallback( + async (q: string) => { + const trimmed = q.trim() + if (!trimmed) { + setMatches([]) + setMatchIndex(0) + onActiveTurnChange?.(null) + setSearching(false) + return + } + + const seq = ++searchSeq.current + setSearching(true) + + try { + let results: ConversationMessageMatch[] = [] + + if (conversationId > 0) { + // Backend search across full conversation history (including unloaded turns) + results = await searchConversationMessages(conversationId, trimmed) + + // Check if liveMessage (streaming) matches and append if needed + if (liveMessage && liveMessage.content) { + const liveContent = liveMessage.content as unknown[] + const liveText = Array.isArray(liveContent) + ? liveContent + .map((item) => + item && + typeof item === "object" && + "text" in item && + typeof (item as { text: unknown }).text === "string" + ? (item as { text: string }).text + : "" + ) + .join(" ") + : "" + const qLower = trimmed.toLowerCase() + if (liveText.toLowerCase().includes(qLower)) { + const count = liveText.toLowerCase().split(qLower).length - 1 + results.push({ + turn_index: Number.MAX_SAFE_INTEGER, + turn_id: liveMessage.id, + role: "assistant", + match_count: count, + snippet: liveText.slice(0, 80), + }) + } + } + } else { + // Draft conversation: search in-memory thread items + const qLower = trimmed.toLowerCase() + let turnIdx = 0 + for (const item of threadItems) { + if (item.kind !== "turn") continue + const turnText = extractText(item.group.parts) + if (turnText.toLowerCase().includes(qLower)) { + const count = turnText.toLowerCase().split(qLower).length - 1 + results.push({ + turn_index: turnIdx, + turn_id: item.group.id, + role: item.group.role, + match_count: count, + snippet: turnText.slice(0, 80), + }) + } + turnIdx++ + } + } + + if (searchSeq.current === seq) { + setMatches(results) + setMatchIndex(0) + if (results.length > 0) { + void jumpToMatch(results[0]) + } else { + onActiveTurnChange?.(null) + } + } + } catch (err) { + console.error("[ConversationFindBar] search error:", err) + if (searchSeq.current === seq) { + setMatches([]) + onActiveTurnChange?.(null) + } + } finally { + if (searchSeq.current === seq) { + setSearching(false) + } + } + }, + [conversationId, jumpToMatch, liveMessage, onActiveTurnChange, threadItems] + ) + + // Debounced input change + const handleQueryChange = (val: string) => { + setQuery(val) + if (debounceTimer.current) { + clearTimeout(debounceTimer.current) + } + debounceTimer.current = setTimeout(() => { + void executeSearch(val) + }, 150) + } + + // Navigate next/prev + const step = useCallback( + (forward: boolean) => { + if (matches.length === 0) return + const nextIndex = forward + ? (matchIndex + 1) % matches.length + : (matchIndex - 1 + matches.length) % matches.length + setMatchIndex(nextIndex) + void jumpToMatch(matches[nextIndex]) + }, + [jumpToMatch, matchIndex, matches] + ) + + if (!open) return null + + return ( +
+ + handleQueryChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + step(!e.shiftKey) + } else if (e.key === "Escape") { + e.preventDefault() + onClose() + } + }} + spellCheck={false} + autoComplete="off" + placeholder={t("placeholder")} + aria-label={t("placeholder")} + className="h-7 w-44 sm:w-56 rounded bg-transparent px-2 text-xs text-foreground placeholder:text-muted-foreground outline-none" + /> + +
+ {searching ? ( + + ) : loadingOlder ? ( + + {t("loadingOlder")} + + ) : query.trim() && matches.length === 0 ? ( + {t("noMatches")} + ) : matches.length > 0 ? ( + + {t("matchCount", { + current: matchIndex + 1, + total: matches.length, + })} + + ) : null} +
+ +
+ + + +
+
+ ) +}) diff --git a/src/components/message/message-list-view.tsx b/src/components/message/message-list-view.tsx index 9ae0acf0d1..dccb01c871 100644 --- a/src/components/message/message-list-view.tsx +++ b/src/components/message/message-list-view.tsx @@ -1,5 +1,7 @@ "use client" +import { ConversationFindBar } from "@/components/chat/conversation-find-bar" + import { memo, useCallback, @@ -1253,7 +1255,7 @@ const HistoricalMessageGroup = memo(function HistoricalMessageGroup({ const forkPointUnnamed = isForkPointUnnamed(forkPoint, isThreadTail) return ( -
+
{group.role === "user" && group.images.length > 0 ? ( @@ -1343,6 +1345,7 @@ const AutoScrollOnSend = memo(function AutoScrollOnSend({ signal: number }) { const { scrollToBottom } = useStickToBottomContext() + const chatAnimations = useChatAnimationsEnabled() const lastSignalRef = useRef(signal) @@ -1386,6 +1389,25 @@ export function MessageListView({ onSaveNoteSelection, onForkFromTurn, }: MessageListViewProps) { + const [findOpen, setFindOpen] = useState(false) + const [findFocusToken, setFindFocusToken] = useState(0) + + useEffect(() => { + if (!isActive) return + const handleKeyDown = (e: KeyboardEvent) => { + if ( + (e.metaKey || e.ctrlKey) && + !e.altKey && + e.key.toLowerCase() === "f" + ) { + e.preventDefault() + setFindOpen(true) + setFindFocusToken((t) => t + 1) + } + } + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [isActive]) const t = useTranslations("Folder.chat.messageList") const sharedT = useTranslations("Folder.chat.shared") const pageHandoffName = usePageHandoffName() @@ -2100,6 +2122,18 @@ export function MessageListView({ ref={selectionBoxRef} className="chat-motion-scope relative flex h-full min-h-0 flex-col" > + setFindOpen(false)} + threadItems={threadItems} + liveMessage={liveMessage} + scrollApiRef={scrollApiRef} + containerRef={selectionBoxRef} + loadOlderTurnsThrough={loadOlderTurnsThrough} + turnsOffset={windowedDetail?.turns_offset ?? 0} + /> { + return getTransport().call("search_conversation_messages", { + conversationId, + query, + }) +} + export async function removeFolderFromHistory(path: string): Promise { return getTransport().call("remove_folder_from_history", { path }) } diff --git a/src/lib/keyboard-shortcuts.test.ts b/src/lib/keyboard-shortcuts.test.ts index 8144452208..933b8fb255 100644 --- a/src/lib/keyboard-shortcuts.test.ts +++ b/src/lib/keyboard-shortcuts.test.ts @@ -35,6 +35,26 @@ function keyEvent( } } +describe("in-conversation find shortcut", () => { + it("defaults to Ctrl/Cmd+F", () => { + const ids = SHORTCUT_DEFINITIONS.map((definition) => definition.id) + expect(ids).toContain("find_in_conversation") + expect(DEFAULT_SHORTCUTS.find_in_conversation).toBe("mod+f") + expect( + matchShortcutEvent( + keyEvent("f", { ctrlKey: true }), + DEFAULT_SHORTCUTS.find_in_conversation + ) + ).toBe(true) + expect( + matchShortcutEvent( + keyEvent("f", { metaKey: true }), + DEFAULT_SHORTCUTS.find_in_conversation + ) + ).toBe(true) + }) +}) + describe("tab cycling shortcuts", () => { it("registers next_tab and prev_tab with defaults", () => { const ids = SHORTCUT_DEFINITIONS.map((definition) => definition.id) diff --git a/src/lib/keyboard-shortcuts.ts b/src/lib/keyboard-shortcuts.ts index c346a069a8..bde38b3c40 100644 --- a/src/lib/keyboard-shortcuts.ts +++ b/src/lib/keyboard-shortcuts.ts @@ -1,5 +1,6 @@ export type ShortcutActionId = | "toggle_search" + | "find_in_conversation" | "toggle_sidebar" | "toggle_terminal" | "new_terminal_tab" @@ -37,6 +38,9 @@ export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [ { id: "toggle_search", }, + { + id: "find_in_conversation", + }, { id: "toggle_sidebar", }, @@ -134,6 +138,7 @@ export type ShortcutSettings = Record export const DEFAULT_SHORTCUTS: ShortcutSettings = { toggle_search: "mod+k", + find_in_conversation: "mod+f", toggle_sidebar: "mod+b", toggle_terminal: "mod+j", new_terminal_tab: "mod+t", diff --git a/src/lib/types.ts b/src/lib/types.ts index a00b0cbd98..93656ed488 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1068,6 +1068,15 @@ export interface ConversationOutline { prefix_hash: string } +/** One match result for in-conversation search. */ +export interface ConversationMessageMatch { + turn_index: number + turn_id: string + role: "user" | "assistant" | "system" + match_count: number + snippet: string +} + export type ConversationStatus = | "in_progress" | "pending_review" From 77c98e67b1c34cb3da7d9a4d184f37c92496d73c Mon Sep 17 00:00:00 2001 From: chuiyu <74444912+ana52070@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:36:30 +0800 Subject: [PATCH 2/2] fix(chat): remove unused conversation search type import --- src/components/chat/conversation-find-bar.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/components/chat/conversation-find-bar.tsx b/src/components/chat/conversation-find-bar.tsx index f36be6dc5e..4f21a2eb13 100644 --- a/src/components/chat/conversation-find-bar.tsx +++ b/src/components/chat/conversation-find-bar.tsx @@ -14,10 +14,7 @@ import { useTranslations } from "next-intl" import type { ThreadRenderItem } from "@/components/message/message-list-view" import type { MessageScrollContextValue } from "@/components/message/message-scroll-context" import { searchConversationMessages } from "@/lib/api" -import type { - AdaptedContentPart, - AdaptedMessage, -} from "@/lib/adapters/ai-elements-adapter" +import type { AdaptedContentPart } from "@/lib/adapters/ai-elements-adapter" import type { ConversationMessageMatch } from "@/lib/types" export interface ConversationFindBarProps {