diff --git a/documents/reader_pipeline/reader_pipeline_engineering_spec.md b/documents/reader_pipeline/reader_pipeline_engineering_spec.md index 8631320..25bbf56 100644 --- a/documents/reader_pipeline/reader_pipeline_engineering_spec.md +++ b/documents/reader_pipeline/reader_pipeline_engineering_spec.md @@ -95,6 +95,17 @@ conversation package 不仅包含正文,还包含正文之外的 sidecar 结 - `content_text` 只作为 fallback plain text - table / math / code 必须按 `semantic_ast_v2` 保真渲染 - 不允许 reader 再从正文尾部推断 citation 或 artifact +- reader search 也必须遵守同一份 package-aware contract,而不是重新退回 `content_text` 单源扫描 +- search occurrence 固定覆盖: + - `body` + - `source` + - `attachment` + - `artifact` + - `annotation` +- Reader 内的 occurrence 顺序固定为: + - 先按消息顺序 + - 同消息内按 `body -> source -> attachment -> artifact -> annotation` +- single CJK 字符查询允许进入 full-text reader search;single non-CJK 字符保持 title/snippet-only,不进入 reader 全文命中导航 ### 4.2.2 Sources, Attachment, And Artifact Sections @@ -113,6 +124,10 @@ conversation package 不仅包含正文,还包含正文之外的 sidecar 结 - 允许显示 excerpt,但 excerpt 只来自 `markdownSnapshot / plainText / normalizedHtmlSnapshot` - 本轮不要求完整 Artifact 预览复刻 - 任何动态内容都不直接 live replay +- Reader search 命中 sidecar 时: + - 自动展开对应 `Sources / Attachments / Artifacts` section + - 滚动并聚焦到具体 item + - 当前命中项使用与正文相同的 active highlight 语义 ### 4.3 Export diff --git a/documents/ui_refactor/threads_search_engineering_spec.md b/documents/ui_refactor/threads_search_engineering_spec.md index cff23ce..9abef30 100644 --- a/documents/ui_refactor/threads_search_engineering_spec.md +++ b/documents/ui_refactor/threads_search_engineering_spec.md @@ -2,7 +2,7 @@ Version: topic-based canonical spec Phase: threads-search-and-reader-navigation -Status: Decision Complete (Docs only; no code implementation in this pass) +Status: Active implementation baseline Audience: Frontend engineers, QA, release owners --- @@ -18,11 +18,12 @@ Locked decisions: 1. `SearchSession` is promoted to `VestiSidepanel` top-level state instead of remaining local to `TimelinePage`. 2. Search result ordering remains `updated_at` descending. -3. Offscreen search returns only lightweight conversation-level match summaries. -4. Reader builds occurrence-level navigation locally after messages are loaded. +3. Offscreen search returns lightweight conversation-level match summaries with source taxonomy. +4. Reader builds occurrence-level navigation locally after messages are loaded, including sidecar targets. 5. Reader must expose an explicit `reader_building_index` state. 6. List restore uses `anchorConversationId` as the primary restore target rather than raw `scrollTop`. 7. Highlight scope is limited to text-capable nodes; it does not expand into `code_block`, `math`, or table-cell-level rich parsing in this phase. +8. Query readiness is shared: empty query disables search, a single CJK character enters full-text, and a single non-CJK character remains title/snippet-only. --- @@ -31,8 +32,8 @@ Locked decisions: Current behavior remains split across two unrelated mechanisms: 1. Title and snippet matching happen locally in the Threads list. -2. Message-body search goes through `searchConversationIdsByText(query)` and returns only `number[]` conversation ids. -3. Reader does not receive search context and cannot navigate or highlight body hits. +2. Legacy paths still assume `content_text` is the only full-text source, while package-aware helpers already understand `citations[] / attachments[] / artifacts[]`. +3. Reader-side search historically understood body text only and could not navigate sidecar hits. 4. Opening Reader unmounts `TimelinePage`, so local search state would be lost unless it is lifted. This means the current implementation can only answer "did this conversation match somewhere in messages" but cannot drive: @@ -67,6 +68,19 @@ Boundary rule: Goal: replace `searchConversationIdsByText` with a lightweight summary interface that can power list excerpts and Reader entry. +#### Query readiness contract + +```ts +export type SearchReadiness = "empty" | "title_snippet_only" | "fulltext"; +``` + +Rules: +- empty query => `empty` +- single CJK character => `fulltext` +- single Latin/digit/symbol character => `title_snippet_only` +- query length `>= 2` => `fulltext` +- title/snippet highlight may still run for `title_snippet_only`; offscreen full-text scan must not + #### Repository / storage / messaging signature ```ts @@ -79,6 +93,8 @@ export interface ConversationMatchSummary { conversationId: number; firstMatchedMessageId: number; bestExcerpt: string; + firstMatchedSurface: "body" | "source" | "attachment" | "artifact" | "annotation"; + matchedSurfaces: Array<"body" | "source" | "attachment" | "artifact" | "annotation">; } export async function searchConversationMatchesByText( @@ -111,9 +127,15 @@ ConversationMatchSummary[] Semantics: - `firstMatchedMessageId` is the earliest matched message within that conversation by `created_at`; if timestamps tie, the lower message id wins. -- `bestExcerpt` must come from the same message identified by `firstMatchedMessageId`. +- `bestExcerpt` and `firstMatchedSurface` must come from the same message identified by `firstMatchedMessageId`. - `conversationIds` is an optional candidate-set constraint from the currently filtered list and exists to reduce offscreen scan cost. -- `matchedInMessages` is not a repository field; it is derived in the list layer from the presence of a summary plus local title/snippet matching. +- `matchedSurfaces` is a conversation-level union used for explanation and QA, not for ranking. +- message search projection must cover: + - `body` + - `source` + - `attachment` + - `artifact` + - `annotation` Explicit exclusions for this phase: - no `totalOccurrenceCount` @@ -131,8 +153,14 @@ Goal: make `ConversationCard` visually distinguish title hits, snippet hits, and Key changes: - introduce `splitWithHighlight(text, query): HighlightedSegment[]` as a reusable pure function -- use `bestExcerpt` to replace the displayed snippet when the hit exists only in message body -- keep the existing `Matched in messages` badge as the explanatory hint +- use `bestExcerpt` to replace the displayed snippet when the hit exists only in search projection +- surface-specific hint copy is fixed: + - `Matched in messages` + - `Matched in sources` + - `Matched in attachments` + - `Matched in artifacts` + - `Matched in notes` +- do not show the hint when title or snippet already matched locally Completion criteria: - title-hit, snippet-hit, and message-only-hit cards are visually distinguishable @@ -170,6 +198,7 @@ Key changes: - `AstMessageRenderer` uses the shared highlight splitter on text-capable nodes - `MessageBubble` uses the same utility in plain-text fallback mode - Reader navigation scrolls and focuses the active rendered occurrence target +- sidecar hits must auto-expand the owning `Sources / Attachments / Artifacts` disclosure and focus the concrete item Allowed highlight targets: - paragraph text @@ -178,6 +207,9 @@ Allowed highlight targets: - blockquote text - `strong` - `em` +- source label / host +- attachment `indexAlt / label / mime` +- artifact `title / descriptor / excerpt` - `code_inline` Out of scope for this phase: diff --git a/documents/ui_refactor/threads_search_state_machine_contract.md b/documents/ui_refactor/threads_search_state_machine_contract.md index 8e34b7c..9c1e212 100644 --- a/documents/ui_refactor/threads_search_state_machine_contract.md +++ b/documents/ui_refactor/threads_search_state_machine_contract.md @@ -28,7 +28,8 @@ stateDiagram-v2 [*] --> list_idle list_idle --> list_results: QUERY_CHANGED / local title-snippet hit - list_idle --> list_searching_body: QUERY_CHANGED(query.length >= 2) + list_idle --> list_searching_body: QUERY_CHANGED(fulltext query) + list_idle --> list_results: QUERY_CHANGED(title/snippet-only query) list_idle --> list_empty: QUERY_CHANGED / no local hit and no body hit list_searching_body --> list_results: BODY_SEARCH_RESOLVED(results > 0) @@ -38,7 +39,8 @@ stateDiagram-v2 list_results --> list_results: QUERY_CHANGED / FILTER_CHANGED / RESULTS_RESHAPED list_results --> reader_loading_messages: OPEN_READER / freeze search session - list_empty --> list_searching_body: QUERY_CHANGED(query.length >= 2) + list_empty --> list_searching_body: QUERY_CHANGED(fulltext query) + list_empty --> list_results: QUERY_CHANGED(title/snippet-only query) list_empty --> list_idle: QUERY_CLEARED reader_loading_messages --> reader_building_index: MESSAGES_LOADED @@ -67,6 +69,8 @@ export interface ConversationMatchSummary { conversationId: number; firstMatchedMessageId: number; bestExcerpt: string; + firstMatchedSurface: "body" | "source" | "attachment" | "artifact" | "annotation"; + matchedSurfaces: Array<"body" | "source" | "attachment" | "artifact" | "annotation">; } ``` @@ -74,10 +78,23 @@ Rules: - repository, `storageService`, and messaging/offscreen handler all keep the same query and response shape. - message type for this contract is `SEARCH_CONVERSATION_MATCHES_BY_TEXT`. - `firstMatchedMessageId` is the earliest matched message by `created_at`; ties fall back to lower message id. -- `bestExcerpt` must be cut from the same message identified by `firstMatchedMessageId`. +- `bestExcerpt` and `firstMatchedSurface` must be cut from the same message identified by `firstMatchedMessageId`. - `conversationIds` limits the scan to the current list candidate set when provided. - offscreen never returns occurrence-level detail. +### 3.1.1 Query readiness contract + +```ts +export type SearchReadiness = "empty" | "title_snippet_only" | "fulltext"; +``` + +Rules: +- empty query => `empty` +- single CJK character => `fulltext` +- single non-CJK character => `title_snippet_only` +- query length `>= 2` => `fulltext` +- title/snippet-only queries must not dispatch offscreen full-text search + ### 3.2 Search session contract ```ts @@ -105,6 +122,7 @@ Rules: export interface ReaderOccurrence { occurrenceKey: string; messageId: number; + surface: "body" | "source" | "attachment" | "artifact" | "annotation"; nodeKey: string; charOffset: number; length: number; @@ -120,9 +138,14 @@ export interface ReaderSearchModel { Rules: - `ReaderOccurrence` is derived locally from loaded messages. +- `surface` distinguishes body hits from sidecar hits and must survive prev/next navigation. - `nodeKey` must be stable between index building and render targeting. - `nodeKey` should use an AST/renderer path string, not a random uuid. -- recommended form: `msg-42:p[1]:text[0]` +- recommended forms: + - `msg-42:p[1]:text[0]` + - `msg-42:source[0]:label` + - `msg-42:attachment[0]:indexAlt` + - `msg-42:artifact[0]:excerpt` ### 3.4 Threads state union @@ -208,6 +231,7 @@ Rules: - `BACK_TO_LIST` restores the same session as mutable list state. - `MESSAGES_LOADED` does not imply navigation readiness. - only `INDEX_BUILT` may enter `reader_ready`. +- `QUERY_CHANGED` for `title_snippet_only` query must never enter `list_searching_body`. --- @@ -232,6 +256,7 @@ Required behavior: - building `occurrences[]` - injecting `` segments - locating the active occurrence for `scrollIntoView` and focus +- sidecar targets must be mappable back to a disclosure section plus concrete item key so Reader can auto-expand on match This bridge is the reason `nodeKey` is a first-class field in the state contract rather than an implementation footnote. diff --git a/documents/ui_runtime/ui_runtime_package_sidecar_manual_acceptance.md b/documents/ui_runtime/ui_runtime_package_sidecar_manual_acceptance.md index 52c289b..6cdff63 100644 --- a/documents/ui_runtime/ui_runtime_package_sidecar_manual_acceptance.md +++ b/documents/ui_runtime/ui_runtime_package_sidecar_manual_acceptance.md @@ -81,6 +81,10 @@ For each relevant case, confirm: - annotation export anchor text is not blank when the anchor turn only has sidecars - prompt/compression transcript includes sidecar summaries when body text is absent - search/retrieval can still surface attachment-only messages via summary text +- single CJK character query enters full-text search across body + sidecars +- single non-CJK character query remains title/snippet-only and does not trigger body-sidecar full-text scan +- Threads search can distinguish `Matched in messages / sources / attachments / artifacts / notes` +- Reader search auto-expands the owning `Sources / Attachments / Artifacts` section when the active hit lands in a sidecar item 6. Metadata - title still follows app-shell title truth, not the largest body heading @@ -95,3 +99,5 @@ Before sign-off, answer all of these: - Did any attachment imply raw replay support? - Did any dynamic artifact render as a live embedded surface? - Did any attachment-only message disappear from preview, export, or prompt flow? +- Did any single CJK character query fail to reach full-text body + sidecar search? +- Did any Reader sidecar hit fail to auto-expand and focus the correct item? diff --git a/frontend/src/lib/db/repository.ts b/frontend/src/lib/db/repository.ts index 7408722..521bc0e 100644 --- a/frontend/src/lib/db/repository.ts +++ b/frontend/src/lib/db/repository.ts @@ -22,6 +22,7 @@ import type { WeeklyLiteReportV1, WeeklyReportRecord, SearchConversationMatchesQuery, + SearchMatchSurface, } from "../types"; import type { ConversationFilters } from "../messaging/protocol"; import { @@ -38,6 +39,13 @@ import { SUPPORTED_PLATFORMS, normalizePlatform } from "../platform"; import { normalizeMessageAttachments } from "../utils/messageAttachments"; import { normalizeMessageArtifacts } from "../utils/messageArtifacts"; import { normalizeMessageCitations } from "../utils/messageCitations"; +import { + buildAnnotationSearchEntry, + buildMessageSearchEntries, + buildSearchExcerpt, + compareSearchSurfacePriority, +} from "../utils/messageSearchProjection"; +import { normalizeSearchQuery, shouldRunFullTextSearch } from "../utils/searchReadiness"; import { db } from "./schema"; import { enforceStorageWriteGuard, getStorageUsageSnapshot } from "./storageLimits"; import type { @@ -925,65 +933,18 @@ export async function getAnnotationExportContext( } export async function searchConversationIdsByText(query: string): Promise { - const normalizedQuery = query.trim().toLowerCase(); - if (normalizedQuery.length < 2) { + if (!shouldRunFullTextSearch(query)) { return []; } - - const conversationIds = new Set(); - await db.messages.toCollection().each((record) => { - const conversationId = record.conversation_id; - if (typeof conversationId !== "number" || conversationIds.has(conversationId)) { - return; - } - - const content = record.content_text; - if (typeof content !== "string") { - return; - } - - if (content.toLowerCase().includes(normalizedQuery)) { - conversationIds.add(conversationId); - } - }); - - await db.annotations.toCollection().each((record) => { - const conversationId = (record as AnnotationRecord).conversation_id; - if (typeof conversationId !== "number" || conversationIds.has(conversationId)) { - return; - } - - const content = (record as AnnotationRecord).content_text; - if (typeof content !== "string") { - return; - } - - if (content.toLowerCase().includes(normalizedQuery)) { - conversationIds.add(conversationId); - } - }); - - return Array.from(conversationIds); -} - -function buildExcerpt(text: string, normalizedQuery: string): string { - const lower = text.toLowerCase(); - const idx = lower.indexOf(normalizedQuery); - if (idx < 0) { - return ""; - } - const start = Math.max(0, idx - 30); - const end = Math.min(text.length, idx + normalizedQuery.length + 60); - const prefix = start > 0 ? "..." : ""; - const suffix = end < text.length ? "..." : ""; - return `${prefix}${text.slice(start, end)}${suffix}`; + const summaries = await searchConversationMatchesByText({ query }); + return summaries.map((summary) => summary.conversationId); } export async function searchConversationMatchesByText( params: SearchConversationMatchesQuery ): Promise { - const normalizedQuery = params.query.trim().toLowerCase(); - if (normalizedQuery.length < 2) { + const normalizedQuery = normalizeSearchQuery(params.query); + if (!shouldRunFullTextSearch(normalizedQuery)) { return []; } @@ -998,7 +959,13 @@ export async function searchConversationMatchesByText( const matchMap = new Map< number, - { messageId: number; createdAt: number; excerpt: string } + { + messageId: number; + createdAt: number; + excerpt: string; + firstMatchedSurface: SearchMatchSurface; + matchedSurfaces: Set; + } >(); const collection = candidateIds @@ -1011,40 +978,106 @@ export async function searchConversationMatchesByText( return; } - const content = record.content_text; - if (typeof content !== "string") { + const messageId = record.id; + if (typeof messageId !== "number") { return; } - if (!content.toLowerCase().includes(normalizedQuery)) { + const matchedEntries = buildMessageSearchEntries({ + id: messageId, + content_text: record.content_text, + content_ast: record.content_ast, + citations: record.citations, + attachments: record.attachments, + artifacts: record.artifacts, + }).filter((entry) => entry.text.toLowerCase().includes(normalizedQuery)); + if (matchedEntries.length === 0) { return; } - const messageId = record.id; - if (typeof messageId !== "number") { - return; - } + const surfaceSet = new Set(matchedEntries.map((entry) => entry.surface)); + const bestEntry = [...matchedEntries].sort((left, right) => + compareSearchSurfacePriority(left.surface, right.surface) + )[0]; const createdAt = record.created_at ?? 0; const existing = matchMap.get(conversationId); const shouldReplace = !existing || createdAt < existing.createdAt || - (createdAt === existing.createdAt && messageId < existing.messageId); + (createdAt === existing.createdAt && messageId < existing.messageId) || + (createdAt === existing.createdAt && + messageId === existing.messageId && + compareSearchSurfacePriority(bestEntry.surface, existing.firstMatchedSurface) < 0); if (shouldReplace) { + const nextSurfaces = existing + ? new Set([...existing.matchedSurfaces, ...surfaceSet]) + : surfaceSet; matchMap.set(conversationId, { messageId, createdAt, - excerpt: buildExcerpt(content, normalizedQuery), + excerpt: buildSearchExcerpt(bestEntry.text, normalizedQuery), + firstMatchedSurface: bestEntry.surface, + matchedSurfaces: nextSurfaces, }); + return; + } + + surfaceSet.forEach((surface) => existing?.matchedSurfaces.add(surface)); + }); + + await db.annotations.toCollection().each((record) => { + const conversationId = record.conversation_id; + if ( + typeof conversationId !== "number" || + (candidateIds && !candidateIds.includes(conversationId)) + ) { + return; + } + + const entry = buildAnnotationSearchEntry(record); + if (!entry || !entry.text.toLowerCase().includes(normalizedQuery)) { + return; } + + const existing = matchMap.get(conversationId); + if (existing) { + existing.matchedSurfaces.add("annotation"); + } + + const createdAt = record.created_at ?? 0; + const shouldReplace = + !existing || + createdAt < existing.createdAt || + (createdAt === existing.createdAt && entry.messageId < existing.messageId) || + (createdAt === existing.createdAt && + entry.messageId === existing.messageId && + compareSearchSurfacePriority("annotation", existing.firstMatchedSurface) < 0); + + if (!shouldReplace) { + return; + } + + const nextSurfaces = existing + ? new Set([...existing.matchedSurfaces, "annotation"]) + : new Set(["annotation"]); + + matchMap.set(conversationId, { + messageId: entry.messageId, + createdAt, + excerpt: buildSearchExcerpt(entry.text, normalizedQuery), + firstMatchedSurface: "annotation", + matchedSurfaces: nextSurfaces, + }); }); return Array.from(matchMap.entries()).map(([conversationId, match]) => ({ conversationId, firstMatchedMessageId: match.messageId, bestExcerpt: match.excerpt, + firstMatchedSurface: match.firstMatchedSurface, + matchedSurfaces: Array.from(match.matchedSurfaces).sort(compareSearchSurfacePriority), })); } diff --git a/frontend/src/lib/types/index.ts b/frontend/src/lib/types/index.ts index 9aaaa17..0e39e87 100644 --- a/frontend/src/lib/types/index.ts +++ b/frontend/src/lib/types/index.ts @@ -65,10 +65,28 @@ export interface SearchConversationMatchesQuery { conversationIds?: number[]; } +export type SearchMatchSurface = + | "body" + | "source" + | "attachment" + | "artifact" + | "annotation"; + +export type SearchReadiness = "empty" | "title_snippet_only" | "fulltext"; + +export interface MessageSearchEntry { + surface: SearchMatchSurface; + messageId: number; + targetKey: string; + text: string; +} + export interface ConversationMatchSummary { conversationId: number; firstMatchedMessageId: number; bestExcerpt: string; + firstMatchedSurface: SearchMatchSurface; + matchedSurfaces: SearchMatchSurface[]; } export interface VectorRecord { diff --git a/frontend/src/lib/utils/messageSearchProjection.ts b/frontend/src/lib/utils/messageSearchProjection.ts new file mode 100644 index 0000000..61ecf34 --- /dev/null +++ b/frontend/src/lib/utils/messageSearchProjection.ts @@ -0,0 +1,172 @@ +import { + formatArtifactDescriptor, + getArtifactExcerptText, +} from "@vesti/content-package"; +import type { AnnotationRecord } from "../db/schema"; +import type { + Message, + MessageSearchEntry, + SearchMatchSurface, +} from "../types"; +import { resolveCanonicalBodyText } from "./messageContentPackage"; + +type MessageSearchProjectionLike = Pick< + Message, + "id" | "content_text" | "citations" | "attachments" | "artifacts" +> & { + content_ast?: unknown; +}; + +export interface AnnotationSearchEntry { + surface: "annotation"; + messageId: number; + targetKey: string; + text: string; +} + +const SEARCH_SURFACE_PRIORITY: Record = { + body: 0, + source: 1, + attachment: 2, + artifact: 3, + annotation: 4, +}; + +export function compareSearchSurfacePriority( + left: SearchMatchSurface, + right: SearchMatchSurface +): number { + return SEARCH_SURFACE_PRIORITY[left] - SEARCH_SURFACE_PRIORITY[right]; +} + +export function getSearchMatchHintLabel(surface: SearchMatchSurface): string { + switch (surface) { + case "source": + return "Matched in sources"; + case "attachment": + return "Matched in attachments"; + case "artifact": + return "Matched in artifacts"; + case "annotation": + return "Matched in notes"; + case "body": + default: + return "Matched in messages"; + } +} + +export function buildSearchExcerpt(text: string, normalizedQuery: string): string { + const lower = text.toLowerCase(); + const idx = lower.indexOf(normalizedQuery); + if (idx < 0) { + return ""; + } + + const start = Math.max(0, idx - 30); + const end = Math.min(text.length, idx + normalizedQuery.length + 60); + const prefix = start > 0 ? "..." : ""; + const suffix = end < text.length ? "..." : ""; + return `${prefix}${text.slice(start, end)}${suffix}`; +} + +export function buildMessageSearchEntries( + message: MessageSearchProjectionLike +): MessageSearchEntry[] { + const entries: MessageSearchEntry[] = []; + const bodyText = resolveCanonicalBodyText(message); + if (bodyText) { + entries.push({ + surface: "body", + messageId: message.id, + targetKey: `msg-${message.id}:body`, + text: bodyText, + }); + } + + (message.citations ?? []).forEach((citation, index) => { + const value = [`Source: ${citation.label}`, citation.host ? `(${citation.host})` : ""] + .filter(Boolean) + .join(" ") + .trim(); + if (!value) { + return; + } + + entries.push({ + surface: "source", + messageId: message.id, + targetKey: `msg-${message.id}:source[${index}]`, + text: value, + }); + }); + + (message.attachments ?? []).forEach((attachment, index) => { + const value = [ + `Attachment: ${attachment.indexAlt}`, + attachment.label && attachment.label !== attachment.indexAlt ? `- ${attachment.label}` : "", + attachment.mime ? `(${attachment.mime})` : "", + ] + .filter(Boolean) + .join(" ") + .trim(); + if (!value) { + return; + } + + entries.push({ + surface: "attachment", + messageId: message.id, + targetKey: `msg-${message.id}:attachment[${index}]`, + text: value, + }); + }); + + (message.artifacts ?? []).forEach((artifact, index) => { + const title = artifact.label ?? artifact.kind; + const descriptor = formatArtifactDescriptor(artifact); + const summary = [`Artifact: ${title}`, descriptor ? `(${descriptor})` : ""] + .filter(Boolean) + .join(" ") + .trim(); + if (summary) { + entries.push({ + surface: "artifact", + messageId: message.id, + targetKey: `msg-${message.id}:artifact[${index}]:summary`, + text: summary, + }); + } + + const excerpt = getArtifactExcerptText(artifact, { + maxLines: 2, + maxCharsPerLine: 120, + separator: " | ", + }); + if (excerpt) { + entries.push({ + surface: "artifact", + messageId: message.id, + targetKey: `msg-${message.id}:artifact[${index}]:excerpt`, + text: excerpt, + }); + } + }); + + return entries; +} + +export function buildAnnotationSearchEntry( + annotation: Pick & { id?: number } +): AnnotationSearchEntry | null { + const text = annotation.content_text.trim(); + if (!text || typeof annotation.message_id !== "number") { + return null; + } + + return { + surface: "annotation", + messageId: annotation.message_id, + targetKey: `msg-${annotation.message_id}:annotation[${annotation.id ?? 0}]`, + text, + }; +} diff --git a/frontend/src/lib/utils/searchReadiness.ts b/frontend/src/lib/utils/searchReadiness.ts new file mode 100644 index 0000000..ec7924e --- /dev/null +++ b/frontend/src/lib/utils/searchReadiness.ts @@ -0,0 +1,31 @@ +import type { SearchReadiness } from "../types"; + +const SINGLE_CJK_QUERY_PATTERN = + /^[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\u3040-\u30ff\uac00-\ud7af]$/u; + +export function normalizeSearchQuery(query: string): string { + return query.trim().toLowerCase(); +} + +export function getSearchReadiness(query: string): SearchReadiness { + const normalizedQuery = normalizeSearchQuery(query); + if (!normalizedQuery) { + return "empty"; + } + + if (normalizedQuery.length === 1) { + return SINGLE_CJK_QUERY_PATTERN.test(normalizedQuery) + ? "fulltext" + : "title_snippet_only"; + } + + return "fulltext"; +} + +export function shouldRunFullTextSearch(query: string): boolean { + return getSearchReadiness(query) === "fulltext"; +} + +export function shouldHighlightSearchQuery(query: string): boolean { + return getSearchReadiness(query) !== "empty"; +} diff --git a/frontend/src/sidepanel/components/ConversationCard.tsx b/frontend/src/sidepanel/components/ConversationCard.tsx index 2de9727..f30b0d0 100644 --- a/frontend/src/sidepanel/components/ConversationCard.tsx +++ b/frontend/src/sidepanel/components/ConversationCard.tsx @@ -30,10 +30,11 @@ import { } from "./ui/dropdown-menu"; import { resolveTurnCount } from "~lib/capture/turn-metrics"; import { getConversationCaptureFreshnessAt } from "~lib/conversations/timestamps"; -import type { Conversation } from "~lib/types"; +import type { Conversation, SearchMatchSurface } from "~lib/types"; import { updateConversationAndSync } from "~lib/services/syncActions"; import { PlatformTag } from "./PlatformTag"; import { splitWithHighlight } from "../lib/highlight"; +import { getSearchMatchHintLabel } from "~lib/utils/messageSearchProjection"; const TOOLTIP_DELAY_MS = 200; const COPY_FEEDBACK_MS = 1500; @@ -149,6 +150,7 @@ interface ConversationCardProps { matchedInMessagesOnly?: boolean; searchQuery?: string; messageExcerpt?: string | null; + messageMatchSurface?: SearchMatchSurface | null; // Batch selection support isBatchMode?: boolean; isSelected?: boolean; @@ -168,6 +170,7 @@ export function ConversationCard({ matchedInMessagesOnly = false, searchQuery = "", messageExcerpt = null, + messageMatchSurface = null, isBatchMode = false, isSelected = false, onToggleSelect, @@ -194,6 +197,9 @@ export function ConversationCard({ matchedInMessagesOnly && messageExcerpt ? messageExcerpt : conversation.snippet; + const messageMatchHint = matchedInMessagesOnly + ? getSearchMatchHintLabel(messageMatchSurface ?? "body") + : null; const renderHighlightedText = (text: string) => { const segments = splitWithHighlight(text, searchQuery); @@ -657,6 +663,11 @@ export function ConversationCard({ {showExpandedDetails && (
+ {messageMatchHint ? ( +

+ {messageMatchHint} +

+ ) : null}

{renderHighlightedText(snippetText)}

diff --git a/frontend/src/sidepanel/components/MessageBubble.tsx b/frontend/src/sidepanel/components/MessageBubble.tsx index 016e894..fb4e674 100644 --- a/frontend/src/sidepanel/components/MessageBubble.tsx +++ b/frontend/src/sidepanel/components/MessageBubble.tsx @@ -17,6 +17,7 @@ import { resolveMessageRenderPlan, type MessageRenderPlan, type OccurrenceIndexMap, + type ReaderSidecarTarget, } from "../lib/readerSearch"; const COLLAPSE_THRESHOLD_PX = 110; @@ -38,6 +39,7 @@ interface MessageBubbleProps { platform: Platform; renderPlan?: MessageRenderPlan | null; occurrenceIndexMap?: OccurrenceIndexMap | null; + sidecarTargetMap?: Record | null; currentIndex?: number | null; } @@ -46,17 +48,24 @@ export function MessageBubble({ platform, renderPlan, occurrenceIndexMap, + sidecarTargetMap, currentIndex, }: MessageBubbleProps) { const [isExpanded, setIsExpanded] = useState(false); const [copied, setCopied] = useState(false); const [canCollapse, setCanCollapse] = useState(false); const bodyRef = useRef(null); + const sidecarsRef = useRef(null); const copyTimerRef = useRef(null); const plan = renderPlan ?? resolveMessageRenderPlan(message, platform); const shouldUseAst = plan.mode === "ast" && plan.renderAst; const indexMap = occurrenceIndexMap ?? {}; const activeIndex = typeof currentIndex === "number" ? currentIndex : null; + const activeSidecarTarget = resolveActiveSidecarTarget( + indexMap, + sidecarTargetMap ?? {}, + activeIndex + ); const canonicalBodyText = resolveCanonicalBodyText(message); const renderedFallbackText = canonicalBodyText || buildMessagePreviewText(message); @@ -144,6 +153,26 @@ export function MessageBubble({ }; }, []); + useEffect(() => { + if (!activeSidecarTarget || activeSidecarTarget.itemKey.indexOf(`msg-${message.id}:`) !== 0) { + return; + } + + const frame = window.requestAnimationFrame(() => { + const selector = `[data-sidecar-item-key="${escapeAttributeValue( + activeSidecarTarget.itemKey + )}"]`; + const target = sidecarsRef.current?.querySelector(selector); + if (target instanceof HTMLElement) { + target.scrollIntoView({ block: "center", behavior: "smooth" }); + } + }); + + return () => { + window.cancelAnimationFrame(frame); + }; + }, [activeSidecarTarget, message.id]); + const handleCopy = () => { navigator.clipboard.writeText(copyText).catch(() => {}); setCopied(true); @@ -226,27 +255,40 @@ export function MessageBubble({
{hasSidecars ? ( -
+
{citationCount > 0 ? (
} + forceOpen={activeSidecarTarget?.section === "sources"} >
- {(message.citations ?? []).map((citation) => ( - -
{citation.label}
-
{citation.host}
-
- ))} + {(message.citations ?? []).map((citation, index) => { + const itemKey = `msg-${message.id}:source[${index}]`; + const isActive = activeSidecarTarget?.itemKey === itemKey; + + return ( + +
+ {renderHighlightSegments(citation.label, `${itemKey}:label`)} +
+
+ {renderHighlightSegments(citation.host, `${itemKey}:host`)} +
+
+ ); + })}
@@ -259,9 +301,12 @@ export function MessageBubble({ count={attachmentCount} icon={} trayVariant="compact" + forceOpen={activeSidecarTarget?.section === "attachments"} >
{(message.attachments ?? []).map((attachment, index) => { + const itemKey = `msg-${message.id}:attachment[${index}]`; + const isActive = activeSidecarTarget?.itemKey === itemKey; const secondaryLabel = attachment.label && attachment.label !== attachment.indexAlt ? attachment.label @@ -270,14 +315,23 @@ export function MessageBubble({ return (
-
{attachment.indexAlt}
+
+ {renderHighlightSegments(attachment.indexAlt, `${itemKey}:indexAlt`)} +
{secondaryLabel ? ( -
{secondaryLabel}
+
+ {renderHighlightSegments(secondaryLabel, `${itemKey}:label`)} +
) : null} {attachment.mime ? ( -
{attachment.mime}
+
+ {renderHighlightSegments(attachment.mime, `${itemKey}:mime`)} +
) : null}
); @@ -293,9 +347,12 @@ export function MessageBubble({ title={artifactCount === 1 ? "Artifact" : "Artifacts"} count={artifactCount} icon={} + forceOpen={activeSidecarTarget?.section === "artifacts"} >
{(message.artifacts ?? []).map((artifact, index) => { + const itemKey = `msg-${message.id}:artifact[${index}]`; + const isActive = activeSidecarTarget?.itemKey === itemKey; const excerpt = getArtifactExcerptText(artifact, { maxLines: 2, maxCharsPerLine: 100, @@ -304,16 +361,27 @@ export function MessageBubble({ return (
- {artifact.label || artifact.kind} + {renderHighlightSegments( + artifact.label || artifact.kind, + `${itemKey}:title` + )}
- {formatArtifactDescriptor(artifact)} + {renderHighlightSegments( + formatArtifactDescriptor(artifact), + `${itemKey}:descriptor` + )}
{excerpt ? ( -
{excerpt}
+
+ {renderHighlightSegments(excerpt, `${itemKey}:excerpt`)} +
) : null}
); @@ -328,6 +396,31 @@ export function MessageBubble({ ); } +function resolveActiveSidecarTarget( + occurrenceIndexMap: OccurrenceIndexMap, + sidecarTargetMap: Record, + activeIndex: number | null +): ReaderSidecarTarget | null { + if (activeIndex === null) { + return null; + } + + for (const [nodeKey, indexes] of Object.entries(occurrenceIndexMap)) { + if (!sidecarTargetMap[nodeKey]) { + continue; + } + if (indexes.some((entry) => entry.index === activeIndex)) { + return sidecarTargetMap[nodeKey]; + } + } + + return null; +} + +function escapeAttributeValue(value: string): string { + return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); +} + function renderFallbackContent( text: string, messageId: number, diff --git a/frontend/src/sidepanel/components/ReaderSidecarDisclosure.tsx b/frontend/src/sidepanel/components/ReaderSidecarDisclosure.tsx index dbd36df..7b6902f 100644 --- a/frontend/src/sidepanel/components/ReaderSidecarDisclosure.tsx +++ b/frontend/src/sidepanel/components/ReaderSidecarDisclosure.tsx @@ -1,3 +1,4 @@ +import { useEffect, useRef } from "react"; import type { ReactNode } from "react"; import { ChevronDown } from "lucide-react"; @@ -7,6 +8,7 @@ interface ReaderSidecarDisclosureProps { icon: ReactNode; children: ReactNode; defaultOpen?: boolean; + forceOpen?: boolean; trayVariant?: "default" | "compact"; } @@ -16,8 +18,10 @@ export function ReaderSidecarDisclosure({ icon, children, defaultOpen = false, + forceOpen = false, trayVariant = "default", }: ReaderSidecarDisclosureProps) { + const detailsRef = useRef(null); const trayWrapClassName = trayVariant === "compact" ? "reader-sidecar-tray-wrap reader-sidecar-tray-wrap-compact" @@ -27,8 +31,17 @@ export function ReaderSidecarDisclosure({ ? "reader-sidecar-tray reader-sidecar-tray-compact" : "reader-sidecar-tray"; + useEffect(() => { + if (!forceOpen || !detailsRef.current || detailsRef.current.open) { + return; + } + + detailsRef.current.open = true; + }, [forceOpen]); + return (
diff --git a/frontend/src/sidepanel/containers/ConversationList.tsx b/frontend/src/sidepanel/containers/ConversationList.tsx index d644d53..bdcc756 100644 --- a/frontend/src/sidepanel/containers/ConversationList.tsx +++ b/frontend/src/sidepanel/containers/ConversationList.tsx @@ -21,6 +21,11 @@ import { updateConversationTitle, } from "~lib/services/storageService"; import { buildMessageFallbackDisplayText } from "~lib/utils/messageContentPackage"; +import { + getSearchReadiness, + normalizeSearchQuery, + shouldRunFullTextSearch, +} from "~lib/utils/searchReadiness"; import { trackCardActionClick } from "~lib/services/telemetry"; import type { DatePreset } from "../types/timelineFilters"; import { ConversationCard } from "../components/ConversationCard"; @@ -191,7 +196,9 @@ export function ConversationList({ const searchDebounceRef = useRef(null); const listContainerRef = useRef(null); const lastAnchorRef = useRef(null); - const normalizedSearchQuery = searchQuery.trim().toLowerCase(); + const normalizedSearchQuery = normalizeSearchQuery(searchQuery); + const searchReadiness = getSearchReadiness(normalizedSearchQuery); + const shouldRunMessageSearch = shouldRunFullTextSearch(normalizedSearchQuery); const filterKey = useMemo(() => { const platforms = Array.from(selectedPlatforms).sort().join(","); return `${datePreset}|${platforms}`; @@ -245,7 +252,7 @@ export function ConversationList({ searchDebounceRef.current = null; } - if (normalizedSearchQuery.length < 2) { + if (!shouldRunMessageSearch) { setIsMessageSearchPending(false); onResultSummaryMapChange({}); return; @@ -311,19 +318,16 @@ export function ConversationList({ filterKey, normalizedSearchQuery, onResultSummaryMapChange, + shouldRunMessageSearch, ]); const filteredConversations = useMemo(() => { const convs = conversations ?? []; return convs.reduce((acc, conversation) => { const baseMatch = matchesSearch(conversation, normalizedSearchQuery); - const summary = - normalizedSearchQuery.length >= 2 - ? resultSummaryMap[conversation.id] - : undefined; + const summary = shouldRunMessageSearch ? resultSummaryMap[conversation.id] : undefined; const textMatch = Boolean(summary); - const matchesQuery = - normalizedSearchQuery.length === 0 ? true : baseMatch || textMatch; + const matchesQuery = searchReadiness === "empty" ? true : baseMatch || textMatch; if (!matchesQuery) return acc; if (!matchesDatePreset(getConversationOriginAt(conversation), datePreset)) { return acc; @@ -341,9 +345,11 @@ export function ConversationList({ }, [ conversations, datePreset, + searchReadiness, normalizedSearchQuery, resultSummaryMap, selectedPlatforms, + shouldRunMessageSearch, ]); useEffect(() => { @@ -520,13 +526,12 @@ export function ConversationList({ return next.filter((item) => { const baseMatch = matchesSearch(item, normalizedSearchQuery); - const textMatch = - normalizedSearchQuery.length >= 2 && resultSummaryMap[item.id]; + const textMatch = shouldRunMessageSearch && resultSummaryMap[item.id]; return baseMatch || Boolean(textMatch); }); }); }, - [normalizedSearchQuery, resultSummaryMap] + [normalizedSearchQuery, resultSummaryMap, shouldRunMessageSearch] ); if (loading) { @@ -587,6 +592,9 @@ export function ConversationList({ messageExcerpt={ item.matchedInMessagesOnly ? item.summary?.bestExcerpt ?? null : null } + messageMatchSurface={ + item.matchedInMessagesOnly ? item.summary?.firstMatchedSurface ?? null : null + } onClick={() => onSelect(item.conversation)} onCopyFullText={handleCopyFullText} onOpenSource={handleOpenSource} diff --git a/frontend/src/sidepanel/containers/ReaderView.tsx b/frontend/src/sidepanel/containers/ReaderView.tsx index bebd04d..517ab56 100644 --- a/frontend/src/sidepanel/containers/ReaderView.tsx +++ b/frontend/src/sidepanel/containers/ReaderView.tsx @@ -14,6 +14,7 @@ import { buildReaderSearchArtifacts, type MessageRenderPlan, type OccurrenceIndexMap, + type ReaderSidecarTarget, } from "../lib/readerSearch"; import { createSearchModel, @@ -24,6 +25,7 @@ import type { ThreadsEvent, ThreadsState, } from "../types/threadsSearch"; +import { shouldRunFullTextSearch } from "~lib/utils/searchReadiness"; interface ReaderViewProps { conversation: Conversation; @@ -51,8 +53,11 @@ export function ReaderView({ const [renderPlanByMessageId, setRenderPlanByMessageId] = useState< Record >({}); + const [sidecarTargetMap, setSidecarTargetMap] = useState>( + {} + ); const scrollRef = useRef(null); - const hasQuery = searchQuery.trim().length > 0; + const hasQuery = shouldRunFullTextSearch(searchQuery); const occurrenceCount = searchModel?.occurrences.length ?? 0; const currentIndex = searchModel?.currentIndex ?? 0; const isLoading = mode === "reader_loading_messages"; @@ -77,6 +82,7 @@ export function ReaderView({ setMessages([]); setLoadedConversationId(null); setRenderPlanByMessageId({}); + setSidecarTargetMap({}); getMessages(conversation.id) .then((data) => { if (cancelled) return; @@ -98,12 +104,17 @@ export function ReaderView({ useEffect(() => { if (!isBuilding) return; - const { occurrences, renderPlanByMessageId: plans } = buildReaderSearchArtifacts({ + const { + occurrences, + renderPlanByMessageId: plans, + sidecarTargetMap: nextSidecarTargetMap, + } = buildReaderSearchArtifacts({ messages, platform: conversation.platform, query: searchQuery, }); setRenderPlanByMessageId(plans); + setSidecarTargetMap(nextSidecarTargetMap); const nextSearchModel = createSearchModel( searchQuery, firstMatchedMessageId, @@ -237,6 +248,7 @@ export function ReaderView({ platform={conversation.platform} renderPlan={renderPlanByMessageId[msg.id] ?? null} occurrenceIndexMap={occurrenceIndexMap} + sidecarTargetMap={sidecarTargetMap} currentIndex={isReady ? currentIndex : null} /> ))} diff --git a/frontend/src/sidepanel/lib/highlight.ts b/frontend/src/sidepanel/lib/highlight.ts index 2ae0d11..2d9e3f3 100644 --- a/frontend/src/sidepanel/lib/highlight.ts +++ b/frontend/src/sidepanel/lib/highlight.ts @@ -1,11 +1,13 @@ +import { normalizeSearchQuery, shouldHighlightSearchQuery } from "~lib/utils/searchReadiness"; + export interface HighlightSegment { text: string; highlight: boolean; } export function splitWithHighlight(text: string, query: string): HighlightSegment[] { - const normalizedQuery = query.trim().toLowerCase(); - if (!text || !normalizedQuery || normalizedQuery.length < 2) { + const normalizedQuery = normalizeSearchQuery(query); + if (!text || !shouldHighlightSearchQuery(normalizedQuery)) { return [{ text, highlight: false }]; } diff --git a/frontend/src/sidepanel/lib/readerSearch.ts b/frontend/src/sidepanel/lib/readerSearch.ts index 0c559e1..1c22e51 100644 --- a/frontend/src/sidepanel/lib/readerSearch.ts +++ b/frontend/src/sidepanel/lib/readerSearch.ts @@ -1,4 +1,8 @@ -import type { Message, Platform } from "~lib/types"; +import { + formatArtifactDescriptor, + getArtifactExcerptText, +} from "@vesti/content-package"; +import type { Message, Platform, SearchMatchSurface } from "~lib/types"; import type { AstNode, AstRoot } from "~lib/types/ast"; import { astNodeToPlainText, @@ -10,9 +14,9 @@ import { buildMessageFallbackDisplayText, resolveCanonicalBodyText, } from "~lib/utils/messageContentPackage"; +import { normalizeSearchQuery, shouldRunFullTextSearch } from "~lib/utils/searchReadiness"; import type { ReaderOccurrence } from "../types/threadsSearch"; -const MIN_QUERY_LENGTH = 1; const MIN_AST_COVERAGE_RATIO = 0.55; const MIN_MATH_AST_COVERAGE_RATIO = 0.2; const MIN_TEXT_LENGTH_FOR_AST_CHECK = 120; @@ -37,6 +41,7 @@ export interface MessageRenderPlan { export interface ReaderSearchArtifacts { occurrences: ReaderOccurrence[]; renderPlanByMessageId: Record; + sidecarTargetMap: Record; } export interface ReaderOccurrenceIndex { @@ -46,6 +51,12 @@ export interface ReaderOccurrenceIndex { export type OccurrenceIndexMap = Record; +export interface ReaderSidecarTarget { + section: "sources" | "attachments" | "artifacts"; + surface: Exclude; + itemKey: string; +} + export interface HighlightSegment { text: string; occurrenceIndex: number | null; @@ -68,10 +79,6 @@ interface OccurrenceIndexRef { current: number; } -export function normalizeSearchQuery(query: string): string { - return query.trim().toLowerCase(); -} - export function buildReaderSearchArtifacts(params: { messages: Message[]; platform: Platform; @@ -81,12 +88,14 @@ export function buildReaderSearchArtifacts(params: { const normalizedQuery = normalizeSearchQuery(query); const renderPlanByMessageId: Record = {}; const occurrences: ReaderOccurrence[] = []; + const sidecarTargetMap: Record = {}; const occurrenceIndexRef: OccurrenceIndexRef = { current: 0 }; + const shouldIndexQuery = shouldRunFullTextSearch(normalizedQuery); for (const message of messages) { const renderPlan = resolveMessageRenderPlan(message, platform); renderPlanByMessageId[message.id] = renderPlan; - if (normalizedQuery.length < MIN_QUERY_LENGTH) { + if (!shouldIndexQuery) { continue; } @@ -98,19 +107,26 @@ export function buildReaderSearchArtifacts(params: { renderPlan.renderAst, normalizedQuery ); - continue; + } else { + appendFallbackOccurrences( + occurrences, + occurrenceIndexRef, + message.id, + buildMessageFallbackDisplayText(message), + normalizedQuery + ); } - appendFallbackOccurrences( + appendSidecarOccurrences( occurrences, + sidecarTargetMap, occurrenceIndexRef, - message.id, - buildMessageFallbackDisplayText(message), + message, normalizedQuery ); } - return { occurrences, renderPlanByMessageId }; + return { occurrences, renderPlanByMessageId, sidecarTargetMap }; } export function buildOccurrenceIndexMap( @@ -272,6 +288,7 @@ function appendAstOccurrences( occurrences, occurrenceIndexRef, messageId, + "body", buildAstNodeKey(messageId, path), node.text, normalizedQuery @@ -284,6 +301,7 @@ function appendAstOccurrences( occurrences, occurrenceIndexRef, messageId, + "body", buildAstNodeKey(messageId, path), node.text, normalizedQuery @@ -341,6 +359,7 @@ function appendFallbackOccurrences( occurrences, occurrenceIndexRef, messageId, + "body", segment.nodeKey, segment.text, normalizedQuery @@ -348,10 +367,157 @@ function appendFallbackOccurrences( } } +function appendSidecarOccurrences( + occurrences: ReaderOccurrence[], + sidecarTargetMap: Record, + occurrenceIndexRef: OccurrenceIndexRef, + message: Message, + normalizedQuery: string +): void { + (message.citations ?? []).forEach((citation, index) => { + const itemKey = `msg-${message.id}:source[${index}]`; + appendSidecarFieldOccurrences( + occurrences, + sidecarTargetMap, + occurrenceIndexRef, + message.id, + "source", + "sources", + itemKey, + `${itemKey}:label`, + citation.label, + normalizedQuery + ); + appendSidecarFieldOccurrences( + occurrences, + sidecarTargetMap, + occurrenceIndexRef, + message.id, + "source", + "sources", + itemKey, + `${itemKey}:host`, + citation.host, + normalizedQuery + ); + }); + + (message.attachments ?? []).forEach((attachment, index) => { + const itemKey = `msg-${message.id}:attachment[${index}]`; + appendSidecarFieldOccurrences( + occurrences, + sidecarTargetMap, + occurrenceIndexRef, + message.id, + "attachment", + "attachments", + itemKey, + `${itemKey}:indexAlt`, + attachment.indexAlt, + normalizedQuery + ); + appendSidecarFieldOccurrences( + occurrences, + sidecarTargetMap, + occurrenceIndexRef, + message.id, + "attachment", + "attachments", + itemKey, + `${itemKey}:label`, + attachment.label ?? "", + normalizedQuery + ); + appendSidecarFieldOccurrences( + occurrences, + sidecarTargetMap, + occurrenceIndexRef, + message.id, + "attachment", + "attachments", + itemKey, + `${itemKey}:mime`, + attachment.mime ?? "", + normalizedQuery + ); + }); + + (message.artifacts ?? []).forEach((artifact, index) => { + const itemKey = `msg-${message.id}:artifact[${index}]`; + appendSidecarFieldOccurrences( + occurrences, + sidecarTargetMap, + occurrenceIndexRef, + message.id, + "artifact", + "artifacts", + itemKey, + `${itemKey}:title`, + artifact.label || artifact.kind, + normalizedQuery + ); + const descriptor = describeArtifact(artifact); + appendSidecarFieldOccurrences( + occurrences, + sidecarTargetMap, + occurrenceIndexRef, + message.id, + "artifact", + "artifacts", + itemKey, + `${itemKey}:descriptor`, + descriptor, + normalizedQuery + ); + const excerpt = buildArtifactExcerpt(artifact); + appendSidecarFieldOccurrences( + occurrences, + sidecarTargetMap, + occurrenceIndexRef, + message.id, + "artifact", + "artifacts", + itemKey, + `${itemKey}:excerpt`, + excerpt, + normalizedQuery + ); + }); +} + +function appendSidecarFieldOccurrences( + occurrences: ReaderOccurrence[], + sidecarTargetMap: Record, + occurrenceIndexRef: OccurrenceIndexRef, + messageId: number, + surface: Exclude, + section: ReaderSidecarTarget["section"], + itemKey: string, + nodeKey: string, + text: string, + normalizedQuery: string +): void { + if (!text) { + return; + } + + sidecarTargetMap[nodeKey] = { section, surface, itemKey }; + appendOccurrencesFromText( + occurrences, + occurrenceIndexRef, + messageId, + surface, + nodeKey, + text, + normalizedQuery + ); +} + function appendOccurrencesFromText( occurrences: ReaderOccurrence[], occurrenceIndexRef: OccurrenceIndexRef, messageId: number, + surface: SearchMatchSurface, nodeKey: string, text: string, normalizedQuery: string @@ -361,6 +527,7 @@ function appendOccurrencesFromText( occurrences.push({ occurrenceKey: `occ-${occurrenceIndexRef.current}`, messageId, + surface, nodeKey, charOffset: match.charOffset, length: match.length, @@ -373,7 +540,7 @@ function findTextOccurrences( text: string, normalizedQuery: string ): TextOccurrence[] { - if (!normalizedQuery || normalizedQuery.length < MIN_QUERY_LENGTH) { + if (!shouldRunFullTextSearch(normalizedQuery)) { return []; } const lower = text.toLowerCase(); @@ -390,6 +557,18 @@ function findTextOccurrences( return occurrences; } +function describeArtifact(artifact: NonNullable[number]): string { + return formatArtifactDescriptor(artifact); +} + +function buildArtifactExcerpt(artifact: NonNullable[number]): string { + return getArtifactExcerptText(artifact, { + maxLines: 2, + maxCharsPerLine: 120, + separator: " | ", + }); +} + function normalizeForCoverage(value: string): string { return value.replace(/\s+/g, " ").trim(); } diff --git a/frontend/src/sidepanel/lib/threadsSearchReducer.ts b/frontend/src/sidepanel/lib/threadsSearchReducer.ts index 606c6d4..bf407ec 100644 --- a/frontend/src/sidepanel/lib/threadsSearchReducer.ts +++ b/frontend/src/sidepanel/lib/threadsSearchReducer.ts @@ -3,6 +3,7 @@ import type { Message, Platform, } from "~lib/types"; +import { getSearchReadiness, shouldRunFullTextSearch } from "~lib/utils/searchReadiness"; import type { DatePreset, HeaderMode } from "../types/timelineFilters"; import type { ReaderSearchModel, @@ -57,11 +58,15 @@ function resolveListMode( session: SearchSession, preferResults: boolean ): ThreadsState { - const hasQuery = session.normalizedQuery.length >= 2; + const readiness = getSearchReadiness(session.normalizedQuery); + const hasQuery = readiness !== "empty"; const hasResults = Object.keys(session.resultSummaryMap).length > 0; if (!hasQuery) { return { mode: "list_idle", session }; } + if (readiness === "title_snippet_only") { + return { mode: "list_results", session }; + } if (preferResults || hasResults) { return { mode: "list_results", session }; } @@ -124,7 +129,7 @@ export function threadsReducer( case "QUERY_CHANGED": { if (readerMode) return state; const session = updateSessionQuery(state.session, event.query); - if (session.normalizedQuery.length < 2) { + if (!shouldRunFullTextSearch(session.normalizedQuery)) { return resolveListMode(session, false); } return { @@ -149,7 +154,7 @@ export function threadsReducer( event.datePreset, event.selectedPlatforms ); - if (session.normalizedQuery.length < 2) { + if (!shouldRunFullTextSearch(session.normalizedQuery)) { return resolveListMode(session, false); } return { @@ -159,7 +164,7 @@ export function threadsReducer( } case "BODY_SEARCH_STARTED": { if (readerMode) return state; - if (state.session.normalizedQuery.length < 2) { + if (!shouldRunFullTextSearch(state.session.normalizedQuery)) { return resolveListMode(state.session, false); } if (state.mode === "list_searching_body") { @@ -176,7 +181,7 @@ export function threadsReducer( ...state.session, resultSummaryMap: buildResultSummaryMap(event.summaries), }; - if (session.normalizedQuery.length < 2) { + if (!shouldRunFullTextSearch(session.normalizedQuery)) { return resolveListMode(session, false); } return event.hasResults diff --git a/frontend/src/sidepanel/types/threadsSearch.ts b/frontend/src/sidepanel/types/threadsSearch.ts index 0c19758..a2cdcf1 100644 --- a/frontend/src/sidepanel/types/threadsSearch.ts +++ b/frontend/src/sidepanel/types/threadsSearch.ts @@ -1,4 +1,9 @@ -import type { ConversationMatchSummary, Message, Platform } from "~lib/types"; +import type { + ConversationMatchSummary, + Message, + Platform, + SearchMatchSurface, +} from "~lib/types"; import type { DatePreset, HeaderMode } from "./timelineFilters"; export interface SearchSession { @@ -19,6 +24,7 @@ export type FrozenSearchSession = Readonly; export interface ReaderOccurrence { occurrenceKey: string; messageId: number; + surface: SearchMatchSurface; nodeKey: string; charOffset: number; length: number; diff --git a/frontend/src/style.css b/frontend/src/style.css index 9bf9e21..f13f093 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -2982,6 +2982,14 @@ opacity: 0.88; } +.reader-sidecar-row-active { + background-color: hsl(var(--accent-primary-light) / 0.55); + border-radius: 7px; + margin: 0 -5px; + padding-left: 5px; + padding-right: 5px; +} + .reader-sidecar-row-title { font-size: 10.75px; font-weight: 600;