Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ describe('conversationSearch.pure', () => {
createdAt: contentSession.updatedAt,
snippet: null,
preview: 'billing search details',
occurrenceCount: 1,
score: 99,
ftsRank: null,
vectorRank: 1,
Expand All @@ -77,6 +78,7 @@ describe('conversationSearch.pure', () => {
createdAt: newerContentSession.updatedAt,
snippet: null,
preview: 'billing search details',
occurrenceCount: 1,
score: 99,
ftsRank: null,
vectorRank: 1,
Expand All @@ -102,6 +104,7 @@ describe('conversationSearch.pure', () => {
createdAt: contentSession.updatedAt,
snippet: null,
preview: 'billing search details',
occurrenceCount: 1,
score: 99,
ftsRank: null,
vectorRank: 1,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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',
'`<div>` and `a < b`\n\n```html\n<section>visible code</section>\n```',
'<section>visible code</section>',
);

expect(preview.keywordMatchedVisibleText).toBe(true);
expect(preview.preview).toContain('<div>');
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',
'<https://example.com/ticket-123> <span>hidden tag</span>',
'ticket-123',
);

expect(preview.keywordMatchedVisibleText).toBe(true);
expect(preview.preview).toContain('https://example.com/ticket-123');
expect(preview.preview).not.toContain('<span>');
});

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');
}
});
});
49 changes: 25 additions & 24 deletions apps/desktop/src/main/localDb/conversationSearch.pure.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import {
visibleMarkdownTextForSearch,
visiblePlanReviewTextForSearch,
} from '../../shared/conversationSearch.js';
import { stripGoalVerdictBlock } from '../../shared/goalVerdict.js';
import type {
ConversationSearchContentHit,
ConversationSearchResultItem,
Expand Down Expand Up @@ -234,6 +239,7 @@ export interface NormalizedConversationContentPreview {
preview: string;
snippet: string | null;
keywordMatchedVisibleText: boolean;
occurrenceCount: number;
}

export function normalizeConversationContentPreview(
Expand All @@ -249,6 +255,7 @@ export function normalizeConversationContentPreview(
preview: textPreview(visibleText, max),
snippet,
keywordMatchedVisibleText: ranges.length > 0,
occurrenceCount: ranges.length,
};
}

Expand Down Expand Up @@ -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') {
Expand All @@ -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 (
Expand Down Expand Up @@ -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<string, unknown>;
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 {
Expand All @@ -358,26 +371,18 @@ function preferredObjectText(obj: Record<string, unknown>): 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 {
Expand All @@ -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)) {
Expand Down
39 changes: 25 additions & 14 deletions apps/desktop/src/main/localDb/conversationSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof item> => 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<typeof item> => item !== null)
.sort(
(a, b) =>
b.score - a.score ||
activityMs(b.session) - activityMs(a.session) ||
a.index - b.index,
);

const content = await searchContentUntilUniqueSessions({
query,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/main/localDb/ipc/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -42,6 +43,7 @@ export function registerSearchIpc(): void {
includeArchived,
sortBy,
semanticMode,
messagesOnly,
filters,
unnamedLabel,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ export function AskUserQuestionBubble({ message }: AskUserQuestionBubbleProps) {

return (
<div
data-session-search-body=""
className={cn(
'flex w-full min-w-0 flex-col gap-[8px]',
'rounded-[12px] border border-[var(--border-default)]',
Expand All @@ -127,7 +128,10 @@ export function AskUserQuestionBubble({ message }: AskUserQuestionBubbleProps) {
>
{pair.question && (
<div className="flex min-w-0 items-start gap-[8px]">
<span className="mt-[2px] w-[14px] shrink-0 select-none text-11 font-medium leading-[1.4] text-[var(--text-tertiary)]">
<span
data-session-search-ignore=""
className="mt-[2px] w-[14px] shrink-0 select-none text-11 font-medium leading-[1.4] text-[var(--text-tertiary)]"
>
Q
</span>
<span className="min-w-0 flex-1 whitespace-pre-wrap text-13 font-normal leading-[1.45] text-[var(--text-secondary)] [overflow-wrap:anywhere]">
Expand All @@ -137,7 +141,10 @@ export function AskUserQuestionBubble({ message }: AskUserQuestionBubbleProps) {
)}

<div className="flex min-w-0 items-start gap-[8px]">
<span className="mt-[1px] w-[14px] shrink-0 select-none text-12 font-medium leading-[1.4] text-[var(--text-primary)]">
<span
data-session-search-ignore=""
className="mt-[1px] w-[14px] shrink-0 select-none text-12 font-medium leading-[1.4] text-[var(--text-primary)]"
>
</span>
{pair.skipped ? (
Expand Down
10 changes: 6 additions & 4 deletions apps/desktop/src/renderer/components/chat/AssistantMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 已结束)。
<div data-session-search-body="">
{ghostRenderCard && messageClientId && !showOriginal ? (
// 出口钩子自绘:意识卡片替换气泡(净化后的静态 HTML,沙箱 iframe)。
// 复用 GhostToolCard(自带主机身份头 + 主题注入 + 沙箱),turn 级无
// 工具名/参数,running 恒 false(turn 已结束)。
<GhostToolCard
callId={messageClientId}
ghostId={ghostRenderCard.ghostId}
Expand Down Expand Up @@ -323,6 +324,7 @@ export const AssistantMessage = memo(function AssistantMessage({
currentSessionTitle={currentSessionTitle}
/>
)}
</div>
{/* 自绘卡在场:提供原文 ↔ 意识卡片切换(信任边界,主机绘制,始终可切回原文)。 */}
{ghostRenderCard && (
<button
Expand Down
Loading