highlight search terms in results and messages (#6702)

**Category:** fix
**User Impact:** Search terms are now highlighted in yellow in Cmd+F
results and in the message opened from a result.

**Problem:** Search returned relevant messages, but users still had to
reread each preview and destination message to discover where the query
appeared. Search navigation could also lose or misapply highlighting
during rapid typing, repeated navigation, thread opening, and forum
navigation.

**Solution:** Render match-focused previews with a shared yellow
treatment, carry the result query through navigation, and apply it only
to the clicked destination. The implementation binds highlights to the
debounced result set, supports token and prefix matching, and handles
channels, threads, forums, diffs, and wave messages consistently.

<details>
<summary>File changes</summary>

**desktop/src/app/AppShell.tsx**
Carries the active result query into search-hit navigation so the
destination can preserve the user's context.

**desktop/src/app/navigation/searchHitEventCache.ts**
Adds a bounded, one-shot cache for result queries and navigation IDs
alongside cached search-hit events.

**desktop/src/app/navigation/searchHitNavigation.test.mjs**
Covers query retention, one-shot consumption, repeated same-route
activations, forum posts, forced routing, and cancellation.

**desktop/src/app/navigation/searchHitNavigation.ts**
Associates each search-result activation with a unique navigation ID and
forwards it to channel or forum destinations.

**desktop/src/app/navigation/useAppNavigation.ts**
Extends channel and forum navigation to carry search navigation state
without changing normal navigation behavior.

**desktop/src/app/routes/ChannelRouteScreen.tsx**
Consumes search highlight state after navigation and retains it for the
selected message while clearing it on community context changes.

**desktop/src/app/routes/channels.$channelId.posts.$postId.tsx**
Validates and forwards forum search-navigation IDs.

**desktop/src/app/routes/channels.$channelId.tsx**
Validates and forwards channel search-navigation IDs.

**desktop/src/features/channels/ui/ChannelPane.tsx**
Passes the selected result ID and query into the main timeline and open
thread panel.

**desktop/src/features/channels/ui/ChannelPane.types.ts**
Defines the destination-highlight contract for channel panes.

**desktop/src/features/channels/ui/ChannelScreen.tsx**
Routes destination highlighting to either forum content or the channel
timeline.

**desktop/src/features/channels/ui/ChannelScreen.types.ts**
Defines route-level search-highlight inputs.

**desktop/src/features/channels/ui/ForumChannelContent.tsx**
Forwards search context into expanded forum threads.

**desktop/src/features/forum/ui/ForumThreadPanel.tsx**
Highlights the matching text in the selected forum post or reply.

**desktop/src/features/forum/ui/ForumView.tsx**
Carries selected-result context from forum routing into the thread
panel.

**desktop/src/features/messages/ui/DiffMessage.tsx**
Forwards the query into diff descriptions and rendered diff content.

**desktop/src/features/messages/ui/DiffViewer.tsx**
Provides a highlighted match excerpt for structured diffs and highlights
fallback raw diff text.

**desktop/src/features/messages/ui/MessageRow.tsx**
Passes destination queries into normal Markdown, diff, and wave message
renderers.

**desktop/src/features/messages/ui/MessageThreadPanel.tsx**
Applies highlighting to the selected thread head or reply without
tinting unrelated messages.

**desktop/src/features/messages/ui/WaveMessageAttachment.tsx**
Highlights matches in wave-message fallback text.

**desktop/src/features/search/lib/searchMatch.test.mjs**
Covers case-insensitive, literal, multi-term, prefix, one-character, and
late-preview matches.

**desktop/src/features/search/lib/searchMatch.ts**
Centralizes token extraction, case-insensitive match splitting, and
match-focused preview generation.

**desktop/src/features/search/ui/HighlightedSearchText.tsx**
Adds the reusable accessible mark renderer used by result and
specialized message surfaces.

**desktop/src/features/search/ui/TopbarSearch.tsx**
Highlights result previews, centers excerpts around matches, strips
search operators, and hides stale results during debounce transitions.

**desktop/src/features/sidebar/ui/AppSidebar.types.ts**
Updates the sidebar search callback contract to include the result
query.

**desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx**
Passes query-aware result selection through the pinned search entry
point.

**desktop/src/shared/lib/rehypeSearchHighlight.ts**
Uses the shared matcher to mark every matching Markdown text segment
while leaving code blocks untouched.

**desktop/src/shared/lib/searchHighlightStyle.ts**
Defines one yellow highlight treatment for light and dark themes.

**desktop/src/shared/ui/markdown/nodeCache.test.mjs**
Verifies all Markdown matches, one-character scoped searches, code
exclusion, and transient cache behavior.

**desktop/src/shared/ui/markdown/nodeCache.ts**
Enables transient highlighting for one-character scoped searches without
polluting the Markdown cache.

**desktop/tests/e2e/smoke.spec.ts**
Exercises result and destination highlighting, same-route forum
activation, and stale-query suppression end to end.

</details>

## Reproduction steps

1. Open a channel containing a longer message and press Cmd+F.
2. Search for a word that appears later in the message, such as
`mentions`.
3. Confirm each matching result shows the word in yellow and keeps the
match visible in its preview.
4. Open a result and confirm the same term is highlighted in yellow in
the destination message.
5. Repeat with a thread reply or forum post, and quickly change the
query to confirm stale results are not selectable.

## Demo

- **Before:** Search results returned the correct message but offered no
visual indication of where the query matched; opening the message also
showed no match treatment.
- **After:** Every matching term is marked in yellow in the result
preview and in the exact channel, thread, or forum message opened from
that result.

---------

Signed-off-by: tulsi <tulsi@block.xyz>
This commit is contained in:
tulsi
2026-08-25 12:22:12 -04:00
committed by GitHub
parent 9b6a637d01
commit 29f2054c69
37 changed files with 1207 additions and 284 deletions

View File

@@ -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],
);

View File

@@ -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);
});

View File

@@ -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<SearchHighlightNavigation>;
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,
};
}

View File

@@ -51,6 +51,7 @@ test("search-hit navigation preserves forced message routing while active", asyn
options: {
force: true,
messageId: "message",
searchHighlight: undefined,
threadRootId: "thread-root",
},
},
@@ -58,6 +59,45 @@ test("search-hit navigation preserves forced message routing while active", asyn
assert.equal(getCachedSearchHitEvent("message")?.id, "message");
});
test("search-hit navigation carries trimmed highlight state and forces repeated activations", async () => {
clearSearchHitEventCache();
const calls = [];
await openSearchHitWithNavigation(plainMessage, {
goChannel: async (channelId, options) => {
calls.push({ channelId, options });
return true;
},
goForumPost: async () => false,
query: " Mentions ",
});
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 carries transient same-route activation state", 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.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 () => {
clearSearchHitEventCache();
let resolveLookup;

View File

@@ -1,21 +1,28 @@
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";
type SearchHitNavigationActions = {
force?: boolean;
query?: string;
goChannel: (
channelId: string,
options?: {
force?: boolean;
messageId?: string;
searchHighlight?: ReturnType<typeof createSearchHighlightNavigation>;
threadRootId?: string | null;
},
) => Promise<unknown>;
goForumPost: (
channelId: string,
postId: string,
options?: { force?: boolean; replyId?: string },
options?: {
force?: boolean;
replyId?: string;
searchHighlight?: ReturnType<typeof createSearchHighlightNavigation>;
},
) => Promise<unknown>;
signal?: AbortSignal;
};
@@ -30,6 +37,10 @@ export async function openSearchHitWithNavigation(
}
const isLifecycleBound = Boolean(actions.signal);
const searchHighlight = createSearchHighlightNavigation(
hit.eventId,
actions.query,
);
if (!isLifecycleBound) {
cacheSearchHitEvent(hit);
}
@@ -47,14 +58,16 @@ export async function openSearchHitWithNavigation(
if (destination.kind === "forum-post") {
return actions.goForumPost(destination.channelId, destination.postId, {
force: actions.force,
force: actions.force || Boolean(searchHighlight),
replyId: destination.replyId,
searchHighlight,
});
}
return actions.goChannel(destination.channelId, {
force: actions.force,
force: actions.force || Boolean(searchHighlight),
messageId: destination.messageId,
searchHighlight,
threadRootId: destination.threadRootId,
});
}

View File

@@ -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,14 +33,23 @@ export function useAppNavigation() {
to: string;
params?: Record<string, string>;
search?: Record<string, string | undefined>;
state?: Record<string, unknown>;
state?:
| Record<string, unknown>
| ((
previousState: Record<string, unknown>,
) => Record<string, unknown>);
},
behavior: NavigationBehavior = {},
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;
}
@@ -265,6 +275,9 @@ export function useAppNavigation() {
* silently swallowed (block/buzz#3509). */
force?: boolean;
messageId?: string;
/** 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;
@@ -290,6 +303,12 @@ export function useAppNavigation() {
...(options?.thread ? { thread: options.thread } : {}),
...(options?.autoSend ? { autoSend: options.autoSend } : {}),
},
state: options?.preserveSearchHighlight
? undefined
: (previousState: Record<string, unknown>) => ({
...previousState,
searchHighlight: options?.searchHighlight ?? null,
}),
},
{
force: options?.force,
@@ -329,6 +348,9 @@ export function useAppNavigation() {
force?: boolean;
replace?: boolean;
replyId?: string;
/** Preserve an active search highlight; ordinary navigation clears it. */
preserveSearchHighlight?: boolean;
searchHighlight?: SearchHighlightNavigation;
},
) => {
return commitNavigation(
@@ -338,7 +360,15 @@ export function useAppNavigation() {
channelId,
postId,
},
search: options?.replyId ? { replyId: options.replyId } : {},
search: {
...(options?.replyId ? { replyId: options.replyId } : {}),
},
state: options?.preserveSearchHighlight
? undefined
: (previousState: Record<string, unknown>) => ({
...previousState,
searchHighlight: options?.searchHighlight ?? null,
}),
},
{
force: options?.force,
@@ -406,6 +436,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;
},
@@ -414,6 +446,7 @@ export function useAppNavigation() {
force: behavior?.force,
goChannel,
goForumPost,
query: behavior?.query,
signal: behavior?.signal,
}),
[goChannel, goForumPost],

View File

@@ -1,5 +1,6 @@
import * as React from "react";
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";
@@ -20,6 +21,7 @@ import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
type ChannelRouteScreenProps = {
autoSendDraftKey: string | null;
channelId: string;
searchHighlight: SearchHighlightNavigation | null | undefined;
selectedPostId: string | null;
targetMessageId: string | null;
targetReplyId: string | null;
@@ -100,6 +102,7 @@ async function fetchRouteTargetEvents(
export function ChannelRouteScreen({
autoSendDraftKey,
channelId,
searchHighlight,
selectedPostId,
targetMessageId,
targetReplyId,
@@ -132,23 +135,67 @@ export function ChannelRouteScreen({
const cachedTarget = getCachedSearchHitEvent(targetMessageId);
return cachedTarget ? [cachedTarget] : [];
});
// 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
// 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<string>(
`${channelId}::${selectedPostId ?? ""}`,
const [activeSearchHighlight, setActiveSearchHighlight] =
React.useState<SearchHighlightNavigation | null>(searchHighlight ?? null);
const appliedSearchActivationIdRef = React.useRef<string | null>(
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(() => {
const resetKey = `${channelId}::${selectedPostId ?? ""}`;
if (previousResetKeyRef.current === resetKey) return;
previousResetKeyRef.current = resetKey;
if (searchHighlight === null) {
appliedSearchActivationIdRef.current = null;
setActiveSearchHighlight(null);
return;
}
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 (appliedSearchActivationIdRef.current === searchHighlight.activationId) {
return;
}
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<string>(channelId);
React.useEffect(() => {
if (previousResetKeyRef.current === channelId) return;
previousResetKeyRef.current = channelId;
appliedSearchActivationIdRef.current = null;
setTargetMessageEvents([]);
}, [channelId, selectedPostId]);
setActiveSearchHighlight(null);
}, [channelId]);
React.useEffect(() => {
let isCancelled = false;
@@ -234,6 +281,8 @@ export function ChannelRouteScreen({
targetForumReplyId={targetReplyId}
targetMessageEvents={targetMessageEvents}
targetMessageId={targetMessageId}
targetSearchMessageId={activeSearchHighlight?.messageId}
targetSearchQuery={activeSearchHighlight?.query}
/>
);
}

View File

@@ -1,6 +1,7 @@
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";
@@ -33,6 +34,9 @@ function ForumPostRouteComponent() {
usePreviewFeatureWarning("forum");
const { channelId, postId } = Route.useParams();
const search = Route.useSearch();
const searchHighlight = useLocation({
select: selectSearchHighlightRouteState,
});
return (
<React.Suspense
@@ -41,6 +45,7 @@ function ForumPostRouteComponent() {
<ChannelRouteScreen
autoSendDraftKey={null}
channelId={channelId}
searchHighlight={searchHighlight}
selectedPostId={postId}
targetMessageId={null}
targetReplyId={search.replyId ?? null}

View File

@@ -1,5 +1,7 @@
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 {
parseProfilePanelTab,
@@ -59,6 +61,9 @@ const ChannelRouteScreen = React.lazy(async () => {
function ChannelRouteComponent() {
const { channelId } = Route.useParams();
const search = Route.useSearch();
const searchHighlight = useLocation({
select: selectSearchHighlightRouteState,
});
const isHuddleTranscript = huddleWindowChannelId() !== null;
return (
@@ -74,6 +79,7 @@ function ChannelRouteComponent() {
<ChannelRouteScreen
autoSendDraftKey={search.autoSend ?? null}
channelId={channelId}
searchHighlight={searchHighlight}
selectedPostId={null}
targetMessageId={search.messageId ?? null}
targetReplyId={null}

View File

@@ -0,0 +1,39 @@
import assert from "node:assert/strict";
import test from "node:test";
const { selectSearchHighlightRouteState } = await import(
"./searchHighlightRouteState.ts"
);
const searchHighlight = {
activationId: "activation",
messageId: "message",
query: "mentions",
};
test("selects valid transient search highlight state", () => {
assert.deepEqual(
selectSearchHighlightRouteState({ state: { searchHighlight } }),
searchHighlight,
);
});
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", () => {
assert.equal(
selectSearchHighlightRouteState({
state: { searchHighlight: { messageId: "message", query: "mentions" } },
}),
undefined,
);
});

View File

@@ -0,0 +1,17 @@
import {
parseSearchHighlightNavigation,
type SearchHighlightNavigation,
} from "@/app/navigation/searchHighlightNavigation";
export function selectSearchHighlightRouteState(location: {
state: unknown;
}): 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;
}

View File

@@ -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";
@@ -152,6 +153,8 @@ export const ChannelPane = React.memo(function ChannelPane({
profilePanelTab,
profilePanelView,
targetMessageId,
targetSearchMessageId,
targetSearchQuery,
threadAllMessages,
threadHeadMessage,
threadMessages,
@@ -176,6 +179,10 @@ export const ChannelPane = React.memo(function ChannelPane({
currentPubkey,
);
const mainComposerMedia = useMediaUpload({ deferUploadsUntilSend: true });
const searchHighlightProps = useSearchHighlightProps(
targetSearchMessageId,
targetSearchQuery,
);
const [isMainDeferredEditPending, setMainDeferredEditPending] =
React.useState(false);
const isNonMemberView =
@@ -194,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();
@@ -252,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,
@@ -339,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,
);
@@ -696,6 +694,7 @@ export const ChannelPane = React.memo(function ChannelPane({
}
onTargetReached={onTargetReached}
onToggleReaction={onToggleReaction}
{...searchHighlightProps.timeline}
targetMessageId={targetMessageId}
splitThreadPanelOpen={
useSplitAuxiliaryPane &&
@@ -894,6 +893,7 @@ export const ChannelPane = React.memo(function ChannelPane({
replyTargetMessage={threadReplyTargetMessage}
scrollTargetHighlights={!layoutScrollTargetId}
scrollTargetId={layoutScrollTargetId ?? threadScrollTargetId}
{...searchHighlightProps.thread}
threadHead={threadHeadMessage}
videoReviewPresentation={threadVideoReviewPresentation}
widthPx={threadPanelWidthPx}
@@ -940,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

View File

@@ -181,6 +181,10 @@ export type ChannelPaneProps = {
threadReplyUnreadCounts?: ReadonlyMap<string, number>;
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;

View File

@@ -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,6 +86,7 @@ export function ChannelScreen({
targetForumReplyId,
targetMessageEvents,
targetMessageId,
...searchTarget
}: ChannelScreenProps) {
const queryClient = useQueryClient();
const { goHome } = useAppNavigation();
@@ -171,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,
@@ -214,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;
@@ -233,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,
);
@@ -253,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,
@@ -305,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),
@@ -318,7 +309,7 @@ export function ChannelScreen({
currentIdentity,
welcomeGuideAgent,
});
const relayAgentsQuery = useRelayAgentsQuery();
const relayAgentsQuery = agentHooks.useRelayAgentsQuery();
const relayAgents = relayAgentsQuery.data ?? [];
const knownAgentPubkeys = React.useMemo(
() =>
@@ -385,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(
@@ -502,7 +493,8 @@ export function ChannelScreen({
editMessageMutation,
editTargetId,
editTargetIsThreadReply:
editTargetMessage !== null && isThreadReply(editTargetMessage.tags ?? []),
editTargetMessage !== null &&
threading.isThreadReply(editTargetMessage.tags ?? []),
expandedThreadReplyIds,
getFirstReplyIdForMessage,
getReplyDescendantIdsForMessage,
@@ -632,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)),
@@ -825,27 +814,30 @@ export function ChannelScreen({
>
{activeChannel ? (
activeChannel.channelType === "forum" ? (
<ForumChannelContent
canResetPanelWidth={canResetThreadPanelWidth}
channel={activeChannel}
currentPubkey={currentPubkey}
header={channelHeader}
onClosePost={onCloseForumPost}
onCloseProfilePanel={handleCloseProfilePanel}
onOpenDm={handleOpenDm}
onOpenProfilePanel={handleOpenProfilePanel}
onPanelResizeStart={handleThreadPanelResizeStart}
onProfilePanelTabChange={setProfilePanelTab}
onProfilePanelViewChange={setProfilePanelView}
onResetPanelWidth={handleThreadPanelWidthReset}
onSelectPost={onSelectForumPost}
panelWidthPx={threadPanelWidthPx}
profilePanelPubkey={profilePanelPubkey}
profilePanelTab={profilePanelTab}
profilePanelView={profilePanelView}
selectedPostId={selectedForumPostId}
targetReplyId={targetForumReplyId}
/>
searchForwarding.renderSearchAwareForum(
<ForumChannelContent
canResetPanelWidth={canResetThreadPanelWidth}
channel={activeChannel}
currentPubkey={currentPubkey}
header={channelHeader}
onClosePost={onCloseForumPost}
onCloseProfilePanel={handleCloseProfilePanel}
onOpenDm={handleOpenDm}
onOpenProfilePanel={handleOpenProfilePanel}
onPanelResizeStart={handleThreadPanelResizeStart}
onProfilePanelTabChange={setProfilePanelTab}
onProfilePanelViewChange={setProfilePanelView}
onResetPanelWidth={handleThreadPanelWidthReset}
onSelectPost={onSelectForumPost}
panelWidthPx={threadPanelWidthPx}
profilePanelPubkey={profilePanelPubkey}
profilePanelTab={profilePanelTab}
profilePanelView={profilePanelView}
selectedPostId={selectedForumPostId}
targetReplyId={targetForumReplyId}
/>,
searchTarget,
)
) : (
<React.Suspense
fallback={
@@ -854,141 +846,144 @@ export function ChannelScreen({
/>
}
>
<GuardedChannelPane
activeChannel={activeChannel}
activityAgents={channelAgentSessionAgents}
agentPubkeys={agentPubkeys}
agentPubkeysPending={agentPubkeysPending}
agentSessionAgents={agentSessionAgents}
autoSendDraftKey={autoSendDraftKey}
onAutoSendComplete={clearAutoSend}
botTypingEntries={botTypingEntries}
channelManagementOpen={channelManagementOpen}
currentPubkey={currentPubkey}
canResetThreadPanelWidth={canResetThreadPanelWidth}
fetchOlder={fetchOlder}
header={channelHeader}
hasOlderMessages={hasOlderMessages}
historyExhausted={historyExhausted}
onAddAgent={handleOpenAddBot}
onBrowseChannels={openBrowseChannels}
onCreateChannel={openCreateChannel}
onOpenMembers={handleOpenMembersSidebar}
isFetchingOlder={isFetchingOlder}
isHuddleTranscript={isHuddleTranscript}
entranceMessageId={welcomeEntranceMessageId}
onEntranceMessageComplete={handleWelcomeEntranceComplete}
welcomeKickoffStage={welcomeKickoffStage}
welcomeKickoffSettingUp={welcomeKickoffSettingUp}
editTarget={
editTargetMessage
? buildMessageComposerEditTarget(
editTargetMessage,
messageProfiles,
(pubkey) =>
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}
/>
{searchForwarding.renderSearchAwareChannel(
<GuardedChannelPane
activeChannel={activeChannel}
activityAgents={channelAgentSessionAgents}
agentPubkeys={agentPubkeys}
agentPubkeysPending={agentPubkeysPending}
agentSessionAgents={agentSessionAgents}
autoSendDraftKey={autoSendDraftKey}
onAutoSendComplete={clearAutoSend}
botTypingEntries={botTypingEntries}
channelManagementOpen={channelManagementOpen}
currentPubkey={currentPubkey}
canResetThreadPanelWidth={canResetThreadPanelWidth}
fetchOlder={fetchOlder}
header={channelHeader}
hasOlderMessages={hasOlderMessages}
historyExhausted={historyExhausted}
onAddAgent={handleOpenAddBot}
onBrowseChannels={openBrowseChannels}
onCreateChannel={openCreateChannel}
onOpenMembers={handleOpenMembersSidebar}
isFetchingOlder={isFetchingOlder}
isHuddleTranscript={isHuddleTranscript}
entranceMessageId={welcomeEntranceMessageId}
onEntranceMessageComplete={handleWelcomeEntranceComplete}
welcomeKickoffStage={welcomeKickoffStage}
welcomeKickoffSettingUp={welcomeKickoffSettingUp}
editTarget={
editTargetMessage
? buildMessageComposerEditTarget(
editTargetMessage,
messageProfiles,
(pubkey) =>
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,
)}
</React.Suspense>
)
) : (

View File

@@ -22,4 +22,8 @@ export type ChannelScreenProps = {
targetForumReplyId: string | null;
targetMessageEvents: RelayEvent[];
targetMessageId: string | null;
/** Exact clicked result id, retained after route target cleanup. */
targetSearchMessageId?: string;
/** Search text to highlight within the opened result message. */
targetSearchQuery?: string;
};

View File

@@ -42,6 +42,8 @@ type ForumChannelContentProps = {
profilePanelView: ProfilePanelView;
selectedPostId: string | null;
targetReplyId: string | null;
targetSearchMessageId?: string;
targetSearchQuery?: string;
};
/**
@@ -71,6 +73,8 @@ export function ForumChannelContent({
profilePanelView,
selectedPostId,
targetReplyId,
targetSearchMessageId,
targetSearchQuery,
}: ForumChannelContentProps) {
return (
<>
@@ -88,6 +92,8 @@ export function ForumChannelContent({
onSelectPost={onSelectPost}
selectedPostId={selectedPostId}
targetReplyId={targetReplyId}
targetSearchMessageId={targetSearchMessageId}
targetSearchQuery={targetSearchQuery}
/>
</React.Suspense>
</section>

View File

@@ -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<React.ComponentProps<typeof ForumChannelContent>>,
target: SearchTarget,
) => React.cloneElement(node, target);
export const renderSearchAwareChannel = (
node: React.ReactElement<React.ComponentProps<typeof GuardedChannelPane>>,
target: SearchTarget,
) => React.cloneElement(node, target);

View File

@@ -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],
);
}

View File

@@ -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}
/>
</div>
</div>
@@ -143,6 +148,8 @@ export function ForumThreadPanel({
canDeletePost,
isDeletingPost,
targetEventId,
targetSearchMessageId,
targetSearchQuery,
}: ForumThreadPanelProps) {
const scrollRef = React.useRef<HTMLDivElement>(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
}
/>
</div>
</div>
@@ -286,6 +298,11 @@ export function ForumThreadPanel({
onDelete={onDeleteReply}
profiles={profiles}
reply={reply}
searchQuery={
targetSearchMessageId === reply.eventId
? targetSearchQuery
: undefined
}
/>
))}

View File

@@ -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<HTMLDivElement>(null);
@@ -156,6 +160,8 @@ export function ForumView({
onTargetReached={onTargetReached}
profiles={profiles}
targetEventId={targetReplyId}
targetSearchMessageId={targetSearchMessageId}
targetSearchQuery={targetSearchQuery}
thread={threadQuery.data}
/>
);

View File

@@ -2,6 +2,7 @@ import * as React from "react";
import { FileDiff, Maximize2 } from "lucide-react";
import { getDiffTitleBadge } from "@/features/messages/lib/parseDiff";
import { HighlightedSearchText } from "@/features/search/ui/HighlightedSearchText";
import { isSafeUrl } from "@/shared/lib/url";
import { Button } from "@/shared/ui/button";
import { useSmoothCorners } from "@/shared/ui/smoothCorners";
@@ -14,6 +15,7 @@ type DiffMessageProps = {
filePath?: string;
commitSha?: string;
description?: string;
searchQuery?: string;
truncated?: boolean;
onExpand?: () => 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 && (
<div className="px-3 py-1.5 text-xs text-muted-foreground border-b border-border/40 bg-muted/20">
{description}
<HighlightedSearchText query={searchQuery ?? ""} text={description} />
</div>
)}
@@ -126,6 +129,7 @@ export default function DiffMessage({
className="p-3"
content={content}
fallbackFilePath={filePath}
searchQuery={searchQuery}
viewType="unified"
/>
</div>

View File

@@ -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 (
<pre className="p-3 whitespace-pre-wrap font-mono text-xs text-muted-foreground">
{content}
<HighlightedSearchText query={searchQuery ?? ""} text={content} />
</pre>
);
}
@@ -70,6 +77,17 @@ export function DiffViewer({
return (
<div className={cn("buzz-diff-theme", className)}>
{searchPreview ? (
<pre
className="mb-3 whitespace-pre-wrap rounded-lg bg-muted/35 p-2 font-mono text-xs text-muted-foreground"
data-testid="diff-search-preview"
>
<HighlightedSearchText
query={searchQuery ?? ""}
text={searchPreview}
/>
</pre>
) : null}
<div className="space-y-3">
{files.map((file) => {
const label = getDiffFileLabel(file, fallbackFilePath);

View File

@@ -396,6 +396,7 @@ export const MessageRow = React.memo(
setExpandedDiffId(message.id);
}}
repoUrl={getTag("repo")}
searchQuery={searchQuery}
truncated={getTag("truncated") === "true"}
/>
</React.Suspense>
@@ -417,6 +418,7 @@ export const MessageRow = React.memo(
fallbackText={waveMessage.fallbackText}
huddleMemberPubkeys={huddleMemberPubkeys}
huddleMemberPubkeysPending={huddleMemberPubkeysPending}
searchQuery={searchQuery}
/>
);
}

View File

@@ -81,6 +81,8 @@ type MessageThreadPanelProps = ThreadPanelLayoutProps & {
onScrollTargetResolved: () => void;
onScrollTargetSettled?: (messageId: string) => void;
scrollTargetHighlights?: boolean;
searchMessageId?: string | null;
searchQuery?: string;
onSelectReplyTarget: (message: TimelineMessage) => void;
onSend: (
content: string,
@@ -185,6 +187,8 @@ export function MessageThreadPanel({
replyTargetMessage,
scrollTargetId,
scrollTargetHighlights = true,
searchMessageId,
searchQuery,
threadHead,
videoReviewPresentation,
threadReplies,
@@ -562,6 +566,9 @@ export function MessageThreadPanel({
onUnfollowThread ? (_msg) => onUnfollowThread() : undefined
}
profiles={profiles}
searchQuery={
searchMessageId === threadHead.id ? searchQuery : undefined
}
showDepthGuides={shouldShowThreadBranchGuides}
videoReviewCommentRootId={videoReviewPresentation?.commentRootIdsByMessageId.get(
threadHead.id,
@@ -725,6 +732,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,

View File

@@ -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({
<span className="buzz-wave-hand">👋</span>
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>{fallbackText}</AttachmentTitle>
<AttachmentTitle>
<HighlightedSearchText
query={searchQuery ?? ""}
text={fallbackText}
/>
</AttachmentTitle>
<AttachmentDescription>
Start a huddle to talk to them.
</AttachmentDescription>

View File

@@ -0,0 +1,113 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildSearchResultPreview, splitSearchMatches } from "./searchMatch.ts";
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 " },
{ isMatch: true, key: "13-8", text: "mentions" },
]);
});
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" },
]);
});
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 keeps one-character prefixes on lexeme boundaries", () => {
assert.deepEqual(splitSearchMatches("A plan", "a"), [
{ isMatch: true, key: "0-1", text: "A" },
{ isMatch: false, key: "1-5", text: " plan" },
]);
});
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);
assert.equal(preview.length <= 96, true);
assert.match(preview, /mentions/i);
assert.match(preview, /^\.\.\./);
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),
"abcdefg...",
);
});

View File

@@ -0,0 +1,201 @@
export type SearchMatchPart = {
isMatch: boolean;
key: string;
text: string;
};
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<string, SearchHighlightTerm>();
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,
);
}
/**
* 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[] {
return getSearchHighlightMatchers(query).map((matcher) => matcher.value);
}
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 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,
): 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) {
const originalLexeme = text.slice(lexeme.start, lexeme.end);
spans.push({
start: lexeme.start,
end:
lexeme.start +
getOriginalPrefixLength(originalLexeme, 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 spans = getMatchSpans(text, query);
if (spans.length === 0) {
return [{ isMatch: false, key: "0", text }];
}
const parts: SearchMatchPart[] = [];
let offset = 0;
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;
}
/**
* 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,
query: string,
maxLength = 96,
): string {
const text = content.trim();
if (!text) {
return "No message body.";
}
if (text.length <= maxLength) {
return text;
}
const matchIndex = getMatchSpans(text, query)[0]?.start ?? -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}`;
}

View File

@@ -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 ? (
<mark
className={SEARCH_MATCH_HIGHLIGHT_CLASS}
data-search-match="true"
key={part.key}
>
{part.text}
</mark>
) : (
<React.Fragment key={part.key}>{part.text}</React.Fragment>
),
);
}

View File

@@ -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<void>;
onBrowseChannels?: () => void | Promise<void>;
onCreateAgent?: () => void | Promise<void>;
@@ -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,10 @@ export function TopbarSearch({
scopeChannelId,
});
const trimmedQuery = query.trim();
// 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)
@@ -504,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),
@@ -516,8 +511,11 @@ export function TopbarSearch({
? scopeChannel
? []
: suggestionResults
: groupedSearchResults;
: resultsAreCurrent
? groupedSearchResults
: [];
const isSearchLoading =
(!isShowingSuggestions && !resultsAreCurrent) ||
isWaitingOnFromResolution ||
searchQuery.isLoading ||
fuzzyUserCandidatesQuery.isLoading ||
@@ -581,7 +579,7 @@ export function TopbarSearch({
return;
}
onOpenResult(result.hit);
onOpenResult(result.hit, resultQuery);
},
[
onBrowseChannels,
@@ -592,6 +590,7 @@ export function TopbarSearch({
onOpenUser,
openAfterExit,
setQuery,
resultQuery,
],
);
@@ -697,7 +696,7 @@ export function TopbarSearch({
? result.action.description
: result.kind === "user"
? getUserSecondaryLabel(result.user)
: truncateResultText(result.hit.content);
: buildSearchResultPreview(result.hit.content, resultQuery);
const trailingLabel =
result.kind === "channel"
? getChannelSuggestionMeta(result.channel)
@@ -771,7 +770,7 @@ export function TopbarSearch({
) : null}
{preview ? (
<span className="col-start-1 mt-1.5 block min-w-0 truncate text-sm leading-5 text-muted-foreground">
{preview}
<HighlightedSearchText query={resultQuery} text={preview} />
</span>
) : null}
</span>
@@ -873,12 +872,13 @@ export function TopbarSearch({
</div>
</div>
)
) : isSearchLoading && searchableResults.length === 0 ? (
) : isSearchLoading && visibleSearchableResults.length === 0 ? (
<div className="max-h-[min(60vh,32rem)] overflow-y-auto">
{currentChannelSearchAction}
<SearchResultsSkeleton />
</div>
) : searchQuery.error instanceof Error && searchableResults.length === 0 ? (
) : searchQuery.error instanceof Error &&
visibleSearchableResults.length === 0 ? (
<div className="max-h-[min(60vh,32rem)] overflow-y-auto">
{currentChannelSearchAction}
<p
@@ -890,7 +890,7 @@ export function TopbarSearch({
{searchQuery.error.message}
</p>
</div>
) : searchableResults.length === 0 ? (
) : visibleSearchableResults.length === 0 ? (
<div className="max-h-[min(60vh,32rem)] overflow-y-auto">
{currentChannelSearchAction}
<p
@@ -964,7 +964,7 @@ export function TopbarSearch({
)}
</button>
<DialogContent
aria-busy={isSearchLoading && searchableResults.length === 0}
aria-busy={isSearchLoading && visibleSearchableResults.length === 0}
className="mt-[18vh] max-w-2xl self-start gap-0 overflow-hidden rounded-2xl p-0 shadow-2xl"
data-testid="search-results"
onOpenAutoFocus={(event) => {

View File

@@ -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];

View File

@@ -30,7 +30,7 @@ type AppSidebarPinnedHeaderProps = {
onCreateAgent: () => void;
onCreateChannel: () => void;
onOpenDm: (input: { pubkeys: string[] }) => Promise<void>;
onOpenSearchResult: (hit: SearchHit) => void;
onOpenSearchResult: (hit: SearchHit, query: string) => void;
onSelectChannel: (channelId: string) => void;
searchChannels: Channel[];
searchFocusRequest: number;

View File

@@ -6,6 +6,9 @@
* 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.
interface HastText {
type: "text";
@@ -34,45 +37,32 @@ 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)})`, "i");
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 (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (!part) continue;
if (i % 2 === 1) {
// Odd indices from split-with-capture are always the match.
for (const part of parts) {
if (part.isMatch) {
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 }],
children: [{ type: "text", value: part.text }],
});
} else {
result.push({ type: "text", value: part });
result.push({ type: "text", value: part.text });
}
}
} else if (isElement(node)) {

View File

@@ -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";

View File

@@ -227,9 +227,27 @@ test("hardLineBreaks changes the parse and the cache key", () => {
assert.equal(withoutBreaks, withoutBreaksAgain);
});
test("active search queries bypass the cache", () => {
clearMarkdownNodeCache();
const first = renderCachedMarkdown({ ...BASE, searchQuery: "bold" });
const second = renderCachedMarkdown({ ...BASE, searchQuery: "bold" });
assert.notEqual(first, second);
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, 1);
});
test("active search queries bypass the cache and highlight every match", () => {
clearMarkdownNodeCache();
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, /<code>bold<\/code>/);
});

View File

@@ -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 <ReactMarkdown/>:
@@ -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);

View File

@@ -4418,6 +4418,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,

View File

@@ -346,6 +346,171 @@ 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("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("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 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,
}) => {
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,
}) => {
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,
}) => {
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("/");