diff --git a/apps/desktop/src/main/localDb/__tests__/conversationSearch.pure.test.ts b/apps/desktop/src/main/localDb/__tests__/conversationSearch.pure.test.ts index 6dcc9791a0d..4bdf5d9c358 100644 --- a/apps/desktop/src/main/localDb/__tests__/conversationSearch.pure.test.ts +++ b/apps/desktop/src/main/localDb/__tests__/conversationSearch.pure.test.ts @@ -53,6 +53,7 @@ describe('conversationSearch.pure', () => { createdAt: contentSession.updatedAt, snippet: null, preview: 'billing search details', + occurrenceCount: 1, score: 99, ftsRank: null, vectorRank: 1, @@ -77,6 +78,7 @@ describe('conversationSearch.pure', () => { createdAt: newerContentSession.updatedAt, snippet: null, preview: 'billing search details', + occurrenceCount: 1, score: 99, ftsRank: null, vectorRank: 1, @@ -102,6 +104,7 @@ describe('conversationSearch.pure', () => { createdAt: contentSession.updatedAt, snippet: null, preview: 'billing search details', + occurrenceCount: 1, score: 99, ftsRank: null, vectorRank: 1, @@ -126,6 +129,7 @@ describe('conversationSearch.pure', () => { createdAt: target.updatedAt, snippet: 'search settings', preview: 'search settings preview', + occurrenceCount: 1, score: 1, ftsRank: 1, vectorRank: null, @@ -153,6 +157,7 @@ describe('conversationSearch.pure', () => { createdAt: '2026-01-01T00:00:00.000Z', snippet: 'older search settings', preview: 'older search settings preview', + occurrenceCount: 1, score: 1, ftsRank: 2, vectorRank: null, @@ -167,6 +172,7 @@ describe('conversationSearch.pure', () => { createdAt: '2026-01-02T00:00:00.000Z', snippet: 'newer search settings', preview: 'newer search settings preview', + occurrenceCount: 1, score: 9, ftsRank: 1, vectorRank: null, @@ -265,14 +271,111 @@ describe('conversationSearch.pure', () => { }); }); - it('extracts visible AskUser and plan review text for conversation search', () => { + it('matches and counts the complete query phrase', () => { + expect( + normalizeConversationContentPreview( + 'assistant', + 'error then timeout; error timeout and ERROR TIMEOUT', + 'error timeout', + ), + ).toMatchObject({ + keywordMatchedVisibleText: true, + occurrenceCount: 2, + }); + expect( + normalizeConversationContentPreview( + 'assistant', + 'error happened before a later timeout', + 'error timeout', + ), + ).toMatchObject({ + keywordMatchedVisibleText: false, + occurrenceCount: 0, + }); + }); + + it('keeps visible code text while excluding Markdown source details', () => { + const preview = normalizeConversationContentPreview( + 'assistant', + '`
` and `a < b`\n\n```html\n
visible code
\n```', + '
visible code
', + ); + + expect(preview.keywordMatchedVisibleText).toBe(true); + expect(preview.preview).toContain('
'); + expect(preview.preview).toContain('a < b'); + }); + + it('excludes Markdown link destinations from visible search text', () => { + const preview = normalizeConversationContentPreview( + 'assistant', + '[visible label](https://example.com/hidden-token)', + 'hidden-token', + ); + + expect(preview.keywordMatchedVisibleText).toBe(false); + expect(preview.occurrenceCount).toBe(0); + expect(preview.preview).toBe('visible label'); + }); + + it('keeps Markdown autolinks visible while excluding raw HTML tags', () => { + const preview = normalizeConversationContentPreview( + 'assistant', + ' hidden tag', + 'ticket-123', + ); + + expect(preview.keywordMatchedVisibleText).toBe(true); + expect(preview.preview).toContain('https://example.com/ticket-123'); + expect(preview.preview).not.toContain(''); + }); + + it('excludes trailing goal protocol blocks from assistant search text', () => { + const content = 'Visible answer.\n\n```json\n{"goal_status":"done","note":"hidden token"}\n```'; + + expect(normalizeConversationContentPreview('assistant', content, 'hidden token')).toMatchObject({ + keywordMatchedVisibleText: false, + occurrenceCount: 0, + preview: 'Visible answer.', + }); + }); + + it('normalizes whitespace in both the visible text and query', () => { + expect( + normalizeConversationContentPreview( + 'assistant', + 'error\n\t timeout', + 'error \n timeout', + ), + ).toMatchObject({ + keywordMatchedVisibleText: true, + occurrenceCount: 1, + }); + }); + + it('extracts only rendered AskUser and plan review text for conversation search', () => { expect(visibleMessageTextForConversationSearch('ask_user', { questions: [{ question: 'Which branch should I use?' }], answers: { q0: 'Use main' }, })).toBe('Which branch should I use? Use main'); - expect(visibleMessageTextForConversationSearch('plan_review', { + + const planReview = { plan: 'Update the search index', feedback: 'Keep the UI stable', - })).toBe('Update the search index Keep the UI stable'); + }; + expect(visibleMessageTextForConversationSearch('plan_review', { + ...planReview, + status: 'pending', + })).toBe(''); + expect(visibleMessageTextForConversationSearch('plan_review', { + ...planReview, + status: 'revised', + })).toBe('Keep the UI stable'); + for (const status of ['approved', 'expired', 'cancelled']) { + expect(visibleMessageTextForConversationSearch('plan_review', { + ...planReview, + status, + })).toBe('Update the search index'); + } }); }); diff --git a/apps/desktop/src/main/localDb/conversationSearch.pure.ts b/apps/desktop/src/main/localDb/conversationSearch.pure.ts index aa9d1b4d6a1..b673cbe0f40 100644 --- a/apps/desktop/src/main/localDb/conversationSearch.pure.ts +++ b/apps/desktop/src/main/localDb/conversationSearch.pure.ts @@ -1,3 +1,8 @@ +import { + visibleMarkdownTextForSearch, + visiblePlanReviewTextForSearch, +} from '../../shared/conversationSearch.js'; +import { stripGoalVerdictBlock } from '../../shared/goalVerdict.js'; import type { ConversationSearchContentHit, ConversationSearchResultItem, @@ -234,6 +239,7 @@ export interface NormalizedConversationContentPreview { preview: string; snippet: string | null; keywordMatchedVisibleText: boolean; + occurrenceCount: number; } export function normalizeConversationContentPreview( @@ -249,6 +255,7 @@ export function normalizeConversationContentPreview( preview: textPreview(visibleText, max), snippet, keywordMatchedVisibleText: ranges.length > 0, + occurrenceCount: ranges.length, }; } @@ -276,7 +283,7 @@ function userVisibleText(content: unknown): string { } function userVisibleTextRaw(content: unknown): string { - if (typeof content === 'string') return content; + if (typeof content === 'string') return visibleMarkdownTextForSearch(content); const blocksText = textBlocksText(content); if (blocksText) return blocksText; if (content && typeof content === 'object') { @@ -288,7 +295,9 @@ function userVisibleTextRaw(content: unknown): string { } function assistantVisibleText(content: unknown): string { - if (typeof content === 'string') return content; + if (typeof content === 'string') { + return visibleMarkdownTextForSearch(stripGoalVerdictBlock(content)); + } const blocksText = textBlocksText(content); if (blocksText) return blocksText; if ( @@ -329,9 +338,13 @@ function askUserVisibleText(content: unknown): string { } function planReviewVisibleText(content: unknown): string { - if (!content || typeof content !== 'object') return typeof content === 'string' ? content : ''; + if (!content || typeof content !== 'object') return ''; const obj = content as Record; - return [obj.plan, obj.feedback].filter((value): value is string => typeof value === 'string').join('\n'); + return visiblePlanReviewTextForSearch({ + status: typeof obj.status === 'string' ? obj.status : undefined, + plan: typeof obj.plan === 'string' ? obj.plan : undefined, + feedback: typeof obj.feedback === 'string' ? obj.feedback : undefined, + }); } function textBlocksText(content: unknown): string { @@ -358,26 +371,18 @@ function preferredObjectText(obj: Record): string { } function keywordRanges(text: string, query: string): Array<{ start: number; end: number }> { - const tokens = [...new Set(query.match(/[\p{L}\p{N}]+/gu) ?? [])] - .map((token) => token.trim()) - .filter((token) => token.length > 0) - .sort((a, b) => b.length - a.length); - if (tokens.length === 0 || !text) return []; + const phrase = query.replace(/\s+/g, ' ').trim(); + if (!phrase || !text) return []; const lowerText = text.toLocaleLowerCase(); + const lowerPhrase = phrase.toLocaleLowerCase(); const ranges: Array<{ start: number; end: number }> = []; - for (const token of tokens) { - const lowerToken = token.toLocaleLowerCase(); - let index = lowerText.indexOf(lowerToken); - while (index >= 0) { - const next = { start: index, end: index + token.length }; - if (!ranges.some((range) => rangesOverlap(range, next))) { - ranges.push(next); - } - index = lowerText.indexOf(lowerToken, index + lowerToken.length); - } + let index = lowerText.indexOf(lowerPhrase); + while (index >= 0) { + ranges.push({ start: index, end: index + phrase.length }); + index = lowerText.indexOf(lowerPhrase, index + lowerPhrase.length); } - return ranges.sort((a, b) => a.start - b.start); + return ranges; } function visibleSnippet(text: string, range: { start: number; end: number }, max: number): string { @@ -392,10 +397,6 @@ function visibleSnippet(text: string, range: { start: number; end: number }, max return `${prefix}${text.slice(start, end).trim()}${suffix}`; } -function rangesOverlap(a: { start: number; end: number }, b: { start: number; end: number }): boolean { - return a.start < b.end && b.start < a.end; -} - function extractText(value: unknown): string { if (typeof value === 'string') return value; if (Array.isArray(value)) { diff --git a/apps/desktop/src/main/localDb/conversationSearch.ts b/apps/desktop/src/main/localDb/conversationSearch.ts index adbc141ab9d..5a35c193950 100644 --- a/apps/desktop/src/main/localDb/conversationSearch.ts +++ b/apps/desktop/src/main/localDb/conversationSearch.ts @@ -89,20 +89,30 @@ export async function searchConversations( const allowedSessionIds = sessionRows.map((row) => row.id); const activityCutoff = cutoffForLastActivity(filters.lastActivity); - const titleMatches = sessionRows - .map((row, index) => { - // 匹配与命中下标都按**界面上显示的**标题算:未起名会话行上显示的是本地化兜底 - // 文案,拿原始哨兵匹配会两头错位 —— 搜可见文案一条都搜不到,搜 "New Maker" 反而 - // 命中一堆不显示这个词的行,高亮下标也会落在别的字上(PR #1031 review P1)。 - // renderer 渲染时调同一个 conversationSearchTitle,两端逐字一致。 - const match = fuzzyTitleMatch(conversationSearchTitle(row.title, request.unnamedLabel), query); - if (!match) return null; - const session = sessionSummaries.get(row.id); - if (!session) return null; - return { session, score: match.score, indices: match.indices, index }; - }) - .filter((item): item is NonNullable => item !== null) - .sort((a, b) => b.score - a.score || activityMs(b.session) - activityMs(a.session) || a.index - b.index); + const titleMatches = request.messagesOnly + ? [] + : sessionRows + .map((row, index) => { + // 匹配与命中下标都按**界面上显示的**标题算:未起名会话行上显示的是本地化兜底 + // 文案,拿原始哨兵匹配会两头错位 —— 搜可见文案一条都搜不到,搜 "New Maker" 反而 + // 命中一堆不显示这个词的行,高亮下标也会落在别的字上(PR #1031 review P1)。 + // renderer 渲染时调同一个 conversationSearchTitle,两端逐字一致。 + const match = fuzzyTitleMatch( + conversationSearchTitle(row.title, request.unnamedLabel), + query, + ); + if (!match) return null; + const session = sessionSummaries.get(row.id); + if (!session) return null; + return { session, score: match.score, indices: match.indices, index }; + }) + .filter((item): item is NonNullable => item !== null) + .sort( + (a, b) => + b.score - a.score || + activityMs(b.session) - activityMs(a.session) || + a.index - b.index, + ); const content = await searchContentUntilUniqueSessions({ query, @@ -138,6 +148,7 @@ export async function searchConversations( createdAt: new Date(hit.createdAt).toISOString(), snippet: preview.snippet, preview: preview.preview, + occurrenceCount: preview.occurrenceCount, score: hit.score, ftsRank, vectorRank: hit.vectorRank, diff --git a/apps/desktop/src/main/localDb/ipc/search.ts b/apps/desktop/src/main/localDb/ipc/search.ts index 27720f26372..eaae9e6f780 100644 --- a/apps/desktop/src/main/localDb/ipc/search.ts +++ b/apps/desktop/src/main/localDb/ipc/search.ts @@ -34,6 +34,7 @@ export function registerSearchIpc(): void { const semanticMode = optionalEnum(body.semanticMode, SEMANTIC_MODE_VALUES, 'semanticMode') as | ConversationSearchSemanticMode | undefined; + const messagesOnly = typeof body.messagesOnly === 'boolean' ? body.messagesOnly : undefined; const filters = parseFilters(body.filters); const unnamedLabel = parseUnnamedLabel(body.unnamedLabel); return searchConversations({ @@ -42,6 +43,7 @@ export function registerSearchIpc(): void { includeArchived, sortBy, semanticMode, + messagesOnly, filters, unnamedLabel, }); diff --git a/apps/desktop/src/renderer/components/chat/AskUserQuestionBubble.tsx b/apps/desktop/src/renderer/components/chat/AskUserQuestionBubble.tsx index fea2b727b19..50ee5589500 100644 --- a/apps/desktop/src/renderer/components/chat/AskUserQuestionBubble.tsx +++ b/apps/desktop/src/renderer/components/chat/AskUserQuestionBubble.tsx @@ -110,6 +110,7 @@ export function AskUserQuestionBubble({ message }: AskUserQuestionBubbleProps) { return (
{pair.question && (
- + Q @@ -137,7 +141,10 @@ export function AskUserQuestionBubble({ message }: AskUserQuestionBubbleProps) { )}
- + {pair.skipped ? ( diff --git a/apps/desktop/src/renderer/components/chat/AssistantMessage.tsx b/apps/desktop/src/renderer/components/chat/AssistantMessage.tsx index 855f2a805cc..b40003ce168 100644 --- a/apps/desktop/src/renderer/components/chat/AssistantMessage.tsx +++ b/apps/desktop/src/renderer/components/chat/AssistantMessage.tsx @@ -288,10 +288,11 @@ export const AssistantMessage = memo(function AssistantMessage({ 'text-[var(--msg-assistant-text)]', )} > - {ghostRenderCard && messageClientId && !showOriginal ? ( - // 出口钩子自绘:意识卡片替换气泡(净化后的静态 HTML,沙箱 iframe)。 - // 复用 GhostToolCard(自带主机身份头 + 主题注入 + 沙箱),turn 级无 - // 工具名/参数,running 恒 false(turn 已结束)。 +
+ {ghostRenderCard && messageClientId && !showOriginal ? ( + // 出口钩子自绘:意识卡片替换气泡(净化后的静态 HTML,沙箱 iframe)。 + // 复用 GhostToolCard(自带主机身份头 + 主题注入 + 沙箱),turn 级无 + // 工具名/参数,running 恒 false(turn 已结束)。 )} +
{/* 自绘卡在场:提供原文 ↔ 意识卡片切换(信任边界,主机绘制,始终可切回原文)。 */} {ghostRenderCard && (
)} @@ -186,6 +192,7 @@ export function PlanReviewBubble({ localFileRefs={localFileRefs} plan={plan} collapsedMaxHeight={INACTIVE_COLLAPSED_MAX_HEIGHT} + searchFocused={searchFocused} /> )}
@@ -214,6 +221,7 @@ function PlanMarkdownBody({ localFileRefs, plan, collapsedMaxHeight, + searchFocused, }: { workingDir: string; currentSessionId?: string; @@ -221,6 +229,7 @@ function PlanMarkdownBody({ localFileRefs?: readonly KnownLocalFileRef[]; plan: string; collapsedMaxHeight: number; + searchFocused?: boolean; }) { const { t } = useTranslation(); const [expanded, setExpanded] = useState(false); @@ -244,11 +253,12 @@ function PlanMarkdownBody({ return () => observer.disconnect(); }, [collapsedMaxHeight, plan]); - const collapsed = !expanded && overflowing; + const collapsed = !expanded && overflowing && !searchFocused; return (
; }; + /** Temporarily reveal a collapsed body while this message owns the active search hit. */ + searchFocused?: boolean; /** /goal 目标设定/更新标记:在气泡上方渲一个「目标 / 目标已更新」徽标。 */ goalBadge?: { updated: boolean }; /** 订阅槽①:本条消息被意识钩子拦下(未发出)。存在时气泡下方渲一条 error @@ -701,6 +703,7 @@ export function UserMessage({ isLastUserMessage, automationOrigin, hookSource, + searchFocused, goalBadge, blockedByGhost, }: UserMessageProps) { @@ -875,7 +878,8 @@ export function UserMessage({ mayExceedVisualLineThreshold(collapseMeasureBody, collapseThreshold); const { mirrorRef: collapseMirrorRef, shouldCollapse: shouldCollapseLongMessage } = useUserMessageAutoCollapse(collapseMeasureBody, collapseMeasureEnabled, collapseThreshold); - const longMessageCollapsed = shouldCollapseLongMessage && !longMessageExpanded; + const longMessageCollapsed = + shouldCollapseLongMessage && !longMessageExpanded && !searchFocused; // message-actions hover state — raw hover boolean, no debounce here. // The bar component owns its own fade lifecycle (250ms trailing debounce @@ -1313,6 +1317,7 @@ export function UserMessage({ )} {inlineQuoteCount > 0 ? (
) : displayBubbleBody.trim() ? (
{ + it('finds every case-insensitive occurrence', () => { + const root = document.createElement('div'); root.textContent = 'GPT and gpt and GPT'; + expect(findSessionSearchRanges(root, 'gpt').map((r) => r.toString())).toEqual(['GPT', 'gpt', 'GPT']); + }); + it('creates a range across markdown text nodes', () => { + const root = document.createElement('div'); root.innerHTML = 'GPT-5.6'; + expect(findSessionSearchRanges(root, 'GPT')[0].toString()).toBe('GPT'); + }); + it('limits ranges to the supplied message body', () => { + const root = document.createElement('div'); root.innerHTML = 'GPT label
GPT body GPT
'; + const body = root.querySelector('[data-session-search-body]')!; + expect(findSessionSearchRanges(body, 'GPT').map((r) => r.toString())).toEqual(['GPT', 'GPT']); + }); + it('matches across block boundaries while preserving inline splits', () => { + const root = document.createElement('div'); root.innerHTML = '

error

timeout

'; + expect(findSessionSearchRanges(root, 'error timeout')).toHaveLength(1); + const inline = document.createElement('div'); inline.innerHTML = 'GPT'; + expect(findSessionSearchRanges(inline, 'GPT')).toHaveLength(1); + }); + it('skips interactive and aria-hidden text', () => { + const root = document.createElement('div'); root.innerHTML = '

GPT

'; + expect(findSessionSearchRanges(root, 'GPT')).toHaveLength(1); + }); +}); diff --git a/apps/desktop/src/renderer/components/chat/sessionSearchHighlight.ts b/apps/desktop/src/renderer/components/chat/sessionSearchHighlight.ts new file mode 100644 index 00000000000..e332f1ca315 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/sessionSearchHighlight.ts @@ -0,0 +1,95 @@ +const BLOCK_TAGS = new Set([ + 'ADDRESS', 'ARTICLE', 'ASIDE', 'BLOCKQUOTE', 'DIV', 'DL', 'FIELDSET', 'FIGURE', + 'FOOTER', 'FORM', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'HEADER', 'HR', 'LI', + 'MAIN', 'NAV', 'OL', 'P', 'PRE', 'SECTION', 'TABLE', 'UL', +]); +const SKIPPED_TEXT_ANCESTOR = + 'button,input,textarea,select,script,style,[aria-hidden="true"],[data-session-search-ignore]'; + +interface TextSegment { node: Text; start: number; end: number } +interface NormalizedSearchText { text: string; starts: number[]; ends: number[] } + +function normalizeSearchText(source: string): NormalizedSearchText { + const lower = source.toLocaleLowerCase(); + let text = ''; + const starts: number[] = []; + const ends: number[] = []; + let whitespaceStart = -1; + for (let index = 0; index < lower.length; index += 1) { + if (/\s/.test(lower[index])) { + if (text && whitespaceStart < 0) whitespaceStart = index; + continue; + } + if (whitespaceStart >= 0) { + text += ' '; + starts.push(whitespaceStart); + ends.push(index); + whitespaceStart = -1; + } + text += lower[index]; + starts.push(index); + ends.push(index + 1); + } + return { text, starts, ends }; +} + +function blockAncestor(node: Node): Element | null { + let current: Element | null = node.parentElement; + while (current) { + if (BLOCK_TAGS.has(current.tagName)) return current; + current = current.parentElement; + } + return null; +} + +/** Build DOM ranges for visible, non-interactive text matches without changing React's DOM tree. */ +export function findSessionSearchRanges(root: Element, query: string): Range[] { + const normalizedQuery = query.replace(/\s+/g, ' ').trim().toLocaleLowerCase(); + if (!normalizedQuery) return []; + const segments: TextSegment[] = []; + let text = ''; + let previousBlock: Element | null = null; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + const textNode = node as Text; + const parent = textNode.parentElement; + const value = textNode.data; + if ( + !parent || + !value || + parent.closest(SKIPPED_TEXT_ANCESTOR) + ) continue; + const block = blockAncestor(textNode); + if (block && previousBlock && block !== previousBlock && text.length > 0 && !/\s$/.test(text)) { + text += ' '; + } + const start = text.length; + text += value; + segments.push({ node: textNode, start, end: text.length }); + previousBlock = block; + } + const normalized = normalizeSearchText(text); + const ranges: Range[] = []; + let startSegmentIndex = 0; + let endSegmentIndex = 0; + let offset = normalized.text.indexOf(normalizedQuery); + while (offset >= 0) { + const normalizedEnd = offset + normalizedQuery.length; + const sourceStart = normalized.starts[offset]; + const sourceEnd = normalized.ends[normalizedEnd - 1]; + if (sourceStart === undefined || sourceEnd === undefined) break; + while (segments[startSegmentIndex]?.end <= sourceStart) startSegmentIndex += 1; + endSegmentIndex = Math.max(endSegmentIndex, startSegmentIndex); + while (segments[endSegmentIndex]?.end < sourceEnd) endSegmentIndex += 1; + const startSegment = segments[startSegmentIndex]; + const endSegment = segments[endSegmentIndex]; + if (startSegment && endSegment && startSegment.start <= sourceStart && sourceStart < startSegment.end && endSegment.start < sourceEnd && sourceEnd <= endSegment.end) { + const range = document.createRange(); + range.setStart(startSegment.node, sourceStart - startSegment.start); + range.setEnd(endSegment.node, sourceEnd - endSegment.start); + ranges.push(range); + } + offset = normalized.text.indexOf(normalizedQuery, normalizedEnd); + } + return ranges; +} diff --git a/apps/desktop/src/renderer/components/find-in-page/FindInPageBar.tsx b/apps/desktop/src/renderer/components/find-in-page/FindInPageBar.tsx index b28e36507f2..79e71ace3bd 100644 --- a/apps/desktop/src/renderer/components/find-in-page/FindInPageBar.tsx +++ b/apps/desktop/src/renderer/components/find-in-page/FindInPageBar.tsx @@ -4,7 +4,7 @@ import { ChevronDown, ChevronUp, X } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useAppShortcut } from '@/hooks/useAppShortcut'; -import { isFindInPageClaimed } from './findInPageOwnership'; +import { isFindInPageClaimed, subscribeFindInPageClaim } from './findInPageOwnership'; /** * F-FIP-1 — Find in Page overlay (Ctrl/Cmd+F). @@ -57,6 +57,12 @@ export function FindInPageBar() { window.electronAPI.stopFindInPage('clearSelection'); }, []); + useEffect(() => { + return subscribeFindInPageClaim(() => { + if (isFindInPageClaimed()) close(); + }); + }, [close]); + // Global find-in-page shortcut (registry 默认 Ctrl/Cmd+F, 用户可改绑) → // open + focus. Capture phase so editable inputs (TipTap, plain inputs, // contenteditable) don't swallow the chord first. @@ -96,7 +102,7 @@ export function FindInPageBar() { [], ); - if (!open) return null; + if (!open || isFindInPageClaimed()) return null; return (
void>(); + +function notifyListeners(): void { + for (const listener of listeners) listener(); +} export function acquireFindInPage(): () => void { claimCount += 1; + notifyListeners(); let released = false; return () => { if (released) return; released = true; claimCount = Math.max(0, claimCount - 1); + notifyListeners(); }; } export function isFindInPageClaimed(): boolean { return claimCount > 0; } + +export function subscribeFindInPageClaim(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} diff --git a/apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx b/apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx index 2450255ae46..39ff6169b78 100644 --- a/apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx +++ b/apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx @@ -21,16 +21,33 @@ import { useMemo, useRef, useState, + useSyncExternalStore, } from 'react'; import type { CSSProperties, ReactNode } from 'react'; import { useLocation, useNavigate, useOutletContext, useParams } from 'react-router-dom'; import { dbToMakerAgentKind, normalizeDbAgentKind } from '../../../shared/agentKindConversion'; import { useTranslation } from 'react-i18next'; +import { acquireFindInPage } from '@/components/find-in-page/findInPageOwnership'; +import { useAppShortcut } from '@/hooks/useAppShortcut'; +import { searchConversations } from '@/lib/conversationSearchService'; +import { SessionSearchBar } from './SessionSearchBar'; import type { AgentInputReference } from '@cindy/maker-shared/agent-input-projection'; import { connectedProvidersForAgent, providerOffersModel, } from '@cindy/model-providers'; +import { getGhostCardSnapshot, subscribeGhostCards } from '@/cindy-brain/ghostCardStore'; +import { splitGhostDirective } from '@/cindy-brain/ghostCommand'; +import { + joinChatQuoteTextSegments, + parseChatQuoteSegments, +} from '@/lib/chatQuotes'; +import { stripGoalVerdictBlock } from '@/lib/goalVerdict'; +import { isSyntheticTriggerText } from '../../../shared/interruptedTurn'; +import { + visibleMarkdownTextForSearch, + visiblePlanReviewTextForSearch, +} from '../../../shared/conversationSearch'; import { useProportionalWidth } from '@/hooks/useProportionalWidth'; import { Activity, @@ -524,11 +541,22 @@ export function CCAgentSessionView({ const [focusedMessageTarget, setFocusedMessageTarget] = useState<{ clientId: string; requestId: number; + occurrenceIndex?: number; } | null>(null); - const requestFocusMessage = useCallback((clientId: string) => { + const [sessionSearchOpen, setSessionSearchOpen] = useState(false); + const [sessionSearchQuery, setSessionSearchQuery] = useState(''); + const [sessionSearchHits, setSessionSearchHits] = useState< + Array<{ messageId: string; messageClientId: string; occurrenceIndex: number }> + >([]); + const [sessionSearchActive, setSessionSearchActive] = useState(-1); + const [sessionSearchPending, setSessionSearchPending] = useState(false); + const sessionSearchInputRef = useRef(null); + const sessionSearchRequestRef = useRef(0); + const requestFocusMessage = useCallback((clientId: string, occurrenceIndex?: number) => { setFocusedMessageTarget((current) => ({ clientId, requestId: (current?.requestId ?? 0) + 1, + occurrenceIndex, })); }, []); const clearSearchJumpState = useCallback(() => { @@ -566,6 +594,253 @@ export function CCAgentSessionView({ // chat rail / 协同 worker 面板,带 sessionIdProp 或处于 compact/orca 语境)不参与。 const ownsRoute = !sessionIdProp && !isCompactRail && !isOrcaMode; const showInlineControlledBanner = ownsRoute || showControlledBanner; + + useEffect(() => { + if (!ownsRoute) return; + return acquireFindInPage(); + }, [ownsRoute]); + + useAppShortcut( + 'find-in-page', + () => { + if (!ownsRoute) return false; + setSessionSearchOpen(true); + queueMicrotask(() => { + sessionSearchInputRef.current?.focus(); + sessionSearchInputRef.current?.select(); + }); + return true; + }, + { stopImmediate: true }, + ); + + const closeSessionSearch = useCallback(() => { + sessionSearchRequestRef.current += 1; + setSessionSearchOpen(false); + setSessionSearchQuery(''); + setSessionSearchHits([]); + setSessionSearchActive(-1); + setSessionSearchPending(false); + }, []); + + const focusSessionSearchHit = useCallback( + (hit: { messageId: string; messageClientId: string; occurrenceIndex: number }) => { + if (!sessionId) return; + const loaded = makerChatStore + .getSnapshot(sessionId) + .messages.some((message) => message.clientId === hit.messageClientId); + if (loaded) { + requestFocusMessage(hit.messageClientId, hit.occurrenceIndex); + return; + } + void makerChatStore + .loadAroundMessageClientId(sessionId, hit.messageClientId, { radius: 60 }) + .then((message) => { + if (message) requestFocusMessage(message.clientId, hit.occurrenceIndex); + }) + .catch((err) => { + log.warn('Failed to load in-conversation search hit:', err); + }); + }, + [requestFocusMessage, sessionId], + ); + + const ghostCardSnapshot = useSyncExternalStore( + subscribeGhostCards, + getGhostCardSnapshot, + getGhostCardSnapshot, + ); + + useEffect(() => { + if (!ownsRoute || !sessionSearchOpen || !sessionId) return; + const query = sessionSearchQuery.trim(); + const requestId = ++sessionSearchRequestRef.current; + if (!query) { + setSessionSearchHits([]); + setSessionSearchActive(-1); + setSessionSearchPending(false); + return; + } + + const normalizedQuery = query.replace(/\s+/g, ' ').trim().toLocaleLowerCase(); + const currentMessages = makerChatStore.getSnapshot(sessionId).messages; + const hiddenGhostMessageClientIds = new Set(); + + function searchVisibleMessageText(message: { + role: string; + content: string; + askUserQuestions?: Array<{ question: string }>; + askUserAnswers?: Record; + askUserReply?: string | null; + planReviewPlan?: string; + planReviewFeedback?: string; + planReviewStatus?: 'pending' | 'approved' | 'revised' | 'expired' | 'cancelled'; + quotesEncoded?: boolean; + hookSource?: { userText?: string }; + }): string { + if (message.role === 'ask_user') { + const questions = message.askUserQuestions ?? []; + const parts = questions.flatMap((question) => [ + question.question, + ...(message.askUserAnswers?.[question.question] + ? [message.askUserAnswers[question.question]] + : []), + ]); + if (parts.length > 0) return parts.join('\n'); + return [message.content, message.askUserReply ?? ''].filter(Boolean).join('\n'); + } + if (message.role === 'plan_review') { + return visiblePlanReviewTextForSearch({ + status: message.planReviewStatus, + plan: message.planReviewPlan, + feedback: message.planReviewFeedback, + }); + } + + let visibleContent = message.content; + if (message.role === 'assistant') { + visibleContent = stripGoalVerdictBlock(visibleContent); + } else if (message.role === 'user') { + try { + const parsed = JSON.parse(visibleContent) as unknown; + if ( + parsed && + typeof parsed === 'object' && + !Array.isArray(parsed) && + ((parsed as { orcaSource?: unknown }).orcaSource === 'lead' || + (parsed as { orcaSource?: unknown }).orcaSource === 'worker') && + typeof (parsed as { content?: unknown }).content === 'string' + ) { + visibleContent = (parsed as { content: string }).content; + } + } catch { + // 普通用户消息不是 Orca JSON,保留原文。 + } + if (message.hookSource) { + visibleContent = message.hookSource.userText ?? visibleContent; + } + const ghostSplit = splitGhostDirective(visibleContent); + if (ghostSplit) visibleContent = ghostSplit.body; + if (message.quotesEncoded) { + visibleContent = joinChatQuoteTextSegments( + parseChatQuoteSegments(visibleContent), + ); + } + } + return visibleContent; + } + + const localHits = currentMessages + .filter( + (message) => + message.role === 'user' || + message.role === 'assistant' || + message.role === 'ask_user' || + message.role === 'plan_review', + ) + .filter((message) => { + if (message.role !== 'assistant') return true; + const entry = ghostCardSnapshot.byCallId.get(message.clientId); + if (entry?.status === 'ready') { + hiddenGhostMessageClientIds.add(message.clientId); + return false; + } + return true; + }) + .flatMap((message) => { + const sourceContent = searchVisibleMessageText(message); + if (isSyntheticTriggerText(sourceContent)) return []; + const content = (message.role === 'plan_review' + ? sourceContent + : visibleMarkdownTextForSearch(sourceContent)) + .replace(/\s+/g, ' ') + .trim() + .toLocaleLowerCase(); + const hits: Array<{ + messageId: string; + messageClientId: string; + occurrenceIndex: number; + }> = []; + let offset = content.indexOf(normalizedQuery); + let occurrenceIndex = 0; + while (offset >= 0) { + hits.push({ + messageId: message.clientId, + messageClientId: message.clientId, + occurrenceIndex, + }); + occurrenceIndex += 1; + offset = content.indexOf(normalizedQuery, offset + normalizedQuery.length); + } + return hits; + }); + setSessionSearchHits(localHits); + setSessionSearchActive(localHits.length > 0 ? 0 : -1); + if (localHits[0]) focusSessionSearchHit(localHits[0]); + setSessionSearchPending(true); + + const timer = window.setTimeout(() => { + void searchConversations({ + query, + limit: 50, + semanticMode: 'keyword', + messagesOnly: true, + filters: { status: 'all', sessionIds: [sessionId] }, + }) + .then((response) => { + if (requestId !== sessionSearchRequestRef.current) return; + const seen = new Set(localHits.map((hit) => hit.messageClientId)); + const remoteHits = response.results.flatMap((result) => + result.contentHits + .filter( + (hit) => + !seen.has(hit.messageClientId) && + !hiddenGhostMessageClientIds.has(hit.messageClientId) && + hit.occurrenceCount > 0, + ) + .flatMap((hit) => + Array.from({ length: hit.occurrenceCount }, (_, occurrenceIndex) => ({ + messageId: hit.messageId, + messageClientId: hit.messageClientId, + occurrenceIndex, + })), + ), + ); + const hits = [...localHits, ...remoteHits]; + setSessionSearchHits(hits); + setSessionSearchActive((active) => + active >= 0 || hits.length === 0 ? active : 0, + ); + if (hits[0] && localHits.length === 0) focusSessionSearchHit(hits[0]); + setSessionSearchPending(false); + }) + .catch(() => { + if (requestId !== sessionSearchRequestRef.current) return; + setSessionSearchPending(false); + }); + }, 200); + return () => window.clearTimeout(timer); + }, [ + focusSessionSearchHit, + ghostCardSnapshot.version, + ownsRoute, + sessionId, + sessionSearchOpen, + sessionSearchQuery, + ]); + + const navigateSessionSearch = useCallback( + (direction: 1 | -1) => { + if (sessionSearchHits.length === 0) return; + const next = + (Math.max(sessionSearchActive, 0) + direction + sessionSearchHits.length) % + sessionSearchHits.length; + setSessionSearchActive(next); + focusSessionSearchHit(sessionSearchHits[next]); + }, + [focusSessionSearchHit, sessionSearchActive, sessionSearchHits], + ); + // 平台分流:mac 右栏开关放在 ContentHeader 右端(见 ContentHeader.tsx),Windows // 放在下方 chip 栈第一行。两端都靠 ownsRoute 限定只在全屏聊天视图出现。 const isMac = window.electronAPI?.platform === 'darwin'; @@ -3063,6 +3338,8 @@ export function CCAgentSessionView({ contentWidth={messageWidth} focusMessageClientId={focusedMessageTarget?.clientId ?? null} focusMessageRequestId={focusedMessageTarget?.requestId ?? 0} + searchQuery={sessionSearchOpen ? sessionSearchQuery.trim() : undefined} + focusMessageOccurrenceIndex={focusedMessageTarget?.occurrenceIndex} forkOrigin={forkOrigin} onOpenForkOrigin={handleOpenForkOrigin} /> @@ -3175,6 +3452,19 @@ export function CCAgentSessionView({ } }} > + {ownsRoute && sessionSearchOpen && ( + navigateSessionSearch(1)} + onPrevious={() => navigateSessionSearch(-1)} + onClose={closeSessionSearch} + /> + )} {showOrcaLeadIdentityBar && (
diff --git a/apps/desktop/src/renderer/features/cc-agent/SessionSearchBar.tsx b/apps/desktop/src/renderer/features/cc-agent/SessionSearchBar.tsx new file mode 100644 index 00000000000..8483b442e3d --- /dev/null +++ b/apps/desktop/src/renderer/features/cc-agent/SessionSearchBar.tsx @@ -0,0 +1,89 @@ +import { forwardRef } from 'react'; +import { ChevronDown, ChevronUp, X } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +import { cn } from '@/lib/utils'; + +interface SessionSearchBarProps { + query: string; + total: number; + activeIndex: number; + searching: boolean; + onChange: (query: string) => void; + onNext: () => void; + onPrevious: () => void; + onClose: () => void; +} + +/** Search controls scoped to the current conversation's message content. */ +export const SessionSearchBar = forwardRef( + function SessionSearchBar( + { query, total, activeIndex, searching, onChange, onNext, onPrevious, onClose }, + ref, + ) { + const { t } = useTranslation(); + return ( +
+ onChange(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + onClose(); + } else if (event.key === 'Enter' && !event.nativeEvent.isComposing) { + event.preventDefault(); + event.stopPropagation(); + if (event.shiftKey) onPrevious(); + else onNext(); + } + }} + className="min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground" + /> + + {query ? (searching ? '…' : total > 0 ? `${activeIndex + 1}/${total}` : '0/0') : ''} + + + + +
+ ); + }, +); diff --git a/apps/desktop/src/renderer/lib/goalVerdict.ts b/apps/desktop/src/renderer/lib/goalVerdict.ts index 33d8c26ff3a..5ac60a89e2b 100644 --- a/apps/desktop/src/renderer/lib/goalVerdict.ts +++ b/apps/desktop/src/renderer/lib/goalVerdict.ts @@ -1,33 +1 @@ -/** - * 显示层剥离 /goal 协议块。 - * - * /goal 协议要求模型在末尾吐一个结构化 JSON 块,main 用代码解析它驱动流程: - * - 每个执行轮:`{"goal_status":...}` 裁决块(verdict.ts 解析,驱动续跑/停) - * - 历史版本可能残留的 `{"goal_setup":...}` 配置块 - * 这些块对用户都是噪声,显示时剥掉;**原文仍保留在 DB / transcript**(只动渲染,不动数据)。 - * - * 保守只剥**末尾**的块(协议要求块放回复最后),避免误删正文里恰好出现的 JSON: - * 1. 末尾的 ```json … goal_status|goal_setup … ``` 围栏块(含可选语言标记 / 尾随空白); - * 2. 没有围栏时,末尾裸的 {"goal_status"|"goal_setup":…} 对象。 - * (块内不含嵌套花括号时成立;note/reason 里若含 `{` `}` 会漏剥,与原 goal_status 行为一致。) - */ - -const TRAILING_FENCED_BLOCK = /\n?\s*```(?:json|jsonc)?\s*\{[^{}]*"goal_(?:status|setup)"[^{}]*\}\s*```\s*$/i; -const TRAILING_BARE_BLOCK = /\n?\s*\{[^{}]*"goal_(?:status|setup)"[^{}]*\}\s*$/i; - -export function stripGoalVerdictBlock(content: string): string { - if ( - !content || - typeof content !== 'string' || - (!content.includes('goal_status') && !content.includes('goal_setup')) - ) { - return content; - } - if (TRAILING_FENCED_BLOCK.test(content)) { - return content.replace(TRAILING_FENCED_BLOCK, '').trimEnd(); - } - if (TRAILING_BARE_BLOCK.test(content)) { - return content.replace(TRAILING_BARE_BLOCK, '').trimEnd(); - } - return content; -} +export { stripGoalVerdictBlock } from '../../shared/goalVerdict'; diff --git a/apps/desktop/src/renderer/styles/globals.css b/apps/desktop/src/renderer/styles/globals.css index b5404a83c7e..1b7436c8717 100644 --- a/apps/desktop/src/renderer/styles/globals.css +++ b/apps/desktop/src/renderer/styles/globals.css @@ -1104,6 +1104,16 @@ body.resizing-pane [data-ghost-webview] { } } +::highlight(cindy-session-search-match) { + background-color: hsl(var(--search-match-bg)); + color: hsl(var(--search-match-fg)); +} + +::highlight(cindy-session-search-active) { + background-color: hsl(var(--search-match-active-bg)); + color: hsl(var(--search-match-active-fg)); +} + /* F-MSG-2: Code highlight theme imports — moved to top of file (CSS @import must precede all other statements) */ :root { diff --git a/apps/desktop/src/renderer/themes/__tests__/cindyDecisionData.ts b/apps/desktop/src/renderer/themes/__tests__/cindyDecisionData.ts index 409675c436a..9221d236b8d 100644 --- a/apps/desktop/src/renderer/themes/__tests__/cindyDecisionData.ts +++ b/apps/desktop/src/renderer/themes/__tests__/cindyDecisionData.ts @@ -177,6 +177,8 @@ export const HSL_FORMAT_IDS = [ 'sidebar-muted', 'sidebar-action-icon', 'search-match-bg', + 'search-match-active-bg', + 'search-match-active-fg', 'search-match-fg', 'content-area', 'welcome-text', diff --git a/apps/desktop/src/renderer/themes/colors.ts b/apps/desktop/src/renderer/themes/colors.ts index 45b48dcce3b..b3b9e0d7039 100644 --- a/apps/desktop/src/renderer/themes/colors.ts +++ b/apps/desktop/src/renderer/themes/colors.ts @@ -432,6 +432,14 @@ registerColor('search-match-fg', { light: '0 0% 15%', dark: '0 0% 90%', }, 'Near-black #262626 — text inherit'); +registerColor('search-match-active-bg', { + light: '45 100% 67%', + dark: '45 100% 38%', +}, 'Stronger current-match highlight'); +registerColor('search-match-active-fg', { + light: '0 0% 10%', + dark: '0 0% 96%', +}, 'Current-match text contrast'); // UpdateBanner — Relaunch button (White Pill variant) registerColor('update-btn-border', { diff --git a/apps/desktop/src/shared/conversationSearch.ts b/apps/desktop/src/shared/conversationSearch.ts index 1e886a7b9a0..e1ddec9f1f3 100644 --- a/apps/desktop/src/shared/conversationSearch.ts +++ b/apps/desktop/src/shared/conversationSearch.ts @@ -15,12 +15,21 @@ export type ConversationSearchMessageRole = | 'plan_review' | 'thinking'; +export type PlanReviewSearchStatus = + | 'pending' + | 'approved' + | 'revised' + | 'expired' + | 'cancelled'; + export interface ConversationSearchRequest { query: string; limit?: number; sortBy?: ConversationSearchSortBy; semanticMode?: ConversationSearchSemanticMode; filters?: ConversationSearchFilters; + /** Limit matching to message content, excluding session title matches. */ + messagesOnly?: boolean; /** * @deprecated Use filters.status instead. Kept so older renderer builds keep * the previous active-only / active+archived behavior. @@ -76,6 +85,8 @@ export interface ConversationSearchContentHit { createdAt: string; snippet: string | null; preview: string; + /** Number of visible keyword occurrences in this message. */ + occurrenceCount: number; score: number; ftsRank: number | null; vectorRank: number | null; @@ -114,3 +125,91 @@ export interface ConversationSearchResponse { vectorSkipReason: string | null; poolCapped: boolean; } + +/** Project only the plan-review fields that its current bubble state renders. */ +export function visiblePlanReviewTextForSearch(input: { + status?: PlanReviewSearchStatus | string; + plan?: string | null; + feedback?: string | null; +}): string { + const status = input.status ?? 'pending'; + if (status === 'revised') return input.feedback ?? ''; + if (status === 'approved' || status === 'expired' || status === 'cancelled') { + return visibleMarkdownTextForSearch(input.plan ?? ''); + } + return ''; +} + +/** Remove Markdown/HTML source details that are not rendered as visible message text. */ +export function visibleMarkdownTextForSearch(source: string): string { + const protectedCode: string[] = []; + let markerPrefix = 'cindy-search-code-'; + while (source.includes(markerPrefix)) markerPrefix += '-'; + const markerSuffix = ''; + let text = source.replace(/```[^\n]*\n?[\s\S]*?```|~~~[^\n]*\n?[\s\S]*?~~~|`[^`\n]*`/g, (code) => { + const index = protectedCode.push(visibleCodeText(code)) - 1; + return `${markerPrefix}${index}${markerSuffix}`; + }); + text = text.replace(//g, ''); + text = preserveMarkdownAutolinks(text); + text = text.replace(/<[^>]+>/g, ''); + text = stripMarkdownLinkDestinations(text); + return text.replace( + new RegExp(`${markerPrefix}(\\d+)${markerSuffix}`, 'g'), + (_marker, index: string) => protectedCode[Number(index)] ?? '', + ); +} + +function preserveMarkdownAutolinks(source: string): string { + return source.replace( + /<((?:https?:\/\/|mailto:)[^<>\s]+|[^<>\s@]+@[^<>\s@]+)>/gi, + (_match, visible: string) => visible.replace(/^mailto:/i, ''), + ); +} + +function visibleCodeText(source: string): string { + if (source.startsWith('```') || source.startsWith('~~~')) { + const openingEnd = source.indexOf('\n'); + const bodyStart = openingEnd >= 0 ? openingEnd + 1 : 3; + return source.slice(bodyStart, -3); + } + return source.slice(1, -1); +} + +function stripMarkdownLinkDestinations(source: string): string { + let output = ''; + let cursor = 0; + while (cursor < source.length) { + const labelStart = source[cursor] === '!' && source[cursor + 1] === '[' + ? cursor + 1 + : source[cursor] === '[' + ? cursor + : -1; + if (labelStart < 0) { + output += source[cursor]; + cursor += 1; + continue; + } + const labelEnd = source.indexOf('](', labelStart + 1); + if (labelEnd < 0) { + output += source[cursor]; + cursor += 1; + continue; + } + let destinationEnd = labelEnd + 2; + let depth = 1; + while (destinationEnd < source.length && depth > 0) { + if (source[destinationEnd] === '(') depth += 1; + else if (source[destinationEnd] === ')') depth -= 1; + destinationEnd += 1; + } + if (depth !== 0) { + output += source[cursor]; + cursor += 1; + continue; + } + output += source.slice(labelStart + 1, labelEnd); + cursor = destinationEnd; + } + return output; +} diff --git a/apps/desktop/src/shared/goalVerdict.ts b/apps/desktop/src/shared/goalVerdict.ts new file mode 100644 index 00000000000..0de841cac9e --- /dev/null +++ b/apps/desktop/src/shared/goalVerdict.ts @@ -0,0 +1,23 @@ +/** + * Remove trailing /goal protocol blocks that are stored in assistant messages + * but intentionally omitted from the rendered conversation. + */ +const TRAILING_FENCED_BLOCK = /\n?\s*```(?:json|jsonc)?\s*\{[^{}]*"goal_(?:status|setup)"[^{}]*\}\s*```\s*$/i; +const TRAILING_BARE_BLOCK = /\n?\s*\{[^{}]*"goal_(?:status|setup)"[^{}]*\}\s*$/i; + +export function stripGoalVerdictBlock(content: string): string { + if ( + !content || + typeof content !== 'string' || + (!content.includes('goal_status') && !content.includes('goal_setup')) + ) { + return content; + } + if (TRAILING_FENCED_BLOCK.test(content)) { + return content.replace(TRAILING_FENCED_BLOCK, '').trimEnd(); + } + if (TRAILING_BARE_BLOCK.test(content)) { + return content.replace(TRAILING_BARE_BLOCK, '').trimEnd(); + } + return content; +}