From b6e51def011c6d79464d1ed8478a9e33a73bfd03 Mon Sep 17 00:00:00 2001 From: tulsi Date: Mon, 24 Aug 2026 11:30:22 -0400 Subject: [PATCH 1/9] fix(desktop): highlight search terms in results Signed-off-by: tulsi --- desktop/src/app/AppShell.tsx | 4 +- .../src/app/navigation/searchHitEventCache.ts | 26 ++++++- .../navigation/searchHitNavigation.test.mjs | 16 ++++ .../src/app/navigation/searchHitNavigation.ts | 5 +- .../src/app/navigation/useAppNavigation.ts | 3 + desktop/src/app/routes/ChannelRouteScreen.tsx | 44 ++++++++--- .../src/features/channels/ui/ChannelPane.tsx | 11 +++ .../features/channels/ui/ChannelPane.types.ts | 4 + .../features/channels/ui/ChannelScreen.tsx | 6 ++ .../channels/ui/ChannelScreen.types.ts | 4 + .../channels/ui/ForumChannelContent.tsx | 6 ++ .../features/forum/ui/ForumThreadPanel.tsx | 17 ++++ desktop/src/features/forum/ui/ForumView.tsx | 6 ++ .../messages/ui/MessageThreadPanel.tsx | 12 +++ .../features/search/lib/searchMatch.test.mjs | 37 +++++++++ .../src/features/search/lib/searchMatch.ts | 77 +++++++++++++++++++ .../search/ui/HighlightedSearchText.tsx | 26 +++++++ .../src/features/search/ui/TopbarSearch.tsx | 29 +++---- .../features/sidebar/ui/AppSidebar.types.ts | 2 +- .../sidebar/ui/AppSidebarPinnedHeader.tsx | 2 +- .../src/shared/lib/rehypeSearchHighlight.ts | 14 ++-- .../src/shared/lib/searchHighlightStyle.ts | 3 + .../src/shared/ui/markdown/nodeCache.test.mjs | 16 +++- desktop/tests/e2e/smoke.spec.ts | 24 ++++++ 24 files changed, 348 insertions(+), 46 deletions(-) create mode 100644 desktop/src/features/search/lib/searchMatch.test.mjs create mode 100644 desktop/src/features/search/lib/searchMatch.ts create mode 100644 desktop/src/features/search/ui/HighlightedSearchText.tsx create mode 100644 desktop/src/shared/lib/searchHighlightStyle.ts diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index e111f93ca0e..468435e15ec 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -648,8 +648,8 @@ export function AppShell() { ); const handleOpenSearchResult = React.useCallback( - (hit: SearchHit) => { - void openSearchHit(hit); + (hit: SearchHit, query: string) => { + void openSearchHit(hit, { query }); }, [openSearchHit], ); diff --git a/desktop/src/app/navigation/searchHitEventCache.ts b/desktop/src/app/navigation/searchHitEventCache.ts index b57a5f01fb1..7a69af36fb7 100644 --- a/desktop/src/app/navigation/searchHitEventCache.ts +++ b/desktop/src/app/navigation/searchHitEventCache.ts @@ -2,6 +2,7 @@ import type { RelayEvent, SearchHit } from "@/shared/api/types"; const MAX_CACHED_EVENTS = 200; const searchHitEventCache = new Map(); +const searchHitQueryCache = new Map(); function trimCache() { if (searchHitEventCache.size <= MAX_CACHED_EVENTS) { @@ -15,6 +16,7 @@ function trimCache() { break; } searchHitEventCache.delete(key); + searchHitQueryCache.delete(key); removed++; } } @@ -31,15 +33,25 @@ export function buildSearchHitEvent(hit: SearchHit): RelayEvent { }; } -export function cacheSearchHitEvent(hit: SearchHit): RelayEvent { +export function cacheSearchHitEvent( + hit: SearchHit, + query?: string, +): RelayEvent { const event = buildSearchHitEvent(hit); searchHitEventCache.set(event.id, event); + const trimmedQuery = query?.trim(); + if (trimmedQuery) { + searchHitQueryCache.set(event.id, trimmedQuery); + } else { + searchHitQueryCache.delete(event.id); + } trimCache(); return event; } export function clearSearchHitEventCache(): void { searchHitEventCache.clear(); + searchHitQueryCache.clear(); } export function getCachedSearchHitEvent( @@ -51,3 +63,15 @@ export function getCachedSearchHitEvent( return searchHitEventCache.get(eventId) ?? null; } + +export function consumeCachedSearchHitQuery( + eventId: string | null | undefined, +): string | null { + if (!eventId) { + return null; + } + + const query = searchHitQueryCache.get(eventId) ?? null; + searchHitQueryCache.delete(eventId); + return query; +} diff --git a/desktop/src/app/navigation/searchHitNavigation.test.mjs b/desktop/src/app/navigation/searchHitNavigation.test.mjs index 74e5f108af6..22c9fa8abcc 100644 --- a/desktop/src/app/navigation/searchHitNavigation.test.mjs +++ b/desktop/src/app/navigation/searchHitNavigation.test.mjs @@ -58,6 +58,22 @@ test("search-hit navigation preserves forced message routing while active", asyn assert.equal(getCachedSearchHitEvent("message")?.id, "message"); }); +test("search-hit navigation retains the query for destination highlighting", async () => { + clearSearchHitEventCache(); + const { consumeCachedSearchHitQuery } = await import( + "./searchHitEventCache.ts" + ); + + await openSearchHitWithNavigation(plainMessage, { + goChannel: async () => true, + goForumPost: async () => false, + query: " Mentions ", + }); + + assert.equal(consumeCachedSearchHitQuery("message"), "Mentions"); + assert.equal(consumeCachedSearchHitQuery("message"), null); +}); + test("cancelled search-hit navigation cannot repopulate cache or route", async () => { clearSearchHitEventCache(); let resolveLookup; diff --git a/desktop/src/app/navigation/searchHitNavigation.ts b/desktop/src/app/navigation/searchHitNavigation.ts index 8523340d481..79db380823e 100644 --- a/desktop/src/app/navigation/searchHitNavigation.ts +++ b/desktop/src/app/navigation/searchHitNavigation.ts @@ -4,6 +4,7 @@ import type { SearchHit } from "@/shared/api/types"; type SearchHitNavigationActions = { force?: boolean; + query?: string; goChannel: ( channelId: string, options?: { @@ -31,7 +32,7 @@ export async function openSearchHitWithNavigation( const isLifecycleBound = Boolean(actions.signal); if (!isLifecycleBound) { - cacheSearchHitEvent(hit); + cacheSearchHitEvent(hit, actions.query); } const destination = await resolveDestination(hit); @@ -42,7 +43,7 @@ export async function openSearchHitWithNavigation( if (isLifecycleBound) { // Delay community-scoped writes for notification routing until async // destination resolution completes and its owner is still current. - cacheSearchHitEvent(hit); + cacheSearchHitEvent(hit, actions.query); } if (destination.kind === "forum-post") { diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index c4776564e34..790647effc7 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -376,6 +376,8 @@ export function useAppNavigation() { * Used by desktop-notification activation so a click is never * silently swallowed (block/buzz#3509). */ force?: boolean; + /** Search text to highlight after opening this result. */ + query?: string; /** Stop notification-driven routing when its owning lifecycle ends. */ signal?: AbortSignal; }, @@ -384,6 +386,7 @@ export function useAppNavigation() { force: behavior?.force, goChannel, goForumPost, + query: behavior?.query, signal: behavior?.signal, }), [goChannel, goForumPost], diff --git a/desktop/src/app/routes/ChannelRouteScreen.tsx b/desktop/src/app/routes/ChannelRouteScreen.tsx index d4626d2c6fa..e4a0c1106e4 100644 --- a/desktop/src/app/routes/ChannelRouteScreen.tsx +++ b/desktop/src/app/routes/ChannelRouteScreen.tsx @@ -1,6 +1,9 @@ import * as React from "react"; -import { getCachedSearchHitEvent } from "@/app/navigation/searchHitEventCache"; +import { + getCachedSearchHitEvent, + consumeCachedSearchHitQuery, +} from "@/app/navigation/searchHitEventCache"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useChannelsQuery } from "@/features/channels/hooks"; import { useOpenChannelDirectoryQuery } from "@/features/channels/openChannelDirectory"; @@ -132,23 +135,38 @@ export function ChannelRouteScreen({ const cachedTarget = getCachedSearchHitEvent(targetMessageId); return cachedTarget ? [cachedTarget] : []; }); + const [searchHighlight, setSearchHighlight] = React.useState<{ + messageId: string; + query: string; + } | null>(() => { + const messageId = targetMessageId ?? targetReplyId ?? selectedPostId; + const query = consumeCachedSearchHitQuery(messageId); + return messageId && query ? { messageId, query } : null; + }); - // Reset spliced target events when the channel context changes (channel - // switch or entering/leaving a forum post). Tied to channel identity rather - // than the route target so clearing the `messageId` param mid-channel keeps - // the deep-linked row in view. Seeded with the mount key so the initial + // Reset spliced target events and search highlighting when the channel + // changes. Tied to channel identity rather + // than the route target so clearing the `messageId` param or resolving a + // forum post mid-channel keeps the clicked highlight in view. Seeded with + // the mount key so the initial // cache-seeded events survive first commit; only a genuine channel change // clears them. Declared before the fetch effect so a channel switch clears // stale events before the new target is fetched. - const previousResetKeyRef = React.useRef( - `${channelId}::${selectedPostId ?? ""}`, - ); + const previousResetKeyRef = React.useRef(channelId); React.useEffect(() => { - const resetKey = `${channelId}::${selectedPostId ?? ""}`; - if (previousResetKeyRef.current === resetKey) return; - previousResetKeyRef.current = resetKey; + if (previousResetKeyRef.current === channelId) return; + previousResetKeyRef.current = channelId; setTargetMessageEvents([]); - }, [channelId, selectedPostId]); + setSearchHighlight(null); + }, [channelId]); + + React.useEffect(() => { + const messageId = targetMessageId ?? targetReplyId ?? selectedPostId; + const query = consumeCachedSearchHitQuery(messageId); + if (messageId && query) { + setSearchHighlight({ messageId, query }); + } + }, [targetMessageId, targetReplyId, selectedPostId]); React.useEffect(() => { let isCancelled = false; @@ -234,6 +252,8 @@ export function ChannelRouteScreen({ targetForumReplyId={targetReplyId} targetMessageEvents={targetMessageEvents} targetMessageId={targetMessageId} + targetSearchMessageId={searchHighlight?.messageId} + targetSearchQuery={searchHighlight?.query} /> ); } diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index bccc163ed40..19ee6295de9 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -147,6 +147,8 @@ export const ChannelPane = React.memo(function ChannelPane({ profilePanelTab, profilePanelView, targetMessageId, + targetSearchMessageId, + targetSearchQuery, threadAllMessages, threadHeadMessage, threadMessages, @@ -169,6 +171,11 @@ export const ChannelPane = React.memo(function ChannelPane({ currentPubkey, ); const mainComposerMedia = useMediaUpload({ deferUploadsUntilSend: true }); + const searchMatchingMessageIds = React.useMemo( + () => + targetSearchMessageId ? new Set([targetSearchMessageId]) : undefined, + [targetSearchMessageId], + ); const [isMainDeferredEditPending, setMainDeferredEditPending] = React.useState(false); const isNonMemberView = @@ -629,6 +636,8 @@ export const ChannelPane = React.memo(function ChannelPane({ } onTargetReached={onTargetReached} onToggleReaction={onToggleReaction} + searchMatchingMessageIds={searchMatchingMessageIds} + searchQuery={targetSearchQuery} targetMessageId={targetMessageId} splitThreadPanelOpen={ useSplitAuxiliaryPane && @@ -828,6 +837,8 @@ export const ChannelPane = React.memo(function ChannelPane({ replyTargetMessage={threadReplyTargetMessage} scrollTargetHighlights={!layoutScrollTargetId} scrollTargetId={layoutScrollTargetId ?? threadScrollTargetId} + searchMessageId={targetSearchMessageId} + searchQuery={targetSearchQuery} threadHead={threadHeadMessage} videoReviewPresentation={threadVideoReviewPresentation} widthPx={threadPanelWidthPx} diff --git a/desktop/src/features/channels/ui/ChannelPane.types.ts b/desktop/src/features/channels/ui/ChannelPane.types.ts index 760ef58073b..d6b1fefd5fb 100644 --- a/desktop/src/features/channels/ui/ChannelPane.types.ts +++ b/desktop/src/features/channels/ui/ChannelPane.types.ts @@ -178,6 +178,10 @@ export type ChannelPaneProps = { threadReplyUnreadCounts?: ReadonlyMap; threadFirstUnreadReplyId?: string | null; targetMessageId: string | null; + /** Exact clicked result id, including a reply routed into the thread panel. */ + targetSearchMessageId?: string | null; + /** Search text to highlight within the clicked result. */ + targetSearchQuery?: string; typingPubkeys: string[]; isFollowingThread?: boolean; onFollowThread?: () => void; diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 68df9bc05c6..c8d8c706f26 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -96,6 +96,8 @@ export function ChannelScreen({ targetForumReplyId, targetMessageEvents, targetMessageId, + targetSearchMessageId, + targetSearchQuery, }: ChannelScreenProps) { const queryClient = useQueryClient(); const { goHome } = useAppNavigation(); @@ -830,6 +832,8 @@ export function ChannelScreen({ profilePanelView={profilePanelView} selectedPostId={selectedForumPostId} targetReplyId={targetForumReplyId} + targetSearchMessageId={targetSearchMessageId} + targetSearchQuery={targetSearchQuery} /> ) : ( @@ -88,6 +92,8 @@ export function ForumChannelContent({ onSelectPost={onSelectPost} selectedPostId={selectedPostId} targetReplyId={targetReplyId} + targetSearchMessageId={targetSearchMessageId} + targetSearchQuery={targetSearchQuery} /> diff --git a/desktop/src/features/forum/ui/ForumThreadPanel.tsx b/desktop/src/features/forum/ui/ForumThreadPanel.tsx index 7bf40fbffe3..348fa157000 100644 --- a/desktop/src/features/forum/ui/ForumThreadPanel.tsx +++ b/desktop/src/features/forum/ui/ForumThreadPanel.tsx @@ -41,6 +41,8 @@ type ForumThreadPanelProps = { canDeletePost?: boolean; isDeletingPost?: boolean; targetEventId?: string | null; + targetSearchMessageId?: string; + targetSearchQuery?: string; }; function canDeleteReply( @@ -57,12 +59,14 @@ function ReplyRow({ profiles, channelNames, onDelete, + searchQuery, }: { reply: ThreadReply; currentPubkey?: string; profiles?: UserProfileLookup; channelNames?: string[]; onDelete?: (eventId: string) => void; + searchQuery?: string; }) { const replyAuthorLabel = resolveUserLabel({ pubkey: reply.pubkey, @@ -122,6 +126,7 @@ function ReplyRow({ imetaByUrl={parseImetaTags(reply.tags)} mentionNames={replyMentionNames} mentionPubkeysByName={replyMentionPubkeysByName} + searchQuery={searchQuery} /> @@ -143,6 +148,8 @@ export function ForumThreadPanel({ canDeletePost, isDeletingPost, targetEventId, + targetSearchMessageId, + targetSearchQuery, }: ForumThreadPanelProps) { const scrollRef = React.useRef(null); const { channels } = useChannelNavigation(); @@ -268,6 +275,11 @@ export function ForumThreadPanel({ imetaByUrl={parseImetaTags(post.tags)} mentionNames={postMentionNames} mentionPubkeysByName={postMentionPubkeysByName} + searchQuery={ + targetSearchMessageId === post.eventId + ? targetSearchQuery + : undefined + } /> @@ -286,6 +298,11 @@ export function ForumThreadPanel({ onDelete={onDeleteReply} profiles={profiles} reply={reply} + searchQuery={ + targetSearchMessageId === reply.eventId + ? targetSearchQuery + : undefined + } /> ))} diff --git a/desktop/src/features/forum/ui/ForumView.tsx b/desktop/src/features/forum/ui/ForumView.tsx index 9efada55492..670ee4e6dfb 100644 --- a/desktop/src/features/forum/ui/ForumView.tsx +++ b/desktop/src/features/forum/ui/ForumView.tsx @@ -30,6 +30,8 @@ type ForumViewProps = { onTargetReached?: (messageId: string) => void; selectedPostId: string | null; targetReplyId: string | null; + targetSearchMessageId?: string; + targetSearchQuery?: string; }; function canDelete(postPubkey: string, currentPubkey?: string): boolean { @@ -47,6 +49,8 @@ export function ForumView({ onTargetReached, selectedPostId, targetReplyId, + targetSearchMessageId, + targetSearchQuery, }: ForumViewProps) { const [isComposerOpen, setIsComposerOpen] = React.useState(false); const postsScrollRef = React.useRef(null); @@ -156,6 +160,8 @@ export function ForumView({ onTargetReached={onTargetReached} profiles={profiles} targetEventId={targetReplyId} + targetSearchMessageId={targetSearchMessageId} + targetSearchQuery={targetSearchQuery} thread={threadQuery.data} /> ); diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index d2650c84ac4..16e7e0bbc8d 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -78,6 +78,8 @@ type MessageThreadPanelProps = ThreadPanelLayoutProps & { onScrollTargetResolved: () => void; onScrollTargetSettled?: (messageId: string) => void; scrollTargetHighlights?: boolean; + searchMessageId?: string | null; + searchQuery?: string; onSelectReplyTarget: (message: TimelineMessage) => void; onSend: ( content: string, @@ -229,6 +231,8 @@ export function MessageThreadPanel({ replyTargetMessage, scrollTargetId, scrollTargetHighlights = true, + searchMessageId, + searchQuery, threadHead, videoReviewPresentation, threadReplies, @@ -604,6 +608,9 @@ export function MessageThreadPanel({ onUnfollowThread ? (_msg) => onUnfollowThread() : undefined } profiles={profiles} + searchQuery={ + searchMessageId === threadHead.id ? searchQuery : undefined + } showDepthGuides={shouldShowThreadBranchGuides} videoReviewCommentRootId={videoReviewPresentation?.commentRootIdsByMessageId.get( threadHead.id, @@ -767,6 +774,11 @@ export function MessageThreadPanel({ onSendToChannel={stableSendToChannel} onToggleReaction={onToggleReaction} profiles={profiles} + searchQuery={ + searchMessageId === entry.message.id + ? searchQuery + : undefined + } showDepthGuides={shouldShowThreadBranchGuides} videoReviewCommentRootId={videoReviewPresentation?.commentRootIdsByMessageId.get( entry.message.id, diff --git a/desktop/src/features/search/lib/searchMatch.test.mjs b/desktop/src/features/search/lib/searchMatch.test.mjs new file mode 100644 index 00000000000..1d18bda6d10 --- /dev/null +++ b/desktop/src/features/search/lib/searchMatch.test.mjs @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildSearchResultPreview, splitSearchMatches } from "./searchMatch.ts"; + +test("splitSearchMatches highlights every case-insensitive literal match", () => { + assert.deepEqual(splitSearchMatches("Mentions and mentions", "mentions"), [ + { isMatch: true, key: "0-8", text: "Mentions" }, + { isMatch: false, key: "8-5", text: " and " }, + { isMatch: true, key: "13-8", text: "mentions" }, + ]); +}); + +test("splitSearchMatches treats regex punctuation literally", () => { + assert.deepEqual(splitSearchMatches("Use C++ (not C)", "C++"), [ + { isMatch: false, key: "0-4", text: "Use " }, + { isMatch: true, key: "4-3", text: "C++" }, + { isMatch: false, key: "7-8", text: " (not C)" }, + ]); +}); + +test("buildSearchResultPreview keeps a late match visible", () => { + const content = `${"prefix ".repeat(30)}mentions appear here ${"suffix ".repeat(20)}`; + const preview = buildSearchResultPreview(content, "mentions", 96); + + assert.equal(preview.length <= 96, true); + assert.match(preview, /mentions/i); + assert.match(preview, /^\.\.\./); + assert.match(preview, /\.\.\.$/); +}); + +test("buildSearchResultPreview keeps the existing leading excerpt without a match", () => { + assert.equal( + buildSearchResultPreview("abcdefghijklmnopqrstuvwxyz", "missing", 10), + "abcdefg...", + ); +}); diff --git a/desktop/src/features/search/lib/searchMatch.ts b/desktop/src/features/search/lib/searchMatch.ts new file mode 100644 index 00000000000..3f41944fd3e --- /dev/null +++ b/desktop/src/features/search/lib/searchMatch.ts @@ -0,0 +1,77 @@ +export type SearchMatchPart = { + isMatch: boolean; + key: string; + text: string; +}; + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** Split text around every case-insensitive literal match of the query. */ +export function splitSearchMatches( + text: string, + query: string, +): SearchMatchPart[] { + const trimmedQuery = query.trim(); + if (!trimmedQuery) { + return [{ isMatch: false, key: "0", text }]; + } + + const pattern = new RegExp(`(${escapeRegExp(trimmedQuery)})`, "gi"); + let offset = 0; + return text + .split(pattern) + .filter(Boolean) + .map((part) => { + const key = `${offset}-${part.length}`; + offset += part.length; + return { + isMatch: part.toLowerCase() === trimmedQuery.toLowerCase(), + key, + text: part, + }; + }); +} + +/** + * Build a compact result excerpt that keeps the first literal match visible. + * Context is biased slightly before the match so the result still reads like + * a sentence while avoiding a snippet whose matching word is offscreen. + */ +export function buildSearchResultPreview( + content: string, + query: string, + maxLength = 96, +): string { + const text = content.trim(); + if (!text) { + return "No message body."; + } + if (text.length <= maxLength) { + return text; + } + + const trimmedQuery = query.trim(); + const matchIndex = trimmedQuery + ? text.toLowerCase().indexOf(trimmedQuery.toLowerCase()) + : -1; + if (matchIndex < 0) { + return `${text.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`; + } + + const contextBefore = Math.min(32, Math.floor(maxLength / 3)); + let start = Math.max(0, matchIndex - contextBefore); + const end = Math.min(text.length, start + maxLength); + + if (end === text.length) { + start = Math.max(0, end - maxLength); + } + + const prefix = start > 0 ? "..." : ""; + const suffix = end < text.length ? "..." : ""; + const available = Math.max(0, maxLength - prefix.length - suffix.length); + const excerpt = text.slice(start, start + available).trim(); + + return `${prefix}${excerpt}${suffix}`; +} diff --git a/desktop/src/features/search/ui/HighlightedSearchText.tsx b/desktop/src/features/search/ui/HighlightedSearchText.tsx new file mode 100644 index 00000000000..51745f88737 --- /dev/null +++ b/desktop/src/features/search/ui/HighlightedSearchText.tsx @@ -0,0 +1,26 @@ +import * as React from "react"; + +import { splitSearchMatches } from "@/features/search/lib/searchMatch"; +import { SEARCH_MATCH_HIGHLIGHT_CLASS } from "@/shared/lib/searchHighlightStyle"; + +export function HighlightedSearchText({ + query, + text, +}: { + query: string; + text: string; +}) { + return splitSearchMatches(text, query).map((part) => + part.isMatch ? ( + + {part.text} + + ) : ( + {part.text} + ), + ); +} diff --git a/desktop/src/features/search/ui/TopbarSearch.tsx b/desktop/src/features/search/ui/TopbarSearch.tsx index 963ae6cf42e..6f695286fd2 100644 --- a/desktop/src/features/search/ui/TopbarSearch.tsx +++ b/desktop/src/features/search/ui/TopbarSearch.tsx @@ -3,6 +3,8 @@ import * as React from "react"; import { resolveUserLabel } from "@/features/profile/lib/identity"; import { getMinimumSearchQueryLength } from "@/features/search/hooks"; +import { parseSearchOperators } from "@/features/search/lib/parseSearchOperators"; +import { buildSearchResultPreview } from "@/features/search/lib/searchMatch"; import { useSearchResults } from "@/features/search/useSearchResults"; import { resultIcon, @@ -15,6 +17,7 @@ import { getChannelScopeLabel, SearchDialogInputRow, } from "@/features/search/ui/SearchScopeControls"; +import { HighlightedSearchText } from "@/features/search/ui/HighlightedSearchText"; import { useSearchMenuKeyboardNavigation } from "@/features/search/ui/useSearchMenuKeyboardNavigation"; import type { Channel, SearchHit, UserSearchResult } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; @@ -36,7 +39,7 @@ type TopbarSearchProps = { currentChannelId?: string | null; focusRequest?: number; onOpenChannel: (channelId: string) => void; - onOpenResult: (hit: SearchHit) => void; + onOpenResult: (hit: SearchHit, query: string) => void; onOpenUser?: (user: UserSearchResult) => void | Promise; onBrowseChannels?: () => void | Promise; onCreateAgent?: () => void | Promise; @@ -72,19 +75,6 @@ type SearchHitContextLabel = { text: string; }; -function truncateResultText(content: string, maxLength = 96) { - const trimmed = content.trim(); - if (trimmed.length === 0) { - return "No message body."; - } - - if (trimmed.length <= maxLength) { - return trimmed; - } - - return `${trimmed.slice(0, maxLength - 3).trimEnd()}...`; -} - function formatRelativeTime(unixSeconds: number) { const diff = Math.floor(Date.now() / 1_000) - unixSeconds; @@ -436,6 +426,7 @@ export function TopbarSearch({ scopeChannelId, }); const trimmedQuery = query.trim(); + const highlightQuery = parseSearchOperators(trimmedQuery).text; const isIconVariant = variant === "icon"; const currentChannel = currentChannelId ? (channelLookup.get(currentChannelId) ?? null) @@ -581,7 +572,7 @@ export function TopbarSearch({ return; } - onOpenResult(result.hit); + onOpenResult(result.hit, highlightQuery); }, [ onBrowseChannels, @@ -592,6 +583,7 @@ export function TopbarSearch({ onOpenUser, openAfterExit, setQuery, + highlightQuery, ], ); @@ -697,7 +689,7 @@ export function TopbarSearch({ ? result.action.description : result.kind === "user" ? getUserSecondaryLabel(result.user) - : truncateResultText(result.hit.content); + : buildSearchResultPreview(result.hit.content, highlightQuery); const trailingLabel = result.kind === "channel" ? getChannelSuggestionMeta(result.channel) @@ -771,7 +763,10 @@ export function TopbarSearch({ ) : null} {preview ? ( - {preview} + ) : null} diff --git a/desktop/src/features/sidebar/ui/AppSidebar.types.ts b/desktop/src/features/sidebar/ui/AppSidebar.types.ts index 8d626c45938..ab35b8b598c 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.types.ts +++ b/desktop/src/features/sidebar/ui/AppSidebar.types.ts @@ -88,7 +88,7 @@ export type AppSidebarProps = { onSelectWorkflows: () => void; onSelectHome: () => void; onSelectChannel: (channelId: string) => void; - onOpenSearchResult: (hit: SearchHit) => void; + onOpenSearchResult: (hit: SearchHit, query: string) => void; /** Full channel set for global search, including channels outside the joined sidebar list. */ searchChannels: Channel[]; searchFocusRequests: readonly [global: number, channel: number]; diff --git a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx index 1e0db29cac1..a9bf7058eb6 100644 --- a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx @@ -30,7 +30,7 @@ type AppSidebarPinnedHeaderProps = { onCreateAgent: () => void; onCreateChannel: () => void; onOpenDm: (input: { pubkeys: string[] }) => Promise; - onOpenSearchResult: (hit: SearchHit) => void; + onOpenSearchResult: (hit: SearchHit, query: string) => void; onSelectChannel: (channelId: string) => void; searchChannels: Channel[]; searchFocusRequest: number; diff --git a/desktop/src/shared/lib/rehypeSearchHighlight.ts b/desktop/src/shared/lib/rehypeSearchHighlight.ts index df7f1de2923..547f3c3a0e7 100644 --- a/desktop/src/shared/lib/rehypeSearchHighlight.ts +++ b/desktop/src/shared/lib/rehypeSearchHighlight.ts @@ -6,6 +6,8 @@ * ReactMarkdown's architecture — no post-render tree walking needed. */ +import { SEARCH_MATCH_HIGHLIGHT_CLASS } from "@/shared/lib/searchHighlightStyle"; + // Minimal HAST types — matches the pattern in rehypeImageGallery.ts. interface HastText { type: "text"; @@ -43,7 +45,7 @@ export default function rehypeSearchHighlight({ query }: { query: string }) { const trimmed = query.trim(); if (trimmed.length < 2) return; - const pattern = new RegExp(`(${escapeRegExp(trimmed)})`, "i"); + const pattern = new RegExp(`(${escapeRegExp(trimmed)})`, "gi"); function walk(nodes: HastNode[]): HastNode[] { const result: HastNode[] = []; @@ -56,18 +58,16 @@ export default function rehypeSearchHighlight({ query }: { query: string }) { continue; } - for (let i = 0; i < parts.length; i++) { - const part = parts[i]; + for (const part of parts) { if (!part) continue; - if (i % 2 === 1) { - // Odd indices from split-with-capture are always the match. + if (part.toLowerCase() === trimmed.toLowerCase()) { result.push({ type: "element", tagName: "mark", properties: { - className: - "rounded-xs bg-primary/20 text-foreground dark:bg-primary/30", + className: SEARCH_MATCH_HIGHLIGHT_CLASS, + "data-search-match": "true", }, children: [{ type: "text", value: part }], }); diff --git a/desktop/src/shared/lib/searchHighlightStyle.ts b/desktop/src/shared/lib/searchHighlightStyle.ts new file mode 100644 index 00000000000..0ede60986c9 --- /dev/null +++ b/desktop/src/shared/lib/searchHighlightStyle.ts @@ -0,0 +1,3 @@ +/** Shared visual treatment for literal search matches. */ +export const SEARCH_MATCH_HIGHLIGHT_CLASS = + "rounded-xs bg-yellow-300/80 text-yellow-950 dark:bg-yellow-300/70 dark:text-yellow-950"; diff --git a/desktop/src/shared/ui/markdown/nodeCache.test.mjs b/desktop/src/shared/ui/markdown/nodeCache.test.mjs index e29abe1ee1e..3a44e9f6d99 100644 --- a/desktop/src/shared/ui/markdown/nodeCache.test.mjs +++ b/desktop/src/shared/ui/markdown/nodeCache.test.mjs @@ -227,9 +227,19 @@ test("hardLineBreaks changes the parse and the cache key", () => { assert.equal(withoutBreaks, withoutBreaksAgain); }); -test("active search queries bypass the cache", () => { +test("active search queries bypass the cache and highlight every match", () => { clearMarkdownNodeCache(); - const first = renderCachedMarkdown({ ...BASE, searchQuery: "bold" }); - const second = renderCachedMarkdown({ ...BASE, searchQuery: "bold" }); + const input = { + ...BASE, + content: "Bold and bold, but not code `bold`.", + searchQuery: "bold", + }; + const first = renderCachedMarkdown(input); + const second = renderCachedMarkdown(input); + const html = renderToStaticMarkup(first); + assert.notEqual(first, second); + assert.equal((html.match(/data-search-match="true"/g) ?? []).length, 2); + assert.match(html, /bg-yellow-300/); + assert.match(html, /bold<\/code>/); }); diff --git a/desktop/tests/e2e/smoke.spec.ts b/desktop/tests/e2e/smoke.spec.ts index b3ea8bea617..d770137ff61 100644 --- a/desktop/tests/e2e/smoke.spec.ts +++ b/desktop/tests/e2e/smoke.spec.ts @@ -346,6 +346,30 @@ test("opens sidebar search with the shortcut and loads the exact result", async ); }); +test("highlights the query in search results and the opened message", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-engineering").click(); + await page.keyboard.press("ControlOrMeta+f"); + await page.getByTestId("search-dialog-input").fill("SHIPPED"); + + const result = page.getByTestId("search-result-mock-engineering-shipped"); + await expect(result).toBeVisible(); + await expect(result.locator("mark")).toHaveText("shipped"); + await expect(result.locator("mark")).toHaveClass(/bg-yellow-300/); + + await result.click(); + + const message = page + .getByTestId("message-timeline") + .locator('[data-message-id="mock-engineering-shipped"]'); + await expect(message).toBeVisible(); + await expect(message.locator('[data-search-match="true"]')).toHaveText( + "shipped", + ); +}); + test("opens channel matches from search", async ({ page }) => { await page.goto("/"); From 1b560bc3a539ba4a37151839b4f19a74ca067d79 Mon Sep 17 00:00:00 2001 From: tulsi Date: Mon, 24 Aug 2026 12:57:12 -0400 Subject: [PATCH 2/9] fix(desktop): harden search result highlighting Signed-off-by: tulsi --- .../src/app/navigation/searchHitEventCache.ts | 53 ++++++++++++------- .../navigation/searchHitNavigation.test.mjs | 35 ++++++++++-- .../src/app/navigation/searchHitNavigation.ts | 16 ++++-- .../src/app/navigation/useAppNavigation.ts | 14 ++++- desktop/src/app/routes/ChannelRouteScreen.tsx | 24 +++++---- .../channels.$channelId.posts.$postId.tsx | 7 +++ .../src/app/routes/channels.$channelId.tsx | 3 ++ .../src/features/messages/ui/DiffMessage.tsx | 6 ++- .../src/features/messages/ui/DiffViewer.tsx | 20 ++++++- .../src/features/messages/ui/MessageRow.tsx | 2 + .../messages/ui/WaveMessageAttachment.tsx | 10 +++- .../features/search/lib/searchMatch.test.mjs | 18 +++++++ .../src/features/search/lib/searchMatch.ts | 42 ++++++++++----- .../src/features/search/ui/TopbarSearch.tsx | 35 ++++++------ .../src/shared/lib/rehypeSearchHighlight.ts | 22 +++----- .../src/shared/ui/markdown/nodeCache.test.mjs | 8 +++ desktop/src/shared/ui/markdown/nodeCache.ts | 4 +- desktop/tests/e2e/smoke.spec.ts | 42 +++++++++++++++ 18 files changed, 277 insertions(+), 84 deletions(-) diff --git a/desktop/src/app/navigation/searchHitEventCache.ts b/desktop/src/app/navigation/searchHitEventCache.ts index 7a69af36fb7..b1d59667ca3 100644 --- a/desktop/src/app/navigation/searchHitEventCache.ts +++ b/desktop/src/app/navigation/searchHitEventCache.ts @@ -2,22 +2,35 @@ import type { RelayEvent, SearchHit } from "@/shared/api/types"; const MAX_CACHED_EVENTS = 200; const searchHitEventCache = new Map(); -const searchHitQueryCache = new Map(); +const searchHitQueryCache = new Map< + string, + { eventId: string; query: string } +>(); function trimCache() { - if (searchHitEventCache.size <= MAX_CACHED_EVENTS) { - return; - } - - const overflow = searchHitEventCache.size - MAX_CACHED_EVENTS; - let removed = 0; + const eventOverflow = searchHitEventCache.size - MAX_CACHED_EVENTS; + let removedEvents = 0; for (const key of searchHitEventCache.keys()) { - if (removed >= overflow) { + if (removedEvents >= eventOverflow) { break; } searchHitEventCache.delete(key); - searchHitQueryCache.delete(key); - removed++; + for (const [navigationId, entry] of searchHitQueryCache) { + if (entry.eventId === key) { + searchHitQueryCache.delete(navigationId); + } + } + removedEvents++; + } + + const queryOverflow = searchHitQueryCache.size - MAX_CACHED_EVENTS; + let removedQueries = 0; + for (const navigationId of searchHitQueryCache.keys()) { + if (removedQueries >= queryOverflow) { + break; + } + searchHitQueryCache.delete(navigationId); + removedQueries++; } } @@ -36,14 +49,18 @@ export function buildSearchHitEvent(hit: SearchHit): RelayEvent { export function cacheSearchHitEvent( hit: SearchHit, query?: string, + searchNavigationId = hit.eventId, ): RelayEvent { const event = buildSearchHitEvent(hit); searchHitEventCache.set(event.id, event); const trimmedQuery = query?.trim(); if (trimmedQuery) { - searchHitQueryCache.set(event.id, trimmedQuery); + searchHitQueryCache.set(searchNavigationId, { + eventId: event.id, + query: trimmedQuery, + }); } else { - searchHitQueryCache.delete(event.id); + searchHitQueryCache.delete(searchNavigationId); } trimCache(); return event; @@ -65,13 +82,13 @@ export function getCachedSearchHitEvent( } export function consumeCachedSearchHitQuery( - eventId: string | null | undefined, -): string | null { - if (!eventId) { + searchNavigationId: string | null | undefined, +): { eventId: string; query: string } | null { + if (!searchNavigationId) { return null; } - const query = searchHitQueryCache.get(eventId) ?? null; - searchHitQueryCache.delete(eventId); - return query; + const entry = searchHitQueryCache.get(searchNavigationId) ?? null; + searchHitQueryCache.delete(searchNavigationId); + return entry; } diff --git a/desktop/src/app/navigation/searchHitNavigation.test.mjs b/desktop/src/app/navigation/searchHitNavigation.test.mjs index 22c9fa8abcc..3bf0f0deb56 100644 --- a/desktop/src/app/navigation/searchHitNavigation.test.mjs +++ b/desktop/src/app/navigation/searchHitNavigation.test.mjs @@ -51,6 +51,7 @@ test("search-hit navigation preserves forced message routing while active", asyn options: { force: true, messageId: "message", + searchNavigationId: undefined, threadRootId: "thread-root", }, }, @@ -58,20 +59,46 @@ test("search-hit navigation preserves forced message routing while active", asyn assert.equal(getCachedSearchHitEvent("message")?.id, "message"); }); -test("search-hit navigation retains the query for destination highlighting", async () => { +test("search-hit navigation retains the query and marks repeated route activations", async () => { clearSearchHitEventCache(); const { consumeCachedSearchHitQuery } = await import( "./searchHitEventCache.ts" ); + const calls = []; await openSearchHitWithNavigation(plainMessage, { - goChannel: async () => true, + goChannel: async (channelId, options) => { + calls.push({ channelId, options }); + return true; + }, goForumPost: async () => false, query: " Mentions ", }); - assert.equal(consumeCachedSearchHitQuery("message"), "Mentions"); - assert.equal(consumeCachedSearchHitQuery("message"), null); + const searchNavigationId = calls[0].options.searchNavigationId; + assert.match(searchNavigationId, /^message:/); + assert.deepEqual(consumeCachedSearchHitQuery(searchNavigationId), { + eventId: "message", + query: "Mentions", + }); + assert.equal(consumeCachedSearchHitQuery(searchNavigationId), null); +}); + +test("forum-post search navigation marks same-route activations", async () => { + clearSearchHitEventCache(); + const forumPost = { ...forumComment, eventId: "post", kind: 45001 }; + const calls = []; + + await openSearchHitWithNavigation(forumPost, { + goChannel: async () => false, + goForumPost: async (channelId, postId, options) => { + calls.push({ channelId, postId, options }); + return true; + }, + query: "mentions", + }); + + assert.match(calls[0].options.searchNavigationId, /^post:/); }); test("cancelled search-hit navigation cannot repopulate cache or route", async () => { diff --git a/desktop/src/app/navigation/searchHitNavigation.ts b/desktop/src/app/navigation/searchHitNavigation.ts index 79db380823e..40574a415d5 100644 --- a/desktop/src/app/navigation/searchHitNavigation.ts +++ b/desktop/src/app/navigation/searchHitNavigation.ts @@ -10,13 +10,18 @@ type SearchHitNavigationActions = { options?: { force?: boolean; messageId?: string; + searchNavigationId?: string; threadRootId?: string | null; }, ) => Promise; goForumPost: ( channelId: string, postId: string, - options?: { force?: boolean; replyId?: string }, + options?: { + force?: boolean; + replyId?: string; + searchNavigationId?: string; + }, ) => Promise; signal?: AbortSignal; }; @@ -31,8 +36,11 @@ export async function openSearchHitWithNavigation( } const isLifecycleBound = Boolean(actions.signal); + const searchNavigationId = actions.query + ? `${hit.eventId}:${crypto.randomUUID()}` + : undefined; if (!isLifecycleBound) { - cacheSearchHitEvent(hit, actions.query); + cacheSearchHitEvent(hit, actions.query, searchNavigationId); } const destination = await resolveDestination(hit); @@ -43,19 +51,21 @@ export async function openSearchHitWithNavigation( if (isLifecycleBound) { // Delay community-scoped writes for notification routing until async // destination resolution completes and its owner is still current. - cacheSearchHitEvent(hit, actions.query); + cacheSearchHitEvent(hit, actions.query, searchNavigationId); } if (destination.kind === "forum-post") { return actions.goForumPost(destination.channelId, destination.postId, { force: actions.force, replyId: destination.replyId, + searchNavigationId, }); } return actions.goChannel(destination.channelId, { force: actions.force, messageId: destination.messageId, + searchNavigationId, threadRootId: destination.threadRootId, }); } diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index 790647effc7..ce6b517e3e6 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -251,6 +251,8 @@ export function useAppNavigation() { * silently swallowed (block/buzz#3509). */ force?: boolean; messageId?: string; + /** Search result id that makes repeated same-route activations observable. */ + searchNavigationId?: string; replace?: boolean; /** Open this thread panel directly without waiting for a timeline row. */ thread?: string; @@ -270,6 +272,9 @@ export function useAppNavigation() { threadRootId: options.threadRootId ?? undefined, } : {}), + ...(options?.searchNavigationId + ? { searchNavigationId: options.searchNavigationId } + : {}), ...(options?.agentSession ? { agentSession: options.agentSession } : {}), @@ -306,6 +311,8 @@ export function useAppNavigation() { force?: boolean; replace?: boolean; replyId?: string; + /** Search result id that makes repeated same-route activations observable. */ + searchNavigationId?: string; }, ) => commitNavigation( @@ -315,7 +322,12 @@ export function useAppNavigation() { channelId, postId, }, - search: options?.replyId ? { replyId: options.replyId } : {}, + search: { + ...(options?.replyId ? { replyId: options.replyId } : {}), + ...(options?.searchNavigationId + ? { searchNavigationId: options.searchNavigationId } + : {}), + }, }, { force: options?.force, diff --git a/desktop/src/app/routes/ChannelRouteScreen.tsx b/desktop/src/app/routes/ChannelRouteScreen.tsx index e4a0c1106e4..d380847407a 100644 --- a/desktop/src/app/routes/ChannelRouteScreen.tsx +++ b/desktop/src/app/routes/ChannelRouteScreen.tsx @@ -23,6 +23,7 @@ import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; type ChannelRouteScreenProps = { autoSendDraftKey: string | null; channelId: string; + searchNavigationId: string | null; selectedPostId: string | null; targetMessageId: string | null; targetReplyId: string | null; @@ -103,6 +104,7 @@ async function fetchRouteTargetEvents( export function ChannelRouteScreen({ autoSendDraftKey, channelId, + searchNavigationId, selectedPostId, targetMessageId, targetReplyId, @@ -138,11 +140,7 @@ export function ChannelRouteScreen({ const [searchHighlight, setSearchHighlight] = React.useState<{ messageId: string; query: string; - } | null>(() => { - const messageId = targetMessageId ?? targetReplyId ?? selectedPostId; - const query = consumeCachedSearchHitQuery(messageId); - return messageId && query ? { messageId, query } : null; - }); + } | null>(null); // Reset spliced target events and search highlighting when the channel // changes. Tied to channel identity rather @@ -161,12 +159,18 @@ export function ChannelRouteScreen({ }, [channelId]); React.useEffect(() => { - const messageId = targetMessageId ?? targetReplyId ?? selectedPostId; - const query = consumeCachedSearchHitQuery(messageId); - if (messageId && query) { - setSearchHighlight({ messageId, query }); + if (!searchNavigationId) { + return; + } + + const highlight = consumeCachedSearchHitQuery(searchNavigationId); + if (highlight) { + setSearchHighlight({ + messageId: highlight.eventId, + query: highlight.query, + }); } - }, [targetMessageId, targetReplyId, selectedPostId]); + }, [searchNavigationId]); React.useEffect(() => { let isCancelled = false; diff --git a/desktop/src/app/routes/channels.$channelId.posts.$postId.tsx b/desktop/src/app/routes/channels.$channelId.posts.$postId.tsx index 1025cc1e89a..6fde13c67e8 100644 --- a/desktop/src/app/routes/channels.$channelId.posts.$postId.tsx +++ b/desktop/src/app/routes/channels.$channelId.posts.$postId.tsx @@ -6,6 +6,7 @@ import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; type ForumPostRouteSearch = { replyId?: string; + searchNavigationId?: string; }; function validateForumPostSearch( @@ -16,6 +17,11 @@ function validateForumPostSearch( typeof search.replyId === "string" && search.replyId.length > 0 ? search.replyId : undefined, + searchNavigationId: + typeof search.searchNavigationId === "string" && + search.searchNavigationId.length > 0 + ? search.searchNavigationId + : undefined, }; } @@ -41,6 +47,7 @@ function ForumPostRouteComponent() { void; }; @@ -32,6 +34,7 @@ export default function DiffMessage({ filePath, commitSha, description, + searchQuery, truncated, onExpand, }: DiffMessageProps) { @@ -116,7 +119,7 @@ export default function DiffMessage({ {description && (
- {description} +
)} @@ -126,6 +129,7 @@ export default function DiffMessage({ className="p-3" content={content} fallbackFilePath={filePath} + searchQuery={searchQuery} viewType="unified" /> diff --git a/desktop/src/features/messages/ui/DiffViewer.tsx b/desktop/src/features/messages/ui/DiffViewer.tsx index 1444cf99777..ed6703f66cb 100644 --- a/desktop/src/features/messages/ui/DiffViewer.tsx +++ b/desktop/src/features/messages/ui/DiffViewer.tsx @@ -2,6 +2,8 @@ import { Diff, Hunk, type ViewType } from "react-diff-view"; import "react-diff-view/style/index.css"; import { useMemo } from "react"; +import { buildSearchResultPreview } from "@/features/search/lib/searchMatch"; +import { HighlightedSearchText } from "@/features/search/ui/HighlightedSearchText"; import { countDiffFileChanges, DIFF_TYPE_LABELS, @@ -18,6 +20,7 @@ type DiffViewerProps = { fallbackFilePath?: string; viewType?: ViewType; className?: string; + searchQuery?: string; }; function FileChangeBadge({ @@ -46,16 +49,20 @@ export function DiffViewer({ fallbackFilePath, viewType = "unified", className, + searchQuery, }: DiffViewerProps) { const { files, parseError } = useMemo( () => parseUnifiedDiff(content), [content], ); + const searchPreview = searchQuery + ? buildSearchResultPreview(content, searchQuery, 160) + : null; if (parseError) { return (
-        {content}
+        
       
); } @@ -70,6 +77,17 @@ export function DiffViewer({ return (
+ {searchPreview ? ( +
+          
+        
+ ) : null}
{files.map((file) => { const label = getDiffFileLabel(file, fallbackFilePath); diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 536631b02d3..0573a3186b8 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -396,6 +396,7 @@ export const MessageRow = React.memo( setExpandedDiffId(message.id); }} repoUrl={getTag("repo")} + searchQuery={searchQuery} truncated={getTag("truncated") === "true"} /> @@ -417,6 +418,7 @@ export const MessageRow = React.memo( fallbackText={waveMessage.fallbackText} huddleMemberPubkeys={huddleMemberPubkeys} huddleMemberPubkeysPending={huddleMemberPubkeysPending} + searchQuery={searchQuery} /> ); } diff --git a/desktop/src/features/messages/ui/WaveMessageAttachment.tsx b/desktop/src/features/messages/ui/WaveMessageAttachment.tsx index f782326295a..9c1a6feeeea 100644 --- a/desktop/src/features/messages/ui/WaveMessageAttachment.tsx +++ b/desktop/src/features/messages/ui/WaveMessageAttachment.tsx @@ -3,6 +3,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { channelsQueryKey } from "@/features/channels/hooks"; +import { HighlightedSearchText } from "@/features/search/ui/HighlightedSearchText"; import { useHuddle } from "@/features/huddle"; import { formatHuddleActionError } from "@/features/huddle/lib/huddleError"; import { @@ -20,6 +21,7 @@ type WaveMessageAttachmentProps = { fallbackText: string; huddleMemberPubkeys?: readonly string[]; huddleMemberPubkeysPending?: boolean; + searchQuery?: string; }; export function WaveMessageAttachment({ @@ -27,6 +29,7 @@ export function WaveMessageAttachment({ fallbackText, huddleMemberPubkeys = [], huddleMemberPubkeysPending = false, + searchQuery, }: WaveMessageAttachmentProps) { const queryClient = useQueryClient(); const { isStarting, startHuddle } = useHuddle(); @@ -68,7 +71,12 @@ export function WaveMessageAttachment({ 👋 - {fallbackText} + + + Start a huddle to talk to them. diff --git a/desktop/src/features/search/lib/searchMatch.test.mjs b/desktop/src/features/search/lib/searchMatch.test.mjs index 1d18bda6d10..12ee7e5d27c 100644 --- a/desktop/src/features/search/lib/searchMatch.test.mjs +++ b/desktop/src/features/search/lib/searchMatch.test.mjs @@ -19,6 +19,24 @@ test("splitSearchMatches treats regex punctuation literally", () => { ]); }); +test("splitSearchMatches highlights non-adjacent prefix-search terms", () => { + assert.deepEqual(splitSearchMatches("agent status mentions", "agent ment"), [ + { isMatch: true, key: "0-5", text: "agent" }, + { isMatch: false, key: "5-8", text: " status " }, + { isMatch: true, key: "13-4", text: "ment" }, + { isMatch: false, key: "17-4", text: "ions" }, + ]); +}); + +test("splitSearchMatches supports one-character scoped search", () => { + assert.deepEqual(splitSearchMatches("A plan", "a"), [ + { isMatch: true, key: "0-1", text: "A" }, + { isMatch: false, key: "1-3", text: " pl" }, + { isMatch: true, key: "4-1", text: "a" }, + { isMatch: false, key: "5-1", text: "n" }, + ]); +}); + test("buildSearchResultPreview keeps a late match visible", () => { const content = `${"prefix ".repeat(30)}mentions appear here ${"suffix ".repeat(20)}`; const preview = buildSearchResultPreview(content, "mentions", 96); diff --git a/desktop/src/features/search/lib/searchMatch.ts b/desktop/src/features/search/lib/searchMatch.ts index 3f41944fd3e..349b0ec0cc3 100644 --- a/desktop/src/features/search/lib/searchMatch.ts +++ b/desktop/src/features/search/lib/searchMatch.ts @@ -8,17 +8,34 @@ function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } -/** Split text around every case-insensitive literal match of the query. */ +/** + * Terms used by desktop prefix search. Completed whitespace-delimited terms + * match whole words; the trailing term also matches word prefixes. + */ +export function getSearchHighlightTerms(query: string): string[] { + const terms = query + .trim() + .split(/\s+/) + .map((term) => term.replace(/^[^\p{L}\p{N}_]+|[^\p{L}\p{N}_+]+$/gu, "")) + .filter(Boolean); + + return [...new Set(terms.map((term) => term.toLocaleLowerCase()))].sort( + (left, right) => right.length - left.length, + ); +} + +/** Split text around every case-insensitive token/prefix match of the query. */ export function splitSearchMatches( text: string, query: string, ): SearchMatchPart[] { - const trimmedQuery = query.trim(); - if (!trimmedQuery) { + const terms = getSearchHighlightTerms(query); + if (terms.length === 0) { return [{ isMatch: false, key: "0", text }]; } - const pattern = new RegExp(`(${escapeRegExp(trimmedQuery)})`, "gi"); + const pattern = new RegExp(`(${terms.map(escapeRegExp).join("|")})`, "giu"); + const termSet = new Set(terms); let offset = 0; return text .split(pattern) @@ -27,7 +44,7 @@ export function splitSearchMatches( const key = `${offset}-${part.length}`; offset += part.length; return { - isMatch: part.toLowerCase() === trimmedQuery.toLowerCase(), + isMatch: termSet.has(part.toLocaleLowerCase()), key, text: part, }; @@ -35,9 +52,9 @@ export function splitSearchMatches( } /** - * Build a compact result excerpt that keeps the first literal match visible. - * Context is biased slightly before the match so the result still reads like - * a sentence while avoiding a snippet whose matching word is offscreen. + * Build a compact result excerpt that keeps the first matching search term + * visible. Context is biased before the match so the excerpt still reads like + * a sentence while avoiding a match that is clipped offscreen. */ export function buildSearchResultPreview( content: string, @@ -52,10 +69,11 @@ export function buildSearchResultPreview( return text; } - const trimmedQuery = query.trim(); - const matchIndex = trimmedQuery - ? text.toLowerCase().indexOf(trimmedQuery.toLowerCase()) - : -1; + const normalizedText = text.toLocaleLowerCase(); + const matchIndex = getSearchHighlightTerms(query).reduce((earliest, term) => { + const index = normalizedText.indexOf(term); + return index >= 0 && (earliest < 0 || index < earliest) ? index : earliest; + }, -1); if (matchIndex < 0) { return `${text.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`; } diff --git a/desktop/src/features/search/ui/TopbarSearch.tsx b/desktop/src/features/search/ui/TopbarSearch.tsx index 6f695286fd2..70365497bcc 100644 --- a/desktop/src/features/search/ui/TopbarSearch.tsx +++ b/desktop/src/features/search/ui/TopbarSearch.tsx @@ -426,7 +426,10 @@ export function TopbarSearch({ scopeChannelId, }); const trimmedQuery = query.trim(); - const highlightQuery = parseSearchOperators(trimmedQuery).text; + // Bind highlights to the debounced result source so stale results can never + // pair with newly typed text during the debounce window. + const resultQuery = parseSearchOperators(debouncedQuery).text; + const resultsAreCurrent = debouncedQuery === trimmedQuery; const isIconVariant = variant === "icon"; const currentChannel = currentChannelId ? (channelLookup.get(currentChannelId) ?? null) @@ -495,9 +498,10 @@ export function TopbarSearch({ ), [currentPubkeyNormalized, results], ); + const visibleSearchableResults = resultsAreCurrent ? searchableResults : []; const searchResultSections = React.useMemo( - () => groupSearchResults(searchableResults), - [searchableResults], + () => groupSearchResults(visibleSearchableResults), + [visibleSearchableResults], ); const groupedSearchResults = React.useMemo( () => searchResultSections.flatMap((section) => section.results), @@ -507,8 +511,11 @@ export function TopbarSearch({ ? scopeChannel ? [] : suggestionResults - : groupedSearchResults; + : resultsAreCurrent + ? groupedSearchResults + : []; const isSearchLoading = + (!isShowingSuggestions && !resultsAreCurrent) || isWaitingOnFromResolution || searchQuery.isLoading || fuzzyUserCandidatesQuery.isLoading || @@ -572,7 +579,7 @@ export function TopbarSearch({ return; } - onOpenResult(result.hit, highlightQuery); + onOpenResult(result.hit, resultQuery); }, [ onBrowseChannels, @@ -583,7 +590,7 @@ export function TopbarSearch({ onOpenUser, openAfterExit, setQuery, - highlightQuery, + resultQuery, ], ); @@ -689,7 +696,7 @@ export function TopbarSearch({ ? result.action.description : result.kind === "user" ? getUserSecondaryLabel(result.user) - : buildSearchResultPreview(result.hit.content, highlightQuery); + : buildSearchResultPreview(result.hit.content, resultQuery); const trailingLabel = result.kind === "channel" ? getChannelSuggestionMeta(result.channel) @@ -763,10 +770,7 @@ export function TopbarSearch({ ) : null} {preview ? ( - + ) : null} @@ -868,12 +872,13 @@ export function TopbarSearch({
) - ) : isSearchLoading && searchableResults.length === 0 ? ( + ) : isSearchLoading && visibleSearchableResults.length === 0 ? (
{currentChannelSearchAction}
- ) : searchQuery.error instanceof Error && searchableResults.length === 0 ? ( + ) : searchQuery.error instanceof Error && + visibleSearchableResults.length === 0 ? (
{currentChannelSearchAction}

- ) : searchableResults.length === 0 ? ( + ) : visibleSearchableResults.length === 0 ? (
{currentChannelSearchAction}

{ diff --git a/desktop/src/shared/lib/rehypeSearchHighlight.ts b/desktop/src/shared/lib/rehypeSearchHighlight.ts index 547f3c3a0e7..e3de49a6649 100644 --- a/desktop/src/shared/lib/rehypeSearchHighlight.ts +++ b/desktop/src/shared/lib/rehypeSearchHighlight.ts @@ -6,6 +6,7 @@ * ReactMarkdown's architecture — no post-render tree walking needed. */ +import { splitSearchMatches } from "@/features/search/lib/searchMatch"; import { SEARCH_MATCH_HIGHLIGHT_CLASS } from "@/shared/lib/searchHighlightStyle"; // Minimal HAST types — matches the pattern in rehypeImageGallery.ts. @@ -36,32 +37,21 @@ function isText(node: HastNode): node is HastText { return node.type === "text"; } -function escapeRegExp(string: string): string { - return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - export default function rehypeSearchHighlight({ query }: { query: string }) { return (tree: HastRoot) => { - const trimmed = query.trim(); - if (trimmed.length < 2) return; - - const pattern = new RegExp(`(${escapeRegExp(trimmed)})`, "gi"); - function walk(nodes: HastNode[]): HastNode[] { const result: HastNode[] = []; for (const node of nodes) { if (isText(node)) { - const parts = node.value.split(pattern); - if (parts.length === 1) { + const parts = splitSearchMatches(node.value, query); + if (!parts.some((part) => part.isMatch)) { result.push(node); continue; } for (const part of parts) { - if (!part) continue; - - if (part.toLowerCase() === trimmed.toLowerCase()) { + if (part.isMatch) { result.push({ type: "element", tagName: "mark", @@ -69,10 +59,10 @@ export default function rehypeSearchHighlight({ query }: { query: string }) { className: SEARCH_MATCH_HIGHLIGHT_CLASS, "data-search-match": "true", }, - children: [{ type: "text", value: part }], + children: [{ type: "text", value: part.text }], }); } else { - result.push({ type: "text", value: part }); + result.push({ type: "text", value: part.text }); } } } else if (isElement(node)) { diff --git a/desktop/src/shared/ui/markdown/nodeCache.test.mjs b/desktop/src/shared/ui/markdown/nodeCache.test.mjs index 3a44e9f6d99..c95708c3bf6 100644 --- a/desktop/src/shared/ui/markdown/nodeCache.test.mjs +++ b/desktop/src/shared/ui/markdown/nodeCache.test.mjs @@ -227,6 +227,14 @@ test("hardLineBreaks changes the parse and the cache key", () => { assert.equal(withoutBreaks, withoutBreaksAgain); }); +test("single-character scoped search highlights the destination", () => { + const html = renderToStaticMarkup( + renderCachedMarkdown({ ...BASE, content: "A plan", searchQuery: "a" }), + ); + + assert.equal((html.match(/data-search-match="true"/g) ?? []).length, 2); +}); + test("active search queries bypass the cache and highlight every match", () => { clearMarkdownNodeCache(); const input = { diff --git a/desktop/src/shared/ui/markdown/nodeCache.ts b/desktop/src/shared/ui/markdown/nodeCache.ts index 549ca892eab..9e012f0549d 100644 --- a/desktop/src/shared/ui/markdown/nodeCache.ts +++ b/desktop/src/shared/ui/markdown/nodeCache.ts @@ -94,7 +94,7 @@ function buildMarkdownElement(input: MarkdownParseInputs): React.ReactElement { if (input.leadingInlineContent) { rehypePlugins.push(rehypeLeadingInlineContent); } - if (input.searchQuery && input.searchQuery.trim().length >= 2) { + if (input.searchQuery && input.searchQuery.trim().length >= 1) { rehypePlugins.push([rehypeSearchHighlight, { query: input.searchQuery }]); } // Called as a plain function rather than rendered as : @@ -131,7 +131,7 @@ export function renderCachedMarkdown( // than churn the cache with per-query variants. Oversized content parses // fresh too — see MARKDOWN_NODE_CACHE_MAX_CONTENT_LENGTH. if ( - (input.searchQuery && input.searchQuery.trim().length >= 2) || + (input.searchQuery && input.searchQuery.trim().length >= 1) || input.content.length > MARKDOWN_NODE_CACHE_MAX_CONTENT_LENGTH ) { return buildMarkdownElement(input); diff --git a/desktop/tests/e2e/smoke.spec.ts b/desktop/tests/e2e/smoke.spec.ts index d770137ff61..af3d9d5bd08 100644 --- a/desktop/tests/e2e/smoke.spec.ts +++ b/desktop/tests/e2e/smoke.spec.ts @@ -370,6 +370,48 @@ test("highlights the query in search results and the opened message", async ({ ); }); +test("highlights the clicked forum post when its route is already open", async ({ + page, +}) => { + await page.goto( + "/#/channels/a27e1ee9-76a6-5bdf-a5d5-1d85610dad11/posts/mock-forum-release-thread", + ); + await expect( + page.locator('[data-forum-event-id="mock-forum-release-thread"]'), + ).toBeVisible(); + await page.keyboard.press("ControlOrMeta+f"); + await page.getByTestId("search-dialog-input").fill("checklist"); + + const result = page.getByTestId("search-result-mock-forum-release-thread"); + await expect(result).toBeVisible(); + await result.click(); + + const post = page.locator( + '[data-forum-event-id="mock-forum-release-thread"]', + ); + await expect(post.locator('[data-search-match="true"]')).toHaveText( + "checklist", + ); +}); + +test("does not expose stale search results with a newly typed query", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-engineering").click(); + await page.keyboard.press("ControlOrMeta+f"); + const input = page.getByTestId("search-dialog-input"); + await input.fill("shipped"); + await expect( + page.getByTestId("search-result-mock-engineering-shipped"), + ).toBeVisible(); + + await input.fill("mentions"); + await expect( + page.getByTestId("search-result-mock-engineering-shipped"), + ).toHaveCount(0); +}); + test("opens channel matches from search", async ({ page }) => { await page.goto("/"); From bec014507a5edc03d5964c30fe8c807eb86b2b7e Mon Sep 17 00:00:00 2001 From: tulsi Date: Mon, 24 Aug 2026 13:49:23 -0400 Subject: [PATCH 3/9] test(desktop): accept search navigation state Signed-off-by: tulsi --- desktop/tests/e2e/smoke.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/tests/e2e/smoke.spec.ts b/desktop/tests/e2e/smoke.spec.ts index af3d9d5bd08..1cf774c18e2 100644 --- a/desktop/tests/e2e/smoke.spec.ts +++ b/desktop/tests/e2e/smoke.spec.ts @@ -338,7 +338,7 @@ test("opens sidebar search with the shortcut and loads the exact result", async await page.keyboard.press("Enter"); await expect(page).toHaveURL( - /#\/channels\/1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9\?messageId=mock-engineering-shipped$/, + /#\/channels\/1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9\?messageId=mock-engineering-shipped&searchNavigationId=mock-engineering-shipped%3A[^&]+$/, ); await expect(page.getByTestId("chat-title")).toHaveText("engineering"); await expect(page.getByTestId("message-timeline")).toContainText( From 3a9155fb98a7ee6e92816b85ff52fdf2997fb366 Mon Sep 17 00:00:00 2001 From: tulsi Date: Mon, 24 Aug 2026 15:27:59 -0400 Subject: [PATCH 4/9] fix(desktop): align search highlight activation and tokens Signed-off-by: tulsi --- desktop/src/app/routes/ChannelRouteScreen.tsx | 22 ++- .../features/search/lib/searchMatch.test.mjs | 53 ++++-- .../src/features/search/lib/searchMatch.ts | 153 ++++++++++++++---- .../src/shared/ui/markdown/nodeCache.test.mjs | 4 +- desktop/src/testing/e2eBridge.ts | 9 ++ desktop/tests/e2e/smoke.spec.ts | 31 ++++ 6 files changed, 220 insertions(+), 52 deletions(-) diff --git a/desktop/src/app/routes/ChannelRouteScreen.tsx b/desktop/src/app/routes/ChannelRouteScreen.tsx index d380847407a..d23068506d8 100644 --- a/desktop/src/app/routes/ChannelRouteScreen.tsx +++ b/desktop/src/app/routes/ChannelRouteScreen.tsx @@ -141,6 +141,7 @@ export function ChannelRouteScreen({ messageId: string; query: string; } | null>(null); + const activeSearchNavigationIdRef = React.useRef(null); // Reset spliced target events and search highlighting when the channel // changes. Tied to channel identity rather @@ -154,22 +155,31 @@ export function ChannelRouteScreen({ React.useEffect(() => { if (previousResetKeyRef.current === channelId) return; previousResetKeyRef.current = channelId; + activeSearchNavigationIdRef.current = null; setTargetMessageEvents([]); setSearchHighlight(null); }, [channelId]); React.useEffect(() => { if (!searchNavigationId) { + activeSearchNavigationIdRef.current = null; + setSearchHighlight(null); + return; + } + if (activeSearchNavigationIdRef.current === searchNavigationId) { return; } + activeSearchNavigationIdRef.current = searchNavigationId; const highlight = consumeCachedSearchHitQuery(searchNavigationId); - if (highlight) { - setSearchHighlight({ - messageId: highlight.eventId, - query: highlight.query, - }); - } + setSearchHighlight( + highlight + ? { + messageId: highlight.eventId, + query: highlight.query, + } + : null, + ); }, [searchNavigationId]); React.useEffect(() => { diff --git a/desktop/src/features/search/lib/searchMatch.test.mjs b/desktop/src/features/search/lib/searchMatch.test.mjs index 12ee7e5d27c..71db9b5e5a7 100644 --- a/desktop/src/features/search/lib/searchMatch.test.mjs +++ b/desktop/src/features/search/lib/searchMatch.test.mjs @@ -3,7 +3,7 @@ import test from "node:test"; import { buildSearchResultPreview, splitSearchMatches } from "./searchMatch.ts"; -test("splitSearchMatches highlights every case-insensitive literal match", () => { +test("splitSearchMatches highlights every case-insensitive lexeme match", () => { assert.deepEqual(splitSearchMatches("Mentions and mentions", "mentions"), [ { isMatch: true, key: "0-8", text: "Mentions" }, { isMatch: false, key: "8-5", text: " and " }, @@ -11,11 +11,38 @@ test("splitSearchMatches highlights every case-insensitive literal match", () => ]); }); -test("splitSearchMatches treats regex punctuation literally", () => { - assert.deepEqual(splitSearchMatches("Use C++ (not C)", "C++"), [ - { isMatch: false, key: "0-4", text: "Use " }, - { isMatch: true, key: "4-3", text: "C++" }, - { isMatch: false, key: "7-8", text: " (not C)" }, +test("splitSearchMatches normalizes punctuation into search lexemes", () => { + assert.deepEqual(splitSearchMatches("foo bar release", "foo-bar"), [ + { isMatch: true, key: "0-3", text: "foo" }, + { isMatch: false, key: "3-1", text: " " }, + { isMatch: true, key: "4-3", text: "bar" }, + { isMatch: false, key: "7-8", text: " release" }, + ]); +}); + +test("splitSearchMatches keeps completed tokens on lexeme boundaries", () => { + assert.deepEqual( + splitSearchMatches("projectile notes about project planning", "project pl"), + [ + { + isMatch: false, + key: "0-23", + text: "projectile notes about ", + }, + { isMatch: true, key: "23-7", text: "project" }, + { isMatch: false, key: "30-1", text: " " }, + { isMatch: true, key: "31-2", text: "pl" }, + { isMatch: false, key: "33-6", text: "anning" }, + ], + ); +}); + +test("splitSearchMatches preserves exact and prefix modes for a repeated term", () => { + assert.deepEqual(splitSearchMatches("foo foobar", "foo foo"), [ + { isMatch: true, key: "0-3", text: "foo" }, + { isMatch: false, key: "3-1", text: " " }, + { isMatch: true, key: "4-3", text: "foo" }, + { isMatch: false, key: "7-3", text: "bar" }, ]); }); @@ -28,12 +55,10 @@ test("splitSearchMatches highlights non-adjacent prefix-search terms", () => { ]); }); -test("splitSearchMatches supports one-character scoped search", () => { +test("splitSearchMatches keeps one-character prefixes on lexeme boundaries", () => { assert.deepEqual(splitSearchMatches("A plan", "a"), [ { isMatch: true, key: "0-1", text: "A" }, - { isMatch: false, key: "1-3", text: " pl" }, - { isMatch: true, key: "4-1", text: "a" }, - { isMatch: false, key: "5-1", text: "n" }, + { isMatch: false, key: "1-5", text: " plan" }, ]); }); @@ -47,6 +72,14 @@ test("buildSearchResultPreview keeps a late match visible", () => { assert.match(preview, /\.\.\.$/); }); +test("buildSearchResultPreview ignores an invalid completed-token substring", () => { + const content = `${"projectile filler ".repeat(20)}project planning release notes`; + const preview = buildSearchResultPreview(content, "project pl", 80); + + assert.match(preview, /project planning/); + assert.match(preview, /^\.\.\./); +}); + test("buildSearchResultPreview keeps the existing leading excerpt without a match", () => { assert.equal( buildSearchResultPreview("abcdefghijklmnopqrstuvwxyz", "missing", 10), diff --git a/desktop/src/features/search/lib/searchMatch.ts b/desktop/src/features/search/lib/searchMatch.ts index 349b0ec0cc3..5030ed1f708 100644 --- a/desktop/src/features/search/lib/searchMatch.ts +++ b/desktop/src/features/search/lib/searchMatch.ts @@ -4,51 +4,140 @@ export type SearchMatchPart = { text: string; }; -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +type SearchHighlightTerm = { + isPrefix: boolean; + value: string; +}; + +type TextLexeme = { + end: number; + normalized: string; + start: number; +}; + +// PostgreSQL's `simple` text-search configuration breaks ordinary punctuation +// into lexemes (for example, `foo-bar` contributes `foo` and `bar`). Keep the +// desktop highlighter on those lexical boundaries rather than treating raw +// whitespace tokens as unrestricted substrings. +const LEXEME_PATTERN = /[\p{L}\p{N}]+/gu; + +function extractLexemes(value: string): string[] { + return Array.from(value.matchAll(LEXEME_PATTERN), (match) => + match[0].toLowerCase(), + ); +} + +function getSearchHighlightMatchers(query: string): SearchHighlightTerm[] { + const rawTokens = query.trim().split(/\s+/).filter(Boolean); + const matchers: SearchHighlightTerm[] = []; + + rawTokens.forEach((rawToken, tokenIndex) => { + const isPrefix = tokenIndex === rawTokens.length - 1; + for (const value of extractLexemes(rawToken)) { + matchers.push({ isPrefix, value }); + } + }); + + // Deduplicate repeated constraints without collapsing exact and prefix modes: + // `foo foo` asks Postgres for both an exact `foo` and a `foo:*` lexeme. + const deduped = new Map(); + for (const matcher of matchers) { + deduped.set( + `${matcher.isPrefix ? "prefix" : "exact"}:${matcher.value}`, + matcher, + ); + } + return [...deduped.values()].sort( + (left, right) => right.value.length - left.value.length, + ); } /** - * Terms used by desktop prefix search. Completed whitespace-delimited terms - * match whole words; the trailing term also matches word prefixes. + * Lexemes used by desktop prefix search after punctuation normalization. + * Completed whitespace-delimited tokens match exactly; only lexemes from the + * trailing token match prefixes. */ export function getSearchHighlightTerms(query: string): string[] { - const terms = query - .trim() - .split(/\s+/) - .map((term) => term.replace(/^[^\p{L}\p{N}_]+|[^\p{L}\p{N}_+]+$/gu, "")) - .filter(Boolean); - - return [...new Set(terms.map((term) => term.toLocaleLowerCase()))].sort( - (left, right) => right.length - left.length, - ); + return getSearchHighlightMatchers(query).map((matcher) => matcher.value); } -/** Split text around every case-insensitive token/prefix match of the query. */ +function getTextLexemes(text: string): TextLexeme[] { + return Array.from(text.matchAll(LEXEME_PATTERN), (match) => ({ + end: (match.index ?? 0) + match[0].length, + normalized: match[0].toLowerCase(), + start: match.index ?? 0, + })); +} + +function getMatchSpans( + text: string, + query: string, +): Array<{ end: number; start: number }> { + const matchers = getSearchHighlightMatchers(query); + if (matchers.length === 0) { + return []; + } + + const spans: Array<{ end: number; start: number }> = []; + for (const lexeme of getTextLexemes(text)) { + const exactMatch = matchers.find( + (matcher) => !matcher.isPrefix && matcher.value === lexeme.normalized, + ); + if (exactMatch) { + spans.push({ start: lexeme.start, end: lexeme.end }); + continue; + } + + const prefixMatch = matchers.find( + (matcher) => + matcher.isPrefix && lexeme.normalized.startsWith(matcher.value), + ); + if (prefixMatch) { + spans.push({ + start: lexeme.start, + end: lexeme.start + prefixMatch.value.length, + }); + } + } + + return spans; +} + +/** Split text around case-insensitive lexeme/prefix matches of the query. */ export function splitSearchMatches( text: string, query: string, ): SearchMatchPart[] { - const terms = getSearchHighlightTerms(query); - if (terms.length === 0) { + const spans = getMatchSpans(text, query); + if (spans.length === 0) { return [{ isMatch: false, key: "0", text }]; } - const pattern = new RegExp(`(${terms.map(escapeRegExp).join("|")})`, "giu"); - const termSet = new Set(terms); + const parts: SearchMatchPart[] = []; let offset = 0; - return text - .split(pattern) - .filter(Boolean) - .map((part) => { - const key = `${offset}-${part.length}`; - offset += part.length; - return { - isMatch: termSet.has(part.toLocaleLowerCase()), - key, - text: part, - }; + for (const span of spans) { + if (span.start > offset) { + parts.push({ + isMatch: false, + key: `${offset}-${span.start - offset}`, + text: text.slice(offset, span.start), + }); + } + parts.push({ + isMatch: true, + key: `${span.start}-${span.end - span.start}`, + text: text.slice(span.start, span.end), }); + offset = span.end; + } + if (offset < text.length) { + parts.push({ + isMatch: false, + key: `${offset}-${text.length - offset}`, + text: text.slice(offset), + }); + } + return parts; } /** @@ -69,11 +158,7 @@ export function buildSearchResultPreview( return text; } - const normalizedText = text.toLocaleLowerCase(); - const matchIndex = getSearchHighlightTerms(query).reduce((earliest, term) => { - const index = normalizedText.indexOf(term); - return index >= 0 && (earliest < 0 || index < earliest) ? index : earliest; - }, -1); + const matchIndex = getMatchSpans(text, query)[0]?.start ?? -1; if (matchIndex < 0) { return `${text.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`; } diff --git a/desktop/src/shared/ui/markdown/nodeCache.test.mjs b/desktop/src/shared/ui/markdown/nodeCache.test.mjs index c95708c3bf6..becca46499f 100644 --- a/desktop/src/shared/ui/markdown/nodeCache.test.mjs +++ b/desktop/src/shared/ui/markdown/nodeCache.test.mjs @@ -227,12 +227,12 @@ test("hardLineBreaks changes the parse and the cache key", () => { assert.equal(withoutBreaks, withoutBreaksAgain); }); -test("single-character scoped search highlights the destination", () => { +test("single-character scoped search stays on lexeme boundaries", () => { const html = renderToStaticMarkup( renderCachedMarkdown({ ...BASE, content: "A plan", searchQuery: "a" }), ); - assert.equal((html.match(/data-search-match="true"/g) ?? []).length, 2); + assert.equal((html.match(/data-search-match="true"/g) ?? []).length, 1); }); test("active search queries bypass the cache and highlight every match", () => { diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 3252a025c0f..6e7d3f5d84c 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -4414,6 +4414,15 @@ function getMockMessageStore(channelId: string): RelayEvent[] { content: "Release checklist: async feedback thread.", sig: "mocksig".repeat(20).slice(0, 128), }, + { + id: "mock-forum-offsite-thread", + pubkey: ALICE_PUBKEY, + created_at: Math.floor(Date.now() / 1000) - 85 * 60, + kind: 45001, + tags: [["h", channelId]], + content: "Team offsite planning and travel notes.", + sig: "mocksig".repeat(20).slice(0, 128), + }, { id: "mock-forum-release-reply", pubkey: ALICE_PUBKEY, diff --git a/desktop/tests/e2e/smoke.spec.ts b/desktop/tests/e2e/smoke.spec.ts index 1cf774c18e2..8538df0a142 100644 --- a/desktop/tests/e2e/smoke.spec.ts +++ b/desktop/tests/e2e/smoke.spec.ts @@ -394,6 +394,37 @@ test("highlights the clicked forum post when its route is already open", async ( ); }); +test("ordinary forum navigation clears a prior search highlight", async ({ + page, +}) => { + await page.goto( + "/#/channels/a27e1ee9-76a6-5bdf-a5d5-1d85610dad11/posts/mock-forum-release-thread", + ); + await page.getByTestId("open-search").click(); + await page.getByTestId("search-dialog-input").fill("checklist"); + await page.getByTestId("search-result-mock-forum-release-thread").click(); + + const releasePost = page.locator( + '[data-forum-event-id="mock-forum-release-thread"]', + ); + await expect(releasePost.locator('[data-search-match="true"]')).toHaveText( + "checklist", + ); + + await page.getByTestId("channel-watercooler").click(); + await page.getByText("Team offsite planning and travel notes.").click(); + await expect( + page.locator('[data-forum-event-id="mock-forum-offsite-thread"]'), + ).toBeVisible(); + await page.getByTestId("channel-watercooler").click(); + await page.getByText("Release checklist: async feedback thread.").click(); + + await expect(releasePost).toBeVisible(); + await expect(releasePost.locator('[data-search-match="true"]')).toHaveCount( + 0, + ); +}); + test("does not expose stale search results with a newly typed query", async ({ page, }) => { From 810d5feadf653922bb77559cd2688a91d5063f82 Mon Sep 17 00:00:00 2001 From: tulsi Date: Mon, 24 Aug 2026 17:00:43 -0400 Subject: [PATCH 5/9] refactor(desktop): keep channel surfaces under size gate Signed-off-by: tulsi --- .../src/features/channels/ui/ChannelPane.tsx | 27 +- .../features/channels/ui/ChannelScreen.tsx | 381 +++++++++--------- .../channels/ui/searchTargetForwarding.tsx | 20 + .../channels/ui/useSearchHighlightProps.ts | 18 + 4 files changed, 229 insertions(+), 217 deletions(-) create mode 100644 desktop/src/features/channels/ui/searchTargetForwarding.tsx create mode 100644 desktop/src/features/channels/ui/useSearchHighlightProps.ts diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 331b730a876..19bb21b62fe 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -49,6 +49,7 @@ import { import { useWelcomeComposerBanner } from "@/features/channels/ui/useWelcomeComposerBanner"; import { mentionsKnownAgent } from "@/features/channels/ui/ChannelPane.helpers"; import { HuddleStartingView, HuddleTranscriptIntro } from "@/features/huddle"; +import { useSearchHighlightProps } from "@/features/channels/ui/useSearchHighlightProps"; import { useChannelIntro } from "@/features/channels/ui/useChannelIntro"; import type { ChannelPaneProps } from "@/features/channels/ui/ChannelPane.types"; import * as agentSessionSelection from "@/features/channels/ui/agentSessionSelection"; @@ -178,10 +179,9 @@ export const ChannelPane = React.memo(function ChannelPane({ currentPubkey, ); const mainComposerMedia = useMediaUpload({ deferUploadsUntilSend: true }); - const searchMatchingMessageIds = React.useMemo( - () => - targetSearchMessageId ? new Set([targetSearchMessageId]) : undefined, - [targetSearchMessageId], + const searchHighlightProps = useSearchHighlightProps( + targetSearchMessageId, + targetSearchQuery, ); const [isMainDeferredEditPending, setMainDeferredEditPending] = React.useState(false); @@ -201,8 +201,6 @@ export const ChannelPane = React.memo(function ChannelPane({ channelPaneMountedRef.current = false; }; }, []); - // Clear only the auto-send key so thread state survives deferred submission; - // older wrappers fall back to goChannel to prevent back-navigation replay. const handleAutoSubmitComplete = React.useCallback(() => { if (onAutoSendComplete) { onAutoSendComplete(); @@ -259,9 +257,6 @@ export const ChannelPane = React.memo(function ChannelPane({ ); const timeoutState = useTimeoutState(); - // A moderation DM (1:1 with the relay identity) is read-only for the member; - // only DMs pay for the NIP-11 `self` lookup. Fails open: no `relaySelf` → - // ordinary DM, composer enabled. const relaySelfQuery = useRelaySelfQuery(activeChannel?.channelType === "dm"); const isModerationDmChannel = isModerationDm( activeChannel ?? null, @@ -346,10 +341,6 @@ export const ChannelPane = React.memo(function ChannelPane({ !isMainDeferredEditPending && !isSinglePanelView; const hasTypingActivity = typingPubkeys.length > 0; - // Unified working set for the composer bar: observer-derived turns primary, - // bot typing fallback (both folded together by agentWorkingSignal). This is - // what makes the bar show for an agent whose observer stream is live but - // whose typing signal never arrives — and vice versa. const composerWorkingBotPubkeys = useChannelWorkingAgentPubkeys( activeChannel?.id ?? null, ); @@ -703,8 +694,7 @@ export const ChannelPane = React.memo(function ChannelPane({ } onTargetReached={onTargetReached} onToggleReaction={onToggleReaction} - searchMatchingMessageIds={searchMatchingMessageIds} - searchQuery={targetSearchQuery} + {...searchHighlightProps.timeline} targetMessageId={targetMessageId} splitThreadPanelOpen={ useSplitAuxiliaryPane && @@ -903,8 +893,7 @@ export const ChannelPane = React.memo(function ChannelPane({ replyTargetMessage={threadReplyTargetMessage} scrollTargetHighlights={!layoutScrollTargetId} scrollTargetId={layoutScrollTargetId ?? threadScrollTargetId} - searchMessageId={targetSearchMessageId} - searchQuery={targetSearchQuery} + {...searchHighlightProps.thread} threadHead={threadHeadMessage} videoReviewPresentation={threadVideoReviewPresentation} widthPx={threadPanelWidthPx} @@ -951,10 +940,6 @@ export const ChannelPane = React.memo(function ChannelPane({ })() ) : activeChannel && selectedAgent ? ( (() => { - // When the panel was opened from a different channel than the - // currently active one, re-scope it to the active channel so - // that both the content/header AND channel-backed actions (e.g. - // Stop current turn) operate on the same channel object. const effectiveAgentSessionChannelId = openAgentSessionChannelId && activeChannel.id !== openAgentSessionChannelId diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 8be3c93e3c2..fe0c4d23b7d 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -7,24 +7,14 @@ import { useChannelPaneHandlers } from "@/features/channels/useChannelPaneHandle import { useMessageEventProfilePubkeys } from "@/features/channels/useMessageEventProfilePubkeys"; import { useMessageOwnerProfiles } from "@/features/channels/useMessageOwnerProfiles"; import { useThreadTargetSync } from "@/features/channels/useThreadTargetSync"; -import { - useChannelMembersQuery, - useJoinChannelMutation, -} from "@/features/channels/hooks"; -import { - MSG_PREFIX, - THREAD_PREFIX, -} from "@/features/channels/readState/readStateFormat"; +import * as channelHooks from "@/features/channels/hooks"; +import * as readStateFormat from "@/features/channels/readState/readStateFormat"; import { ChannelScreenEmptyState } from "@/features/channels/ui/ChannelScreenEmptyState"; import { ChannelScreenHeader } from "@/features/channels/ui/ChannelScreenHeader"; import { WelcomeAgentCreateDialog } from "@/features/channels/ui/WelcomeAgentCreateDialog"; import { ForumChannelContent } from "@/features/channels/ui/ForumChannelContent"; import { MembersSidebar } from "@/features/channels/ui/MembersSidebar"; -import { - useManagedAgentsQuery, - usePersonasQuery, - useRelayAgentsQuery, -} from "@/features/agents/hooks"; +import * as agentHooks from "@/features/agents/hooks"; import { mergeChannelKnownAgentPubkeys } from "@/features/agents/knownAgentPubkeys"; import { useKnownAgentPubkeys } from "@/features/agents/useKnownAgentPubkeys"; import { pickWelcomeGuideAgent } from "@/features/onboarding/welcomeGuide"; @@ -44,10 +34,7 @@ import { import { buildMessageComposerEditTarget } from "@/features/messages/lib/draftMentionRefs"; import { formatTimelineMessages } from "@/features/messages/lib/formatTimelineMessages"; import { DeleteMessageConfirmDialog } from "@/features/messages/ui/DeleteMessageConfirmDialog"; -import { - getThreadReference, - isThreadReply, -} from "@/features/messages/lib/threading"; +import * as threading from "@/features/messages/lib/threading"; import { hasPersistedHydratedChannel } from "@/features/messages/lib/channelHeadCache"; import { resolveTimelineLoadingLatch, @@ -62,10 +49,7 @@ import { useUsersBatchQuery } from "@/features/profile/hooks"; import { useRelaySelfQuery } from "@/features/moderation/hooks"; import type { RelayEvent, RespondToMode } from "@/shared/api/types"; import { ChannelScreenLoadingFallback } from "@/features/channels/ui/ChannelScreenLoadingFallback"; -import { - useHuddleChannelMessages, - useIsHuddleTranscript, -} from "@/features/channels/ui/useHuddleChannelMessages"; +import * as huddleMessages from "@/features/channels/ui/useHuddleChannelMessages"; import { useHuddleReadMarker } from "@/features/channels/ui/useHuddleReadMarker"; import { useHuddleThreadIsolation } from "@/features/channels/ui/useHuddleThreadIsolation"; import { AgentSessionProvider } from "@/shared/context/AgentSessionContext"; @@ -89,6 +73,7 @@ import { useChannelUnreadState } from "./useChannelUnreadState"; import type { ChannelScreenProps } from "./ChannelScreen.types"; import { GuardedChannelPane } from "./GuardedChannelPane"; import { useNavigationGuard } from "./useNavigationGuard"; +import * as searchForwarding from "./searchTargetForwarding"; const EMPTY_RELAY_EVENTS: RelayEvent[] = []; export function ChannelScreen({ activeChannel, @@ -101,8 +86,7 @@ export function ChannelScreen({ targetForumReplyId, targetMessageEvents, targetMessageId, - targetSearchMessageId, - targetSearchQuery, + ...searchTarget }: ChannelScreenProps) { const queryClient = useQueryClient(); const { goHome } = useAppNavigation(); @@ -173,7 +157,8 @@ export function ChannelScreen({ const mainInsetRef = useMainInsetRef(); const currentPubkey = currentIdentity?.pubkey; const activeChannelId = activeChannel?.id ?? null; - const isHuddleTranscript = useIsHuddleTranscript(activeChannelId); + const isHuddleTranscript = + huddleMessages.useIsHuddleTranscript(activeChannelId); const relaySelfPubkey = useRelaySelfQuery(activeChannel !== null).data; const requireThreadEditResolutionRef = React.useRef<() => boolean>( () => true, @@ -216,7 +201,7 @@ export function ChannelScreen({ const messages = messagesQuery.data; if (!messages) return null; for (let index = messages.length - 1; index >= 0; index -= 1) { - if (getThreadReference(messages[index].tags).parentId === null) + if (threading.getThreadReference(messages[index].tags).parentId === null) return messages[index]; } return null; @@ -235,7 +220,8 @@ export function ChannelScreen({ return; } setContextParentResolver((contextId) => - contextId.startsWith(THREAD_PREFIX) || contextId.startsWith(MSG_PREFIX) + contextId.startsWith(readStateFormat.THREAD_PREFIX) || + contextId.startsWith(readStateFormat.MSG_PREFIX) ? activeChannelId : null, ); @@ -255,13 +241,14 @@ export function ChannelScreen({ const toggleReactionMutation = useToggleReactionMutation(); const deleteMessageMutation = useDeleteMessageMutation(activeChannel); const editMessageMutation = useEditMessageMutation(activeChannel); - const joinChannelMutation = useJoinChannelMutation(activeChannelId); + const joinChannelMutation = + channelHooks.useJoinChannelMutation(activeChannelId); const { resolvedMessages, threadSummaries, threadRepliesError: huddleThreadRepliesError, onRetryThreadReplies: onRetryHuddleThreadReplies, - } = useHuddleChannelMessages({ + } = huddleMessages.useHuddleChannelMessages({ activeChannel, isHuddleTranscript, messages: messagesQuery.data ?? EMPTY_RELAY_EVENTS, @@ -307,9 +294,11 @@ export function ChannelScreen({ : [], [activeChannel], ); - const channelMembersQuery = useChannelMembersQuery(activeChannel?.id ?? null); + const channelMembersQuery = channelHooks.useChannelMembersQuery( + activeChannel?.id ?? null, + ); const channelMembers = channelMembersQuery.data; - const managedAgentsQuery = useManagedAgentsQuery(); + const managedAgentsQuery = agentHooks.useManagedAgentsQuery(); const managedAgents = managedAgentsQuery.data ?? []; const welcomeGuideAgent = React.useMemo( () => pickWelcomeGuideAgent(managedAgents), @@ -320,7 +309,7 @@ export function ChannelScreen({ currentIdentity, welcomeGuideAgent, }); - const relayAgentsQuery = useRelayAgentsQuery(); + const relayAgentsQuery = agentHooks.useRelayAgentsQuery(); const relayAgents = relayAgentsQuery.data ?? []; const knownAgentPubkeys = React.useMemo( () => @@ -387,7 +376,7 @@ export function ChannelScreen({ } return pubkeys; }, [knownAgentPubkeys, messageProfiles, communityAgentPubkeys]); - const personasQuery = usePersonasQuery(); + const personasQuery = agentHooks.usePersonasQuery(); const { personaLookup, respondToLookup } = React.useMemo(() => { const agents = managedAgentsQuery.data ?? []; const personaById = new Map( @@ -504,7 +493,8 @@ export function ChannelScreen({ editMessageMutation, editTargetId, editTargetIsThreadReply: - editTargetMessage !== null && isThreadReply(editTargetMessage.tags ?? []), + editTargetMessage !== null && + threading.isThreadReply(editTargetMessage.tags ?? []), expandedThreadReplyIds, getFirstReplyIdForMessage, getReplyDescendantIdsForMessage, @@ -634,9 +624,6 @@ export function ChannelScreen({ isPlaceholderData: messagesQuery.isPlaceholderData, dataLength: messagesQuery.data?.length ?? null, }, - // A persisted head only counts as hydrated when it has rows to paint - // (channelHeadCache.ts), so this bypass never settles onto an empty - // placeholder while the authoritative refresh is still in flight. hasSettledThisChannel || (activeChannelId !== null && hasPersistedHydratedChannel(queryClient, activeChannelId)), @@ -827,174 +814,176 @@ export function ChannelScreen({ > {activeChannel ? ( activeChannel.channelType === "forum" ? ( - - ) : ( - - } - > - - knownAgentPubkeys.has(pubkey) || - !!messageProfiles?.[pubkey]?.isAgent, - ) - : null - } - followThreadById={followThread} - unfollowThreadById={unfollowThread} - isFollowingThreadById={isFollowingThread} - isMessageUnreadById={isMessageUnread} - isFollowingThread={isNotifiedForEffectiveThread} - isSending={sendMessageMutation.isPending} - isSinglePanelView={isSinglePanelView} - isTimelineLoading={isTimelineLoading} - messages={timelineMessages} - threadSummaries={threadSummaries} - huddleThreadRepliesError={huddleThreadRepliesError} - onRetryHuddleThreadReplies={onRetryHuddleThreadReplies} - onCancelEdit={handleCancelEdit} - onCancelThreadReply={handleCancelThreadReply} - onChannelManagementDeleted={handleChannelManagementDeleted} - onFollowThread={ - effectiveOpenThreadHeadId != null && - !isNotifiedForEffectiveThread - ? () => followThread(effectiveOpenThreadHeadId) - : undefined - } - onUnfollowThread={ - effectiveOpenThreadHeadId != null && - isNotifiedForEffectiveThread - ? () => unfollowThread(effectiveOpenThreadHeadId) - : undefined - } - onCloseAgentSession={handleCloseAgentSession} - onBackFromAgentSession={ - hasAgentSessionReturnTarget - ? handleBackFromAgentSession - : undefined - } - onCloseChannelManagement={handleCloseChannelManagement} - onCloseThread={handleCloseThread} - onDelete={ - activeChannel?.archivedAt ? undefined : handleDelete - } - onEdit={activeChannel?.archivedAt ? undefined : handleEdit} - onEditSave={ - activeChannel?.archivedAt ? undefined : handleEditSave - } - onMarkUnread={handleMessageMarkUnread} - onMarkRead={handleMessageMarkRead} - onExpandThreadReplies={handleExpandThreadReplies} - onOpenAgentSession={handleOpenAgentSession} + onClosePost={onCloseForumPost} + onCloseProfilePanel={handleCloseProfilePanel} onOpenDm={handleOpenDm} onOpenProfilePanel={handleOpenProfilePanel} - onResetThreadPanelWidth={handleThreadPanelWidthReset} - onCloseProfilePanel={handleCloseProfilePanel} - onOpenThread={handleOpenThreadAndCloseAgentSession} - onSelectThreadReplyTarget={handleSelectThreadReplyTarget} - onSendMessage={handleSendMessage} - onSendToChannel={handleSendToChannel} - onSendVideoReviewComment={effectiveSendVideoReviewComment} - onSendThreadReply={handleSendThreadReply} - onThreadScrollTargetResolved={() => - setThreadScrollTargetId(null) - } - onThreadPanelResizeStart={handleThreadPanelResizeStart} - onTargetReached={() => - clearMessageRouteTarget({ replace: true }) - } - onToggleReaction={effectiveToggleReaction} - openAgentSessionChannelId={openAgentSessionChannelId} - openAgentSessionPubkey={openAgentSessionPubkey} - openThreadHeadId={effectiveOpenThreadHeadId} - shouldShowThreadSkeleton={shouldShowThreadSkeleton} - onProfilePanelViewChange={setProfilePanelView} + onPanelResizeStart={handleThreadPanelResizeStart} onProfilePanelTabChange={setProfilePanelTab} + onProfilePanelViewChange={setProfilePanelView} + onResetPanelWidth={handleThreadPanelWidthReset} + onSelectPost={onSelectForumPost} + panelWidthPx={threadPanelWidthPx} profilePanelPubkey={profilePanelPubkey} profilePanelTab={profilePanelTab} profilePanelView={profilePanelView} - personaLookup={personaLookup} - profiles={messageProfiles} - ownerProfiles={messageOwnerProfiles} - firstUnreadMessageId={firstUnreadMessageId} - unreadCount={unreadCount} - targetMessageId={mainTimelineTargetMessageId} - targetSearchMessageId={targetSearchMessageId} - targetSearchQuery={targetSearchQuery} - threadAllMessages={displayedThreadAllMessages} - threadHeadMessage={displayedThreadHeadMessage} - threadMessages={displayedThreadMessages} - threadMessagesPending={threadRepliesQuery.isPending} - threadMessagesError={threadRepliesQuery.isError} - onRetryThreadReplies={() => { - void threadRepliesQuery.refetch(); - }} - threadPanelWidthPx={threadPanelWidthPx} - threadTypingPubkeys={threadTypingPubkeys} - threadReplyTargetMessage={displayedThreadReplyTargetMessage} - threadScrollTargetId={threadScrollTargetId} - threadUnreadCounts={threadUnreadCounts} - threadReplyUnreadCounts={threadReplyUnreadCounts} - threadFirstUnreadReplyId={displayedThreadFirstUnreadReplyId} - isJoining={joinChannelMutation.isPending} - onJoinChannel={joinChannelMutation.mutateAsync} - typingPubkeys={humanTypingPubkeys} - /> + selectedPostId={selectedForumPostId} + targetReplyId={targetForumReplyId} + />, + searchTarget, + ) + ) : ( + + } + > + {searchForwarding.renderSearchAwareChannel( + + knownAgentPubkeys.has(pubkey) || + !!messageProfiles?.[pubkey]?.isAgent, + ) + : null + } + followThreadById={followThread} + unfollowThreadById={unfollowThread} + isFollowingThreadById={isFollowingThread} + isMessageUnreadById={isMessageUnread} + isFollowingThread={isNotifiedForEffectiveThread} + isSending={sendMessageMutation.isPending} + isSinglePanelView={isSinglePanelView} + isTimelineLoading={isTimelineLoading} + messages={timelineMessages} + threadSummaries={threadSummaries} + huddleThreadRepliesError={huddleThreadRepliesError} + onRetryHuddleThreadReplies={onRetryHuddleThreadReplies} + onCancelEdit={handleCancelEdit} + onCancelThreadReply={handleCancelThreadReply} + onChannelManagementDeleted={handleChannelManagementDeleted} + onFollowThread={ + effectiveOpenThreadHeadId != null && + !isNotifiedForEffectiveThread + ? () => followThread(effectiveOpenThreadHeadId) + : undefined + } + onUnfollowThread={ + effectiveOpenThreadHeadId != null && + isNotifiedForEffectiveThread + ? () => unfollowThread(effectiveOpenThreadHeadId) + : undefined + } + onCloseAgentSession={handleCloseAgentSession} + onBackFromAgentSession={ + hasAgentSessionReturnTarget + ? handleBackFromAgentSession + : undefined + } + onCloseChannelManagement={handleCloseChannelManagement} + onCloseThread={handleCloseThread} + onDelete={ + activeChannel?.archivedAt ? undefined : handleDelete + } + onEdit={activeChannel?.archivedAt ? undefined : handleEdit} + onEditSave={ + activeChannel?.archivedAt ? undefined : handleEditSave + } + onMarkUnread={handleMessageMarkUnread} + onMarkRead={handleMessageMarkRead} + onExpandThreadReplies={handleExpandThreadReplies} + onOpenAgentSession={handleOpenAgentSession} + onOpenDm={handleOpenDm} + onOpenProfilePanel={handleOpenProfilePanel} + onResetThreadPanelWidth={handleThreadPanelWidthReset} + onCloseProfilePanel={handleCloseProfilePanel} + onOpenThread={handleOpenThreadAndCloseAgentSession} + onSelectThreadReplyTarget={handleSelectThreadReplyTarget} + onSendMessage={handleSendMessage} + onSendToChannel={handleSendToChannel} + onSendVideoReviewComment={effectiveSendVideoReviewComment} + onSendThreadReply={handleSendThreadReply} + onThreadScrollTargetResolved={() => + setThreadScrollTargetId(null) + } + onThreadPanelResizeStart={handleThreadPanelResizeStart} + onTargetReached={() => + clearMessageRouteTarget({ replace: true }) + } + onToggleReaction={effectiveToggleReaction} + openAgentSessionChannelId={openAgentSessionChannelId} + openAgentSessionPubkey={openAgentSessionPubkey} + openThreadHeadId={effectiveOpenThreadHeadId} + shouldShowThreadSkeleton={shouldShowThreadSkeleton} + onProfilePanelViewChange={setProfilePanelView} + onProfilePanelTabChange={setProfilePanelTab} + profilePanelPubkey={profilePanelPubkey} + profilePanelTab={profilePanelTab} + profilePanelView={profilePanelView} + personaLookup={personaLookup} + profiles={messageProfiles} + ownerProfiles={messageOwnerProfiles} + firstUnreadMessageId={firstUnreadMessageId} + unreadCount={unreadCount} + targetMessageId={mainTimelineTargetMessageId} + threadAllMessages={displayedThreadAllMessages} + threadHeadMessage={displayedThreadHeadMessage} + threadMessages={displayedThreadMessages} + threadMessagesPending={threadRepliesQuery.isPending} + threadMessagesError={threadRepliesQuery.isError} + onRetryThreadReplies={() => { + void threadRepliesQuery.refetch(); + }} + threadPanelWidthPx={threadPanelWidthPx} + threadTypingPubkeys={threadTypingPubkeys} + threadReplyTargetMessage={displayedThreadReplyTargetMessage} + threadScrollTargetId={threadScrollTargetId} + threadUnreadCounts={threadUnreadCounts} + threadReplyUnreadCounts={threadReplyUnreadCounts} + threadFirstUnreadReplyId={displayedThreadFirstUnreadReplyId} + isJoining={joinChannelMutation.isPending} + onJoinChannel={joinChannelMutation.mutateAsync} + typingPubkeys={humanTypingPubkeys} + />, + searchTarget, + )} ) ) : ( diff --git a/desktop/src/features/channels/ui/searchTargetForwarding.tsx b/desktop/src/features/channels/ui/searchTargetForwarding.tsx new file mode 100644 index 00000000000..925156258c9 --- /dev/null +++ b/desktop/src/features/channels/ui/searchTargetForwarding.tsx @@ -0,0 +1,20 @@ +import * as React from "react"; + +import type { ForumChannelContent } from "./ForumChannelContent"; +import type { GuardedChannelPane } from "./GuardedChannelPane"; +import type { ChannelScreenProps } from "./ChannelScreen.types"; + +type SearchTarget = Pick< + ChannelScreenProps, + "targetSearchMessageId" | "targetSearchQuery" +>; + +export const renderSearchAwareForum = ( + node: React.ReactElement>, + target: SearchTarget, +) => React.cloneElement(node, target); + +export const renderSearchAwareChannel = ( + node: React.ReactElement>, + target: SearchTarget, +) => React.cloneElement(node, target); diff --git a/desktop/src/features/channels/ui/useSearchHighlightProps.ts b/desktop/src/features/channels/ui/useSearchHighlightProps.ts new file mode 100644 index 00000000000..b506fee182f --- /dev/null +++ b/desktop/src/features/channels/ui/useSearchHighlightProps.ts @@ -0,0 +1,18 @@ +import * as React from "react"; + +export function useSearchHighlightProps( + messageId: string | null | undefined, + query: string | undefined, +) { + const searchMatchingMessageIds = React.useMemo( + () => (messageId ? new Set([messageId]) : undefined), + [messageId], + ); + return React.useMemo( + () => ({ + thread: { searchMessageId: messageId, searchQuery: query }, + timeline: { searchMatchingMessageIds, searchQuery: query }, + }), + [messageId, query, searchMatchingMessageIds], + ); +} From dabf7fb42d61ba030c66797a8650f1650bba632d Mon Sep 17 00:00:00 2001 From: tulsi Date: Tue, 25 Aug 2026 09:52:18 -0400 Subject: [PATCH 6/9] refactor(desktop): keep search highlight state transient Signed-off-by: tulsi --- .../searchHighlightNavigation.test.mjs | 39 +++++++ .../navigation/searchHighlightNavigation.ts | 47 ++++++++ .../src/app/navigation/searchHitEventCache.ts | 59 ++--------- .../navigation/searchHitNavigation.test.mjs | 25 ++--- .../src/app/navigation/searchHitNavigation.ts | 24 +++-- .../src/app/navigation/useAppNavigation.ts | 33 ++++-- desktop/src/app/routes/ChannelRouteScreen.tsx | 100 ++++++++++-------- .../channels.$channelId.posts.$postId.tsx | 14 ++- .../src/app/routes/channels.$channelId.tsx | 11 +- .../routes/searchHighlightRouteState.test.mjs | 32 ++++++ .../app/routes/searchHighlightRouteState.ts | 13 +++ desktop/tests/e2e/smoke.spec.ts | 2 +- 12 files changed, 255 insertions(+), 144 deletions(-) create mode 100644 desktop/src/app/navigation/searchHighlightNavigation.test.mjs create mode 100644 desktop/src/app/navigation/searchHighlightNavigation.ts create mode 100644 desktop/src/app/routes/searchHighlightRouteState.test.mjs create mode 100644 desktop/src/app/routes/searchHighlightRouteState.ts diff --git a/desktop/src/app/navigation/searchHighlightNavigation.test.mjs b/desktop/src/app/navigation/searchHighlightNavigation.test.mjs new file mode 100644 index 00000000000..1e05a62669f --- /dev/null +++ b/desktop/src/app/navigation/searchHighlightNavigation.test.mjs @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +const { createSearchHighlightNavigation, parseSearchHighlightNavigation } = + await import("./searchHighlightNavigation.ts"); + +test("creates trimmed transient state with a unique activation id", () => { + const first = createSearchHighlightNavigation("message", " Mentions "); + const second = createSearchHighlightNavigation("message", "Mentions"); + + assert.deepEqual( + { messageId: first.messageId, query: first.query }, + { messageId: "message", query: "Mentions" }, + ); + assert.notEqual(first.activationId, second.activationId); +}); + +test("does not create highlight state for an empty query", () => { + assert.equal(createSearchHighlightNavigation("message", " "), undefined); + assert.equal( + createSearchHighlightNavigation("message", undefined), + undefined, + ); +}); + +test("parses only complete highlight navigation state", () => { + const state = { + activationId: "activation", + messageId: "message", + query: "mentions", + }; + + assert.deepEqual(parseSearchHighlightNavigation(state), state); + assert.equal( + parseSearchHighlightNavigation({ messageId: "message", query: "mentions" }), + null, + ); + assert.equal(parseSearchHighlightNavigation(null), null); +}); diff --git a/desktop/src/app/navigation/searchHighlightNavigation.ts b/desktop/src/app/navigation/searchHighlightNavigation.ts new file mode 100644 index 00000000000..43d3506ec8c --- /dev/null +++ b/desktop/src/app/navigation/searchHighlightNavigation.ts @@ -0,0 +1,47 @@ +export type SearchHighlightNavigation = { + activationId: string; + messageId: string; + query: string; +}; + +export function createSearchHighlightNavigation( + messageId: string, + query: string | undefined, +): SearchHighlightNavigation | undefined { + const trimmedQuery = query?.trim(); + if (!trimmedQuery) { + return undefined; + } + + return { + activationId: crypto.randomUUID(), + messageId, + query: trimmedQuery, + }; +} + +export function parseSearchHighlightNavigation( + value: unknown, +): SearchHighlightNavigation | null { + if (!value || typeof value !== "object") { + return null; + } + + const candidate = value as Partial; + if ( + typeof candidate.activationId !== "string" || + candidate.activationId.length === 0 || + typeof candidate.messageId !== "string" || + candidate.messageId.length === 0 || + typeof candidate.query !== "string" || + candidate.query.length === 0 + ) { + return null; + } + + return { + activationId: candidate.activationId, + messageId: candidate.messageId, + query: candidate.query, + }; +} diff --git a/desktop/src/app/navigation/searchHitEventCache.ts b/desktop/src/app/navigation/searchHitEventCache.ts index b1d59667ca3..b57a5f01fb1 100644 --- a/desktop/src/app/navigation/searchHitEventCache.ts +++ b/desktop/src/app/navigation/searchHitEventCache.ts @@ -2,35 +2,20 @@ import type { RelayEvent, SearchHit } from "@/shared/api/types"; const MAX_CACHED_EVENTS = 200; const searchHitEventCache = new Map(); -const searchHitQueryCache = new Map< - string, - { eventId: string; query: string } ->(); function trimCache() { - const eventOverflow = searchHitEventCache.size - MAX_CACHED_EVENTS; - let removedEvents = 0; - for (const key of searchHitEventCache.keys()) { - if (removedEvents >= eventOverflow) { - break; - } - searchHitEventCache.delete(key); - for (const [navigationId, entry] of searchHitQueryCache) { - if (entry.eventId === key) { - searchHitQueryCache.delete(navigationId); - } - } - removedEvents++; + if (searchHitEventCache.size <= MAX_CACHED_EVENTS) { + return; } - const queryOverflow = searchHitQueryCache.size - MAX_CACHED_EVENTS; - let removedQueries = 0; - for (const navigationId of searchHitQueryCache.keys()) { - if (removedQueries >= queryOverflow) { + const overflow = searchHitEventCache.size - MAX_CACHED_EVENTS; + let removed = 0; + for (const key of searchHitEventCache.keys()) { + if (removed >= overflow) { break; } - searchHitQueryCache.delete(navigationId); - removedQueries++; + searchHitEventCache.delete(key); + removed++; } } @@ -46,29 +31,15 @@ export function buildSearchHitEvent(hit: SearchHit): RelayEvent { }; } -export function cacheSearchHitEvent( - hit: SearchHit, - query?: string, - searchNavigationId = hit.eventId, -): RelayEvent { +export function cacheSearchHitEvent(hit: SearchHit): RelayEvent { const event = buildSearchHitEvent(hit); searchHitEventCache.set(event.id, event); - const trimmedQuery = query?.trim(); - if (trimmedQuery) { - searchHitQueryCache.set(searchNavigationId, { - eventId: event.id, - query: trimmedQuery, - }); - } else { - searchHitQueryCache.delete(searchNavigationId); - } trimCache(); return event; } export function clearSearchHitEventCache(): void { searchHitEventCache.clear(); - searchHitQueryCache.clear(); } export function getCachedSearchHitEvent( @@ -80,15 +51,3 @@ export function getCachedSearchHitEvent( return searchHitEventCache.get(eventId) ?? null; } - -export function consumeCachedSearchHitQuery( - searchNavigationId: string | null | undefined, -): { eventId: string; query: string } | null { - if (!searchNavigationId) { - return null; - } - - const entry = searchHitQueryCache.get(searchNavigationId) ?? null; - searchHitQueryCache.delete(searchNavigationId); - return entry; -} diff --git a/desktop/src/app/navigation/searchHitNavigation.test.mjs b/desktop/src/app/navigation/searchHitNavigation.test.mjs index 3bf0f0deb56..02276d9eb34 100644 --- a/desktop/src/app/navigation/searchHitNavigation.test.mjs +++ b/desktop/src/app/navigation/searchHitNavigation.test.mjs @@ -51,7 +51,7 @@ test("search-hit navigation preserves forced message routing while active", asyn options: { force: true, messageId: "message", - searchNavigationId: undefined, + searchHighlight: undefined, threadRootId: "thread-root", }, }, @@ -59,11 +59,8 @@ test("search-hit navigation preserves forced message routing while active", asyn assert.equal(getCachedSearchHitEvent("message")?.id, "message"); }); -test("search-hit navigation retains the query and marks repeated route activations", async () => { +test("search-hit navigation carries trimmed highlight state and forces repeated activations", async () => { clearSearchHitEventCache(); - const { consumeCachedSearchHitQuery } = await import( - "./searchHitEventCache.ts" - ); const calls = []; await openSearchHitWithNavigation(plainMessage, { @@ -75,16 +72,13 @@ test("search-hit navigation retains the query and marks repeated route activatio query: " Mentions ", }); - const searchNavigationId = calls[0].options.searchNavigationId; - assert.match(searchNavigationId, /^message:/); - assert.deepEqual(consumeCachedSearchHitQuery(searchNavigationId), { - eventId: "message", - query: "Mentions", - }); - assert.equal(consumeCachedSearchHitQuery(searchNavigationId), null); + assert.equal(calls[0].options.force, true); + assert.equal(calls[0].options.searchHighlight.messageId, "message"); + assert.equal(calls[0].options.searchHighlight.query, "Mentions"); + assert.match(calls[0].options.searchHighlight.activationId, /.+/); }); -test("forum-post search navigation marks same-route activations", async () => { +test("forum-post search navigation carries transient same-route activation state", async () => { clearSearchHitEventCache(); const forumPost = { ...forumComment, eventId: "post", kind: 45001 }; const calls = []; @@ -98,7 +92,10 @@ test("forum-post search navigation marks same-route activations", async () => { query: "mentions", }); - assert.match(calls[0].options.searchNavigationId, /^post:/); + assert.equal(calls[0].options.force, true); + assert.equal(calls[0].options.searchHighlight.messageId, "post"); + assert.equal(calls[0].options.searchHighlight.query, "mentions"); + assert.match(calls[0].options.searchHighlight.activationId, /.+/); }); test("cancelled search-hit navigation cannot repopulate cache or route", async () => { diff --git a/desktop/src/app/navigation/searchHitNavigation.ts b/desktop/src/app/navigation/searchHitNavigation.ts index 40574a415d5..6b180c33f00 100644 --- a/desktop/src/app/navigation/searchHitNavigation.ts +++ b/desktop/src/app/navigation/searchHitNavigation.ts @@ -1,4 +1,5 @@ import { resolveSearchHitDestination } from "@/app/navigation/resolveSearchHitDestination"; +import { createSearchHighlightNavigation } from "@/app/navigation/searchHighlightNavigation"; import { cacheSearchHitEvent } from "@/app/navigation/searchHitEventCache"; import type { SearchHit } from "@/shared/api/types"; @@ -10,7 +11,7 @@ type SearchHitNavigationActions = { options?: { force?: boolean; messageId?: string; - searchNavigationId?: string; + searchHighlight?: ReturnType; threadRootId?: string | null; }, ) => Promise; @@ -20,7 +21,7 @@ type SearchHitNavigationActions = { options?: { force?: boolean; replyId?: string; - searchNavigationId?: string; + searchHighlight?: ReturnType; }, ) => Promise; signal?: AbortSignal; @@ -36,11 +37,12 @@ export async function openSearchHitWithNavigation( } const isLifecycleBound = Boolean(actions.signal); - const searchNavigationId = actions.query - ? `${hit.eventId}:${crypto.randomUUID()}` - : undefined; + const searchHighlight = createSearchHighlightNavigation( + hit.eventId, + actions.query, + ); if (!isLifecycleBound) { - cacheSearchHitEvent(hit, actions.query, searchNavigationId); + cacheSearchHitEvent(hit); } const destination = await resolveDestination(hit); @@ -51,21 +53,21 @@ export async function openSearchHitWithNavigation( if (isLifecycleBound) { // Delay community-scoped writes for notification routing until async // destination resolution completes and its owner is still current. - cacheSearchHitEvent(hit, actions.query, searchNavigationId); + cacheSearchHitEvent(hit); } if (destination.kind === "forum-post") { return actions.goForumPost(destination.channelId, destination.postId, { - force: actions.force, + force: actions.force || Boolean(searchHighlight), replyId: destination.replyId, - searchNavigationId, + searchHighlight, }); } return actions.goChannel(destination.channelId, { - force: actions.force, + force: actions.force || Boolean(searchHighlight), messageId: destination.messageId, - searchNavigationId, + searchHighlight, threadRootId: destination.threadRootId, }); } diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index 96361f23d6e..4978a207365 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -6,6 +6,7 @@ import { useRouter, } from "@tanstack/react-router"; +import type { SearchHighlightNavigation } from "@/app/navigation/searchHighlightNavigation"; import { openSearchHitWithNavigation } from "@/app/navigation/searchHitNavigation"; import { allowNavigation, @@ -32,7 +33,11 @@ export function useAppNavigation() { to: string; params?: Record; search?: Record; - state?: Record; + state?: + | Record + | (( + previousState: Record, + ) => Record); }, behavior: NavigationBehavior = {}, guardedTarget?: GuardedNavigation, @@ -265,8 +270,8 @@ export function useAppNavigation() { * silently swallowed (block/buzz#3509). */ force?: boolean; messageId?: string; - /** Search result id that makes repeated same-route activations observable. */ - searchNavigationId?: string; + /** Transient context for highlighting the selected search result. */ + searchHighlight?: SearchHighlightNavigation; replace?: boolean; /** Open this thread panel directly without waiting for a timeline row. */ thread?: string; @@ -286,15 +291,18 @@ export function useAppNavigation() { threadRootId: options.threadRootId ?? undefined, } : {}), - ...(options?.searchNavigationId - ? { searchNavigationId: options.searchNavigationId } - : {}), ...(options?.agentSession ? { agentSession: options.agentSession } : {}), ...(options?.thread ? { thread: options.thread } : {}), ...(options?.autoSend ? { autoSend: options.autoSend } : {}), }, + state: options?.searchHighlight + ? (previousState: Record) => ({ + ...previousState, + searchHighlight: options.searchHighlight, + }) + : undefined, }, { force: options?.force, @@ -334,8 +342,8 @@ export function useAppNavigation() { force?: boolean; replace?: boolean; replyId?: string; - /** Search result id that makes repeated same-route activations observable. */ - searchNavigationId?: string; + /** Transient context for highlighting the selected search result. */ + searchHighlight?: SearchHighlightNavigation; }, ) => { return commitNavigation( @@ -347,10 +355,13 @@ export function useAppNavigation() { }, search: { ...(options?.replyId ? { replyId: options.replyId } : {}), - ...(options?.searchNavigationId - ? { searchNavigationId: options.searchNavigationId } - : {}), }, + state: options?.searchHighlight + ? (previousState: Record) => ({ + ...previousState, + searchHighlight: options.searchHighlight, + }) + : undefined, }, { force: options?.force, diff --git a/desktop/src/app/routes/ChannelRouteScreen.tsx b/desktop/src/app/routes/ChannelRouteScreen.tsx index d23068506d8..1dfe67e674d 100644 --- a/desktop/src/app/routes/ChannelRouteScreen.tsx +++ b/desktop/src/app/routes/ChannelRouteScreen.tsx @@ -1,9 +1,7 @@ import * as React from "react"; -import { - getCachedSearchHitEvent, - consumeCachedSearchHitQuery, -} from "@/app/navigation/searchHitEventCache"; +import type { SearchHighlightNavigation } from "@/app/navigation/searchHighlightNavigation"; +import { getCachedSearchHitEvent } from "@/app/navigation/searchHitEventCache"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useChannelsQuery } from "@/features/channels/hooks"; import { useOpenChannelDirectoryQuery } from "@/features/channels/openChannelDirectory"; @@ -23,7 +21,7 @@ import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; type ChannelRouteScreenProps = { autoSendDraftKey: string | null; channelId: string; - searchNavigationId: string | null; + searchHighlight: SearchHighlightNavigation | null; selectedPostId: string | null; targetMessageId: string | null; targetReplyId: string | null; @@ -104,7 +102,7 @@ async function fetchRouteTargetEvents( export function ChannelRouteScreen({ autoSendDraftKey, channelId, - searchNavigationId, + searchHighlight, selectedPostId, targetMessageId, targetReplyId, @@ -137,50 +135,62 @@ export function ChannelRouteScreen({ const cachedTarget = getCachedSearchHitEvent(targetMessageId); return cachedTarget ? [cachedTarget] : []; }); - const [searchHighlight, setSearchHighlight] = React.useState<{ - messageId: string; - query: string; - } | null>(null); - const activeSearchNavigationIdRef = React.useRef(null); - - // Reset spliced target events and search highlighting when the channel - // changes. Tied to channel identity rather - // than the route target so clearing the `messageId` param or resolving a - // forum post mid-channel keeps the clicked highlight in view. Seeded with - // the mount key so the initial - // cache-seeded events survive first commit; only a genuine channel change - // clears them. Declared before the fetch effect so a channel switch clears - // stale events before the new target is fetched. - const previousResetKeyRef = React.useRef(channelId); - React.useEffect(() => { - if (previousResetKeyRef.current === channelId) return; - previousResetKeyRef.current = channelId; - activeSearchNavigationIdRef.current = null; - setTargetMessageEvents([]); - setSearchHighlight(null); - }, [channelId]); + const [activeSearchHighlight, setActiveSearchHighlight] = + React.useState(searchHighlight); + const appliedSearchActivationIdRef = React.useRef( + searchHighlight?.activationId ?? null, + ); + // Router state is transient and can be cleared by the target URL cleanup. + // Retain the applied activation locally until an ordinary route transition + // explicitly arrives without search state. React.useEffect(() => { - if (!searchNavigationId) { - activeSearchNavigationIdRef.current = null; - setSearchHighlight(null); + if (!searchHighlight) { + const ordinaryTargetIds = [ + selectedPostId, + targetMessageId, + targetReplyId, + targetThreadRootId, + ].filter((targetId): targetId is string => targetId !== null); + if ( + ordinaryTargetIds.length > 0 && + activeSearchHighlight && + !ordinaryTargetIds.includes(activeSearchHighlight.messageId) + ) { + appliedSearchActivationIdRef.current = null; + setActiveSearchHighlight(null); + } return; } - if (activeSearchNavigationIdRef.current === searchNavigationId) { + if (appliedSearchActivationIdRef.current === searchHighlight.activationId) { return; } - activeSearchNavigationIdRef.current = searchNavigationId; - const highlight = consumeCachedSearchHitQuery(searchNavigationId); - setSearchHighlight( - highlight - ? { - messageId: highlight.eventId, - query: highlight.query, - } - : null, - ); - }, [searchNavigationId]); + appliedSearchActivationIdRef.current = searchHighlight.activationId; + setActiveSearchHighlight(searchHighlight); + }, [ + activeSearchHighlight, + searchHighlight, + selectedPostId, + targetMessageId, + targetReplyId, + targetThreadRootId, + ]); + + // Reset spliced target events when the channel changes. Tied to channel + // identity rather than the route target so clearing the `messageId` param + // mid-channel keeps the deep-linked row in view. Seeded with the mount key so + // the initial cache-seeded events survive first commit; only a genuine + // channel change clears them. Declared before the fetch effect so a channel + // switch clears stale events before the new target is fetched. + const previousResetKeyRef = React.useRef(channelId); + React.useEffect(() => { + if (previousResetKeyRef.current === channelId) return; + previousResetKeyRef.current = channelId; + appliedSearchActivationIdRef.current = null; + setTargetMessageEvents([]); + setActiveSearchHighlight(null); + }, [channelId]); React.useEffect(() => { let isCancelled = false; @@ -266,8 +276,8 @@ export function ChannelRouteScreen({ targetForumReplyId={targetReplyId} targetMessageEvents={targetMessageEvents} targetMessageId={targetMessageId} - targetSearchMessageId={searchHighlight?.messageId} - targetSearchQuery={searchHighlight?.query} + targetSearchMessageId={activeSearchHighlight?.messageId} + targetSearchQuery={activeSearchHighlight?.query} /> ); } diff --git a/desktop/src/app/routes/channels.$channelId.posts.$postId.tsx b/desktop/src/app/routes/channels.$channelId.posts.$postId.tsx index 6fde13c67e8..8fcab41817d 100644 --- a/desktop/src/app/routes/channels.$channelId.posts.$postId.tsx +++ b/desktop/src/app/routes/channels.$channelId.posts.$postId.tsx @@ -1,12 +1,12 @@ import * as React from "react"; -import { createFileRoute } from "@tanstack/react-router"; +import { createFileRoute, useLocation } from "@tanstack/react-router"; +import { selectSearchHighlightRouteState } from "@/app/routes/searchHighlightRouteState"; import { usePreviewFeatureWarning } from "@/shared/features"; import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; type ForumPostRouteSearch = { replyId?: string; - searchNavigationId?: string; }; function validateForumPostSearch( @@ -17,11 +17,6 @@ function validateForumPostSearch( typeof search.replyId === "string" && search.replyId.length > 0 ? search.replyId : undefined, - searchNavigationId: - typeof search.searchNavigationId === "string" && - search.searchNavigationId.length > 0 - ? search.searchNavigationId - : undefined, }; } @@ -39,6 +34,9 @@ function ForumPostRouteComponent() { usePreviewFeatureWarning("forum"); const { channelId, postId } = Route.useParams(); const search = Route.useSearch(); + const searchHighlight = useLocation({ + select: selectSearchHighlightRouteState, + }); return ( { function ChannelRouteComponent() { const { channelId } = Route.useParams(); const search = Route.useSearch(); + const searchHighlight = useLocation({ + select: selectSearchHighlightRouteState, + }); const isHuddleTranscript = huddleWindowChannelId() !== null; return ( @@ -76,7 +79,7 @@ function ChannelRouteComponent() { { + assert.deepEqual( + selectSearchHighlightRouteState({ state: { searchHighlight } }), + searchHighlight, + ); +}); + +test("ordinary navigation without highlight state clears the selection", () => { + assert.equal(selectSearchHighlightRouteState({ state: {} }), null); +}); + +test("ignores malformed router state", () => { + assert.equal( + selectSearchHighlightRouteState({ + state: { searchHighlight: { messageId: "message", query: "mentions" } }, + }), + null, + ); +}); diff --git a/desktop/src/app/routes/searchHighlightRouteState.ts b/desktop/src/app/routes/searchHighlightRouteState.ts new file mode 100644 index 00000000000..c506db63e48 --- /dev/null +++ b/desktop/src/app/routes/searchHighlightRouteState.ts @@ -0,0 +1,13 @@ +import { + parseSearchHighlightNavigation, + type SearchHighlightNavigation, +} from "@/app/navigation/searchHighlightNavigation"; + +export function selectSearchHighlightRouteState(location: { + state: unknown; +}): SearchHighlightNavigation | null { + return parseSearchHighlightNavigation( + (location.state as { searchHighlight?: unknown } | undefined) + ?.searchHighlight, + ); +} diff --git a/desktop/tests/e2e/smoke.spec.ts b/desktop/tests/e2e/smoke.spec.ts index 693295461b2..98c7f5147bb 100644 --- a/desktop/tests/e2e/smoke.spec.ts +++ b/desktop/tests/e2e/smoke.spec.ts @@ -338,7 +338,7 @@ test("opens sidebar search with the shortcut and loads the exact result", async await page.keyboard.press("Enter"); await expect(page).toHaveURL( - /#\/channels\/1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9\?messageId=mock-engineering-shipped&searchNavigationId=mock-engineering-shipped%3A[^&]+$/, + /#\/channels\/1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9\?messageId=mock-engineering-shipped$/, ); await expect(page.getByTestId("chat-title")).toHaveText("engineering"); await expect(page.getByTestId("message-timeline")).toContainText( From 78b71ab0248f7f81e4f6d090026f768a239064a2 Mon Sep 17 00:00:00 2001 From: tulsi Date: Tue, 25 Aug 2026 10:51:37 -0400 Subject: [PATCH 7/9] fix(desktop): clear search highlight on route reselect Signed-off-by: tulsi --- .../src/app/navigation/useAppNavigation.ts | 39 +++++++++------ desktop/src/app/routes/ChannelRouteScreen.tsx | 11 +++-- .../routes/searchHighlightRouteState.test.mjs | 13 +++-- .../app/routes/searchHighlightRouteState.ts | 14 ++++-- desktop/src/app/useHuddlePresentation.ts | 2 +- desktop/tests/e2e/smoke.spec.ts | 47 +++++++++++++++++++ 6 files changed, 99 insertions(+), 27 deletions(-) diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index 4978a207365..3256c209244 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -43,8 +43,13 @@ export function useAppNavigation() { guardedTarget?: GuardedNavigation, ) => { const nextLocation = router.buildLocation(next as never); + const hasStateUpdate = next.state !== undefined; - if (location.href === nextLocation.href && !behavior.force) { + if ( + location.href === nextLocation.href && + !behavior.force && + !hasStateUpdate + ) { return false; } @@ -271,7 +276,7 @@ export function useAppNavigation() { force?: boolean; messageId?: string; /** Transient context for highlighting the selected search result. */ - searchHighlight?: SearchHighlightNavigation; + searchHighlight?: SearchHighlightNavigation | null; replace?: boolean; /** Open this thread panel directly without waiting for a timeline row. */ thread?: string; @@ -297,12 +302,14 @@ export function useAppNavigation() { ...(options?.thread ? { thread: options.thread } : {}), ...(options?.autoSend ? { autoSend: options.autoSend } : {}), }, - state: options?.searchHighlight - ? (previousState: Record) => ({ - ...previousState, - searchHighlight: options.searchHighlight, - }) - : undefined, + state: + options?.searchHighlight !== undefined + ? (previousState: Record) => { + const nextState = { ...previousState }; + nextState.searchHighlight = options.searchHighlight; + return nextState; + } + : undefined, }, { force: options?.force, @@ -343,7 +350,7 @@ export function useAppNavigation() { replace?: boolean; replyId?: string; /** Transient context for highlighting the selected search result. */ - searchHighlight?: SearchHighlightNavigation; + searchHighlight?: SearchHighlightNavigation | null; }, ) => { return commitNavigation( @@ -356,12 +363,14 @@ export function useAppNavigation() { search: { ...(options?.replyId ? { replyId: options.replyId } : {}), }, - state: options?.searchHighlight - ? (previousState: Record) => ({ - ...previousState, - searchHighlight: options.searchHighlight, - }) - : undefined, + state: + options?.searchHighlight !== undefined + ? (previousState: Record) => { + const nextState = { ...previousState }; + nextState.searchHighlight = options.searchHighlight; + return nextState; + } + : undefined, }, { force: options?.force, diff --git a/desktop/src/app/routes/ChannelRouteScreen.tsx b/desktop/src/app/routes/ChannelRouteScreen.tsx index 1dfe67e674d..70f276d42f5 100644 --- a/desktop/src/app/routes/ChannelRouteScreen.tsx +++ b/desktop/src/app/routes/ChannelRouteScreen.tsx @@ -21,7 +21,7 @@ import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; type ChannelRouteScreenProps = { autoSendDraftKey: string | null; channelId: string; - searchHighlight: SearchHighlightNavigation | null; + searchHighlight: SearchHighlightNavigation | null | undefined; selectedPostId: string | null; targetMessageId: string | null; targetReplyId: string | null; @@ -136,7 +136,7 @@ export function ChannelRouteScreen({ return cachedTarget ? [cachedTarget] : []; }); const [activeSearchHighlight, setActiveSearchHighlight] = - React.useState(searchHighlight); + React.useState(searchHighlight ?? null); const appliedSearchActivationIdRef = React.useRef( searchHighlight?.activationId ?? null, ); @@ -145,6 +145,11 @@ export function ChannelRouteScreen({ // Retain the applied activation locally until an ordinary route transition // explicitly arrives without search state. React.useEffect(() => { + if (searchHighlight === null) { + appliedSearchActivationIdRef.current = null; + setActiveSearchHighlight(null); + return; + } if (!searchHighlight) { const ordinaryTargetIds = [ selectedPostId, @@ -270,7 +275,7 @@ export function ChannelRouteScreen({ void closeForumPost(channelId); }} onSelectForumPost={(postId) => { - void goForumPost(channelId, postId); + void goForumPost(channelId, postId, { searchHighlight: null }); }} selectedForumPostId={selectedPostId} targetForumReplyId={targetReplyId} diff --git a/desktop/src/app/routes/searchHighlightRouteState.test.mjs b/desktop/src/app/routes/searchHighlightRouteState.test.mjs index 734dd37bd0d..4bb55791691 100644 --- a/desktop/src/app/routes/searchHighlightRouteState.test.mjs +++ b/desktop/src/app/routes/searchHighlightRouteState.test.mjs @@ -18,8 +18,15 @@ test("selects valid transient search highlight state", () => { ); }); -test("ordinary navigation without highlight state clears the selection", () => { - assert.equal(selectSearchHighlightRouteState({ state: {} }), null); +test("target cleanup without highlight state preserves the selection", () => { + assert.equal(selectSearchHighlightRouteState({ state: {} }), undefined); +}); + +test("ordinary navigation explicitly clears the selection", () => { + assert.equal( + selectSearchHighlightRouteState({ state: { searchHighlight: null } }), + null, + ); }); test("ignores malformed router state", () => { @@ -27,6 +34,6 @@ test("ignores malformed router state", () => { selectSearchHighlightRouteState({ state: { searchHighlight: { messageId: "message", query: "mentions" } }, }), - null, + undefined, ); }); diff --git a/desktop/src/app/routes/searchHighlightRouteState.ts b/desktop/src/app/routes/searchHighlightRouteState.ts index c506db63e48..4fcc01c8d39 100644 --- a/desktop/src/app/routes/searchHighlightRouteState.ts +++ b/desktop/src/app/routes/searchHighlightRouteState.ts @@ -5,9 +5,13 @@ import { export function selectSearchHighlightRouteState(location: { state: unknown; -}): SearchHighlightNavigation | null { - return parseSearchHighlightNavigation( - (location.state as { searchHighlight?: unknown } | undefined) - ?.searchHighlight, - ); +}): SearchHighlightNavigation | null | undefined { + const state = location.state as { searchHighlight?: unknown } | undefined; + if (!(state && "searchHighlight" in state)) { + return undefined; + } + if (state.searchHighlight === null) { + return null; + } + return parseSearchHighlightNavigation(state.searchHighlight) ?? undefined; } diff --git a/desktop/src/app/useHuddlePresentation.ts b/desktop/src/app/useHuddlePresentation.ts index a82916d0449..36f41c7fe1d 100644 --- a/desktop/src/app/useHuddlePresentation.ts +++ b/desktop/src/app/useHuddlePresentation.ts @@ -319,7 +319,7 @@ export function useHuddlePresentation() { showHuddleInMainApp(channelId); return; } - void goChannel(channelId); + void goChannel(channelId, { searchHighlight: null }); }, [goChannel, isHuddleDrawerOpen, showHuddleInMainApp], ); diff --git a/desktop/tests/e2e/smoke.spec.ts b/desktop/tests/e2e/smoke.spec.ts index 98c7f5147bb..2f7bd4bcd4c 100644 --- a/desktop/tests/e2e/smoke.spec.ts +++ b/desktop/tests/e2e/smoke.spec.ts @@ -394,6 +394,53 @@ test("highlights the clicked forum post when its route is already open", async ( ); }); +test("ordinary same-channel activation clears a prior search highlight", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-engineering").click(); + await page.keyboard.press("ControlOrMeta+f"); + await page.getByTestId("search-dialog-input").fill("shipped"); + await page.getByTestId("search-result-mock-engineering-shipped").click(); + + const message = page + .getByTestId("message-timeline") + .locator('[data-message-id="mock-engineering-shipped"]'); + await expect(message.locator('[data-search-match="true"]')).toHaveText( + "shipped", + ); + await expect(page).toHaveURL( + /#\/channels\/1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9(?:\?thread=mock-engineering-shipped)?$/, + ); + + await page.getByTestId("channel-engineering").click(); + + await expect(message.locator('[data-search-match="true"]')).toHaveCount(0); +}); + +test("ordinary same-forum activation clears a prior search highlight", async ({ + page, +}) => { + await page.goto( + "/#/channels/a27e1ee9-76a6-5bdf-a5d5-1d85610dad11/posts/mock-forum-release-thread", + ); + await page.getByTestId("open-search").click(); + await page.getByTestId("search-dialog-input").fill("checklist"); + await page.getByTestId("search-result-mock-forum-release-thread").click(); + + const post = page.locator( + '[data-forum-event-id="mock-forum-release-thread"]', + ); + await expect(post.locator('[data-search-match="true"]')).toHaveText( + "checklist", + ); + + await page.getByTestId("channel-watercooler").click(); + await page.getByText("Release checklist: async feedback thread.").click(); + + await expect(post.locator('[data-search-match="true"]')).toHaveCount(0); +}); + test("ordinary forum navigation clears a prior search highlight", async ({ page, }) => { From 07c12a9bc21d6d80ee9ddc2eae43b9ff6670ac92 Mon Sep 17 00:00:00 2001 From: tulsi Date: Tue, 25 Aug 2026 11:21:42 -0400 Subject: [PATCH 8/9] fix(desktop): clear search state at navigation boundary Signed-off-by: tulsi --- .../src/app/navigation/useAppNavigation.ts | 38 +++++++++---------- desktop/src/app/routes/ChannelRouteScreen.tsx | 2 +- desktop/src/app/useHuddlePresentation.ts | 2 +- desktop/tests/e2e/smoke.spec.ts | 21 ++++++++++ 4 files changed, 41 insertions(+), 22 deletions(-) diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index 3256c209244..c82c4d96eea 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -275,8 +275,9 @@ export function useAppNavigation() { * silently swallowed (block/buzz#3509). */ force?: boolean; messageId?: string; - /** Transient context for highlighting the selected search result. */ - searchHighlight?: SearchHighlightNavigation | null; + /** Preserve an active search highlight; ordinary navigation clears it. */ + preserveSearchHighlight?: boolean; + searchHighlight?: SearchHighlightNavigation; replace?: boolean; /** Open this thread panel directly without waiting for a timeline row. */ thread?: string; @@ -302,14 +303,12 @@ export function useAppNavigation() { ...(options?.thread ? { thread: options.thread } : {}), ...(options?.autoSend ? { autoSend: options.autoSend } : {}), }, - state: - options?.searchHighlight !== undefined - ? (previousState: Record) => { - const nextState = { ...previousState }; - nextState.searchHighlight = options.searchHighlight; - return nextState; - } - : undefined, + state: options?.preserveSearchHighlight + ? undefined + : (previousState: Record) => ({ + ...previousState, + searchHighlight: options?.searchHighlight ?? null, + }), }, { force: options?.force, @@ -349,8 +348,9 @@ export function useAppNavigation() { force?: boolean; replace?: boolean; replyId?: string; - /** Transient context for highlighting the selected search result. */ - searchHighlight?: SearchHighlightNavigation | null; + /** Preserve an active search highlight; ordinary navigation clears it. */ + preserveSearchHighlight?: boolean; + searchHighlight?: SearchHighlightNavigation; }, ) => { return commitNavigation( @@ -363,14 +363,12 @@ export function useAppNavigation() { search: { ...(options?.replyId ? { replyId: options.replyId } : {}), }, - state: - options?.searchHighlight !== undefined - ? (previousState: Record) => { - const nextState = { ...previousState }; - nextState.searchHighlight = options.searchHighlight; - return nextState; - } - : undefined, + state: options?.preserveSearchHighlight + ? undefined + : (previousState: Record) => ({ + ...previousState, + searchHighlight: options?.searchHighlight ?? null, + }), }, { force: options?.force, diff --git a/desktop/src/app/routes/ChannelRouteScreen.tsx b/desktop/src/app/routes/ChannelRouteScreen.tsx index 70f276d42f5..03b08196afb 100644 --- a/desktop/src/app/routes/ChannelRouteScreen.tsx +++ b/desktop/src/app/routes/ChannelRouteScreen.tsx @@ -275,7 +275,7 @@ export function ChannelRouteScreen({ void closeForumPost(channelId); }} onSelectForumPost={(postId) => { - void goForumPost(channelId, postId, { searchHighlight: null }); + void goForumPost(channelId, postId); }} selectedForumPostId={selectedPostId} targetForumReplyId={targetReplyId} diff --git a/desktop/src/app/useHuddlePresentation.ts b/desktop/src/app/useHuddlePresentation.ts index 36f41c7fe1d..a82916d0449 100644 --- a/desktop/src/app/useHuddlePresentation.ts +++ b/desktop/src/app/useHuddlePresentation.ts @@ -319,7 +319,7 @@ export function useHuddlePresentation() { showHuddleInMainApp(channelId); return; } - void goChannel(channelId, { searchHighlight: null }); + void goChannel(channelId); }, [goChannel, isHuddleDrawerOpen, showHuddleInMainApp], ); diff --git a/desktop/tests/e2e/smoke.spec.ts b/desktop/tests/e2e/smoke.spec.ts index 2f7bd4bcd4c..7902b3eace2 100644 --- a/desktop/tests/e2e/smoke.spec.ts +++ b/desktop/tests/e2e/smoke.spec.ts @@ -418,6 +418,27 @@ test("ordinary same-channel activation clears a prior search highlight", async ( await expect(message.locator('[data-search-match="true"]')).toHaveCount(0); }); +test("ordinary rendered channel link clears a prior search highlight", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await page.keyboard.press("ControlOrMeta+f"); + await page.getByTestId("search-dialog-input").fill("welcome"); + await page.getByTestId("search-result-mock-general-welcome").click(); + + const message = page + .getByTestId("message-timeline") + .locator('[data-message-id="mock-general-welcome"]'); + await expect(message.locator('[data-search-match="true"]')).toHaveText( + "Welcome", + ); + + await message.locator('[data-channel-link=""]').click(); + + await expect(message.locator('[data-search-match="true"]')).toHaveCount(0); +}); + test("ordinary same-forum activation clears a prior search highlight", async ({ page, }) => { From 4741f2ad1c231dc0bfe86f7d20eb644aed5002ea Mon Sep 17 00:00:00 2001 From: tulsi Date: Tue, 25 Aug 2026 11:55:02 -0400 Subject: [PATCH 9/9] fix(desktop): map normalized search prefix spans Signed-off-by: tulsi --- .../features/search/lib/searchMatch.test.mjs | 25 +++++++++++++++++++ .../src/features/search/lib/searchMatch.ts | 23 ++++++++++++++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/desktop/src/features/search/lib/searchMatch.test.mjs b/desktop/src/features/search/lib/searchMatch.test.mjs index 71db9b5e5a7..4f9651a75da 100644 --- a/desktop/src/features/search/lib/searchMatch.test.mjs +++ b/desktop/src/features/search/lib/searchMatch.test.mjs @@ -62,6 +62,31 @@ test("splitSearchMatches keeps one-character prefixes on lexeme boundaries", () ]); }); +test("splitSearchMatches maps expanding lowercase prefixes to original spans", () => { + assert.deepEqual(splitSearchMatches("İstanbul release", "İs"), [ + { isMatch: true, key: "0-2", text: "İs" }, + { isMatch: false, key: "2-14", text: "tanbul release" }, + ]); + assert.deepEqual(splitSearchMatches("İstanbul release", "İst"), [ + { isMatch: true, key: "0-3", text: "İst" }, + { isMatch: false, key: "3-13", text: "anbul release" }, + ]); +}); + +test("splitSearchMatches does not split a character whose lowercase form expands", () => { + assert.deepEqual(splitSearchMatches("İstanbul release", "i"), [ + { isMatch: true, key: "0-1", text: "İ" }, + { isMatch: false, key: "1-15", text: "stanbul release" }, + ]); +}); + +test("splitSearchMatches preserves UTF-16 boundaries for supplementary letters", () => { + assert.deepEqual(splitSearchMatches("𐐀İstanbul release", "𐐨İs"), [ + { isMatch: true, key: "0-4", text: "𐐀İs" }, + { isMatch: false, key: "4-14", text: "tanbul release" }, + ]); +}); + test("buildSearchResultPreview keeps a late match visible", () => { const content = `${"prefix ".repeat(30)}mentions appear here ${"suffix ".repeat(20)}`; const preview = buildSearchResultPreview(content, "mentions", 96); diff --git a/desktop/src/features/search/lib/searchMatch.ts b/desktop/src/features/search/lib/searchMatch.ts index 5030ed1f708..f74d1a26784 100644 --- a/desktop/src/features/search/lib/searchMatch.ts +++ b/desktop/src/features/search/lib/searchMatch.ts @@ -69,6 +69,24 @@ function getTextLexemes(text: string): TextLexeme[] { })); } +function getOriginalPrefixLength( + original: string, + normalizedLength: number, +): number { + let normalizedOffset = 0; + let originalOffset = 0; + + for (const character of original) { + normalizedOffset += character.toLowerCase().length; + originalOffset += character.length; + if (normalizedOffset >= normalizedLength) { + return originalOffset; + } + } + + return original.length; +} + function getMatchSpans( text: string, query: string, @@ -93,9 +111,12 @@ function getMatchSpans( matcher.isPrefix && lexeme.normalized.startsWith(matcher.value), ); if (prefixMatch) { + const originalLexeme = text.slice(lexeme.start, lexeme.end); spans.push({ start: lexeme.start, - end: lexeme.start + prefixMatch.value.length, + end: + lexeme.start + + getOriginalPrefixLength(originalLexeme, prefixMatch.value.length), }); } }