From 0286f4a7024b453ccd031f1c5a30b18900040f54 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Tue, 23 Jun 2026 16:17:51 +0100 Subject: [PATCH 1/4] Improve global search --- desktop/src/app/AppShell.tsx | 11 +- desktop/src/app/AppShellOverlays.tsx | 25 +- .../features/agents/openCreateAgentEvent.ts | 30 + desktop/src/features/agents/ui/AgentsView.tsx | 15 + .../search/ui/SearchPromptPlaceholder.tsx | 205 +++++ .../features/search/ui/SearchResultItem.tsx | 56 +- .../src/features/search/ui/TopbarSearch.tsx | 816 ++++++++++++++---- .../src/features/search/useSearchResults.ts | 212 ++++- .../src/features/sidebar/ui/AppSidebar.tsx | 33 +- desktop/src/shared/styles/globals.css | 15 + desktop/src/shared/ui/alert-dialog.tsx | 10 +- desktop/src/shared/ui/deferredModalOpen.ts | 50 ++ desktop/src/shared/ui/dialog.tsx | 10 +- desktop/src/shared/ui/modalMotion.ts | 5 + 14 files changed, 1302 insertions(+), 191 deletions(-) create mode 100644 desktop/src/features/agents/openCreateAgentEvent.ts create mode 100644 desktop/src/features/search/ui/SearchPromptPlaceholder.tsx create mode 100644 desktop/src/shared/ui/deferredModalOpen.ts create mode 100644 desktop/src/shared/ui/modalMotion.ts diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 5507c7245d..4db5c99006 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -52,6 +52,7 @@ import { resolveSlotSound, } from "@/features/notifications/lib/sound"; import { PreventSleepProvider } from "@/features/agents/usePreventSleep"; +import { requestOpenCreateAgent } from "@/features/agents/openCreateAgentEvent"; import { usePresenceSession, usePresenceSubscription, @@ -99,10 +100,8 @@ const LazySettingsScreen = React.lazy(async () => { export function AppShell() { useWebviewZoomShortcuts(); - const workspacesHook = useWorkspaces(); const [isAddWorkspaceOpen, setIsAddWorkspaceOpen] = React.useState(false); - const [isChannelManagementOpen, setIsChannelManagementOpen] = React.useState(false); const [searchFocusRequest, setSearchFocusRequest] = React.useState(0); @@ -142,7 +141,6 @@ export function AppShell() { ) ? locationSearchSection : DEFAULT_SETTINGS_SECTION; - const startupReady = useDeferredStartup(); const identityQuery = useIdentityQuery(); @@ -630,12 +628,10 @@ export function AppShell() { }, []); const handleOpenNewDm = React.useCallback(() => setIsNewDmOpen(true), []); - const handleOpenCreateChannel = React.useCallback( () => setIsCreateChannelOpen(true), [], ); - React.useLayoutEffect(() => { if (settingsOpen) { return; @@ -690,13 +686,11 @@ export function AppShell() { goHome, settingsOpen, ]); - useSettingsShortcuts({ onClose: handleCloseSettings, onOpenSettings: handleOpenSettings, open: settingsOpen, }); - useMarkAsReadShortcuts({ activeChannelId: activeChannel?.id ?? null, activeChannelLastMessageAt: activeChannel?.lastMessageAt, @@ -849,6 +843,9 @@ export function AppShell() { onUpdateWorkspace={workspacesHook.updateWorkspace} onRemoveWorkspace={workspacesHook.removeWorkspace} onSwitchWorkspace={workspacesHook.switchWorkspace} + onCreateAgent={() => + void goAgents().then(requestOpenCreateAgent) + } selfPresenceStatus={presenceSession.currentStatus} workspaces={workspacesHook.workspaces} onCreateChannel={async ({ diff --git a/desktop/src/app/AppShellOverlays.tsx b/desktop/src/app/AppShellOverlays.tsx index 44b99a0417..514492a296 100644 --- a/desktop/src/app/AppShellOverlays.tsx +++ b/desktop/src/app/AppShellOverlays.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import type { Channel } from "@/shared/api/types"; +import { useDeferredModalOpen } from "@/shared/ui/deferredModalOpen"; const ChannelBrowserDialog = React.lazy(async () => { const module = await import("@/features/channels/ui/ChannelBrowserDialog"); @@ -39,17 +40,37 @@ export function AppShellOverlays({ onDeleteActiveChannel, onSelectChannel, }: AppShellOverlaysProps) { + const [visibleBrowseDialogType, setVisibleBrowseDialogType] = + React.useState(null); + const { cancelDeferredModalOpen, openNextFrame: openModalNextFrame } = + useDeferredModalOpen(); + + React.useEffect(() => { + if (browseDialogType === null) { + cancelDeferredModalOpen(); + setVisibleBrowseDialogType(null); + return; + } + + setVisibleBrowseDialogType(null); + openModalNextFrame(() => { + setVisibleBrowseDialogType(browseDialogType); + }); + }, [browseDialogType, cancelDeferredModalOpen, openModalNextFrame]); + + const renderedBrowseDialogType = visibleBrowseDialogType ?? browseDialogType; + return ( <> {browseDialogType !== null ? ( ) : null} diff --git a/desktop/src/features/agents/openCreateAgentEvent.ts b/desktop/src/features/agents/openCreateAgentEvent.ts new file mode 100644 index 0000000000..69c7eac6b9 --- /dev/null +++ b/desktop/src/features/agents/openCreateAgentEvent.ts @@ -0,0 +1,30 @@ +const OPEN_CREATE_AGENT_EVENT = "buzz:open-create-agent"; + +let pendingOpenCreateAgent = false; + +export function requestOpenCreateAgent() { + pendingOpenCreateAgent = true; + window.dispatchEvent(new Event(OPEN_CREATE_AGENT_EVENT)); +} + +export function consumePendingOpenCreateAgent() { + if (!pendingOpenCreateAgent) { + return false; + } + + pendingOpenCreateAgent = false; + return true; +} + +export function subscribeOpenCreateAgent(handler: () => void) { + function handleOpenCreateAgent() { + pendingOpenCreateAgent = false; + handler(); + } + + window.addEventListener(OPEN_CREATE_AGENT_EVENT, handleOpenCreateAgent); + + return () => { + window.removeEventListener(OPEN_CREATE_AGENT_EVENT, handleOpenCreateAgent); + }; +} diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index 639bd3569f..416463ae60 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -1,5 +1,10 @@ +import * as React from "react"; import { topChromeInset } from "@/shared/layout/chromeLayout"; import { cn } from "@/shared/lib/cn"; +import { + consumePendingOpenCreateAgent, + subscribeOpenCreateAgent, +} from "@/features/agents/openCreateAgentEvent"; import { AddAgentToChannelDialog } from "./AddAgentToChannelDialog"; import { AddTeamToChannelDialog } from "./AddTeamToChannelDialog"; import { BatchImportDialog } from "./BatchImportDialog"; @@ -42,6 +47,16 @@ export function AgentsView() { teamActions.updateTeamMutation.isPending || teamActions.deleteTeamMutation.isPending; + React.useEffect(() => { + if (consumePendingOpenCreateAgent()) { + agents.setIsCreateOpen(true); + } + + return subscribeOpenCreateAgent(() => { + agents.setIsCreateOpen(true); + }); + }, [agents.setIsCreateOpen]); + return ( <>
(); + + return [...value].map((character) => { + const occurrence = characterCounts.get(character) ?? 0; + characterCounts.set(character, occurrence + 1); + + return { + character, + key: `${character}-${occurrence}`, + }; + }); +} + +function getPromptEnterTotalSeconds(characterCount: number) { + return ( + SEARCH_PROMPT_ENTER_DURATION_SECONDS + + Math.max(0, characterCount - 1) * SEARCH_PROMPT_ENTER_STAGGER_SECONDS + ); +} + +export function SearchPromptPlaceholder() { + const shouldReduceMotion = useReducedMotion(); + const [wordIndex, setWordIndex] = React.useState(0); + const activeWord = SEARCH_PROMPT_WORDS[wordIndex]; + const activeCharacters = React.useMemo( + () => getPromptCharacters(activeWord), + [activeWord], + ); + const widthAnimationDurationSeconds = getPromptEnterTotalSeconds( + activeCharacters.length, + ); + const measureRef = React.useRef(null); + const pendingWordWidthRef = React.useRef(null); + const [wordWidth, setWordWidth] = React.useState(null); + + React.useEffect(() => { + if (shouldReduceMotion) { + setWordIndex(0); + return; + } + + const intervalId = window.setInterval(() => { + setWordIndex((currentIndex) => { + return (currentIndex + 1) % SEARCH_PROMPT_WORDS.length; + }); + }, SEARCH_PROMPT_ROTATION_MS); + + return () => window.clearInterval(intervalId); + }, [shouldReduceMotion]); + + React.useLayoutEffect(() => { + if (shouldReduceMotion || activeWord.length === 0) { + return; + } + + const width = measureRef.current?.getBoundingClientRect().width; + if (typeof width === "number" && Number.isFinite(width)) { + if (wordWidth === null) { + setWordWidth(width); + } else { + pendingWordWidthRef.current = width; + } + } + }, [activeWord, shouldReduceMotion, wordWidth]); + + const handleWordExitComplete = React.useCallback(() => { + const nextWidth = pendingWordWidthRef.current; + if (nextWidth === null) { + return; + } + + pendingWordWidthRef.current = null; + setWordWidth(nextWidth); + }, []); + + if (shouldReduceMotion) { + return ( + + ); + } + + return ( + + ); +} diff --git a/desktop/src/features/search/ui/SearchResultItem.tsx b/desktop/src/features/search/ui/SearchResultItem.tsx index b8b583dd8d..02e4b83245 100644 --- a/desktop/src/features/search/ui/SearchResultItem.tsx +++ b/desktop/src/features/search/ui/SearchResultItem.tsx @@ -1,32 +1,66 @@ import type * as React from "react"; -import { ArrowRight, FileText, Hash, type LucideIcon } from "lucide-react"; +import { + ArrowRight, + Bot, + FileText, + Hash, + MessageCircle, + Plus, + User, + type LucideIcon, +} from "lucide-react"; import { resolveUserLabel, resolveUserSecondaryLabel, type UserProfileLookup, } from "@/features/profile/lib/identity"; -import type { Channel, SearchHit } from "@/shared/api/types"; +import type { Channel, SearchHit, UserSearchResult } from "@/shared/api/types"; import { Badge } from "@/shared/ui/badge"; import { UserAvatar } from "@/shared/ui/UserAvatar"; export type SearchResult = + | { + kind: "action"; + action: { + description?: string; + id: "create-agent" | "create-channel"; + title: string; + }; + } | { kind: "channel"; channel: Channel } + | { kind: "user"; user: UserSearchResult } | { kind: "message"; hit: SearchHit }; export function resultKey(result: SearchResult) { + if (result.kind === "action") { + return `action-${result.action.id}`; + } + if (result.kind === "channel") { return `channel-${result.channel.id}`; } + if (result.kind === "user") { + return `user-${result.user.pubkey}`; + } + return `message-${result.hit.eventId}`; } export function resultTestId(result: SearchResult) { + if (result.kind === "action") { + return `search-result-action-${result.action.id}`; + } + if (result.kind === "channel") { return `search-result-channel-${result.channel.id}`; } + if (result.kind === "user") { + return `search-result-user-${result.user.pubkey}`; + } + return `search-result-${result.hit.eventId}`; } @@ -34,6 +68,14 @@ export function resultIcon( result: SearchResult, channelLookup: ReadonlyMap, ) { + if (result.kind === "action") { + return result.action.id === "create-agent" ? Bot : Plus; + } + + if (result.kind === "user") { + return result.user.isAgent ? Bot : User; + } + const channelType = result.kind === "channel" ? result.channel.channelType @@ -41,7 +83,15 @@ export function resultIcon( ? channelLookup.get(result.hit.channelId)?.channelType : undefined; - return channelType === "forum" ? FileText : Hash; + if (channelType === "forum") { + return FileText; + } + + if (channelType === "dm") { + return MessageCircle; + } + + return Hash; } export function SearchResultShell({ diff --git a/desktop/src/features/search/ui/TopbarSearch.tsx b/desktop/src/features/search/ui/TopbarSearch.tsx index da74520126..a7939fc8fc 100644 --- a/desktop/src/features/search/ui/TopbarSearch.tsx +++ b/desktop/src/features/search/ui/TopbarSearch.tsx @@ -12,42 +12,58 @@ import { resultTestId, type SearchResult, } from "@/features/search/ui/SearchResultItem"; -import type { Channel, SearchHit } from "@/shared/api/types"; +import { SearchPromptPlaceholder } from "@/features/search/ui/SearchPromptPlaceholder"; +import type { Channel, SearchHit, UserSearchResult } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { Dialog, DialogContent, DialogTitle } from "@/shared/ui/dialog"; +import { useDeferredModalOpen } from "@/shared/ui/deferredModalOpen"; import { - Dialog, - DialogContent, - DialogTitle, - DialogTrigger, -} from "@/shared/ui/dialog"; + MENTION_CHIP_BASE_CLASSES, + MESSAGE_MARKDOWN_CLASS, +} from "@/shared/ui/mentionChip"; import { Skeleton } from "@/shared/ui/skeleton"; import { UserAvatar } from "@/shared/ui/UserAvatar"; type TopbarSearchProps = { + channelLabels?: Record; channels: Channel[]; className?: string; currentPubkey?: string; focusRequest?: number; onOpenChannel: (channelId: string) => void; onOpenResult: (hit: SearchHit) => void; + onOpenUser?: (user: UserSearchResult) => void | Promise; + onCreateAgent?: () => void | Promise; + onCreateChannel?: () => void | Promise; + suggestionChannels?: Channel[]; + variant?: "bar" | "icon"; }; -function describeSearchHit(hit: SearchHit) { - switch (hit.kind) { - case 45001: - return "Forum post"; - case 45003: - return "Forum reply"; - case 43001: - return "Agent job"; - case 43003: - return "Agent update"; - case 46010: - return "Approval"; - default: - return "Message"; - } -} +const MAX_SEARCH_SUGGESTIONS = 4; +const SEARCH_SECTION_TITLE_CLASS = + "px-3 pb-1.5 pt-2 text-xs font-medium text-muted-foreground/70"; +const SEARCH_RESULT_SECTION_ORDER = [ + "channels", + "direct-messages", + "people", + "agents", + "messages", + "actions", +] as const; + +type SearchResultSectionKey = (typeof SEARCH_RESULT_SECTION_ORDER)[number]; + +type SearchResultSection = { + key: SearchResultSectionKey; + results: SearchResult[]; + title: string; +}; + +type SearchHitContextLabel = { + channelLabel: string | null; + text: string; +}; function truncateResultText(content: string, maxLength = 96) { const trimmed = content.trim(); @@ -66,15 +82,19 @@ function formatRelativeTime(unixSeconds: number) { const diff = Math.floor(Date.now() / 1_000) - unixSeconds; if (diff < 60) { - return "now"; + return "just now"; } if (diff < 60 * 60) { - return `${Math.floor(diff / 60)}m`; + return `${Math.floor(diff / 60)}m ago`; } if (diff < 60 * 60 * 24) { - return `${Math.floor(diff / (60 * 60))}h`; + return `${Math.floor(diff / (60 * 60))}h ago`; + } + + if (diff < 60 * 60 * 24 * 7) { + return `${Math.floor(diff / (60 * 60 * 24))}d ago`; } return new Intl.DateTimeFormat("en-US", { @@ -83,6 +103,231 @@ function formatRelativeTime(unixSeconds: number) { }).format(new Date(unixSeconds * 1_000)); } +function getChannelActivityTime(channel: Channel) { + if (!channel.lastMessageAt) { + return 0; + } + + const timestamp = Date.parse(channel.lastMessageAt); + return Number.isFinite(timestamp) ? timestamp : 0; +} + +function getChannelSuggestionMeta(channel: Channel) { + const activityTime = getChannelActivityTime(channel); + + if (activityTime > 0) { + return formatRelativeTime(Math.floor(activityTime / 1_000)); + } + + return null; +} + +function getChannelDisplayName( + channel: Channel, + channelLabels?: Record, +) { + return channelLabels?.[channel.id]?.trim() || channel.name; +} + +function getChannelPreview(channel: Channel) { + if (channel.channelType === "dm") { + return ""; + } + + if (channel.description.trim()) { + return channel.description; + } + + return ""; +} + +function getUserDisplayName(user: UserSearchResult) { + return ( + user.displayName?.trim() || + user.nip05Handle?.trim() || + `${normalizePubkey(user.pubkey).slice(0, 8)}...` + ); +} + +function getUserSecondaryLabel(user: UserSearchResult) { + const displayName = user.displayName?.trim(); + const nip05Handle = user.nip05Handle?.trim(); + + if (nip05Handle && nip05Handle !== displayName) { + return nip05Handle; + } + + return null; +} + +function getSearchHitChannelName( + hit: SearchHit, + channelLookup: ReadonlyMap, + channelLabels?: Record, +) { + const channel = hit.channelId ? channelLookup.get(hit.channelId) : null; + const channelName = + (hit.channelId ? channelLabels?.[hit.channelId]?.trim() : null) || + hit.channelName?.trim() || + channel?.name.trim() || + null; + + if (!channelName) { + return null; + } + + return channelName; +} + +function getSearchHitContextLabel( + hit: SearchHit, + channelLookup: ReadonlyMap, + channelLabels?: Record, +): SearchHitContextLabel { + const channel = hit.channelId ? channelLookup.get(hit.channelId) : null; + const channelName = getSearchHitChannelName( + hit, + channelLookup, + channelLabels, + ); + + if (channel?.channelType === "dm") { + return { + channelLabel: null, + text: "Direct message", + }; + } + + const isThread = hit.kind === 45003 || Boolean(hit.threadRootId); + + return { + channelLabel: channelName, + text: channelName + ? `${isThread ? "Thread" : "Message"} in` + : isThread + ? "Thread" + : "Message", + }; +} + +function getResultSectionKey(result: SearchResult): SearchResultSectionKey { + if (result.kind === "channel") { + return result.channel.channelType === "dm" ? "direct-messages" : "channels"; + } + + if (result.kind === "user") { + return result.user.isAgent ? "agents" : "people"; + } + + if (result.kind === "action") { + return "actions"; + } + + return "messages"; +} + +function getSectionTitle(sectionKey: SearchResultSectionKey) { + switch (sectionKey) { + case "channels": + return "Channels"; + case "direct-messages": + return "Direct messages"; + case "people": + return "People"; + case "agents": + return "Agents"; + case "messages": + return "Most relevant"; + case "actions": + return "Actions"; + } +} + +function SearchHitContextLine({ label }: { label: SearchHitContextLabel }) { + return ( + + {label.text} + {label.channelLabel ? ( + + #{label.channelLabel} + + ) : null} + + ); +} + +function groupSearchResults(results: SearchResult[]): SearchResultSection[] { + const resultsBySection = new Map(); + + for (const result of results) { + const sectionKey = getResultSectionKey(result); + const sectionResults = resultsBySection.get(sectionKey) ?? []; + sectionResults.push(result); + resultsBySection.set(sectionKey, sectionResults); + } + + return SEARCH_RESULT_SECTION_ORDER.flatMap((sectionKey) => { + const sectionResults = resultsBySection.get(sectionKey); + + if (!sectionResults || sectionResults.length === 0) { + return []; + } + + return [ + { + key: sectionKey, + results: sectionResults, + title: getSectionTitle(sectionKey), + }, + ]; + }); +} + +function getSuggestedSearchResults(channels: Channel[]) { + return channels + .filter( + (channel) => + !channel.archivedAt && + (channel.isMember || channel.channelType === "dm"), + ) + .sort((a, b) => { + const activityDiff = + getChannelActivityTime(b) - getChannelActivityTime(a); + if (activityDiff !== 0) { + return activityDiff; + } + + const typeRank = (channel: Channel) => + channel.channelType === "dm" + ? 0 + : channel.channelType === "stream" + ? 1 + : 2; + const rankDiff = typeRank(a) - typeRank(b); + if (rankDiff !== 0) { + return rankDiff; + } + + return a.name.localeCompare(b.name); + }) + .slice(0, MAX_SEARCH_SUGGESTIONS) + .map((channel) => ({ + kind: "channel" as const, + channel, + })); +} + const searchSkeletonRows = [ { iconShape: "rounded-md", @@ -140,17 +385,25 @@ function SearchResultsSkeleton() { } export function TopbarSearch({ + channelLabels, channels, className, currentPubkey, focusRequest = 0, onOpenChannel, onOpenResult, + onOpenUser, + onCreateAgent, + onCreateChannel, + suggestionChannels, + variant = "bar", }: TopbarSearchProps) { const [isOpen, setIsOpen] = React.useState(false); const [selectedMenuIndex, setSelectedMenuIndex] = React.useState(0); const triggerRef = React.useRef(null); const dialogInputRef = React.useRef(null); + const { cancelDeferredModalOpen, openAfterExit, openNextFrame } = + useDeferredModalOpen(); const { channelLookup, debouncedQuery, @@ -159,8 +412,89 @@ export function TopbarSearch({ results, searchQuery, setQuery, - } = useSearchResults({ channels, enabled: isOpen, limit: 8 }); + userSearchQuery, + } = useSearchResults({ channelLabels, channels, enabled: isOpen, limit: 8 }); const trimmedQuery = query.trim(); + const isIconVariant = variant === "icon"; + const currentPubkeyNormalized = currentPubkey + ? normalizePubkey(currentPubkey) + : null; + const suggestedResults = React.useMemo( + () => getSuggestedSearchResults(suggestionChannels ?? channels), + [channels, suggestionChannels], + ); + const suggestionActionResults = React.useMemo(() => { + const actions: SearchResult[] = []; + + if (onCreateChannel) { + actions.push({ + kind: "action", + action: { + id: "create-channel", + title: "Create a new channel", + }, + }); + } + + if (onCreateAgent) { + actions.push({ + kind: "action", + action: { + id: "create-agent", + title: "Create a new agent", + }, + }); + } + + return actions; + }, [onCreateAgent, onCreateChannel]); + const suggestionResults = React.useMemo( + () => [...suggestedResults, ...suggestionActionResults], + [suggestedResults, suggestionActionResults], + ); + const isShowingSuggestions = + debouncedQuery.length < MIN_SEARCH_QUERY_LENGTH && + trimmedQuery.length < MIN_SEARCH_QUERY_LENGTH; + const searchableResults = React.useMemo( + () => + results.filter( + (result) => + result.kind !== "user" || + normalizePubkey(result.user.pubkey) !== currentPubkeyNormalized, + ), + [currentPubkeyNormalized, results], + ); + const searchResultSections = React.useMemo( + () => groupSearchResults(searchableResults), + [searchableResults], + ); + const groupedSearchResults = React.useMemo( + () => searchResultSections.flatMap((section) => section.results), + [searchResultSections], + ); + const activeResults = isShowingSuggestions + ? suggestionResults + : groupedSearchResults; + const isSearchLoading = searchQuery.isLoading || userSearchQuery.isLoading; + + const openSearchDialog = React.useCallback(() => { + setSelectedMenuIndex(0); + openNextFrame(() => setIsOpen(true)); + }, [openNextFrame]); + + const handleSearchOpenChange = React.useCallback( + (nextOpen: boolean) => { + if (nextOpen) { + openSearchDialog(); + return; + } + + cancelDeferredModalOpen(); + setSelectedMenuIndex(0); + setIsOpen(false); + }, + [cancelDeferredModalOpen, openSearchDialog], + ); const openResult = React.useCallback( (result: SearchResult) => { @@ -172,9 +506,36 @@ export function TopbarSearch({ return; } + if (result.kind === "user") { + void onOpenUser?.(result.user); + return; + } + + if (result.kind === "action") { + setSelectedMenuIndex(0); + if (result.action.id === "create-channel") { + openAfterExit(() => { + void onCreateChannel?.(); + }); + } else { + openAfterExit(() => { + void onCreateAgent?.(); + }); + } + return; + } + onOpenResult(result.hit); }, - [onOpenChannel, onOpenResult, setQuery], + [ + onCreateAgent, + onCreateChannel, + onOpenChannel, + onOpenResult, + onOpenUser, + openAfterExit, + setQuery, + ], ); React.useEffect(() => { @@ -182,9 +543,9 @@ export function TopbarSearch({ return; } - setIsOpen(true); + openSearchDialog(); triggerRef.current?.focus(); - }, [focusRequest]); + }, [focusRequest, openSearchDialog]); React.useEffect(() => { if (!isOpen) { @@ -202,25 +563,25 @@ export function TopbarSearch({ React.useEffect(() => { setSelectedMenuIndex((current) => { - if (results.length === 0) { + if (activeResults.length === 0) { return 0; } - return Math.min(current, results.length - 1); + return Math.min(current, activeResults.length - 1); }); - }, [results]); + }, [activeResults]); const handleDialogInputKeyDown = React.useCallback( (event: React.KeyboardEvent) => { - if (event.key === "ArrowDown" && results.length > 0) { + if (event.key === "ArrowDown" && activeResults.length > 0) { event.preventDefault(); setSelectedMenuIndex((current) => - Math.min(current + 1, results.length - 1), + Math.min(current + 1, activeResults.length - 1), ); return; } - if (event.key === "ArrowUp" && results.length > 0) { + if (event.key === "ArrowUp" && activeResults.length > 0) { event.preventDefault(); setSelectedMenuIndex((current) => Math.max(current - 1, 0)); return; @@ -228,132 +589,261 @@ export function TopbarSearch({ if (event.key === "Enter" && !event.nativeEvent.isComposing) { event.preventDefault(); - const result = results[selectedMenuIndex]; + const result = activeResults[selectedMenuIndex]; if (result) { openResult(result); } } }, - [openResult, results, selectedMenuIndex], + [activeResults, openResult, selectedMenuIndex], ); - const searchResultContent = - debouncedQuery.length < MIN_SEARCH_QUERY_LENGTH ? ( -
-

Type at least two characters for live suggestions.

-
- ) : searchQuery.isLoading && results.length === 0 ? ( - - ) : searchQuery.error instanceof Error && results.length === 0 ? ( -

- {searchQuery.error.message} -

- ) : results.length === 0 ? ( -

- No matches for {trimmedQuery}. -

- ) : ( -
- {results.map((result, index) => ( - - ))} -
+ )} + + {result.kind !== "message" && trailingLabel ? ( + + {trailingLabel} + + ) : null} + ); + }; + + const renderSearchResultSections = (sections: SearchResultSection[]) => { + let resultIndex = 0; + + return sections.map((section) => ( +
+
{section.title}
+ {section.results.map((result) => + renderSearchResultRow(result, resultIndex++), + )} +
+ )); + }; + + const searchResultContent = isShowingSuggestions ? ( + suggestionResults.length === 0 ? ( +
+

No recent activity yet.

+
+ ) : ( +
+ {(() => { + let resultIndex = 0; + + return ( + <> + {suggestedResults.length > 0 ? ( +
+
+ Recent activity +
+ {suggestedResults.map((result) => + renderSearchResultRow(result, resultIndex++), + )} +
+ ) : null} + {suggestionActionResults.length > 0 ? ( +
+
Actions
+ {suggestionActionResults.map((result) => + renderSearchResultRow(result, resultIndex++), + )} +
+ ) : null} + + ); + })()} +
+ ) + ) : isSearchLoading && searchableResults.length === 0 ? ( + + ) : searchQuery.error instanceof Error && searchableResults.length === 0 ? ( +

+ {searchQuery.error.message} +

+ ) : searchableResults.length === 0 ? ( +

+ No matches for {trimmedQuery}. +

+ ) : ( +
+ {renderSearchResultSections(searchResultSections)} +
+ ); return (
- - - - + + { event.preventDefault(); @@ -368,22 +858,28 @@ export function TopbarSearch({ Search everything
- { - setQuery(event.target.value); - setSelectedMenuIndex(0); - }} - onKeyDown={handleDialogInputKeyDown} - placeholder="Search everything" - spellCheck={false} - value={query} - /> +
+ {query.length === 0 ? ( + + + + ) : null} + { + setQuery(event.target.value); + setSelectedMenuIndex(0); + }} + onKeyDown={handleDialogInputKeyDown} + spellCheck={false} + value={query} + /> +
ESC diff --git a/desktop/src/features/search/useSearchResults.ts b/desktop/src/features/search/useSearchResults.ts index 116f72744e..e8d7b584b2 100644 --- a/desktop/src/features/search/useSearchResults.ts +++ b/desktop/src/features/search/useSearchResults.ts @@ -1,17 +1,46 @@ import * as React from "react"; -import { useUsersBatchQuery } from "@/features/profile/hooks"; +import { + useManagedAgentsQuery, + useRelayAgentsQuery, +} from "@/features/agents/hooks"; +import { useIsArchivedPredicate } from "@/features/identity-archive/hooks"; +import { + useUserSearchQuery, + useUsersBatchQuery, +} from "@/features/profile/hooks"; +import { rankUserCandidatesBySearch } from "@/features/profile/lib/userCandidateSearch"; import { useSearchMessagesQuery } from "@/features/search/hooks"; import type { SearchResult } from "@/features/search/ui/SearchResultItem"; -import type { Channel } from "@/shared/api/types"; +import type { Channel, SearchHit, UserSearchResult } from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; export const MIN_SEARCH_QUERY_LENGTH = 2; +function formatUserResultName(user: UserSearchResult) { + return user.displayName?.trim() || user.nip05Handle?.trim() || user.pubkey; +} + +function dedupeSearchHits(hits: SearchHit[]) { + const seenEventIds = new Set(); + + return hits.filter((hit) => { + if (seenEventIds.has(hit.eventId)) { + return false; + } + + seenEventIds.add(hit.eventId); + return true; + }); +} + export function useSearchResults({ + channelLabels, channels, enabled, limit = 12, }: { + channelLabels?: Record; channels: Channel[]; enabled: boolean; limit?: number; @@ -19,6 +48,7 @@ export function useSearchResults({ const [query, setQuery] = React.useState(""); const [debouncedQuery, setDebouncedQuery] = React.useState(""); const [selectedIndex, setSelectedIndex] = React.useState(0); + const isArchivedDiscovery = useIsArchivedPredicate(); const channelLookup = React.useMemo( () => new Map(channels.map((channel) => [channel.id, channel])), @@ -30,7 +60,10 @@ export function useSearchResults({ limit, }); - const messageResults = searchQuery.data?.hits ?? []; + const messageResults = React.useMemo( + () => dedupeSearchHits(searchQuery.data?.hits ?? []), + [searchQuery.data?.hits], + ); const channelResults = React.useMemo(() => { if (debouncedQuery.length < MIN_SEARCH_QUERY_LENGTH) { return []; @@ -41,25 +74,176 @@ export function useSearchResults({ return channels .filter( (channel) => - channel.channelType !== "dm" && (channel.archivedAt ? channel.isMember : channel.visibility === "open" || channel.isMember) && - (channel.name.toLowerCase().includes(normalizedQuery) || - channel.description.toLowerCase().includes(normalizedQuery)), + [ + channel.name, + channel.description, + channelLabels?.[channel.id] ?? "", + ].some((value) => value.toLowerCase().includes(normalizedQuery)), ) .sort((a, b) => { - const aNameMatches = a.name.toLowerCase().includes(normalizedQuery); - const bNameMatches = b.name.toLowerCase().includes(normalizedQuery); + const aDisplayName = channelLabels?.[a.id]?.trim() || a.name; + const bDisplayName = channelLabels?.[b.id]?.trim() || b.name; + const aNameMatches = aDisplayName + .toLowerCase() + .includes(normalizedQuery); + const bNameMatches = bDisplayName + .toLowerCase() + .includes(normalizedQuery); if (aNameMatches !== bNameMatches) { return aNameMatches ? -1 : 1; } - return a.name.localeCompare(b.name); + return aDisplayName.localeCompare(bDisplayName); }) .slice(0, 5); - }, [channels, debouncedQuery]); + }, [channelLabels, channels, debouncedQuery]); + + const userSearchQuery = useUserSearchQuery(debouncedQuery, { + enabled: enabled && debouncedQuery.length >= MIN_SEARCH_QUERY_LENGTH, + limit, + }); + const managedAgentsQuery = useManagedAgentsQuery({ enabled }); + const relayAgentsQuery = useRelayAgentsQuery({ enabled }); + const managedAgentPubkeys = React.useMemo( + () => + new Set( + (managedAgentsQuery.data ?? []).map((agent) => + normalizePubkey(agent.pubkey), + ), + ), + [managedAgentsQuery.data], + ); + const relayAgentPubkeys = React.useMemo( + () => + new Set( + (relayAgentsQuery.data ?? []).map((agent) => + normalizePubkey(agent.pubkey), + ), + ), + [relayAgentsQuery.data], + ); + const eligibleAgentPubkeys = React.useMemo(() => { + const pubkeys = new Set(managedAgentPubkeys); + + for (const agent of relayAgentsQuery.data ?? []) { + if (agent.respondTo === "anyone") { + pubkeys.add(normalizePubkey(agent.pubkey)); + } + } + + return pubkeys; + }, [managedAgentPubkeys, relayAgentsQuery.data]); + const userResults = React.useMemo(() => { + if (debouncedQuery.length < MIN_SEARCH_QUERY_LENGTH) { + return []; + } + + const normalizedQuery = debouncedQuery.toLowerCase(); + const candidatesByPubkey = new Map(); + + const matchesQuery = (candidate: UserSearchResult) => + [ + candidate.displayName ?? "", + candidate.nip05Handle ?? "", + candidate.isAgent ? "agent" : "", + normalizePubkey(candidate.pubkey), + ].some((value) => value.toLowerCase().includes(normalizedQuery)); + + const addCandidate = (candidate: UserSearchResult) => { + const pubkey = normalizePubkey(candidate.pubkey); + + if (isArchivedDiscovery(pubkey)) { + return; + } + + const isKnownAgent = + candidate.isAgent || + managedAgentPubkeys.has(pubkey) || + relayAgentPubkeys.has(pubkey); + + if (isKnownAgent && !eligibleAgentPubkeys.has(pubkey)) { + return; + } + + const existing = candidatesByPubkey.get(pubkey); + if (!existing) { + candidatesByPubkey.set(pubkey, { + ...candidate, + pubkey, + isAgent: isKnownAgent, + }); + return; + } + + candidatesByPubkey.set(pubkey, { + pubkey, + avatarUrl: existing.avatarUrl ?? candidate.avatarUrl ?? null, + displayName: + candidate.isAgent && candidate.displayName?.trim() + ? candidate.displayName + : (existing.displayName ?? candidate.displayName), + nip05Handle: existing.nip05Handle ?? candidate.nip05Handle ?? null, + isAgent: existing.isAgent || isKnownAgent, + }); + }; + + for (const user of userSearchQuery.data ?? []) { + addCandidate(user); + } + + for (const agent of relayAgentsQuery.data ?? []) { + if (agent.respondTo !== "anyone") { + continue; + } + + const candidate = { + pubkey: agent.pubkey, + displayName: agent.name, + avatarUrl: null, + nip05Handle: null, + isAgent: true, + }; + + if (matchesQuery(candidate)) { + addCandidate(candidate); + } + } + + for (const agent of managedAgentsQuery.data ?? []) { + const candidate = { + pubkey: agent.pubkey, + displayName: agent.name, + avatarUrl: null, + nip05Handle: null, + isAgent: true, + }; + + if (matchesQuery(candidate)) { + addCandidate(candidate); + } + } + + return rankUserCandidatesBySearch({ + candidates: [...candidatesByPubkey.values()], + getLabel: formatUserResultName, + limit, + query: debouncedQuery, + }); + }, [ + debouncedQuery, + eligibleAgentPubkeys, + isArchivedDiscovery, + limit, + managedAgentPubkeys, + managedAgentsQuery.data, + relayAgentPubkeys, + relayAgentsQuery.data, + userSearchQuery.data, + ]); const results = React.useMemo( () => [ @@ -67,12 +251,16 @@ export function useSearchResults({ kind: "channel" as const, channel, })), + ...userResults.map((user) => ({ + kind: "user" as const, + user, + })), ...messageResults.map((hit) => ({ kind: "message" as const, hit, })), ], - [channelResults, messageResults], + [channelResults, messageResults, userResults], ); const resultProfilesQuery = useUsersBatchQuery( @@ -129,5 +317,7 @@ export function useSearchResults({ selectedResult: results[selectedIndex], setQuery, setSelectedIndex, + userResults, + userSearchQuery, }; } diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 9b925f07fa..0657f8b1b9 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -48,6 +48,7 @@ import { SECTION_ACTION_VISIBILITY_CLASS, SECTION_ICON_BUTTON_CLASS, } from "@/features/sidebar/ui/sidebarSectionStyles"; +import { useDeferredModalOpen } from "@/shared/ui/deferredModalOpen"; import { SidebarUpdateCard } from "@/features/settings/SidebarUpdateCard"; import { useUpdaterContext } from "@/features/settings/hooks/UpdaterProvider"; import { shouldShowSidebarUpdateCard } from "@/features/settings/sidebarUpdateCardVisibility"; @@ -140,6 +141,7 @@ type AppSidebarProps = { updates: Partial>, ) => void; onRemoveWorkspace: (id: string) => void; + onCreateAgent: () => void; onSelectAgents: () => void; onSelectProjects: () => void; onSelectPulse: () => void; @@ -209,6 +211,7 @@ export function AppSidebar({ onOpenDm, onUpdateWorkspace, onRemoveWorkspace, + onCreateAgent, onSelectAgents, onSelectProjects, onSelectPulse, @@ -310,6 +313,14 @@ export function AppSidebar({ const [createDialogKind, setCreateDialogKind] = React.useState(null); + const { openNextFrame: openModalNextFrame } = useDeferredModalOpen(); + const openCreateDialog = React.useCallback( + (kind: CreateChannelKind) => { + setCreateDialogKind(null); + openModalNextFrame(() => setCreateDialogKind(kind)); + }, + [openModalNextFrame], + ); React.useEffect(() => { if (!canShowSidebarUpdateCard) { @@ -324,9 +335,9 @@ export function AppSidebar({ // dialog's `onOpenChange` below. React.useEffect(() => { if (isCreateChannelOpenProp) { - setCreateDialogKind("stream"); + openCreateDialog("stream"); } - }, [isCreateChannelOpenProp]); + }, [isCreateChannelOpenProp, openCreateDialog]); const [collapsedGroups, setCollapsedGroups] = React.useState< Record >({ @@ -506,6 +517,15 @@ export function AppSidebar({ [createDialogKind, onCreateChannel, onCreateForum], ); + const handleOpenCreateChannel = React.useCallback(() => { + if (onCreateChannelOpenChange) { + onCreateChannelOpenChange(true); + return; + } + + openCreateDialog("stream"); + }, [onCreateChannelOpenChange, openCreateDialog]); + return ( onOpenDm({ pubkeys: [user.pubkey] })} + onCreateAgent={onCreateAgent} + onCreateChannel={handleOpenCreateChannel} + suggestionChannels={channels} /> setCreateDialogKind("stream")} + onCreateClick={() => openCreateDialog("stream")} onMarkAllRead={onMarkAllChannelsRead} onMarkChannelRead={onMarkChannelRead} onMarkChannelUnread={onMarkChannelUnread} @@ -756,7 +781,7 @@ export function AppSidebar({ isActiveChannel={selectedView === "channel"} items={forumChannels} listTestId="forum-list" - onCreateClick={() => setCreateDialogKind("forum")} + onCreateClick={() => openCreateDialog("forum")} onMarkAllRead={onMarkAllChannelsRead} onMarkChannelRead={onMarkChannelRead} onMarkChannelUnread={onMarkChannelUnread} diff --git a/desktop/src/shared/styles/globals.css b/desktop/src/shared/styles/globals.css index 5efaba549a..b71cb17d18 100644 --- a/desktop/src/shared/styles/globals.css +++ b/desktop/src/shared/styles/globals.css @@ -929,6 +929,21 @@ color: hsl(var(--muted-foreground) / 0.82); } +.message-markdown .mention-chip.search-channel-chip { + background: hsl(var(--muted)); + color: hsl(var(--muted-foreground) / 0.82); + transition: + background-color 150ms ease-out, + color 150ms ease-out; +} + +.search-result-row[aria-selected="true"] + .message-markdown + .mention-chip.search-channel-chip { + background: hsl(var(--muted) / 0.82); + color: hsl(var(--muted-foreground) / 0.95); +} + .message-markdown.inbox-preview-markdown { --inline-chip-padding-block-start: 0.125rem; --inline-chip-padding-block-end: 0.0625rem; diff --git a/desktop/src/shared/ui/alert-dialog.tsx b/desktop/src/shared/ui/alert-dialog.tsx index 75831c4563..d4bf93d4b1 100644 --- a/desktop/src/shared/ui/alert-dialog.tsx +++ b/desktop/src/shared/ui/alert-dialog.tsx @@ -6,6 +6,10 @@ import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; import { cn } from "@/shared/lib/cn"; import { buttonVariants } from "@/shared/ui/button"; import { MODAL_BACKDROP_BLUR_CLASS } from "@/shared/ui/modalBackdrop"; +import { + MODAL_CONTENT_MOTION_CLASS, + MODAL_OVERLAY_MOTION_CLASS, +} from "@/shared/ui/modalMotion"; const AlertDialog = AlertDialogPrimitive.Root; const AlertDialogTrigger = AlertDialogPrimitive.Trigger; @@ -16,7 +20,8 @@ const AlertDialogOverlay = React.forwardRef< >(({ className, ...props }, ref) => ( (null); + const timeoutRef = React.useRef(null); + + const cancelDeferredModalOpen = React.useCallback(() => { + if (frameRef.current !== null) { + window.cancelAnimationFrame(frameRef.current); + frameRef.current = null; + } + + if (timeoutRef.current !== null) { + window.clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + }, []); + + const openNextFrame = React.useCallback( + (open: () => void) => { + cancelDeferredModalOpen(); + frameRef.current = window.requestAnimationFrame(() => { + frameRef.current = null; + open(); + }); + }, + [cancelDeferredModalOpen], + ); + + const openAfterExit = React.useCallback( + (open: () => void) => { + cancelDeferredModalOpen(); + timeoutRef.current = window.setTimeout(() => { + timeoutRef.current = null; + openNextFrame(open); + }, MODAL_EXIT_ANIMATION_MS); + }, + [cancelDeferredModalOpen, openNextFrame], + ); + + React.useEffect(() => cancelDeferredModalOpen, [cancelDeferredModalOpen]); + + return { + cancelDeferredModalOpen, + openAfterExit, + openNextFrame, + }; +} diff --git a/desktop/src/shared/ui/dialog.tsx b/desktop/src/shared/ui/dialog.tsx index e41d5ae056..88152c9815 100644 --- a/desktop/src/shared/ui/dialog.tsx +++ b/desktop/src/shared/ui/dialog.tsx @@ -7,6 +7,10 @@ import { X } from "lucide-react"; import { cn } from "@/shared/lib/cn"; import { useTheme } from "@/shared/theme/ThemeProvider"; import { MODAL_BACKDROP_BLUR_CLASS } from "@/shared/ui/modalBackdrop"; +import { + MODAL_CONTENT_MOTION_CLASS, + MODAL_OVERLAY_MOTION_CLASS, +} from "@/shared/ui/modalMotion"; const Dialog = DialogPrimitive.Root; const DialogTrigger = DialogPrimitive.Trigger; @@ -22,7 +26,8 @@ const DialogOverlay = React.forwardRef< return ( Date: Tue, 23 Jun 2026 16:49:40 +0100 Subject: [PATCH 2/4] Relax persona catalog scroll assertion --- desktop/tests/e2e/agents.spec.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts index 2ba44e96f0..ab4d1dfa30 100644 --- a/desktop/tests/e2e/agents.spec.ts +++ b/desktop/tests/e2e/agents.spec.ts @@ -159,15 +159,16 @@ test("built-in personas are chosen from the dialog and can be selected", async ( await expect( page.getByTestId("persona-catalog-dialog-scroll-area"), ).toHaveCSS("overflow-y", "auto"); - expect( - await page - .getByTestId("persona-catalog-dialog-scroll-area") - .evaluate( - (element) => - element.scrollHeight > element.clientHeight && - element.clientHeight > 0, - ), - ).toBe(true); + const catalogScrollAreaMetrics = await page + .getByTestId("persona-catalog-dialog-scroll-area") + .evaluate((element) => ({ + clientHeight: element.clientHeight, + scrollHeight: element.scrollHeight, + })); + expect(catalogScrollAreaMetrics.clientHeight).toBeGreaterThan(0); + expect(catalogScrollAreaMetrics.scrollHeight).toBeGreaterThanOrEqual( + catalogScrollAreaMetrics.clientHeight, + ); await expect(page.getByTestId("persona-catalog-dialog-footer")).toBeVisible(); await expect(page.getByRole("tooltip")).toHaveCount(0); const initialCatalogOrder = await getCatalogOrder(page); From 7bad23f3c9a274e90aae1e4f15c66c201a964ea4 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Tue, 23 Jun 2026 17:15:58 +0100 Subject: [PATCH 3/4] Use Tailwind-scale search values --- .../search/ui/SearchPromptPlaceholder.tsx | 15 ++++++++------- desktop/src/features/search/ui/TopbarSearch.tsx | 6 +++--- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/desktop/src/features/search/ui/SearchPromptPlaceholder.tsx b/desktop/src/features/search/ui/SearchPromptPlaceholder.tsx index 8ae7d933f0..801fe7e33f 100644 --- a/desktop/src/features/search/ui/SearchPromptPlaceholder.tsx +++ b/desktop/src/features/search/ui/SearchPromptPlaceholder.tsx @@ -15,8 +15,9 @@ const SEARCH_PROMPT_ENTER_DURATION_SECONDS = 0.54; const SEARCH_PROMPT_EXIT_DURATION_SECONDS = 0.32; const SEARCH_PROMPT_ENTER_STAGGER_SECONDS = 0.014; const SEARCH_PROMPT_EXIT_STAGGER_SECONDS = 0.008; -const SEARCH_PROMPT_Y_OFFSET_PX = 8; -const SEARCH_PROMPT_BLUR_PX = 5; +const SEARCH_PROMPT_Y_OFFSET = "0.5rem"; +const SEARCH_PROMPT_NEGATIVE_Y_OFFSET = "-0.5rem"; +const SEARCH_PROMPT_BLUR = "0.25rem"; const searchPromptPhraseVariants = { animate: { @@ -34,7 +35,7 @@ const searchPromptPhraseVariants = { const searchPromptCharacterVariants = { animate: { - filter: "blur(0px)", + filter: "blur(0)", opacity: 1, transition: { duration: SEARCH_PROMPT_ENTER_DURATION_SECONDS, @@ -43,18 +44,18 @@ const searchPromptCharacterVariants = { y: 0, }, exit: { - filter: `blur(${SEARCH_PROMPT_BLUR_PX}px)`, + filter: `blur(${SEARCH_PROMPT_BLUR})`, opacity: 0, transition: { duration: SEARCH_PROMPT_EXIT_DURATION_SECONDS, ease: SEARCH_PROMPT_EXIT_EASE, }, - y: -SEARCH_PROMPT_Y_OFFSET_PX, + y: SEARCH_PROMPT_NEGATIVE_Y_OFFSET, }, initial: { - filter: `blur(${SEARCH_PROMPT_BLUR_PX}px)`, + filter: `blur(${SEARCH_PROMPT_BLUR})`, opacity: 0, - y: SEARCH_PROMPT_Y_OFFSET_PX, + y: SEARCH_PROMPT_Y_OFFSET, }, }; diff --git a/desktop/src/features/search/ui/TopbarSearch.tsx b/desktop/src/features/search/ui/TopbarSearch.tsx index a7939fc8fc..47719682ee 100644 --- a/desktop/src/features/search/ui/TopbarSearch.tsx +++ b/desktop/src/features/search/ui/TopbarSearch.tsx @@ -753,7 +753,7 @@ export function TopbarSearch({ ) : (
{(() => { @@ -795,7 +795,7 @@ export function TopbarSearch({ No matches for {trimmedQuery}.

) : ( -
+
{renderSearchResultSections(searchResultSections)}
); @@ -807,7 +807,7 @@ export function TopbarSearch({ aria-label="Search everything" className={ isIconVariant - ? "group/search flex size-6 items-center justify-center rounded-[4px] p-1 text-sidebar-foreground/50 transition-colors hover:bg-sidebar-border/35 hover:text-sidebar-foreground focus-visible:bg-sidebar-border/35 focus-visible:text-sidebar-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-sidebar-ring" + ? "group/search flex size-6 items-center justify-center rounded p-1 text-sidebar-foreground/50 transition-colors hover:bg-sidebar-border/35 hover:text-sidebar-foreground focus-visible:bg-sidebar-border/35 focus-visible:text-sidebar-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-sidebar-ring" : "group/search flex h-7 w-full items-center gap-2 rounded-lg bg-sidebar-border/20 px-2 text-left text-xs text-sidebar-foreground/55 transition-colors duration-150 ease-out hover:bg-sidebar-border/35 hover:text-sidebar-foreground focus-visible:bg-sidebar-border/35 focus-visible:text-sidebar-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-sidebar-ring" } data-testid="open-search" From 4679450cd700b4a9fa03d2de61b5f07c15ddb6fe Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Tue, 23 Jun 2026 17:20:46 +0100 Subject: [PATCH 4/4] Preserve search result owner pubkeys --- desktop/src/features/search/useSearchResults.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/desktop/src/features/search/useSearchResults.ts b/desktop/src/features/search/useSearchResults.ts index e8d7b584b2..6c71b0b728 100644 --- a/desktop/src/features/search/useSearchResults.ts +++ b/desktop/src/features/search/useSearchResults.ts @@ -187,6 +187,7 @@ export function useSearchResults({ ? candidate.displayName : (existing.displayName ?? candidate.displayName), nip05Handle: existing.nip05Handle ?? candidate.nip05Handle ?? null, + ownerPubkey: existing.ownerPubkey ?? candidate.ownerPubkey ?? null, isAgent: existing.isAgent || isKnownAgent, }); }; @@ -205,6 +206,7 @@ export function useSearchResults({ displayName: agent.name, avatarUrl: null, nip05Handle: null, + ownerPubkey: null, isAgent: true, }; @@ -219,6 +221,7 @@ export function useSearchResults({ displayName: agent.name, avatarUrl: null, nip05Handle: null, + ownerPubkey: null, isAgent: true, };