From d56763f1ea752dc4adf55a04642586de823a0687 Mon Sep 17 00:00:00 2001 From: xushi <10560530+qingshuizhiren@users.noreply.github.com> Date: Fri, 31 Jul 2026 10:35:55 +0800 Subject: [PATCH 1/8] fix(desktop): scope find to conversation content Replace Chromium whole-page find in conversation views with occurrence-level message search and visible-text highlighting. Exclude hidden Markdown targets, thinking, and tool-process content from matches. Co-Authored-By: Claude Signed-off-by: xushi <10560530+qingshuizhiren@users.noreply.github.com> --- .../__tests__/conversationSearch.pure.test.ts | 18 ++ .../main/localDb/conversationSearch.pure.ts | 7 +- .../src/main/localDb/conversationSearch.ts | 39 ++-- apps/desktop/src/main/localDb/ipc/search.ts | 2 + .../components/chat/MessageStream.tsx | 39 +++- .../__tests__/sessionSearchHighlight.test.ts | 33 ++++ .../components/chat/sessionSearchHighlight.ts | 43 ++++ .../components/find-in-page/FindInPageBar.tsx | 10 +- .../find-in-page/findInPageOwnership.ts | 12 ++ .../features/cc-agent/CCAgentSessionView.tsx | 184 +++++++++++++++++- .../features/cc-agent/SessionSearchBar.tsx | 88 +++++++++ apps/desktop/src/renderer/styles/globals.css | 10 + .../themes/__tests__/cindyDecisionData.ts | 2 + apps/desktop/src/renderer/themes/colors.ts | 8 + apps/desktop/src/shared/conversationSearch.ts | 50 +++++ 15 files changed, 519 insertions(+), 26 deletions(-) create mode 100644 apps/desktop/src/renderer/components/chat/__tests__/sessionSearchHighlight.test.ts create mode 100644 apps/desktop/src/renderer/components/chat/sessionSearchHighlight.ts create mode 100644 apps/desktop/src/renderer/features/cc-agent/SessionSearchBar.tsx 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..550cce6b99a 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,6 +271,18 @@ describe('conversationSearch.pure', () => { }); }); + 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('extracts visible AskUser and plan review text for conversation search', () => { expect(visibleMessageTextForConversationSearch('ask_user', { questions: [{ question: 'Which branch should I use?' }], diff --git a/apps/desktop/src/main/localDb/conversationSearch.pure.ts b/apps/desktop/src/main/localDb/conversationSearch.pure.ts index aa9d1b4d6a1..f1510d09e8c 100644 --- a/apps/desktop/src/main/localDb/conversationSearch.pure.ts +++ b/apps/desktop/src/main/localDb/conversationSearch.pure.ts @@ -1,3 +1,4 @@ +import { visibleMarkdownTextForSearch } from '../../shared/conversationSearch.js'; import type { ConversationSearchContentHit, ConversationSearchResultItem, @@ -234,6 +235,7 @@ export interface NormalizedConversationContentPreview { preview: string; snippet: string | null; keywordMatchedVisibleText: boolean; + occurrenceCount: number; } export function normalizeConversationContentPreview( @@ -249,6 +251,7 @@ export function normalizeConversationContentPreview( preview: textPreview(visibleText, max), snippet, keywordMatchedVisibleText: ranges.length > 0, + occurrenceCount: ranges.length, }; } @@ -276,7 +279,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 +291,7 @@ function userVisibleTextRaw(content: unknown): string { } function assistantVisibleText(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 ( 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/MessageStream.tsx b/apps/desktop/src/renderer/components/chat/MessageStream.tsx index 32735cd461e..376102b884f 100644 --- a/apps/desktop/src/renderer/components/chat/MessageStream.tsx +++ b/apps/desktop/src/renderer/components/chat/MessageStream.tsx @@ -206,6 +206,7 @@ import { } from './autoFollowIntent'; import { useNavigationKeyListener } from './useNavigationKeyListener'; import { suppressScrollbarActivation } from '@/lib/scrollbarAutoHide'; +import { findSessionSearchRanges } from './sessionSearchHighlight'; interface MessageStreamProps { /** Active session id — used to reset scroll state on session switch. */ @@ -251,6 +252,10 @@ interface MessageStreamProps { focusMessageClientId?: string | null; /** Incremented by the parent for each search navigation, including repeated hits. */ focusMessageRequestId?: number; + /** Current in-conversation search query; omitted for non-search navigation. */ + searchQuery?: string; + /** Zero-based occurrence within the focused message's rendered text. */ + focusMessageOccurrenceIndex?: number; /** Source marker shown for sessions forked from another conversation. */ forkOrigin?: { parentSessionId: string; @@ -2226,6 +2231,8 @@ export function MessageStream({ contentWidth, focusMessageClientId, focusMessageRequestId, + searchQuery, + focusMessageOccurrenceIndex, forkOrigin, onOpenForkOrigin, }: MessageStreamProps) { @@ -2305,7 +2312,6 @@ export function MessageStream({ } return RENDER_WINDOW_FIRST_PAINT_ITEMS; }); - const [highlightMessageClientId, setHighlightMessageClientId] = useState(null); const lastAppliedFocusRef = useRef(null); const lastMissingFocusRef = useRef<{ clientId: string; @@ -2522,7 +2528,6 @@ export function MessageStream({ if (highlightApplied) return; highlightApplied = true; root.removeEventListener('scrollend', applyHighlight); - setHighlightMessageClientId(focusMessageClientId); }; root.addEventListener('scrollend', applyHighlight, { once: true }); focusScrollTimerRef.current = window.setTimeout(() => { @@ -2541,6 +2546,30 @@ export function MessageStream({ }; }, [allRenderItems, focusMessageClientId, focusMessageRequestId, visibleRenderItems]); + useLayoutEffect(() => { + const highlights = CSS.highlights; + const matchKey = 'cindy-session-search-match'; + const activeKey = 'cindy-session-search-active'; + highlights.delete(matchKey); + highlights.delete(activeKey); + if (!searchQuery || !focusMessageClientId) return; + const root = scrollRef.current; + const target = root?.querySelector( + `[data-message-client-id="${CSS.escape(focusMessageClientId)}"]`, + ); + if (!target) return; + const ranges = findSessionSearchRanges(target, searchQuery); + if (ranges.length === 0) return; + highlights.set(matchKey, new Highlight(...ranges)); + const activeRange = ranges[Math.min(focusMessageOccurrenceIndex ?? 0, ranges.length - 1)]; + highlights.set(activeKey, new Highlight(activeRange)); + activeRange.startContainer.parentElement?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + return () => { + highlights.delete(matchKey); + highlights.delete(activeKey); + }; + }, [focusMessageClientId, focusMessageOccurrenceIndex, searchQuery, visibleRenderItems]); + // 会话内全部图片的有序 src(全量,来自未裁剪的 allRenderItems),下发给 // ImageLightbox 做翻图。基于全量而非 visibleRenderItems,这样计数 / 翻页 // 立刻覆盖整个会话,不用先往上滚动加载老图。 @@ -3827,11 +3856,7 @@ export function MessageStream({
{ + it('finds every case-insensitive occurrence', () => { + const root = document.createElement('div'); + root.textContent = 'GPT and gpt and GPT'; + + const ranges = findSessionSearchRanges(root, 'gpt'); + + expect(ranges.map((range) => range.toString())).toEqual(['GPT', 'gpt', 'GPT']); + }); + + it('creates a range across markdown text nodes', () => { + const root = document.createElement('div'); + root.innerHTML = 'GPT-5.6'; + + const ranges = findSessionSearchRanges(root, 'GPT'); + + expect(ranges).toHaveLength(1); + expect(ranges[0].toString()).toBe('GPT'); + }); + + 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..c5a0d0e8430 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/sessionSearchHighlight.ts @@ -0,0 +1,43 @@ +const SKIPPED_TEXT_ANCESTOR = 'button,input,textarea,select,script,style,[aria-hidden="true"]'; + +interface TextSegment { + node: Text; + start: number; + end: number; +} + +/** 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.toLocaleLowerCase(); + if (!normalizedQuery) return []; + + const segments: TextSegment[] = []; + let text = ''; + 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 start = text.length; + text += value; + segments.push({ node: textNode, start, end: text.length }); + } + + const normalizedText = text.toLocaleLowerCase(); + const ranges: Range[] = []; + let offset = normalizedText.indexOf(normalizedQuery); + while (offset >= 0) { + const end = offset + normalizedQuery.length; + const startSegment = segments.find((segment) => segment.start <= offset && offset < segment.end); + const endSegment = segments.find((segment) => segment.start < end && end <= segment.end); + if (startSegment && endSegment) { + const range = document.createRange(); + range.setStart(startSegment.node, offset - startSegment.start); + range.setEnd(endSegment.node, end - endSegment.start); + ranges.push(range); + } + offset = normalizedText.indexOf(normalizedQuery, end); + } + 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..6945182a518 100644 --- a/apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx +++ b/apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx @@ -26,11 +26,16 @@ 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 { visibleMarkdownTextForSearch } from '../../../shared/conversationSearch'; import { useProportionalWidth } from '@/hooks/useProportionalWidth'; import { Activity, @@ -524,11 +529,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 +582,157 @@ 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 + .loadAroundMessage(sessionId, hit.messageId, { 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], + ); + + 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.toLocaleLowerCase(); + const currentMessages = makerChatStore.getSnapshot(sessionId).messages; + const localHits = currentMessages + .filter( + (message) => + message.role === 'user' || + message.role === 'assistant' || + message.role === 'ask_user' || + message.role === 'plan_review', + ) + .flatMap((message) => { + const content = visibleMarkdownTextForSearch(message.content).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)) + .flatMap((hit) => + Array.from({ length: Math.max(1, hit.occurrenceCount) }, (_, occurrenceIndex) => ({ + messageId: hit.messageId, + messageClientId: hit.messageClientId, + occurrenceIndex, + })), + ), + ); + const hits = [...localHits, ...remoteHits]; + setSessionSearchHits(hits); + setSessionSearchActive(hits.length > 0 ? 0 : -1); + 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, + 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 +3230,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 +3344,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..9c5b272229c --- /dev/null +++ b/apps/desktop/src/renderer/features/cc-agent/SessionSearchBar.tsx @@ -0,0 +1,88 @@ +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/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..290a9557df5 100644 --- a/apps/desktop/src/shared/conversationSearch.ts +++ b/apps/desktop/src/shared/conversationSearch.ts @@ -21,6 +21,8 @@ export interface ConversationSearchRequest { 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 +78,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 +118,49 @@ export interface ConversationSearchResponse { vectorSkipReason: string | null; poolCapped: boolean; } + +/** Remove Markdown/HTML source details that are not rendered as visible message text. */ +export function visibleMarkdownTextForSearch(source: string): string { + let text = source.replace(//g, ''); + text = text.replace(/<[^>]+>/g, ''); + text = stripMarkdownLinkDestinations(text); + return text; +} + +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; +} From de38901d2daeb18e0d98e0b12db9eacfe8bb004c Mon Sep 17 00:00:00 2001 From: xushi <10560530+qingshuizhiren@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:05:17 +0800 Subject: [PATCH 2/8] fix(desktop): align conversation find matching Use exact phrase matching for persisted history, scope DOM ranges to message bodies, preserve visible code text, and guard unsupported highlight APIs. Co-Authored-By: Claude Signed-off-by: xushi <10560530+qingshuizhiren@users.noreply.github.com> --- .../__tests__/conversationSearch.pure.test.ts | 35 +++++++++++++++++++ .../main/localDb/conversationSearch.pure.ts | 28 +++++---------- .../components/chat/AskUserQuestionBubble.tsx | 11 ++++-- .../components/chat/AssistantMessage.tsx | 10 +++--- .../components/chat/MessageStream.tsx | 13 +++++-- .../components/chat/PlanReviewBubble.tsx | 1 + .../renderer/components/chat/UserMessage.tsx | 2 ++ .../__tests__/sessionSearchHighlight.test.ts | 12 +++++++ .../components/chat/sessionSearchHighlight.ts | 21 ++++++++--- apps/desktop/src/shared/conversationSearch.ts | 24 +++++++++++-- 10 files changed, 123 insertions(+), 34 deletions(-) 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 550cce6b99a..be541967bb9 100644 --- a/apps/desktop/src/main/localDb/__tests__/conversationSearch.pure.test.ts +++ b/apps/desktop/src/main/localDb/__tests__/conversationSearch.pure.test.ts @@ -271,6 +271,41 @@ describe('conversationSearch.pure', () => { }); }); + 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', diff --git a/apps/desktop/src/main/localDb/conversationSearch.pure.ts b/apps/desktop/src/main/localDb/conversationSearch.pure.ts index f1510d09e8c..62703825379 100644 --- a/apps/desktop/src/main/localDb/conversationSearch.pure.ts +++ b/apps/desktop/src/main/localDb/conversationSearch.pure.ts @@ -361,26 +361,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.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 { @@ -395,10 +387,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/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 && ( '; diff --git a/apps/desktop/src/renderer/components/chat/sessionSearchHighlight.ts b/apps/desktop/src/renderer/components/chat/sessionSearchHighlight.ts index c5a0d0e8430..968a5bd29b4 100644 --- a/apps/desktop/src/renderer/components/chat/sessionSearchHighlight.ts +++ b/apps/desktop/src/renderer/components/chat/sessionSearchHighlight.ts @@ -1,4 +1,5 @@ -const SKIPPED_TEXT_ANCESTOR = 'button,input,textarea,select,script,style,[aria-hidden="true"]'; +const SKIPPED_TEXT_ANCESTOR = + 'button,input,textarea,select,script,style,[aria-hidden="true"],[data-session-search-ignore]'; interface TextSegment { node: Text; @@ -26,12 +27,24 @@ export function findSessionSearchRanges(root: Element, query: string): Range[] { const normalizedText = text.toLocaleLowerCase(); const ranges: Range[] = []; + let startSegmentIndex = 0; + let endSegmentIndex = 0; let offset = normalizedText.indexOf(normalizedQuery); while (offset >= 0) { const end = offset + normalizedQuery.length; - const startSegment = segments.find((segment) => segment.start <= offset && offset < segment.end); - const endSegment = segments.find((segment) => segment.start < end && end <= segment.end); - if (startSegment && endSegment) { + while (segments[startSegmentIndex]?.end <= offset) startSegmentIndex += 1; + endSegmentIndex = Math.max(endSegmentIndex, startSegmentIndex); + while (segments[endSegmentIndex]?.end < end) endSegmentIndex += 1; + const startSegment = segments[startSegmentIndex]; + const endSegment = segments[endSegmentIndex]; + if ( + startSegment && + endSegment && + startSegment.start <= offset && + offset < startSegment.end && + endSegment.start < end && + end <= endSegment.end + ) { const range = document.createRange(); range.setStart(startSegment.node, offset - startSegment.start); range.setEnd(endSegment.node, end - endSegment.start); diff --git a/apps/desktop/src/shared/conversationSearch.ts b/apps/desktop/src/shared/conversationSearch.ts index 290a9557df5..80ce89724a6 100644 --- a/apps/desktop/src/shared/conversationSearch.ts +++ b/apps/desktop/src/shared/conversationSearch.ts @@ -121,10 +121,30 @@ export interface ConversationSearchResponse { /** Remove Markdown/HTML source details that are not rendered as visible message text. */ export function visibleMarkdownTextForSearch(source: string): string { - let text = source.replace(//g, ''); + 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 = text.replace(/<[^>]+>/g, ''); text = stripMarkdownLinkDestinations(text); - return text; + return text.replace( + new RegExp(`${markerPrefix}(\\d+)${markerSuffix}`, 'g'), + (_marker, index: string) => protectedCode[Number(index)] ?? '', + ); +} + +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 { From bd10093c20d82ff225d7b7cd8ae86e1ce8046d43 Mon Sep 17 00:00:00 2001 From: xushi <10560530+qingshuizhiren@users.noreply.github.com> Date: Fri, 31 Jul 2026 16:59:24 +0800 Subject: [PATCH 3/8] fix(desktop): exclude hidden ghost card originals from search Signed-off-by: xushi <10560530+qingshuizhiren@users.noreply.github.com> --- .../features/cc-agent/CCAgentSessionView.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx b/apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx index 6945182a518..13e9ab073ae 100644 --- a/apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx +++ b/apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx @@ -21,6 +21,7 @@ import { useMemo, useRef, useState, + useSyncExternalStore, } from 'react'; import type { CSSProperties, ReactNode } from 'react'; import { useLocation, useNavigate, useOutletContext, useParams } from 'react-router-dom'; @@ -35,6 +36,7 @@ import { connectedProvidersForAgent, providerOffersModel, } from '@cindy/model-providers'; +import { getGhostCardSnapshot, subscribeGhostCards } from '@/cindy-brain/ghostCardStore'; import { visibleMarkdownTextForSearch } from '../../../shared/conversationSearch'; import { useProportionalWidth } from '@/hooks/useProportionalWidth'; import { @@ -633,6 +635,12 @@ export function CCAgentSessionView({ [requestFocusMessage, sessionId], ); + const ghostCardSnapshot = useSyncExternalStore( + subscribeGhostCards, + getGhostCardSnapshot, + getGhostCardSnapshot, + ); + useEffect(() => { if (!ownsRoute || !sessionSearchOpen || !sessionId) return; const query = sessionSearchQuery.trim(); @@ -654,6 +662,11 @@ export function CCAgentSessionView({ message.role === 'ask_user' || message.role === 'plan_review', ) + .filter((message) => { + if (message.role !== 'assistant') return true; + const entry = ghostCardSnapshot.byCallId.get(message.clientId); + return !(entry?.status === 'ready'); + }) .flatMap((message) => { const content = visibleMarkdownTextForSearch(message.content).toLocaleLowerCase(); const hits: Array<{ @@ -715,6 +728,7 @@ export function CCAgentSessionView({ return () => window.clearTimeout(timer); }, [ focusSessionSearchHit, + ghostCardSnapshot.version, ownsRoute, sessionId, sessionSearchOpen, From d6fb462d1b1c90eccbcf5ddeeb6122be34bfd6cf Mon Sep 17 00:00:00 2001 From: xushi <10560530+qingshuizhiren@users.noreply.github.com> Date: Fri, 31 Jul 2026 20:03:14 +0800 Subject: [PATCH 4/8] fix(desktop): align search hits with visible messages Signed-off-by: xushi <10560530+qingshuizhiren@users.noreply.github.com> --- .../components/chat/InlineReferenceChip.tsx | 4 ++ .../components/chat/PlanReviewBubble.tsx | 18 +++--- .../renderer/components/chat/QuoteChip.tsx | 1 + .../features/cc-agent/CCAgentSessionView.tsx | 63 +++++++++++++++++-- 4 files changed, 72 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/renderer/components/chat/InlineReferenceChip.tsx b/apps/desktop/src/renderer/components/chat/InlineReferenceChip.tsx index d950c729ec3..3752caff591 100644 --- a/apps/desktop/src/renderer/components/chat/InlineReferenceChip.tsx +++ b/apps/desktop/src/renderer/components/chat/InlineReferenceChip.tsx @@ -39,6 +39,8 @@ export interface InlineReferenceChipProps { * `false`:那里 chip 是一个整体被选中 / 删除的原子节点,内部文字不参与 selection。 */ textSelectable?: boolean; + /** Exclude visual chrome from session-search text/range collection. */ + sessionSearchIgnore?: boolean; } /** Theme-aware 12px reference pill with a formal full-content tooltip. */ @@ -56,6 +58,7 @@ export function InlineReferenceChip({ className, labelClassName, textSelectable = true, + sessionSearchIgnore = false, }: InlineReferenceChipProps) { const interactive = Boolean(onClick || onContextMenu); const sharedClassName = cn( @@ -101,6 +104,7 @@ export function InlineReferenceChip({ role={interactive ? 'button' : undefined} tabIndex={interactive ? 0 : undefined} aria-label={ariaLabel} + data-session-search-ignore={sessionSearchIgnore ? '' : undefined} onClick={onClick} onContextMenu={onContextMenu} onKeyDown={interactive ? handleKeyDown : undefined} diff --git a/apps/desktop/src/renderer/components/chat/PlanReviewBubble.tsx b/apps/desktop/src/renderer/components/chat/PlanReviewBubble.tsx index 8604fe06d5a..78fdd8c0815 100644 --- a/apps/desktop/src/renderer/components/chat/PlanReviewBubble.tsx +++ b/apps/desktop/src/renderer/components/chat/PlanReviewBubble.tsx @@ -167,14 +167,16 @@ export function PlanReviewBubble({ {t('chat.planReviewBubble.feedbackLabel')}
{/* 反馈是用户自己敲的原话,保持纯文本(不当 Markdown 解析)。 */} -

- {feedback || t('chat.planReviewBubble.noFeedback')} -

+
+

+ {feedback || t('chat.planReviewBubble.noFeedback')} +

+
)} diff --git a/apps/desktop/src/renderer/components/chat/QuoteChip.tsx b/apps/desktop/src/renderer/components/chat/QuoteChip.tsx index 46196b631ac..fd0c6387b66 100644 --- a/apps/desktop/src/renderer/components/chat/QuoteChip.tsx +++ b/apps/desktop/src/renderer/components/chat/QuoteChip.tsx @@ -47,6 +47,7 @@ export function QuoteChip({ tooltipContentClassName="max-h-64 w-80 max-w-[70vw] overflow-y-auto whitespace-normal" ariaLabel={quote.text} selected={selected} + sessionSearchIgnore // 刻意的例外:chip 上是把换行折叠成单行的**摘要**,不是引用原文。让它进 // 剪贴板等于把压扁过的文本混进复制结果,原文本身就在被引用的那条消息里。 // 其余消息内 chip(文件名、会话、项目)展示的是完整实体名,默认可复制。 diff --git a/apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx b/apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx index 13e9ab073ae..b5cae35021c 100644 --- a/apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx +++ b/apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx @@ -37,6 +37,13 @@ import { 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 } from '../../../shared/conversationSearch'; import { useProportionalWidth } from '@/hooks/useProportionalWidth'; import { @@ -624,7 +631,7 @@ export function CCAgentSessionView({ return; } void makerChatStore - .loadAroundMessage(sessionId, hit.messageId, { radius: 60 }) + .loadAroundMessageClientId(sessionId, hit.messageClientId, { radius: 60 }) .then((message) => { if (message) requestFocusMessage(message.clientId, hit.occurrenceIndex); }) @@ -654,6 +661,7 @@ export function CCAgentSessionView({ const normalizedQuery = query.toLocaleLowerCase(); const currentMessages = makerChatStore.getSnapshot(sessionId).messages; + const hiddenGhostMessageClientIds = new Set(); const localHits = currentMessages .filter( (message) => @@ -665,10 +673,46 @@ export function CCAgentSessionView({ .filter((message) => { if (message.role !== 'assistant') return true; const entry = ghostCardSnapshot.byCallId.get(message.clientId); - return !(entry?.status === 'ready'); + if (entry?.status === 'ready') { + hiddenGhostMessageClientIds.add(message.clientId); + return false; + } + return true; }) .flatMap((message) => { - const content = visibleMarkdownTextForSearch(message.content).toLocaleLowerCase(); + const rawContent = message.content; + let visibleContent = rawContent; + 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), + ); + } + } + if (isSyntheticTriggerText(visibleContent)) return []; + const content = visibleMarkdownTextForSearch(visibleContent).toLocaleLowerCase(); const hits: Array<{ messageId: string; messageClientId: string; @@ -705,9 +749,14 @@ export function CCAgentSessionView({ const seen = new Set(localHits.map((hit) => hit.messageClientId)); const remoteHits = response.results.flatMap((result) => result.contentHits - .filter((hit) => !seen.has(hit.messageClientId)) + .filter( + (hit) => + !seen.has(hit.messageClientId) && + !hiddenGhostMessageClientIds.has(hit.messageClientId) && + hit.occurrenceCount > 0, + ) .flatMap((hit) => - Array.from({ length: Math.max(1, hit.occurrenceCount) }, (_, occurrenceIndex) => ({ + Array.from({ length: hit.occurrenceCount }, (_, occurrenceIndex) => ({ messageId: hit.messageId, messageClientId: hit.messageClientId, occurrenceIndex, @@ -716,7 +765,9 @@ export function CCAgentSessionView({ ); const hits = [...localHits, ...remoteHits]; setSessionSearchHits(hits); - setSessionSearchActive(hits.length > 0 ? 0 : -1); + setSessionSearchActive((active) => + active >= 0 || hits.length === 0 ? active : 0, + ); if (hits[0] && localHits.length === 0) focusSessionSearchHit(hits[0]); setSessionSearchPending(false); }) From 76d7975fa751bc9fc145027b99809b8f2c554fc4 Mon Sep 17 00:00:00 2001 From: xushi <10560530+qingshuizhiren@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:18:41 +0800 Subject: [PATCH 5/8] fix(desktop): align visible search normalization Signed-off-by: xushi <10560530+qingshuizhiren@users.noreply.github.com> --- .../__tests__/conversationSearch.pure.test.ts | 35 +++++++++++ .../main/localDb/conversationSearch.pure.ts | 5 +- .../__tests__/sessionSearchHighlight.test.ts | 10 +++ .../components/chat/sessionSearchHighlight.ts | 61 +++++++++++++++---- .../features/cc-agent/CCAgentSessionView.tsx | 7 ++- apps/desktop/src/renderer/lib/goalVerdict.ts | 34 +---------- apps/desktop/src/shared/conversationSearch.ts | 8 +++ apps/desktop/src/shared/goalVerdict.ts | 23 +++++++ 8 files changed, 134 insertions(+), 49 deletions(-) create mode 100644 apps/desktop/src/shared/goalVerdict.ts 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 be541967bb9..562d878b7ee 100644 --- a/apps/desktop/src/main/localDb/__tests__/conversationSearch.pure.test.ts +++ b/apps/desktop/src/main/localDb/__tests__/conversationSearch.pure.test.ts @@ -318,6 +318,41 @@ describe('conversationSearch.pure', () => { 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 for complete phrase matching', () => { + expect( + normalizeConversationContentPreview( + 'assistant', + 'error\n\t timeout', + 'error timeout', + ), + ).toMatchObject({ + keywordMatchedVisibleText: true, + occurrenceCount: 1, + }); + }); + it('extracts visible AskUser and plan review text for conversation search', () => { expect(visibleMessageTextForConversationSearch('ask_user', { questions: [{ question: 'Which branch should I use?' }], diff --git a/apps/desktop/src/main/localDb/conversationSearch.pure.ts b/apps/desktop/src/main/localDb/conversationSearch.pure.ts index 62703825379..c2afb74abe6 100644 --- a/apps/desktop/src/main/localDb/conversationSearch.pure.ts +++ b/apps/desktop/src/main/localDb/conversationSearch.pure.ts @@ -1,4 +1,5 @@ import { visibleMarkdownTextForSearch } from '../../shared/conversationSearch.js'; +import { stripGoalVerdictBlock } from '../../shared/goalVerdict.js'; import type { ConversationSearchContentHit, ConversationSearchResultItem, @@ -291,7 +292,9 @@ function userVisibleTextRaw(content: unknown): string { } function assistantVisibleText(content: unknown): string { - if (typeof content === 'string') return visibleMarkdownTextForSearch(content); + if (typeof content === 'string') { + return visibleMarkdownTextForSearch(stripGoalVerdictBlock(content)); + } const blocksText = textBlocksText(content); if (blocksText) return blocksText; if ( diff --git a/apps/desktop/src/renderer/components/chat/__tests__/sessionSearchHighlight.test.ts b/apps/desktop/src/renderer/components/chat/__tests__/sessionSearchHighlight.test.ts index af555e12ed8..a456ab74087 100644 --- a/apps/desktop/src/renderer/components/chat/__tests__/sessionSearchHighlight.test.ts +++ b/apps/desktop/src/renderer/components/chat/__tests__/sessionSearchHighlight.test.ts @@ -36,6 +36,16 @@ describe('findSessionSearchRanges', () => { ]); }); + it('matches collapsed whitespace and maps the range back to rendered text', () => { + const root = document.createElement('div'); + root.innerHTML = 'error\n timeout'; + + const ranges = findSessionSearchRanges(root, 'error timeout'); + + expect(ranges).toHaveLength(1); + expect(ranges[0].toString()).toBe('error\n timeout'); + }); + it('skips interactive and aria-hidden text', () => { const root = document.createElement('div'); root.innerHTML = '

GPT

'; diff --git a/apps/desktop/src/renderer/components/chat/sessionSearchHighlight.ts b/apps/desktop/src/renderer/components/chat/sessionSearchHighlight.ts index 968a5bd29b4..66d7a082d20 100644 --- a/apps/desktop/src/renderer/components/chat/sessionSearchHighlight.ts +++ b/apps/desktop/src/renderer/components/chat/sessionSearchHighlight.ts @@ -7,9 +7,41 @@ interface TextSegment { 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 }; +} + /** 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.toLocaleLowerCase(); + const normalizedQuery = query.replace(/\s+/g, ' ').trim().toLocaleLowerCase(); if (!normalizedQuery) return []; const segments: TextSegment[] = []; @@ -25,32 +57,35 @@ export function findSessionSearchRanges(root: Element, query: string): Range[] { segments.push({ node: textNode, start, end: text.length }); } - const normalizedText = text.toLocaleLowerCase(); + const normalized = normalizeSearchText(text); const ranges: Range[] = []; let startSegmentIndex = 0; let endSegmentIndex = 0; - let offset = normalizedText.indexOf(normalizedQuery); + let offset = normalized.text.indexOf(normalizedQuery); while (offset >= 0) { - const end = offset + normalizedQuery.length; - while (segments[startSegmentIndex]?.end <= offset) startSegmentIndex += 1; + 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 < end) endSegmentIndex += 1; + while (segments[endSegmentIndex]?.end < sourceEnd) endSegmentIndex += 1; const startSegment = segments[startSegmentIndex]; const endSegment = segments[endSegmentIndex]; if ( startSegment && endSegment && - startSegment.start <= offset && - offset < startSegment.end && - endSegment.start < end && - end <= endSegment.end + startSegment.start <= sourceStart && + sourceStart < startSegment.end && + endSegment.start < sourceEnd && + sourceEnd <= endSegment.end ) { const range = document.createRange(); - range.setStart(startSegment.node, offset - startSegment.start); - range.setEnd(endSegment.node, end - endSegment.start); + range.setStart(startSegment.node, sourceStart - startSegment.start); + range.setEnd(endSegment.node, sourceEnd - endSegment.start); ranges.push(range); } - offset = normalizedText.indexOf(normalizedQuery, end); + offset = normalized.text.indexOf(normalizedQuery, normalizedEnd); } return ranges; } diff --git a/apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx b/apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx index b5cae35021c..f964a59f0a2 100644 --- a/apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx +++ b/apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx @@ -659,7 +659,7 @@ export function CCAgentSessionView({ return; } - const normalizedQuery = query.toLocaleLowerCase(); + const normalizedQuery = query.replace(/\s+/g, ' ').trim().toLocaleLowerCase(); const currentMessages = makerChatStore.getSnapshot(sessionId).messages; const hiddenGhostMessageClientIds = new Set(); const localHits = currentMessages @@ -712,7 +712,10 @@ export function CCAgentSessionView({ } } if (isSyntheticTriggerText(visibleContent)) return []; - const content = visibleMarkdownTextForSearch(visibleContent).toLocaleLowerCase(); + const content = visibleMarkdownTextForSearch(visibleContent) + .replace(/\s+/g, ' ') + .trim() + .toLocaleLowerCase(); const hits: Array<{ messageId: string; messageClientId: string; 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/shared/conversationSearch.ts b/apps/desktop/src/shared/conversationSearch.ts index 80ce89724a6..b4fc78d7192 100644 --- a/apps/desktop/src/shared/conversationSearch.ts +++ b/apps/desktop/src/shared/conversationSearch.ts @@ -130,6 +130,7 @@ export function visibleMarkdownTextForSearch(source: string): string { return `${markerPrefix}${index}${markerSuffix}`; }); text = text.replace(//g, ''); + text = preserveMarkdownAutolinks(text); text = text.replace(/<[^>]+>/g, ''); text = stripMarkdownLinkDestinations(text); return text.replace( @@ -138,6 +139,13 @@ export function visibleMarkdownTextForSearch(source: string): string { ); } +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'); 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; +} From b6484671b530f98bb4ca05d704a75122ae07b0cc Mon Sep 17 00:00:00 2001 From: xushi <10560530+qingshuizhiren@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:31:19 +0800 Subject: [PATCH 6/8] fix(desktop): search structured message bodies Signed-off-by: xushi <10560530+qingshuizhiren@users.noreply.github.com> --- .../features/cc-agent/CCAgentSessionView.tsx | 94 +++++++++++++------ 1 file changed, 63 insertions(+), 31 deletions(-) diff --git a/apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx b/apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx index f964a59f0a2..6d54ecf8d71 100644 --- a/apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx +++ b/apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx @@ -662,6 +662,68 @@ export function CCAgentSessionView({ 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; + 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 [message.planReviewPlan, message.planReviewFeedback] + .filter((value): value is string => Boolean(value)) + .join('\n'); + } + + 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) => @@ -680,37 +742,7 @@ export function CCAgentSessionView({ return true; }) .flatMap((message) => { - const rawContent = message.content; - let visibleContent = rawContent; - 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), - ); - } - } + const visibleContent = searchVisibleMessageText(message); if (isSyntheticTriggerText(visibleContent)) return []; const content = visibleMarkdownTextForSearch(visibleContent) .replace(/\s+/g, ' ') From 196aaa846f38d1acbd6facc9fbb7bff213c4f449 Mon Sep 17 00:00:00 2001 From: xushi <10560530+qingshuizhiren@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:51:31 +0800 Subject: [PATCH 7/8] fix(desktop): preserve visible search block boundaries Signed-off-by: xushi <10560530+qingshuizhiren@users.noreply.github.com> --- .../components/chat/PlanReviewBubble.tsx | 2 + .../__tests__/sessionSearchHighlight.test.ts | 51 +++++------------- .../components/chat/sessionSearchHighlight.ts | 53 ++++++++++--------- 3 files changed, 44 insertions(+), 62 deletions(-) diff --git a/apps/desktop/src/renderer/components/chat/PlanReviewBubble.tsx b/apps/desktop/src/renderer/components/chat/PlanReviewBubble.tsx index 78fdd8c0815..9b0892965c6 100644 --- a/apps/desktop/src/renderer/components/chat/PlanReviewBubble.tsx +++ b/apps/desktop/src/renderer/components/chat/PlanReviewBubble.tsx @@ -251,6 +251,7 @@ function PlanMarkdownBody({ return (
{ it('finds every case-insensitive occurrence', () => { - const root = document.createElement('div'); - root.textContent = 'GPT and gpt and GPT'; - - const ranges = findSessionSearchRanges(root, 'gpt'); - - expect(ranges.map((range) => range.toString())).toEqual(['GPT', 'gpt', 'GPT']); + 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'; - - const ranges = findSessionSearchRanges(root, 'GPT'); - - expect(ranges).toHaveLength(1); - expect(ranges[0].toString()).toBe('GPT'); + 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 wrapper = document.createElement('div'); - wrapper.innerHTML = 'GPT label
GPT body GPT
'; - const body = wrapper.querySelector('[data-session-search-body]'); - - expect(body).not.toBeNull(); - expect(findSessionSearchRanges(body!, 'GPT').map((range) => range.toString())).toEqual([ - 'GPT', - 'GPT', - ]); + 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 collapsed whitespace and maps the range back to rendered text', () => { - const root = document.createElement('div'); - root.innerHTML = 'error\n timeout'; - - const ranges = findSessionSearchRanges(root, 'error timeout'); - - expect(ranges).toHaveLength(1); - expect(ranges[0].toString()).toBe('error\n timeout'); + 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

'; - + 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 index 66d7a082d20..0118f12392a 100644 --- a/apps/desktop/src/renderer/components/chat/sessionSearchHighlight.ts +++ b/apps/desktop/src/renderer/components/chat/sessionSearchHighlight.ts @@ -1,17 +1,13 @@ +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[]; -} +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(); @@ -19,7 +15,6 @@ function normalizeSearchText(source: string): NormalizedSearchText { 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; @@ -35,28 +30,45 @@ function normalizeSearchText(source: string): NormalizedSearchText { 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; + if ( + !parent || + !value || + parent.closest(SKIPPED_TEXT_ANCESTOR) || + parent.closest('[data-session-search-collapsed-body]') + ) 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; @@ -72,14 +84,7 @@ export function findSessionSearchRanges(root: Element, query: string): Range[] { 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 - ) { + 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); From be104f8de52176721b1e5c4f35bc7ce348df0d14 Mon Sep 17 00:00:00 2001 From: xushi <10560530+qingshuizhiren@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:18:06 +0800 Subject: [PATCH 8/8] fix(desktop): keep collapsed search hits visible Signed-off-by: xushi <10560530+qingshuizhiren@users.noreply.github.com> --- .../__tests__/conversationSearch.pure.test.ts | 25 +++++++++++++++---- .../main/localDb/conversationSearch.pure.ts | 15 ++++++++--- .../components/chat/MessageStream.tsx | 7 ++++++ .../components/chat/PlanReviewBubble.tsx | 10 ++++++-- .../renderer/components/chat/UserMessage.tsx | 6 ++++- .../components/chat/sessionSearchHighlight.ts | 3 +-- .../features/cc-agent/CCAgentSessionView.tsx | 22 ++++++++++------ .../features/cc-agent/SessionSearchBar.tsx | 1 + apps/desktop/src/shared/conversationSearch.ts | 21 ++++++++++++++++ 9 files changed, 89 insertions(+), 21 deletions(-) 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 562d878b7ee..4bdf5d9c358 100644 --- a/apps/desktop/src/main/localDb/__tests__/conversationSearch.pure.test.ts +++ b/apps/desktop/src/main/localDb/__tests__/conversationSearch.pure.test.ts @@ -340,12 +340,12 @@ describe('conversationSearch.pure', () => { }); }); - it('normalizes whitespace for complete phrase matching', () => { + it('normalizes whitespace in both the visible text and query', () => { expect( normalizeConversationContentPreview( 'assistant', 'error\n\t timeout', - 'error timeout', + 'error \n timeout', ), ).toMatchObject({ keywordMatchedVisibleText: true, @@ -353,14 +353,29 @@ describe('conversationSearch.pure', () => { }); }); - it('extracts visible AskUser and plan review text for conversation search', () => { + 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 c2afb74abe6..b673cbe0f40 100644 --- a/apps/desktop/src/main/localDb/conversationSearch.pure.ts +++ b/apps/desktop/src/main/localDb/conversationSearch.pure.ts @@ -1,4 +1,7 @@ -import { visibleMarkdownTextForSearch } from '../../shared/conversationSearch.js'; +import { + visibleMarkdownTextForSearch, + visiblePlanReviewTextForSearch, +} from '../../shared/conversationSearch.js'; import { stripGoalVerdictBlock } from '../../shared/goalVerdict.js'; import type { ConversationSearchContentHit, @@ -335,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 { @@ -364,7 +371,7 @@ function preferredObjectText(obj: Record): string { } function keywordRanges(text: string, query: string): Array<{ start: number; end: number }> { - const phrase = query.trim(); + const phrase = query.replace(/\s+/g, ' ').trim(); if (!phrase || !text) return []; const lowerText = text.toLocaleLowerCase(); diff --git a/apps/desktop/src/renderer/components/chat/MessageStream.tsx b/apps/desktop/src/renderer/components/chat/MessageStream.tsx index 42a26dab6a2..f8d3ce0ecba 100644 --- a/apps/desktop/src/renderer/components/chat/MessageStream.tsx +++ b/apps/desktop/src/renderer/components/chat/MessageStream.tsx @@ -3891,6 +3891,9 @@ export function MessageStream({ } isLastMessage={msg.clientId === lastMessageClientId} localFileRefs={localFileRefs} + searchFocused={ + Boolean(searchQuery) && msg.clientId === focusMessageClientId + } />
); @@ -3983,6 +3986,7 @@ const MessageItem = memo(function MessageItem({ continuationInFlightProjectionCapability, isLastMessage, localFileRefs, + searchFocused, }: { message: ChatMessage; toolResult?: string; @@ -4028,6 +4032,7 @@ const MessageItem = memo(function MessageItem({ * actionable banner above the composer instead of an inline card. */ isLastMessage?: boolean; localFileRefs: readonly KnownLocalFileRef[]; + searchFocused?: boolean; }) { // silent-stop 自动续跑行(isSyntheticTrigger + systemCardType):渲染成 // 「已自动继续」分隔线,必须在 synthetic early-return 之前检查,否则分隔线被吞。 @@ -4077,6 +4082,7 @@ const MessageItem = memo(function MessageItem({ delivery={message.delivery} goalBadge={message.goalBadge} blockedByGhost={message.blockedByGhost} + searchFocused={searchFocused} /> ); case 'assistant': @@ -4143,6 +4149,7 @@ const MessageItem = memo(function MessageItem({ currentSessionId={sessionId} currentSessionTitle={sessionTitle} localFileRefs={localFileRefs} + searchFocused={searchFocused} /> ); case 'error': diff --git a/apps/desktop/src/renderer/components/chat/PlanReviewBubble.tsx b/apps/desktop/src/renderer/components/chat/PlanReviewBubble.tsx index 9b0892965c6..99b1609d0a4 100644 --- a/apps/desktop/src/renderer/components/chat/PlanReviewBubble.tsx +++ b/apps/desktop/src/renderer/components/chat/PlanReviewBubble.tsx @@ -51,6 +51,8 @@ interface PlanReviewBubbleProps { currentSessionId?: string; currentSessionTitle?: string | null; localFileRefs?: readonly KnownLocalFileRef[]; + /** Temporarily reveal a collapsed plan while its message owns the active search hit. */ + searchFocused?: boolean; } // --------------------------------------------------------------------------- @@ -86,6 +88,7 @@ export function PlanReviewBubble({ currentSessionId, currentSessionTitle, localFileRefs, + searchFocused, }: PlanReviewBubbleProps) { const { t } = useTranslation(); const status = message.planReviewStatus ?? 'pending'; @@ -158,6 +161,7 @@ export function PlanReviewBubble({ localFileRefs={localFileRefs} plan={plan} collapsedMaxHeight={APPROVED_COLLAPSED_MAX_HEIGHT} + searchFocused={searchFocused} /> )} @@ -188,6 +192,7 @@ export function PlanReviewBubble({ localFileRefs={localFileRefs} plan={plan} collapsedMaxHeight={INACTIVE_COLLAPSED_MAX_HEIGHT} + searchFocused={searchFocused} /> )}
@@ -216,6 +221,7 @@ function PlanMarkdownBody({ localFileRefs, plan, collapsedMaxHeight, + searchFocused, }: { workingDir: string; currentSessionId?: string; @@ -223,6 +229,7 @@ function PlanMarkdownBody({ localFileRefs?: readonly KnownLocalFileRef[]; plan: string; collapsedMaxHeight: number; + searchFocused?: boolean; }) { const { t } = useTranslation(); const [expanded, setExpanded] = useState(false); @@ -246,7 +253,7 @@ function PlanMarkdownBody({ return () => observer.disconnect(); }, [collapsedMaxHeight, plan]); - const collapsed = !expanded && overflowing; + const collapsed = !expanded && overflowing && !searchFocused; return (
@@ -277,7 +284,6 @@ function PlanMarkdownBody({ >
; }; + /** 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 diff --git a/apps/desktop/src/renderer/components/chat/sessionSearchHighlight.ts b/apps/desktop/src/renderer/components/chat/sessionSearchHighlight.ts index 0118f12392a..e332f1ca315 100644 --- a/apps/desktop/src/renderer/components/chat/sessionSearchHighlight.ts +++ b/apps/desktop/src/renderer/components/chat/sessionSearchHighlight.ts @@ -57,8 +57,7 @@ export function findSessionSearchRanges(root: Element, query: string): Range[] { if ( !parent || !value || - parent.closest(SKIPPED_TEXT_ANCESTOR) || - parent.closest('[data-session-search-collapsed-body]') + parent.closest(SKIPPED_TEXT_ANCESTOR) ) continue; const block = blockAncestor(textNode); if (block && previousBlock && block !== previousBlock && text.length > 0 && !/\s$/.test(text)) { diff --git a/apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx b/apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx index 6d54ecf8d71..39ff6169b78 100644 --- a/apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx +++ b/apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx @@ -44,7 +44,10 @@ import { } from '@/lib/chatQuotes'; import { stripGoalVerdictBlock } from '@/lib/goalVerdict'; import { isSyntheticTriggerText } from '../../../shared/interruptedTurn'; -import { visibleMarkdownTextForSearch } from '../../../shared/conversationSearch'; +import { + visibleMarkdownTextForSearch, + visiblePlanReviewTextForSearch, +} from '../../../shared/conversationSearch'; import { useProportionalWidth } from '@/hooks/useProportionalWidth'; import { Activity, @@ -671,6 +674,7 @@ export function CCAgentSessionView({ askUserReply?: string | null; planReviewPlan?: string; planReviewFeedback?: string; + planReviewStatus?: 'pending' | 'approved' | 'revised' | 'expired' | 'cancelled'; quotesEncoded?: boolean; hookSource?: { userText?: string }; }): string { @@ -686,9 +690,11 @@ export function CCAgentSessionView({ return [message.content, message.askUserReply ?? ''].filter(Boolean).join('\n'); } if (message.role === 'plan_review') { - return [message.planReviewPlan, message.planReviewFeedback] - .filter((value): value is string => Boolean(value)) - .join('\n'); + return visiblePlanReviewTextForSearch({ + status: message.planReviewStatus, + plan: message.planReviewPlan, + feedback: message.planReviewFeedback, + }); } let visibleContent = message.content; @@ -742,9 +748,11 @@ export function CCAgentSessionView({ return true; }) .flatMap((message) => { - const visibleContent = searchVisibleMessageText(message); - if (isSyntheticTriggerText(visibleContent)) return []; - const content = visibleMarkdownTextForSearch(visibleContent) + const sourceContent = searchVisibleMessageText(message); + if (isSyntheticTriggerText(sourceContent)) return []; + const content = (message.role === 'plan_review' + ? sourceContent + : visibleMarkdownTextForSearch(sourceContent)) .replace(/\s+/g, ' ') .trim() .toLocaleLowerCase(); diff --git a/apps/desktop/src/renderer/features/cc-agent/SessionSearchBar.tsx b/apps/desktop/src/renderer/features/cc-agent/SessionSearchBar.tsx index 9c5b272229c..8483b442e3d 100644 --- a/apps/desktop/src/renderer/features/cc-agent/SessionSearchBar.tsx +++ b/apps/desktop/src/renderer/features/cc-agent/SessionSearchBar.tsx @@ -38,6 +38,7 @@ export const SessionSearchBar = forwardRef onChange(event.target.value)} onKeyDown={(event) => { if (event.key === 'Escape') { diff --git a/apps/desktop/src/shared/conversationSearch.ts b/apps/desktop/src/shared/conversationSearch.ts index b4fc78d7192..e1ddec9f1f3 100644 --- a/apps/desktop/src/shared/conversationSearch.ts +++ b/apps/desktop/src/shared/conversationSearch.ts @@ -15,6 +15,13 @@ export type ConversationSearchMessageRole = | 'plan_review' | 'thinking'; +export type PlanReviewSearchStatus = + | 'pending' + | 'approved' + | 'revised' + | 'expired' + | 'cancelled'; + export interface ConversationSearchRequest { query: string; limit?: number; @@ -119,6 +126,20 @@ export interface ConversationSearchResponse { 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[] = [];