From 39861fc6442ac350fcb80516d9e81b1cd7442e63 Mon Sep 17 00:00:00 2001 From: Mingwwww Date: Wed, 1 Jul 2026 18:03:17 +0800 Subject: [PATCH 1/4] Add chat history search across sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add /search (with /find, /grep, 搜索 aliases) and a `pilotdeck chat` CLI subcommand to search chat history, exposed via channel commands, the UI command handler, and the session module. Co-authored-by: Cursor --- .../protocol/ChannelCommandRegistry.ts | 27 ++ src/cli/commands/chatSearch.ts | 118 +++++ src/cli/pilotdeck.ts | 6 + src/session/index.ts | 13 + src/session/search/formatChatHistorySearch.ts | 94 ++++ src/session/search/searchChatHistory.ts | 404 ++++++++++++++++++ ui/server/routes/commands.js | 39 ++ .../chat/hooks/useChatComposerState.ts | 8 + 8 files changed, 709 insertions(+) create mode 100644 src/cli/commands/chatSearch.ts create mode 100644 src/session/search/formatChatHistorySearch.ts create mode 100644 src/session/search/searchChatHistory.ts diff --git a/src/adapters/channel/protocol/ChannelCommandRegistry.ts b/src/adapters/channel/protocol/ChannelCommandRegistry.ts index 86edb564a..ec18d4859 100644 --- a/src/adapters/channel/protocol/ChannelCommandRegistry.ts +++ b/src/adapters/channel/protocol/ChannelCommandRegistry.ts @@ -12,6 +12,8 @@ */ import type { Gateway } from "../../../gateway/index.js"; +import { resolvePilotHome } from "../../../pilot/index.js"; +import { runChatSearchFormatted } from "../../../cli/commands/chatSearch.js"; // --------------------------------------------------------------------------- // Types @@ -242,6 +244,31 @@ const commands: ChannelCommand[] = [ }, }, + { + name: "search", + aliases: ["find", "grep", "搜索"], + description: "Search chat history across sessions", + systemLevel: true, + handler: async (ctx, arg) => { + const projectRoot = ctx.getProject?.(); + const parsed = arg.trim(); + if (!parsed) { + await ctx.reply( + "用法:/search <关键词> [--all] [--limit N] [--role user|assistant]\n示例:/search docker 部署", + ); + return; + } + + const { text } = await runChatSearchFormatted({ + arg: parsed, + projectRoot, + pilotHome: resolvePilotHome(process.env), + locale: "zh", + }); + await ctx.reply(text); + }, + }, + { name: "help", aliases: ["帮助"], diff --git a/src/cli/commands/chatSearch.ts b/src/cli/commands/chatSearch.ts new file mode 100644 index 000000000..eff37776c --- /dev/null +++ b/src/cli/commands/chatSearch.ts @@ -0,0 +1,118 @@ +import { resolvePilotHome } from "../../pilot/index.js"; +import { + formatChatHistorySearchResults, +} from "../../session/search/formatChatHistorySearch.js"; +import { + parseChatSearchArgs, + searchChatHistory, + type SearchChatHistoryResult, +} from "../../session/search/searchChatHistory.js"; + +export type RunChatSearchOptions = { + pilotHome?: string; + projectRoot?: string; + arg: string; + locale?: "zh" | "en"; +}; + +export async function runChatSearch(options: RunChatSearchOptions): Promise { + const parsed = parseChatSearchArgs(options.arg); + const pilotHome = options.pilotHome ?? resolvePilotHome(process.env); + + return searchChatHistory({ + pilotHome, + projectRoot: parsed.allProjects ? undefined : options.projectRoot, + query: parsed.query, + limit: parsed.limit, + regex: parsed.regex, + caseSensitive: parsed.caseSensitive, + role: parsed.role, + sessionId: parsed.sessionId, + }); +} + +export async function runChatSearchFormatted(options: RunChatSearchOptions): Promise<{ + result: SearchChatHistoryResult; + text: string; +}> { + const result = await runChatSearch(options); + const text = formatChatHistorySearchResults(result, { + locale: options.locale, + includeProject: parseChatSearchArgs(options.arg).allProjects || !options.projectRoot, + }); + return { result, text }; +} + +function readStringFlag(argv: string[], flag: string): string | undefined { + const index = argv.indexOf(flag); + if (index === -1) return undefined; + return argv[index + 1]; +} + +function readNumberFlag(argv: string[], flag: string): number | undefined { + const value = readStringFlag(argv, flag); + if (!value) return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +export async function runChatSearchCli(argv: string[]): Promise { + const subcommand = argv[0]; + if (subcommand !== "search") { + console.error( + "Usage: pilotdeck chat search [--project ] [--all-projects] [--limit N] [--json] [--regex] [--case-sensitive] [--role user|assistant|all] [--session ]", + ); + process.exitCode = 1; + return; + } + + const json = argv.includes("--json"); + const allProjects = argv.includes("--all-projects"); + const projectRoot = readStringFlag(argv, "--project") ?? process.cwd(); + const limit = readNumberFlag(argv, "--limit"); + const regex = argv.includes("--regex"); + const caseSensitive = argv.includes("--case-sensitive"); + const roleFlag = readStringFlag(argv, "--role"); + const role = roleFlag === "user" || roleFlag === "assistant" || roleFlag === "all" ? roleFlag : undefined; + const sessionId = readStringFlag(argv, "--session"); + + const queryParts = argv + .slice(1) + .filter((token, index, all) => { + if (token.startsWith("--")) return false; + const prev = all[index - 1]; + if (prev === "--project" || prev === "--limit" || prev === "--role" || prev === "--session") { + return false; + } + return true; + }); + + const query = queryParts.join(" ").trim(); + if (!query) { + console.error("Error: search keyword is required."); + process.exitCode = 1; + return; + } + + const pilotHome = readStringFlag(argv, "--pilot-home") ?? resolvePilotHome(process.env); + const result = await searchChatHistory({ + pilotHome, + projectRoot: allProjects ? undefined : projectRoot, + query, + limit, + regex, + caseSensitive, + role, + sessionId, + }); + + if (json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + + console.log(formatChatHistorySearchResults(result, { + locale: "en", + includeProject: allProjects, + })); +} diff --git a/src/cli/pilotdeck.ts b/src/cli/pilotdeck.ts index 52c506caa..397c53df4 100644 --- a/src/cli/pilotdeck.ts +++ b/src/cli/pilotdeck.ts @@ -350,6 +350,12 @@ async function main(argv = process.argv.slice(2)): Promise { return; } + if (command === "chat") { + const { runChatSearchCli } = await import("./commands/chatSearch.js"); + await runChatSearchCli(argv.slice(1)); + return; + } + if (command === "tui") { if (!process.stdin.isTTY) { console.error("pilotdeck tui requires an interactive terminal."); diff --git a/src/session/index.ts b/src/session/index.ts index cab916e7d..c04b4ac05 100644 --- a/src/session/index.ts +++ b/src/session/index.ts @@ -14,6 +14,19 @@ export { type SearchSessionsByTitleOptions, type SessionInfo, } from "./storage/SessionList.js"; +export { + formatChatHistorySearchResults, + type FormatChatHistorySearchOptions, +} from "./search/formatChatHistorySearch.js"; +export { + parseChatSearchArgs, + searchChatHistory, + type ChatHistorySearchMatch, + type ChatHistorySearchRole, + type ParsedChatSearchArgs, + type SearchChatHistoryOptions, + type SearchChatHistoryResult, +} from "./search/searchChatHistory.js"; export { buildConversationChain, type TranscriptChainNode, diff --git a/src/session/search/formatChatHistorySearch.ts b/src/session/search/formatChatHistorySearch.ts new file mode 100644 index 000000000..7ab8f588a --- /dev/null +++ b/src/session/search/formatChatHistorySearch.ts @@ -0,0 +1,94 @@ +import type { ChatHistorySearchMatch, SearchChatHistoryResult } from "./searchChatHistory.js"; + +export type FormatChatHistorySearchOptions = { + locale?: "zh" | "en"; + includeProject?: boolean; +}; + +export function formatChatHistorySearchResults( + result: SearchChatHistoryResult, + options: FormatChatHistorySearchOptions = {}, +): string { + const locale = options.locale ?? "zh"; + const includeProject = options.includeProject ?? false; + + if (!result.query.trim()) { + return locale === "zh" + ? "用法:/search <关键词> [--all] [--limit N] [--role user|assistant]\n示例:/search docker 部署" + : "Usage: /search [--all] [--limit N] [--role user|assistant]\nExample: /search docker deploy"; + } + + if (result.matches.length === 0) { + const scope = locale === "zh" + ? `已扫描 ${result.sessionsScanned} 个会话` + : `Scanned ${result.sessionsScanned} session(s)`; + return locale === "zh" + ? `未找到包含「${result.query}」的聊天记录。\n${scope}。` + : `No chat history matches for "${result.query}".\n${scope}.`; + } + + const header = locale === "zh" + ? `🔍 找到 ${result.matches.length} 条匹配「${result.query}」${result.truncated ? "(结果已截断,可加大 --limit)" : ""}` + : `🔍 ${result.matches.length} match(es) for "${result.query}"${result.truncated ? " (truncated — try a higher --limit)" : ""}`; + + const lines = [header, ""]; + result.matches.forEach((match, index) => { + lines.push(formatMatchLine(match, index + 1, { locale, includeProject })); + lines.push(""); + }); + + lines.push( + locale === "zh" + ? "提示:在 Web UI 中打开对应会话后,可用 /search 结果中的片段定位消息。" + : "Tip: open the session in the Web UI to jump to the matched message.", + ); + + return lines.join("\n").trimEnd(); +} + +function formatMatchLine( + match: ChatHistorySearchMatch, + index: number, + options: { locale: "zh" | "en"; includeProject: boolean }, +): string { + const roleLabel = match.role === "user" + ? (options.locale === "zh" ? "用户" : "user") + : (options.locale === "zh" ? "助手" : "assistant"); + const shortId = shortenId(match.sessionId); + const when = formatWhen(match.createdAt, options.locale); + const projectSuffix = options.includeProject && match.projectKey + ? ` · ${basename(match.projectKey)}` + : ""; + + return [ + `${index}. **${match.sessionTitle}** (\`${shortId}\`)${projectSuffix}`, + ` ${roleLabel} · ${when}`, + ` > ${match.snippet}`, + ].join("\n"); +} + +function shortenId(sessionId: string): string { + if (sessionId.length <= 12) return sessionId; + return `${sessionId.slice(0, 8)}…`; +} + +function formatWhen(createdAt: string, locale: "zh" | "en"): string { + if (!createdAt) { + return locale === "zh" ? "未知时间" : "unknown time"; + } + const date = new Date(createdAt); + if (Number.isNaN(date.getTime())) { + return createdAt; + } + return date.toLocaleString(locale === "zh" ? "zh-CN" : "en-US", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +function basename(value: string): string { + const parts = value.split(/[\\/]/); + return parts[parts.length - 1] || value; +} diff --git a/src/session/search/searchChatHistory.ts b/src/session/search/searchChatHistory.ts new file mode 100644 index 000000000..52875b1eb --- /dev/null +++ b/src/session/search/searchChatHistory.ts @@ -0,0 +1,404 @@ +import { createReadStream } from "node:fs"; +import { readdir } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { createInterface } from "node:readline"; +import { getPilotProjectChatDir } from "../../pilot/paths.js"; +import { sanitizeSessionIdForPath } from "../storage/ProjectSessionStorage.js"; +import { parseSessionInfoFromLite, type SessionInfo } from "../storage/SessionList.js"; +import { readSessionLite } from "../storage/SessionLiteReader.js"; + +const ALWAYS_ON_AUXILIARY_PATTERN = /^always-on-(discovery|workspace|report)[:\-]/; +const DEFAULT_LIMIT = 20; +const MAX_LIMIT = 100; +const SNIPPET_RADIUS = 60; + +export type ChatHistorySearchRole = "user" | "assistant"; + +export type ChatHistorySearchMatch = { + sessionId: string; + sessionTitle: string; + projectKey?: string; + role: ChatHistorySearchRole; + text: string; + snippet: string; + createdAt: string; + lineNumber: number; +}; + +export type SearchChatHistoryOptions = { + pilotHome: string; + /** When omitted, searches all projects under pilotHome. */ + projectRoot?: string; + query: string; + limit?: number; + caseSensitive?: boolean; + regex?: boolean; + role?: ChatHistorySearchRole | "all"; + sessionId?: string; + includeInternal?: boolean; +}; + +export type SearchChatHistoryResult = { + query: string; + matches: ChatHistorySearchMatch[]; + truncated: boolean; + sessionsScanned: number; +}; + +export type ParsedChatSearchArgs = { + query: string; + allProjects: boolean; + limit?: number; + regex?: boolean; + caseSensitive?: boolean; + role?: ChatHistorySearchRole | "all"; + sessionId?: string; +}; + +export function parseChatSearchArgs(raw: string): ParsedChatSearchArgs { + const tokens = raw.trim().split(/\s+/).filter(Boolean); + let allProjects = false; + let limit: number | undefined; + let regex = false; + let caseSensitive = false; + let role: ChatHistorySearchRole | "all" | undefined; + let sessionId: string | undefined; + const queryParts: string[] = []; + + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]; + if (token === "--all" || token === "-a") { + allProjects = true; + continue; + } + if (token === "--regex" || token === "-E") { + regex = true; + continue; + } + if (token === "--case-sensitive") { + caseSensitive = true; + continue; + } + if (token === "--limit" || token === "-n") { + const value = Number(tokens[index + 1]); + if (Number.isFinite(value) && value > 0) { + limit = Math.min(Math.floor(value), MAX_LIMIT); + index += 1; + } + continue; + } + if (token === "--role" || token === "-r") { + const value = tokens[index + 1]; + if (value === "user" || value === "assistant" || value === "all") { + role = value; + index += 1; + } + continue; + } + if (token === "--session" || token === "-s") { + const value = tokens[index + 1]; + if (value) { + sessionId = value; + index += 1; + } + continue; + } + queryParts.push(token); + } + + return { + query: queryParts.join(" "), + allProjects, + limit, + regex, + caseSensitive, + role, + sessionId, + }; +} + +export async function searchChatHistory(options: SearchChatHistoryOptions): Promise { + const query = options.query.trim(); + if (!query) { + return { query, matches: [], truncated: false, sessionsScanned: 0 }; + } + + const limit = Math.min(Math.max(1, options.limit ?? DEFAULT_LIMIT), MAX_LIMIT); + const matcher = buildMatcher(query, { + caseSensitive: options.caseSensitive ?? false, + regex: options.regex ?? false, + }); + const roleFilter = options.role ?? "all"; + const includeInternal = options.includeInternal ?? false; + + const sessionFiles = await collectSessionFiles({ + pilotHome: options.pilotHome, + projectRoot: options.projectRoot, + sessionId: options.sessionId, + includeInternal, + }); + + const titleBySession = await buildSessionTitleIndex(sessionFiles); + const matches: ChatHistorySearchMatch[] = []; + let truncated = false; + + for (const file of sessionFiles) { + const fileMatches = await searchSessionFile(file, matcher, roleFilter); + for (const match of fileMatches) { + const title = titleBySession.get(`${file.projectKey ?? ""}:${match.sessionId}`) ?? match.sessionId; + matches.push({ + ...match, + sessionTitle: title, + projectKey: file.projectKey, + }); + if (matches.length >= limit) { + truncated = true; + break; + } + } + if (truncated) break; + } + + return { + query, + matches, + truncated, + sessionsScanned: sessionFiles.length, + }; +} + +type SessionFileTarget = { + path: string; + projectKey?: string; +}; + +type SearchableLine = { + role: ChatHistorySearchRole; + text: string; + createdAt: string; +}; + +type Matcher = (text: string) => boolean; + +function buildMatcher( + query: string, + options: { caseSensitive: boolean; regex: boolean }, +): Matcher { + if (options.regex) { + const flags = options.caseSensitive ? "" : "i"; + const pattern = new RegExp(query, flags); + return (text) => pattern.test(text); + } + + const needle = options.caseSensitive ? query : query.toLowerCase(); + return (text) => { + const haystack = options.caseSensitive ? text : text.toLowerCase(); + return haystack.includes(needle); + }; +} + +async function collectSessionFiles(options: { + pilotHome: string; + projectRoot?: string; + sessionId?: string; + includeInternal: boolean; +}): Promise { + if (options.sessionId) { + const projectRoot = options.projectRoot ?? process.cwd(); + const chatDir = getPilotProjectChatDir(projectRoot, options.pilotHome); + if (!options.includeInternal && isInternalSession(options.sessionId)) { + return []; + } + return [{ + path: join(chatDir, `${sanitizeSessionIdForPath(options.sessionId)}.jsonl`), + projectKey: projectRoot, + }]; + } + + if (options.projectRoot) { + const chatDir = getPilotProjectChatDir(options.projectRoot, options.pilotHome); + return listJsonlFiles(chatDir, options.projectRoot, options.includeInternal); + } + + const projectsDir = resolve(options.pilotHome, "projects"); + let projectIds: string[]; + try { + projectIds = await readdir(projectsDir); + } catch { + return []; + } + + const files: SessionFileTarget[] = []; + for (const projectId of projectIds) { + const chatDir = join(projectsDir, projectId, "chats"); + files.push(...await listJsonlFiles(chatDir, projectId, options.includeInternal)); + } + return files; +} + +async function listJsonlFiles( + chatDir: string, + projectKey: string, + includeInternal: boolean, +): Promise { + let names: string[]; + try { + names = await readdir(chatDir); + } catch { + return []; + } + + const files: SessionFileTarget[] = []; + for (const name of names) { + if (!name.endsWith(".jsonl")) continue; + const sessionId = name.slice(0, -".jsonl".length); + if (!includeInternal && isInternalSession(sessionId)) continue; + files.push({ + path: join(chatDir, name), + projectKey, + }); + } + return files; +} + +async function buildSessionTitleIndex(files: SessionFileTarget[]): Promise> { + const titles = new Map(); + + await Promise.all(files.map(async (file) => { + const sessionId = file.path.split(/[\\/]/).pop()?.replace(/\.jsonl$/, ""); + if (!sessionId) return; + const lite = await readSessionLite(file.path); + if (!lite) return; + const info = parseSessionInfoFromLite(sessionId, lite, file.projectKey); + if (!info) return; + titles.set(`${file.projectKey ?? ""}:${sessionId}`, formatSessionTitle(info)); + })); + + return titles; +} + +function formatSessionTitle(session: SessionInfo): string { + return session.customTitle ?? session.aiTitle ?? session.summary ?? session.sessionId; +} + +async function searchSessionFile( + file: SessionFileTarget, + matcher: Matcher, + roleFilter: ChatHistorySearchRole | "all", +): Promise[]> { + const matches: Omit[] = []; + const stream = createReadStream(file.path, { encoding: "utf8" }); + const reader = createInterface({ input: stream, crlfDelay: Infinity }); + + let lineNumber = 0; + for await (const line of reader) { + lineNumber += 1; + if (!line.trim()) continue; + + let entry: Record; + try { + entry = JSON.parse(line) as Record; + } catch { + continue; + } + + const sessionId = typeof entry.sessionId === "string" ? entry.sessionId : file.path; + const createdAt = typeof entry.createdAt === "string" ? entry.createdAt : ""; + const searchable = extractSearchableLines(entry); + for (const item of searchable) { + if (roleFilter !== "all" && item.role !== roleFilter) continue; + if (!matcher(item.text)) continue; + matches.push({ + sessionId, + role: item.role, + text: item.text, + snippet: buildSnippet(item.text, matcher), + createdAt, + lineNumber, + }); + } + } + + return matches; +} + +function extractSearchableLines(entry: Record): SearchableLine[] { + const createdAt = typeof entry.createdAt === "string" ? entry.createdAt : ""; + const type = entry.type; + + if (type === "accepted_input" && Array.isArray(entry.messages)) { + const text = extractCanonicalText(entry.messages); + if (text) { + return [{ role: "user", text, createdAt }]; + } + return []; + } + + if ((type === "assistant_message" || type === "durable_message") && isRecord(entry.message)) { + const text = extractCanonicalText([entry.message]); + if (text) { + return [{ role: "assistant", text, createdAt }]; + } + } + + return []; +} + +function extractCanonicalText(messages: unknown[]): string | undefined { + const parts: string[] = []; + for (const message of messages) { + if (!isRecord(message) || !Array.isArray(message.content)) continue; + for (const block of message.content) { + if (!isRecord(block) || block.type !== "text") continue; + const text = block.text; + if (typeof text === "string" && text.trim()) { + parts.push(text.trim()); + } + } + } + return parts.length > 0 ? parts.join("\n") : undefined; +} + +function buildSnippet(text: string, matcher: Matcher): string { + const normalized = text.replace(/\s+/g, " ").trim(); + if (!normalized) return ""; + + const lowerText = normalized.toLowerCase(); + let index = 0; + for (let start = 0; start < normalized.length; start += 1) { + const candidate = normalized.slice(start); + if (matcher(candidate) || matcher(normalized.slice(Math.max(0, start - 1)))) { + index = start; + break; + } + if (matcher(normalized.slice(start, start + lowerText.length))) { + index = start; + break; + } + } + + const matchIndex = findFirstMatchIndex(normalized, matcher); + const center = matchIndex >= 0 ? matchIndex : index; + const start = Math.max(0, center - SNIPPET_RADIUS); + const end = Math.min(normalized.length, center + SNIPPET_RADIUS); + const prefix = start > 0 ? "..." : ""; + const suffix = end < normalized.length ? "..." : ""; + return `${prefix}${normalized.slice(start, end)}${suffix}`; +} + +function findFirstMatchIndex(text: string, matcher: Matcher): number { + for (let index = 0; index < text.length; index += 1) { + if (matcher(text.slice(index))) { + return index; + } + } + return -1; +} + +function isInternalSession(sessionId: string): boolean { + return ALWAYS_ON_AUXILIARY_PATTERN.test(sessionId); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/ui/server/routes/commands.js b/ui/server/routes/commands.js index c46687bb5..238b20cec 100644 --- a/ui/server/routes/commands.js +++ b/ui/server/routes/commands.js @@ -12,6 +12,7 @@ import { readPilotDeckConfigFile, resolveModel } from '../services/pilotdeckConf import { resolvePilotHome } from '../utils/pilotPaths.js'; import { executeTurnkeySlashCommand } from '../turnkey-slash.js'; import { getRegisteredCommands } from '../../../src/adapters/channel/protocol/ChannelCommandRegistry.js'; +import { runChatSearchFormatted } from '../../../src/cli/commands/chatSearch.js'; const execFileAsync = promisify(execFile); @@ -273,6 +274,40 @@ const builtInCommands = [ * Built-in command handlers * Each handler returns { type: 'builtin', action: string, data: any } */ +async function executeSearchCommand(args, context) { + const rawArg = (args || []).join(' ').trim(); + if (!rawArg) { + return { + type: 'builtin', + action: 'search', + data: { + error: true, + content: 'Usage: /search [--all] [--limit N] [--role user|assistant]\nExample: /search docker deploy', + }, + }; + } + + const { result, text } = await runChatSearchFormatted({ + arg: rawArg, + projectRoot: context?.projectPath, + pilotHome: resolvePilotHome(process.env), + locale: 'en', + }); + + return { + type: 'builtin', + action: 'search', + data: { + content: text, + format: 'markdown', + query: result.query, + matches: result.matches, + truncated: result.truncated, + sessionsScanned: result.sessionsScanned, + }, + }; +} + const builtInHandlers = { '/help': async (args, context) => { const helpText = `# PilotDeck Commands @@ -544,6 +579,10 @@ Custom commands can be created in: '/turnkey': async (args) => executeTurnkeySlashCommand(args), + '/search': executeSearchCommand, + '/find': executeSearchCommand, + '/grep': executeSearchCommand, + '/update': async (args, context) => { const subcommand = (args && args[0]) || 'apply'; diff --git a/ui/src/components/chat/hooks/useChatComposerState.ts b/ui/src/components/chat/hooks/useChatComposerState.ts index 613129a46..7c123cc3c 100644 --- a/ui/src/components/chat/hooks/useChatComposerState.ts +++ b/ui/src/components/chat/hooks/useChatComposerState.ts @@ -367,6 +367,14 @@ export function useChatComposerState({ break; } + case 'search': + addMessage({ + type: 'assistant', + content: data.content || data.message || 'No search results.', + timestamp: Date.now(), + }); + break; + case 'switchProject': { // The server validates that an arg was supplied; project lookup // happens here because the client already holds the projects list. From 1129ce3679d5f0f19543027dff2e8f4ee252ac79 Mon Sep 17 00:00:00 2001 From: Mingwwww Date: Wed, 1 Jul 2026 20:54:43 +0800 Subject: [PATCH 2/4] feat(ui): add in-chat history search with Cmd+F Add a floating search bar in the chat pane so users can find text within the current conversation, jump between matches, and highlight results. Co-authored-by: Cursor --- .../chat-v2/ChatHistorySearchBar.tsx | 90 +++++++ ui/src/components/chat-v2/MessagesPaneV2.tsx | 47 +++- .../chat-v2/chatHistorySearchUtils.ts | 222 ++++++++++++++++++ .../chat-v2/useChatHistorySearch.ts | 198 ++++++++++++++++ ui/src/index.css | 21 ++ 5 files changed, 572 insertions(+), 6 deletions(-) create mode 100644 ui/src/components/chat-v2/ChatHistorySearchBar.tsx create mode 100644 ui/src/components/chat-v2/chatHistorySearchUtils.ts create mode 100644 ui/src/components/chat-v2/useChatHistorySearch.ts diff --git a/ui/src/components/chat-v2/ChatHistorySearchBar.tsx b/ui/src/components/chat-v2/ChatHistorySearchBar.tsx new file mode 100644 index 000000000..b14e59f43 --- /dev/null +++ b/ui/src/components/chat-v2/ChatHistorySearchBar.tsx @@ -0,0 +1,90 @@ +import type { RefObject } from 'react'; +import { ChevronDown, ChevronUp, Search, X } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +type ChatHistorySearchBarProps = { + query: string; + onQueryChange: (value: string) => void; + matchCount: number; + activeMatchIndex: number; + onPrevious: () => void; + onNext: () => void; + onClose: () => void; + inputRef: RefObject; +}; + +export default function ChatHistorySearchBar({ + query, + onQueryChange, + matchCount, + activeMatchIndex, + onPrevious, + onNext, + onClose, + inputRef, +}: ChatHistorySearchBarProps) { + const { t } = useTranslation(); + const hasQuery = query.trim().length > 0; + const matchLabel = hasQuery + ? matchCount > 0 + ? t('chatSearch.matchCount', { + current: activeMatchIndex + 1, + total: matchCount, + defaultValue: '{{current}} / {{total}}', + }) + : t('chatSearch.noMatches', { defaultValue: 'No matches' }) + : ''; + + return ( +
+ + onQueryChange(event.target.value)} + placeholder={t('chatSearch.placeholder', { defaultValue: 'Search in chat…' }) as string} + className="min-w-0 flex-1 bg-transparent text-[13px] text-neutral-900 outline-none placeholder:text-neutral-400 dark:text-neutral-100" + autoComplete="off" + spellCheck={false} + aria-label={t('chatSearch.placeholder', { defaultValue: 'Search in chat…' }) as string} + /> + {hasQuery ? ( + + {matchLabel} + + ) : null} + + + +
+ ); +} diff --git a/ui/src/components/chat-v2/MessagesPaneV2.tsx b/ui/src/components/chat-v2/MessagesPaneV2.tsx index 3bb7c4bb4..4085e555f 100644 --- a/ui/src/components/chat-v2/MessagesPaneV2.tsx +++ b/ui/src/components/chat-v2/MessagesPaneV2.tsx @@ -15,6 +15,8 @@ import { getSessionRequestParams, isReadOnlySession, type Project, type ProjectS import { getIntrinsicMessageKey } from '../chat/utils/messageKeys'; import MessageRowV2 from './MessageRowV2'; import SubagentDetailModal from './SubagentDetailModal'; +import ChatHistorySearchBar from './ChatHistorySearchBar'; +import { useChatHistorySearch } from './useChatHistorySearch'; import { useSubagentMessages } from './useSubagentMessages'; import { ProcessLiveStatus, ProcessRunHeader, StreamingThinkingPreview, type ProcessTraceStep } from './ProcessTrace'; import { formatProcessDuration } from './processTraceUtils'; @@ -247,6 +249,7 @@ function MeasuredMessageItem({
{children} @@ -927,13 +930,44 @@ export default function MessagesPaneV2({ t, ]); + const keyedMessagesForSearch = useMemo( + () => keyedMessageItems.map((item) => ({ + message: item.message, + messageKey: item.itemKey, + })), + [keyedMessageItems], + ); + + const chatHistorySearch = useChatHistorySearch({ + scrollContainerRef, + keyedMessages: keyedMessagesForSearch, + measuredItemHeights, + allMessagesLoaded, + hasMoreMessages, + loadAllMessages, + sessionId, + }); + return ( -
+
+ {chatHistorySearch.isOpen ? ( + + ) : null} +
{hasSessionLoadError ? (
@@ -1134,6 +1168,7 @@ export default function MessagesPaneV2({ onClose={() => setOpenSubagentId(null)} /> ) : null} +
); } diff --git a/ui/src/components/chat-v2/chatHistorySearchUtils.ts b/ui/src/components/chat-v2/chatHistorySearchUtils.ts new file mode 100644 index 000000000..42db29843 --- /dev/null +++ b/ui/src/components/chat-v2/chatHistorySearchUtils.ts @@ -0,0 +1,222 @@ +import type { ChatMessage } from '../chat/types/types'; + +export type ChatHistorySearchMatch = { + /** Index in the searchable message list. */ + messageIndex: number; + /** Stable key used on `.chat-message[data-message-key]`. */ + messageKey: string; + /** Character offset of the match within the message's searchable text. */ + offset: number; + /** Match length in characters. */ + length: number; +}; + +export type SearchableChatMessage = { + message: ChatMessage; + messageKey: string; + text: string; +}; + +const HIGHLIGHT_CLASS = 'chat-history-search-highlight'; +const ACTIVE_HIGHLIGHT_CLASS = 'chat-history-search-highlight-active'; + +/** Collect plain text from a chat message for in-page search. */ +export function extractSearchableText(message: ChatMessage): string { + const parts: string[] = []; + + if (typeof message.content === 'string' && message.content.trim()) { + parts.push(message.content); + } + if (typeof message.toolInput === 'string' && message.toolInput.trim()) { + parts.push(message.toolInput); + } + const toolContent = message.toolResult?.content; + if (typeof toolContent === 'string' && toolContent.trim()) { + parts.push(toolContent); + } + if (typeof message.toolName === 'string' && message.toolName.trim()) { + parts.push(message.toolName); + } + + return parts.join('\n'); +} + +export function buildSearchableMessages( + items: Array<{ message: ChatMessage; messageKey: string }>, +): SearchableChatMessage[] { + return items + .map(({ message, messageKey }) => ({ + message, + messageKey, + text: extractSearchableText(message), + })) + .filter((entry) => entry.text.trim().length > 0); +} + +/** Find all case-insensitive substring matches across searchable messages. */ +export function findChatHistoryMatches( + items: SearchableChatMessage[], + query: string, +): ChatHistorySearchMatch[] { + const needle = query.trim(); + if (!needle) return []; + + const lowerNeedle = needle.toLowerCase(); + const matches: ChatHistorySearchMatch[] = []; + + items.forEach((entry, messageIndex) => { + const haystack = entry.text; + const lowerHaystack = haystack.toLowerCase(); + let fromIndex = 0; + + while (fromIndex < lowerHaystack.length) { + const found = lowerHaystack.indexOf(lowerNeedle, fromIndex); + if (found < 0) break; + matches.push({ + messageIndex, + messageKey: entry.messageKey, + offset: found, + length: needle.length, + }); + fromIndex = found + Math.max(1, needle.length); + } + }); + + return matches; +} + +/** Scroll the messages container so a virtualized row is brought into view. */ +export function scrollToMessageIndex( + container: HTMLElement, + itemHeights: number[], + messageIndex: number, +): void { + if (messageIndex < 0 || messageIndex >= itemHeights.length) return; + + let offset = 0; + for (let index = 0; index < messageIndex; index += 1) { + offset += Math.max(1, itemHeights[index] ?? 0); + } + + const targetTop = Math.max(0, offset - container.clientHeight * 0.25); + container.scrollTop = targetTop; +} + +export function clearSearchHighlights(container: HTMLElement): void { + container.querySelectorAll(`mark.${HIGHLIGHT_CLASS}`).forEach((node) => { + const mark = node as HTMLElement; + const parent = mark.parentNode; + if (!parent) return; + parent.replaceChild(document.createTextNode(mark.textContent || ''), mark); + parent.normalize(); + }); +} + +function findNthMatchOffset(text: string, query: string, occurrence: number): number { + const lowerText = text.toLowerCase(); + const lowerQuery = query.toLowerCase(); + let fromIndex = 0; + let seen = 0; + + while (fromIndex < lowerText.length) { + const found = lowerText.indexOf(lowerQuery, fromIndex); + if (found < 0) return -1; + if (seen === occurrence) return found; + seen += 1; + fromIndex = found + Math.max(1, lowerQuery.length); + } + + return -1; +} + +function highlightTextNode( + node: Text, + query: string, + occurrence: number, +): { highlighted: boolean; nextOccurrence: number } { + const text = node.textContent || ''; + const offset = findNthMatchOffset(text, query, occurrence); + if (offset < 0) { + return { highlighted: false, nextOccurrence: occurrence }; + } + + const before = text.slice(0, offset); + const match = text.slice(offset, offset + query.length); + const after = text.slice(offset + query.length); + + const fragment = document.createDocumentFragment(); + if (before) fragment.appendChild(document.createTextNode(before)); + + const mark = document.createElement('mark'); + mark.className = `${HIGHLIGHT_CLASS} ${ACTIVE_HIGHLIGHT_CLASS}`; + mark.textContent = match; + fragment.appendChild(mark); + + if (after) fragment.appendChild(document.createTextNode(after)); + + const parent = node.parentNode; + if (!parent) { + return { highlighted: false, nextOccurrence: occurrence }; + } + parent.replaceChild(fragment, node); + + return { highlighted: true, nextOccurrence: occurrence + 1 }; +} + +function countOccurrencesBeforeOffset(text: string, query: string, offset: number): number { + const lowerText = text.slice(0, offset).toLowerCase(); + const lowerQuery = query.toLowerCase(); + if (!lowerQuery) return 0; + + let count = 0; + let fromIndex = 0; + while (fromIndex < lowerText.length) { + const found = lowerText.indexOf(lowerQuery, fromIndex); + if (found < 0) break; + count += 1; + fromIndex = found + Math.max(1, lowerQuery.length); + } + return count; +} + +/** Highlight the active match inside a message element and scroll it into view. */ +export function highlightActiveMatch( + container: HTMLElement, + messageKey: string, + messageText: string, + query: string, + offset: number, +): boolean { + clearSearchHighlights(container); + + const messageEl = container.querySelector( + `.chat-message[data-message-key="${CSS.escape(messageKey)}"]`, + ); + if (!messageEl) return false; + + const occurrence = countOccurrencesBeforeOffset(messageText, query, offset); + const walker = document.createTreeWalker(messageEl, NodeFilter.SHOW_TEXT); + let currentOccurrence = 0; + let highlighted = false; + + while (walker.nextNode()) { + const textNode = walker.currentNode as Text; + if (!textNode.textContent?.trim()) continue; + if (textNode.parentElement?.closest('mark')) continue; + + const result = highlightTextNode(textNode, query, currentOccurrence - occurrence); + if (result.highlighted) { + highlighted = true; + break; + } + currentOccurrence = result.nextOccurrence; + } + + const activeMark = messageEl.querySelector(`mark.${ACTIVE_HIGHLIGHT_CLASS}`); + activeMark?.scrollIntoView({ block: 'center', behavior: 'smooth' }); + if (!activeMark) { + messageEl.scrollIntoView({ block: 'center', behavior: 'smooth' }); + } + + return highlighted; +} diff --git a/ui/src/components/chat-v2/useChatHistorySearch.ts b/ui/src/components/chat-v2/useChatHistorySearch.ts new file mode 100644 index 000000000..43d8c971c --- /dev/null +++ b/ui/src/components/chat-v2/useChatHistorySearch.ts @@ -0,0 +1,198 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { RefObject } from 'react'; +import type { ChatMessage } from '../chat/types/types'; +import { + buildSearchableMessages, + clearSearchHighlights, + findChatHistoryMatches, + highlightActiveMatch, + scrollToMessageIndex, + type ChatHistorySearchMatch, +} from './chatHistorySearchUtils'; + +type UseChatHistorySearchOptions = { + scrollContainerRef: RefObject; + keyedMessages: Array<{ message: ChatMessage; messageKey: string }>; + measuredItemHeights: number[]; + allMessagesLoaded: boolean; + hasMoreMessages: boolean; + loadAllMessages: () => void; + sessionId: string | null; +}; + +export function useChatHistorySearch({ + scrollContainerRef, + keyedMessages, + measuredItemHeights, + allMessagesLoaded, + hasMoreMessages, + loadAllMessages, + sessionId, +}: UseChatHistorySearchOptions) { + const [isOpen, setIsOpen] = useState(false); + const [query, setQuery] = useState(''); + const [activeMatchIndex, setActiveMatchIndex] = useState(0); + const inputRef = useRef(null); + + const searchableMessages = useMemo( + () => buildSearchableMessages(keyedMessages), + [keyedMessages], + ); + + const matches = useMemo( + () => findChatHistoryMatches(searchableMessages, query), + [query, searchableMessages], + ); + + const activeMatch: ChatHistorySearchMatch | null = matches[activeMatchIndex] ?? null; + + const closeSearch = useCallback(() => { + setIsOpen(false); + setQuery(''); + setActiveMatchIndex(0); + const container = scrollContainerRef.current; + if (container) clearSearchHighlights(container); + }, [scrollContainerRef]); + + const openSearch = useCallback(() => { + setIsOpen(true); + requestAnimationFrame(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }); + }, []); + + const ensureAllMessagesLoaded = useCallback(async () => { + if (!hasMoreMessages || allMessagesLoaded) return; + loadAllMessages(); + await new Promise((resolve) => setTimeout(resolve, 350)); + }, [allMessagesLoaded, hasMoreMessages, loadAllMessages]); + + const revealMatch = useCallback(async (match: ChatHistorySearchMatch) => { + await ensureAllMessagesLoaded(); + + const container = scrollContainerRef.current; + if (!container) return; + + scrollToMessageIndex(container, measuredItemHeights, match.messageIndex); + + await new Promise((resolve) => { + requestAnimationFrame(() => { + requestAnimationFrame(() => resolve()); + }); + }); + + const entry = searchableMessages[match.messageIndex]; + if (!entry) return; + + highlightActiveMatch( + container, + match.messageKey, + entry.text, + query.trim(), + match.offset, + ); + }, [ + ensureAllMessagesLoaded, + measuredItemHeights, + query, + scrollContainerRef, + searchableMessages, + ]); + + const goToMatch = useCallback((index: number) => { + if (matches.length === 0) return; + const wrapped = ((index % matches.length) + matches.length) % matches.length; + setActiveMatchIndex(wrapped); + }, [matches.length]); + + const goToNext = useCallback(() => { + goToMatch(activeMatchIndex + 1); + }, [activeMatchIndex, goToMatch]); + + const goToPrevious = useCallback(() => { + goToMatch(activeMatchIndex - 1); + }, [activeMatchIndex, goToMatch]); + + useEffect(() => { + setActiveMatchIndex(0); + }, [query]); + + useEffect(() => { + closeSearch(); + }, [closeSearch, sessionId]); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + const isFindShortcut = (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'f'; + if (isFindShortcut) { + if (document.querySelector('[data-modal-overlay]')) return; + event.preventDefault(); + event.stopPropagation(); + if (isOpen) { + inputRef.current?.focus(); + inputRef.current?.select(); + } else { + openSearch(); + } + return; + } + + if (!isOpen) return; + + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + closeSearch(); + return; + } + + if (event.key === 'Enter' && document.activeElement === inputRef.current) { + event.preventDefault(); + if (event.shiftKey) { + goToPrevious(); + } else { + goToNext(); + } + } + }; + + document.addEventListener('keydown', handleKeyDown, { capture: true }); + return () => document.removeEventListener('keydown', handleKeyDown, { capture: true }); + }, [closeSearch, goToNext, goToPrevious, isOpen, openSearch]); + + useEffect(() => { + if (!isOpen || !activeMatch || !query.trim()) return; + void revealMatch(activeMatch); + }, [activeMatch, isOpen, query, revealMatch]); + + useEffect(() => { + if (matches.length === 0) { + setActiveMatchIndex(0); + return; + } + if (activeMatchIndex >= matches.length) { + setActiveMatchIndex(0); + } + }, [activeMatchIndex, matches.length]); + + useEffect(() => { + if (!isOpen) return; + const container = scrollContainerRef.current; + if (!container) return; + return () => clearSearchHighlights(container); + }, [isOpen, scrollContainerRef]); + + return { + isOpen, + query, + setQuery, + matches, + activeMatchIndex, + inputRef, + openSearch, + closeSearch, + goToNext, + goToPrevious, + }; +} diff --git a/ui/src/index.css b/ui/src/index.css index 7891f6718..f300b3c51 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -967,4 +967,25 @@ .streaming-fade-in blockquote { animation: stream-text-in 0.15s ease-out forwards; } + + mark.chat-history-search-highlight { + border-radius: 2px; + background-color: rgb(254 240 138 / 0.85); + color: inherit; + padding: 0 1px; + } + + .dark mark.chat-history-search-highlight { + background-color: rgb(113 63 18 / 0.75); + } + + mark.chat-history-search-highlight-active { + background-color: rgb(250 204 21); + outline: 1px solid rgb(202 138 4); + } + + .dark mark.chat-history-search-highlight-active { + background-color: rgb(202 138 4); + outline-color: rgb(250 204 21); + } } From dfec29141f2a85dcaedfce74289d18fd1c50ff10 Mon Sep 17 00:00:00 2001 From: Kaguya-19 Date: Thu, 2 Jul 2026 14:27:30 +0800 Subject: [PATCH 3/4] fix: correct chat search match positioning --- src/session/search/searchChatHistory.ts | 55 ++++++++----------- .../chat-v2/chatHistorySearchUtils.ts | 10 ++-- 2 files changed, 30 insertions(+), 35 deletions(-) diff --git a/src/session/search/searchChatHistory.ts b/src/session/search/searchChatHistory.ts index 52875b1eb..1a32dca77 100644 --- a/src/session/search/searchChatHistory.ts +++ b/src/session/search/searchChatHistory.ts @@ -178,7 +178,10 @@ type SearchableLine = { createdAt: string; }; -type Matcher = (text: string) => boolean; +type Matcher = { + test: (text: string) => boolean; + findIndex: (text: string) => number; +}; function buildMatcher( query: string, @@ -187,13 +190,26 @@ function buildMatcher( if (options.regex) { const flags = options.caseSensitive ? "" : "i"; const pattern = new RegExp(query, flags); - return (text) => pattern.test(text); + return { + test: (text) => pattern.test(text), + findIndex: (text) => { + pattern.lastIndex = 0; + const match = pattern.exec(text); + return match?.index ?? -1; + }, + }; } const needle = options.caseSensitive ? query : query.toLowerCase(); - return (text) => { - const haystack = options.caseSensitive ? text : text.toLowerCase(); - return haystack.includes(needle); + return { + test: (text) => { + const haystack = options.caseSensitive ? text : text.toLowerCase(); + return haystack.includes(needle); + }, + findIndex: (text) => { + const haystack = options.caseSensitive ? text : text.toLowerCase(); + return haystack.indexOf(needle); + }, }; } @@ -307,7 +323,7 @@ async function searchSessionFile( const searchable = extractSearchableLines(entry); for (const item of searchable) { if (roleFilter !== "all" && item.role !== roleFilter) continue; - if (!matcher(item.text)) continue; + if (!matcher.test(item.text)) continue; matches.push({ sessionId, role: item.role, @@ -363,22 +379,8 @@ function buildSnippet(text: string, matcher: Matcher): string { const normalized = text.replace(/\s+/g, " ").trim(); if (!normalized) return ""; - const lowerText = normalized.toLowerCase(); - let index = 0; - for (let start = 0; start < normalized.length; start += 1) { - const candidate = normalized.slice(start); - if (matcher(candidate) || matcher(normalized.slice(Math.max(0, start - 1)))) { - index = start; - break; - } - if (matcher(normalized.slice(start, start + lowerText.length))) { - index = start; - break; - } - } - - const matchIndex = findFirstMatchIndex(normalized, matcher); - const center = matchIndex >= 0 ? matchIndex : index; + const matchIndex = matcher.findIndex(normalized); + const center = matchIndex >= 0 ? matchIndex : 0; const start = Math.max(0, center - SNIPPET_RADIUS); const end = Math.min(normalized.length, center + SNIPPET_RADIUS); const prefix = start > 0 ? "..." : ""; @@ -386,15 +388,6 @@ function buildSnippet(text: string, matcher: Matcher): string { return `${prefix}${normalized.slice(start, end)}${suffix}`; } -function findFirstMatchIndex(text: string, matcher: Matcher): number { - for (let index = 0; index < text.length; index += 1) { - if (matcher(text.slice(index))) { - return index; - } - } - return -1; -} - function isInternalSession(sessionId: string): boolean { return ALWAYS_ON_AUXILIARY_PATTERN.test(sessionId); } diff --git a/ui/src/components/chat-v2/chatHistorySearchUtils.ts b/ui/src/components/chat-v2/chatHistorySearchUtils.ts index 42db29843..d2cc5d1db 100644 --- a/ui/src/components/chat-v2/chatHistorySearchUtils.ts +++ b/ui/src/components/chat-v2/chatHistorySearchUtils.ts @@ -1,7 +1,7 @@ import type { ChatMessage } from '../chat/types/types'; export type ChatHistorySearchMatch = { - /** Index in the searchable message list. */ + /** Index in the rendered message list. */ messageIndex: number; /** Stable key used on `.chat-message[data-message-key]`. */ messageKey: string; @@ -14,6 +14,7 @@ export type ChatHistorySearchMatch = { export type SearchableChatMessage = { message: ChatMessage; messageKey: string; + messageIndex: number; text: string; }; @@ -45,9 +46,10 @@ export function buildSearchableMessages( items: Array<{ message: ChatMessage; messageKey: string }>, ): SearchableChatMessage[] { return items - .map(({ message, messageKey }) => ({ + .map(({ message, messageKey }, messageIndex) => ({ message, messageKey, + messageIndex, text: extractSearchableText(message), })) .filter((entry) => entry.text.trim().length > 0); @@ -64,7 +66,7 @@ export function findChatHistoryMatches( const lowerNeedle = needle.toLowerCase(); const matches: ChatHistorySearchMatch[] = []; - items.forEach((entry, messageIndex) => { + items.forEach((entry) => { const haystack = entry.text; const lowerHaystack = haystack.toLowerCase(); let fromIndex = 0; @@ -73,7 +75,7 @@ export function findChatHistoryMatches( const found = lowerHaystack.indexOf(lowerNeedle, fromIndex); if (found < 0) break; matches.push({ - messageIndex, + messageIndex: entry.messageIndex, messageKey: entry.messageKey, offset: found, length: needle.length, From fd1adc2794f75f8140143eb37991efe7fe2abe74 Mon Sep 17 00:00:00 2001 From: Kaguya-19 Date: Thu, 2 Jul 2026 14:35:04 +0800 Subject: [PATCH 4/4] fix: find chat search highlight entry by key --- ui/src/components/chat-v2/useChatHistorySearch.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/components/chat-v2/useChatHistorySearch.ts b/ui/src/components/chat-v2/useChatHistorySearch.ts index 43d8c971c..813741abe 100644 --- a/ui/src/components/chat-v2/useChatHistorySearch.ts +++ b/ui/src/components/chat-v2/useChatHistorySearch.ts @@ -82,7 +82,7 @@ export function useChatHistorySearch({ }); }); - const entry = searchableMessages[match.messageIndex]; + const entry = searchableMessages.find((item) => item.messageKey === match.messageKey); if (!entry) return; highlightActiveMatch(