From 5108762d12fd2fac73c887c0b310614599d9575e Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Fri, 22 May 2026 14:05:15 -0400 Subject: [PATCH] feat: add global search palette Co-authored-by: Cursor --- desktop/src/app/AppHeaderControls.tsx | 58 +++ desktop/src/app/AppShell.tsx | 64 ++-- desktop/src/app/AppShellOverlays.tsx | 3 + .../src/features/search/ui/SearchDialog.tsx | 214 +++++++---- .../features/search/ui/SearchResultItem.tsx | 196 +++++++--- .../src/features/sidebar/ui/AppSidebar.tsx | 48 +-- .../workspaces/ui/WorkspaceSwitcher.tsx | 362 +++++++++++++----- desktop/src/shared/lib/keyboard-shortcuts.ts | 6 +- desktop/tests/e2e/smoke.spec.ts | 26 +- 9 files changed, 691 insertions(+), 286 deletions(-) create mode 100644 desktop/src/app/AppHeaderControls.tsx diff --git a/desktop/src/app/AppHeaderControls.tsx b/desktop/src/app/AppHeaderControls.tsx new file mode 100644 index 000000000..e7fd89c5f --- /dev/null +++ b/desktop/src/app/AppHeaderControls.tsx @@ -0,0 +1,58 @@ +import { ChevronLeft, ChevronRight, Search } from "lucide-react"; + +import { Button } from "@/shared/ui/button"; +import { SidebarTrigger } from "@/shared/ui/sidebar"; + +type AppHeaderControlsProps = { + canGoBack: boolean; + canGoForward: boolean; + onGoBack: () => void; + onGoForward: () => void; + onOpenSearch: () => void; +}; + +export function AppHeaderControls({ + canGoBack, + canGoForward, + onGoBack, + onGoForward, + onOpenSearch, +}: AppHeaderControlsProps) { + return ( +
+ + + + +
+ ); +} diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 837191eb9..36bdd8c44 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -1,9 +1,9 @@ -import { ChevronLeft, ChevronRight } from "lucide-react"; import * as React from "react"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { useQueryClient } from "@tanstack/react-query"; import { Outlet, useLocation } from "@tanstack/react-router"; +import { AppHeaderControls } from "@/app/AppHeaderControls"; import { AppShellProvider } from "@/app/AppShellContext"; import { AppShellOverlays, @@ -57,13 +57,11 @@ import { useDeferredStartup } from "@/shared/hooks/useDeferredStartup"; import { joinChannel } from "@/shared/api/tauri"; import type { Channel, RelayEvent, SearchHit } from "@/shared/api/types"; import { ChannelNavigationProvider } from "@/shared/context/ChannelNavigationContext"; -import { hasPrimaryShortcutModifier } from "@/shared/lib/platform"; -import { Button } from "@/shared/ui/button"; import { - SidebarInset, - SidebarProvider, - SidebarTrigger, -} from "@/shared/ui/sidebar"; + hasPrimaryShortcutModifier, + isMacPlatform, +} from "@/shared/lib/platform"; +import { SidebarInset, SidebarProvider } from "@/shared/ui/sidebar"; type AppView = | "home" @@ -369,6 +367,16 @@ export function AppShell() { }, [openSearchHit], ); + const handleOpenSearchUser = React.useCallback( + (pubkey: string) => { + void openDmMutation + .mutateAsync({ + pubkeys: [pubkey], + }) + .then((directMessage) => goChannel(directMessage.id)); + }, + [goChannel, openDmMutation], + ); const handleDesktopNotificationAction = React.useEffectEvent( async (target: DesktopNotificationTarget) => { @@ -482,6 +490,14 @@ export function AppShell() { } const key = event.key.toLowerCase(); + const isSpace = + isMacPlatform() && (event.key === " " || event.code === "Space"); + if (isSpace && !event.shiftKey) { + event.preventDefault(); + handleOpenSearch(); + return; + } + if (key === "k" && !event.shiftKey) { event.preventDefault(); handleOpenSearch(); @@ -598,31 +614,13 @@ export function AppShell() { className="fixed inset-x-0 top-0 z-20 h-10 cursor-default select-none" data-tauri-drag-region /> -
- - - -
+ { void goAgents(); }} @@ -762,6 +759,7 @@ export function AppShell() { void goHome({ replace: true }); }} onOpenSearchResult={handleOpenSearchResult} + onOpenSearchUser={handleOpenSearchUser} onSearchOpenChange={setIsSearchOpen} onSelectChannel={(channelId) => { void goChannel(channelId); diff --git a/desktop/src/app/AppShellOverlays.tsx b/desktop/src/app/AppShellOverlays.tsx index bc8a98b2f..2c560c41c 100644 --- a/desktop/src/app/AppShellOverlays.tsx +++ b/desktop/src/app/AppShellOverlays.tsx @@ -31,6 +31,7 @@ type AppShellOverlaysProps = { onChannelManagementOpenChange: (open: boolean) => void; onDeleteActiveChannel: () => void; onOpenSearchResult: (hit: SearchHit) => void; + onOpenSearchUser: (pubkey: string) => void; onSearchOpenChange: (open: boolean) => void; onSelectChannel: (channelId: string) => void; }; @@ -47,6 +48,7 @@ export function AppShellOverlays({ onChannelManagementOpenChange, onDeleteActiveChannel, onOpenSearchResult, + onOpenSearchUser, onSearchOpenChange, onSelectChannel, }: AppShellOverlaysProps) { @@ -72,6 +74,7 @@ export function AppShellOverlays({ currentPubkey={currentPubkey} onOpenChannel={onSelectChannel} onOpenResult={onOpenSearchResult} + onOpenUser={onOpenSearchUser} onOpenChange={onSearchOpenChange} open={true} /> diff --git a/desktop/src/features/search/ui/SearchDialog.tsx b/desktop/src/features/search/ui/SearchDialog.tsx index 63b14df29..65eb293cb 100644 --- a/desktop/src/features/search/ui/SearchDialog.tsx +++ b/desktop/src/features/search/ui/SearchDialog.tsx @@ -3,10 +3,14 @@ import { LoaderCircle, MessagesSquare, Search, + X, type LucideIcon, } from "lucide-react"; -import { useUsersBatchQuery } from "@/features/profile/hooks"; +import { + useUserSearchQuery, + useUsersBatchQuery, +} from "@/features/profile/hooks"; import { useSearchMessagesQuery } from "@/features/search/hooks"; import type { Channel, SearchHit } from "@/shared/api/types"; import { @@ -17,6 +21,8 @@ import { resultTestId, SearchResultShell, type SearchResult, + UserResultAvatar, + UserResultBody, } from "@/features/search/ui/SearchResultItem"; import { Dialog, @@ -76,6 +82,7 @@ type SearchDialogProps = { onOpenChange: (open: boolean) => void; onOpenChannel: (channelId: string) => void; onOpenResult: (hit: SearchHit) => void; + onOpenUser: (pubkey: string) => void; }; export function SearchDialog({ @@ -85,6 +92,7 @@ export function SearchDialog({ onOpenChange, onOpenChannel, onOpenResult, + onOpenUser, }: SearchDialogProps) { const [query, setQuery] = React.useState(""); const [debouncedQuery, setDebouncedQuery] = React.useState(""); @@ -99,8 +107,19 @@ export function SearchDialog({ enabled: open, limit: 12, }); + const usersQuery = useUserSearchQuery(debouncedQuery, { + enabled: open && debouncedQuery.length >= MIN_QUERY_LENGTH, + limit: 6, + }); const messageResults = searchQuery.data?.hits ?? []; + const userResults = React.useMemo( + () => + (usersQuery.data ?? []).filter( + (user) => user.pubkey.toLowerCase() !== currentPubkey?.toLowerCase(), + ), + [currentPubkey, usersQuery.data], + ); const channelResults = React.useMemo(() => { if (debouncedQuery.length < MIN_QUERY_LENGTH) { return []; @@ -130,18 +149,39 @@ export function SearchDialog({ }) .slice(0, 5); }, [channels, debouncedQuery]); + const sections = React.useMemo( + () => + [ + { + count: userResults.length, + results: userResults.map((user) => ({ + kind: "user" as const, + user, + })), + title: "People", + }, + { + count: channelResults.length, + results: channelResults.map((channel) => ({ + kind: "channel" as const, + channel, + })), + title: "Channels", + }, + { + count: searchQuery.data?.found ?? messageResults.length, + results: messageResults.map((hit) => ({ + kind: "message" as const, + hit, + })), + title: "Messages", + }, + ].filter((section) => section.results.length > 0), + [channelResults, messageResults, searchQuery.data?.found, userResults], + ); const results = React.useMemo( - () => [ - ...channelResults.map((channel) => ({ - kind: "channel" as const, - channel, - })), - ...messageResults.map((hit) => ({ - kind: "message" as const, - hit, - })), - ], - [channelResults, messageResults], + () => sections.flatMap((section) => section.results), + [sections], ); const resultProfilesQuery = useUsersBatchQuery( messageResults.map((hit) => hit.pubkey), @@ -160,9 +200,14 @@ export function SearchDialog({ return; } + if (result.kind === "user") { + onOpenUser(result.user.pubkey); + return; + } + onOpenResult(result.hit); }, - [onOpenChange, onOpenChannel, onOpenResult], + [onOpenChange, onOpenChannel, onOpenResult, onOpenUser], ); React.useEffect(() => { @@ -204,28 +249,23 @@ export function SearchDialog({ return ( { event.preventDefault(); inputRef.current?.focus(); }} > - - - - - - Search - - - Full-text search across accessible channels. + + Search Sprout + + Search people, channels, and message history across Sprout. -
+
{ setQuery(event.target.value); @@ -255,77 +295,111 @@ export function SearchDialog({ openResult(selectedResult); } }} - placeholder="Search messages, approvals, and forum posts" + placeholder="Search Sprout" ref={inputRef} value={query} /> - - ⌘K - +
-
+
{debouncedQuery.length < MIN_QUERY_LENGTH ? ( - ) : searchQuery.isLoading && results.length === 0 ? ( + ) : (searchQuery.isLoading || usersQuery.isLoading) && + results.length === 0 ? ( - ) : searchQuery.error instanceof Error && results.length === 0 ? ( + ) : (searchQuery.error instanceof Error || + usersQuery.error instanceof Error) && + results.length === 0 ? ( ) : results.length === 0 ? ( ) : ( -
-
- - {channelResults.length + - (searchQuery.data?.found ?? messageResults.length)}{" "} - results - - Enter to open -
+
+
+ {sections.map((section) => { + let sectionStartIndex = 0; + for (const previousSection of sections) { + if (previousSection === section) { + break; + } + sectionStartIndex += previousSection.results.length; + } -
- {results.map((result, index) => ( - openResult(result)} - onMouseEnter={() => setSelectedIndex(index)} - testId={resultTestId(result)} - > - {result.kind === "channel" ? ( - - ) : ( - - )} - - ))} + return ( +
+
+ {section.title} + {section.count} +
+
+ {section.results.map((result, index) => { + const absoluteIndex = sectionStartIndex + index; + + return ( + + ) : undefined + } + onClick={() => openResult(result)} + onMouseEnter={() => + setSelectedIndex(absoluteIndex) + } + testId={resultTestId(result)} + > + {result.kind === "channel" ? ( + + ) : result.kind === "user" ? ( + + ) : ( + + )} + + ); + })} +
+
+ ); + })}
)}
- -
- Search is relay-backed and scoped to channels you can access. -
); diff --git a/desktop/src/features/search/ui/SearchResultItem.tsx b/desktop/src/features/search/ui/SearchResultItem.tsx index d520a25c3..f2a75d6cf 100644 --- a/desktop/src/features/search/ui/SearchResultItem.tsx +++ b/desktop/src/features/search/ui/SearchResultItem.tsx @@ -1,24 +1,71 @@ import type * as React from "react"; -import { ArrowRight, FileText, Hash, type LucideIcon } from "lucide-react"; +import { FileText, Hash, UserRound, type LucideIcon } from "lucide-react"; +import ReactMarkdown, { type Components } from "react-markdown"; +import remarkBreaks from "remark-breaks"; +import remarkGfm from "remark-gfm"; import { resolveUserLabel, resolveUserSecondaryLabel, + truncatePubkey, type UserProfileLookup, } from "@/features/profile/lib/identity"; -import type { Channel, SearchHit } from "@/shared/api/types"; -import { Badge } from "@/shared/ui/badge"; -import { UserAvatar } from "@/shared/ui/UserAvatar"; +import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; +import type { Channel, SearchHit, UserSearchResult } from "@/shared/api/types"; export type SearchResult = | { kind: "channel"; channel: Channel } - | { kind: "message"; hit: SearchHit }; + | { kind: "message"; hit: SearchHit } + | { kind: "user"; user: UserSearchResult }; + +const INLINE_MARKDOWN_COMPONENTS: Components = { + a: ({ children }) => {children}, + blockquote: ({ children }) => {children}, + br: () => " ", + code: ({ children }) => {children}, + em: ({ children }) => {children}, + h1: ({ children }) => {children}, + h2: ({ children }) => {children}, + h3: ({ children }) => {children}, + h4: ({ children }) => {children}, + h5: ({ children }) => {children}, + h6: ({ children }) => {children}, + hr: () => null, + img: ({ alt, src }) => {alt || src || "Image"}, + li: ({ children }) => {children} , + ol: ({ children }) => {children}, + p: ({ children }) => {children}, + pre: ({ children }) => {children}, + strong: ({ children }) => {children}, + table: ({ children }) => {children}, + tbody: ({ children }) => {children}, + td: ({ children }) => {children} , + th: ({ children }) => {children} , + thead: ({ children }) => {children}, + tr: ({ children }) => {children} , + ul: ({ children }) => {children}, +}; + +function InlineMarkdownSnippet({ content }: { content: string }) { + return ( + + {content} + + ); +} export function resultKey(result: SearchResult) { if (result.kind === "channel") { return `channel-${result.channel.id}`; } + if (result.kind === "user") { + return `user-${result.user.pubkey}`; + } + return `message-${result.hit.eventId}`; } @@ -27,6 +74,10 @@ export function resultTestId(result: SearchResult) { 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 +85,10 @@ export function resultIcon( result: SearchResult, channelLookup: ReadonlyMap, ) { + if (result.kind === "user") { + return UserRound; + } + const channelType = result.kind === "channel" ? result.channel.channelType @@ -48,6 +103,7 @@ export function SearchResultShell({ children, icon: Icon, isSelected, + leading, onClick, onMouseEnter, testId, @@ -55,6 +111,7 @@ export function SearchResultShell({ children: React.ReactNode; icon: LucideIcon; isSelected: boolean; + leading?: React.ReactNode; onClick: () => void; onMouseEnter: () => void; testId: string; @@ -63,46 +120,97 @@ export function SearchResultShell({ ); } +function formatUserName(user: UserSearchResult) { + return ( + user.displayName?.trim() || + user.nip05Handle?.trim() || + truncatePubkey(user.pubkey) + ); +} + +function formatUserSecondary(user: UserSearchResult) { + const displayName = user.displayName?.trim(); + const nip05Handle = user.nip05Handle?.trim(); + + if (displayName && nip05Handle) { + return nip05Handle; + } + + return truncatePubkey(user.pubkey); +} + export function ChannelResultBody({ channel }: { channel: Channel }) { + const description = channel.description.trim(); + return (
-
-

{channel.name}

- {channel.channelType} -

- Channel +

+

+ {channel.name} +

+

+ {channel.channelType}

- {channel.description ? ( -

- {channel.description} -

- ) : null}
); } +export function UserResultBody({ user }: { user: UserSearchResult }) { + return ( +
+
+

+ {formatUserName(user)} +

+

+ Person +

+
+
+ ); +} + +export function UserResultAvatar({ user }: { user: UserSearchResult }) { + const label = formatUserName(user); + + return ( + + ); +} + function describeSearchHit(hit: SearchHit) { switch (hit.kind) { case 1: @@ -177,36 +285,30 @@ export function MessageResultBody({ pubkey: hit.pubkey, profiles: resultProfiles, }); - const avatarUrl = - resultProfiles?.[hit.pubkey.toLowerCase()]?.avatarUrl ?? null; + const metadata = [ + hit.channelName, + authorLabel, + formatRelativeTime(hit.createdAt), + ] + .filter(Boolean) + .join(" · "); + const title = [metadata, authorSecondaryLabel, hit.content] + .filter(Boolean) + .join("\n"); return (
-
-

- {hit.channelName} +

+

+

- {describeSearchHit(hit)} - - - {authorLabel} - -

- {formatRelativeTime(hit.createdAt)} +

+ {describeSearchHit(hit)}

- {authorSecondaryLabel ? ( -

- {authorSecondaryLabel} -

- ) : null} -

- {truncateContent(hit.content)} -

); } diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 0e2db8de1..deb9b405d 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -37,7 +37,6 @@ import type { UserStatus, } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; -import { Button } from "@/shared/ui/button"; import { ContextMenu, ContextMenuContent, @@ -124,7 +123,6 @@ type AppSidebarProps = { onOpenAddWorkspace: () => void; onOpenBrowseChannels: () => void; onOpenBrowseForums: () => void; - onOpenSearch: () => void; onHideDm: (channelId: string) => void; onMarkChannelUnread: ( channelId: string, @@ -341,7 +339,6 @@ export function AppSidebar({ onOpenAddWorkspace, onOpenBrowseChannels, onOpenBrowseForums, - onOpenSearch, onHideDm, onMarkChannelUnread, onOpenDm, @@ -462,33 +459,9 @@ export function AppSidebar({ variant="sidebar" > -
- -
-
-
+

{resolvedDisplayName}

- {selfUserStatus?.text || selfUserStatus?.emoji ? ( -

- {selfUserStatus.emoji ? ( - {selfUserStatus.emoji} - ) : null} - {selfUserStatus.text} -

- ) : null} +
diff --git a/desktop/src/features/workspaces/ui/WorkspaceSwitcher.tsx b/desktop/src/features/workspaces/ui/WorkspaceSwitcher.tsx index 18c47fc12..9a72cf81a 100644 --- a/desktop/src/features/workspaces/ui/WorkspaceSwitcher.tsx +++ b/desktop/src/features/workspaces/ui/WorkspaceSwitcher.tsx @@ -6,8 +6,10 @@ import { WifiOff, } from "lucide-react"; import * as React from "react"; +import { useQuery } from "@tanstack/react-query"; import type { Workspace } from "@/features/workspaces/types"; +import { getUserProfile } from "@/shared/api/tauri"; import { DropdownMenu, DropdownMenuContent, @@ -26,6 +28,7 @@ import { isRelayConnectionDegraded, useRelayConnection, } from "@/shared/api/useRelayConnection"; +import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; import { EditWorkspaceDialog } from "./EditWorkspaceDialog"; @@ -38,8 +41,117 @@ const CONNECTION_STATE_LABEL: Record = { disconnected: "Disconnected from relay", }; +function relayIconUrl(relayUrl: string | undefined) { + if (!relayUrl) { + return null; + } + + try { + const url = new URL(relayUrl); + url.protocol = url.protocol === "ws:" ? "http:" : "https:"; + url.pathname = "/favicon.ico"; + url.search = ""; + url.hash = ""; + return url.toString(); + } catch { + return null; + } +} + +function relayInfoUrl(relayUrl: string | undefined) { + if (!relayUrl) { + return null; + } + + try { + const url = new URL(relayUrl); + url.protocol = url.protocol === "ws:" ? "http:" : "https:"; + url.pathname = "/"; + url.search = ""; + url.hash = ""; + return url.toString(); + } catch { + return null; + } +} + +async function fetchRelayProfileIcon(relayUrl: string) { + const infoUrl = relayInfoUrl(relayUrl); + if (!infoUrl) { + return null; + } + + const response = await fetch(infoUrl, { + headers: { + Accept: "application/nostr+json", + }, + }); + + if (!response.ok) { + return null; + } + + const info = (await response.json()) as { self?: unknown }; + if (typeof info.self !== "string" || info.self.length === 0) { + return null; + } + + const profile = await getUserProfile(info.self); + return profile.avatarUrl ? rewriteRelayUrl(profile.avatarUrl) : null; +} + +function RelayIcon({ + associatedIconUrl, + className, + workspace, +}: { + associatedIconUrl?: string | null; + className: string; + workspace: Workspace | null | undefined; +}) { + const iconUrl = relayIconUrl(workspace?.relayUrl); + const fallbackIconUrl = "/sprout.svg"; + const [src, setSrc] = React.useState( + associatedIconUrl ?? iconUrl ?? fallbackIconUrl, + ); + + React.useEffect(() => { + setSrc(associatedIconUrl ?? iconUrl ?? fallbackIconUrl); + }, [associatedIconUrl, iconUrl]); + + if (!src) { + return null; + } + + if (src === fallbackIconUrl) { + return ( + + ); + } + + return ( + { + setSrc((current) => + current === fallbackIconUrl ? null : fallbackIconUrl, + ); + }} + referrerPolicy="no-referrer" + src={src} + /> + ); +} + type WorkspaceSwitcherProps = { activeWorkspace: Workspace | null; + placement?: "header" | "footer"; workspaces: Workspace[]; onSwitchWorkspace: (id: string) => void; onAddWorkspace: () => void; @@ -52,6 +164,7 @@ type WorkspaceSwitcherProps = { export function WorkspaceSwitcher({ activeWorkspace, + placement = "header", workspaces, onSwitchWorkspace, onAddWorkspace, @@ -64,103 +177,164 @@ export function WorkspaceSwitcher({ const connectionState = useRelayConnection(); const degraded = isRelayConnectionDegraded(connectionState); const connectionLabel = CONNECTION_STATE_LABEL[connectionState]; + const isFooterPlacement = placement === "footer"; + const activeRelayIconQuery = useQuery({ + enabled: Boolean(activeWorkspace?.relayUrl), + queryKey: ["relay-profile-icon", activeWorkspace?.relayUrl ?? ""], + queryFn: () => fetchRelayProfileIcon(activeWorkspace?.relayUrl ?? ""), + staleTime: 5 * 60 * 1_000, + gcTime: 30 * 60 * 1_000, + retry: false, + }); + const activeRelayIconUrl = activeRelayIconQuery.data ?? null; + const triggerLabel = degraded + ? `${activeWorkspace?.name ?? "Workspace"} — ${connectionLabel}` + : (activeWorkspace?.name ?? "No workspace"); + const triggerContent = ( + <> + {degraded ? ( + + + + + + + + {connectionLabel} + + + ) : isFooterPlacement ? null : ( + + )} + {!degraded && isFooterPlacement ? ( + + ) : null} + + {activeWorkspace?.name ?? "No workspace"} + + + + ); + const dropdown = ( + + + {isFooterPlacement ? ( + + ) : ( + + {triggerContent} + + )} + + e.preventDefault()} + side={isFooterPlacement ? "top" : "bottom"} + sideOffset={4} + > + {workspaces.map((workspace) => ( + { + onSwitchWorkspace(workspace.id); + }} + > + + {activeWorkspace?.id === workspace.id ? ( + + ) : null} + + + {workspace.name} + + + ))} + + + + Add Workspace + + + + ); return ( <> - - - - - - {degraded ? ( - - - - - - - - {connectionLabel} - - - ) : ( - - 🌱 - - )} - - {activeWorkspace?.name ?? "No workspace"} - - - - - e.preventDefault()} - side="bottom" - sideOffset={4} - > - {workspaces.map((workspace) => ( - { - onSwitchWorkspace(workspace.id); - }} - > - - {activeWorkspace?.id === workspace.id ? ( - - ) : null} - - - {workspace.name} - - - - ))} - - - - Add Workspace - - - - - + {isFooterPlacement ? ( + dropdown + ) : ( + + {dropdown} + + )} 1} diff --git a/desktop/src/shared/lib/keyboard-shortcuts.ts b/desktop/src/shared/lib/keyboard-shortcuts.ts index 0fb2f2043..a36c8945d 100644 --- a/desktop/src/shared/lib/keyboard-shortcuts.ts +++ b/desktop/src/shared/lib/keyboard-shortcuts.ts @@ -19,9 +19,9 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [ // Navigation { id: "quick-search", - label: "Quick search", - description: "Open the search dialog", - keys: "⌘K", + label: "Global search", + description: "Open the global search palette", + keys: "⌘K / ⌘Space", keysWindows: "Ctrl+K", category: "Navigation", }, diff --git a/desktop/tests/e2e/smoke.spec.ts b/desktop/tests/e2e/smoke.spec.ts index 966af274a..b0a8384af 100644 --- a/desktop/tests/e2e/smoke.spec.ts +++ b/desktop/tests/e2e/smoke.spec.ts @@ -44,7 +44,7 @@ async function openSearchDialogWithShortcut( page: import("@playwright/test").Page, ) { const searchDialog = page.getByTestId("search-dialog"); - const openSearchButton = page.getByTestId("open-search"); + const openSearchButton = page.getByTestId("global-search"); await expect(openSearchButton).toBeVisible(); await expect @@ -75,7 +75,7 @@ async function openSearchDialogWithButton( page: import("@playwright/test").Page, ) { const searchDialog = page.getByTestId("search-dialog"); - const openSearchButton = page.getByTestId("open-search"); + const openSearchButton = page.getByTestId("global-search"); await expect(openSearchButton).toBeVisible(); await openSearchButton.click(); @@ -276,6 +276,28 @@ test("opens channel matches from search", async ({ page }) => { await expect(page.getByTestId("chat-title")).toHaveText("engineering"); }); +test("opens people matches from global search", async ({ page }) => { + await page.goto("/"); + + await openSearchDialogWithButton(page); + + await page.getByTestId("search-input").fill("alice"); + const results = page.getByTestId("search-results"); + + await expect(results).toContainText("People"); + await expect(results).toContainText("alice"); + await results + .getByTestId( + "search-result-user-953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f", + ) + .click(); + + await expect(page).toHaveURL( + /#\/channels\/f48efb06-0c93-5025-aac9-2e646bb6bfa8$/, + ); + await expect(page.getByTestId("chat-title")).toHaveText("alice-tyler"); +}); + test("search results use your resolved profile label instead of You", async ({ page, }) => {