diff --git a/crates/sprout-relay/src/api/events.rs b/crates/sprout-relay/src/api/events.rs
new file mode 100644
index 0000000000..63de830dfa
--- /dev/null
+++ b/crates/sprout-relay/src/api/events.rs
@@ -0,0 +1,57 @@
+//! Event lookup endpoints.
+//!
+//! Endpoints:
+//!   GET /api/events/:id — fetch a single stored event by ID
+
+use std::sync::Arc;
+
+use axum::{
+    extract::{Path, State},
+    http::{HeaderMap, StatusCode},
+    response::Json,
+};
+
+use crate::state::AppState;
+
+use super::{api_error, check_channel_access, extract_auth_pubkey, internal_error, not_found};
+
+/// Fetch a single stored event by its 64-char hex ID.
+pub async fn get_event(
+    State(state): State<Arc<AppState>>,
+    headers: HeaderMap,
+    Path(event_id): Path<String>,
+) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
+    let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
+
+    let id_bytes = hex::decode(&event_id)
+        .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid event ID"))?;
+    if id_bytes.len() != 32 {
+        return Err(api_error(StatusCode::BAD_REQUEST, "invalid event ID"));
+    }
+
+    let stored_event = state
+        .db
+        .get_event_by_id(&id_bytes)
+        .await
+        .map_err(|e| internal_error(&format!("db error: {e}")))?
+        .ok_or_else(|| not_found("event not found"))?;
+
+    if let Some(channel_id) = stored_event.channel_id {
+        check_channel_access(&state, channel_id, &pubkey_bytes).await?;
+    } else {
+        return Err(not_found("event not found"));
+    }
+
+    let tags = serde_json::to_value(&stored_event.event.tags)
+        .map_err(|e| internal_error(&format!("tag serialization error: {e}")))?;
+
+    Ok(Json(serde_json::json!({
+        "id": stored_event.event.id.to_hex(),
+        "pubkey": stored_event.event.pubkey.to_hex(),
+        "created_at": stored_event.event.created_at.as_u64(),
+        "kind": stored_event.event.kind.as_u16(),
+        "tags": tags,
+        "content": stored_event.event.content,
+        "sig": stored_event.event.sig.to_string(),
+    })))
+}
diff --git a/crates/sprout-relay/src/api/mod.rs b/crates/sprout-relay/src/api/mod.rs
index 127aa87461..8a9fcef974 100644
--- a/crates/sprout-relay/src/api/mod.rs
+++ b/crates/sprout-relay/src/api/mod.rs
@@ -2,6 +2,7 @@
 //!
 //! Endpoints are split into focused submodules:
 //!   - `channels`  — GET/POST /api/channels
+//!   - `events`    — GET /api/events/:id
 //!   - `search`    — GET /api/search
 //!   - `agents`    — GET /api/agents
 //!   - `presence`  — GET /api/presence
@@ -19,6 +20,8 @@ pub mod channels;
 pub mod channels_metadata;
 /// Direct message endpoints.
 pub mod dms;
+/// Event lookup endpoint.
+pub mod events;
 /// Personalized home feed endpoint.
 pub mod feed;
 /// Channel membership endpoints.
@@ -47,6 +50,7 @@ pub use channels_metadata::{
     set_topic_handler, unarchive_channel_handler, update_channel_handler,
 };
 pub use dms::{add_dm_member_handler, list_dms_handler, open_dm_handler};
+pub use events::get_event;
 pub use feed::feed_handler;
 pub use members::{add_members, join_channel, leave_channel, list_members, remove_member};
 pub use messages::{delete_message, get_thread, list_messages, send_message};
diff --git a/crates/sprout-relay/src/router.rs b/crates/sprout-relay/src/router.rs
index 800274ac4f..d64dca7edd 100644
--- a/crates/sprout-relay/src/router.rs
+++ b/crates/sprout-relay/src/router.rs
@@ -29,6 +29,7 @@ pub fn build_router(state: Arc<AppState>) -> Router {
             "/api/channels",
             get(api::channels_handler).post(api::create_channel),
         )
+        .route("/api/events/{id}", get(api::get_event))
         .route("/api/search", get(api::search_handler))
         .route("/api/agents", get(api::agents_handler))
         .route("/api/presence", get(api::presence_handler))
diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs
index 3ebd5ed756..0f0b442eff 100644
--- a/desktop/src-tauri/src/lib.rs
+++ b/desktop/src-tauri/src/lib.rs
@@ -43,6 +43,13 @@ struct GetFeedQuery<'a> {
     types: Option<&'a str>,
 }
 
+#[derive(Serialize)]
+struct SearchQueryParams<'a> {
+    q: &'a str,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    limit: Option<u32>,
+}
+
 #[derive(Serialize, Deserialize)]
 pub struct FeedItemInfo {
     pub id: String,
@@ -77,6 +84,24 @@ pub struct FeedResponse {
     pub meta: FeedMeta,
 }
 
+#[derive(Serialize, Deserialize)]
+pub struct SearchHitInfo {
+    pub event_id: String,
+    pub content: String,
+    pub kind: u32,
+    pub pubkey: String,
+    pub channel_id: String,
+    pub channel_name: String,
+    pub created_at: u64,
+    pub score: f64,
+}
+
+#[derive(Serialize, Deserialize)]
+pub struct SearchResponse {
+    pub hits: Vec<SearchHitInfo>,
+    pub found: u64,
+}
+
 fn relay_ws_url() -> String {
     std::env::var("SPROUT_RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string())
 }
@@ -273,6 +298,56 @@ async fn get_feed(
         .map_err(|e| format!("parse failed: {e}"))
 }
 
+#[tauri::command]
+async fn search_messages(
+    q: String,
+    limit: Option<u32>,
+    state: tauri::State<'_, AppState>,
+) -> Result<SearchResponse, String> {
+    let pubkey_hex = auth_pubkey_header(&state)?;
+    let url = format!("{}{}", relay_api_base_url(), "/api/search");
+    let response = state
+        .http_client
+        .get(url)
+        .header("X-Pubkey", pubkey_hex)
+        .query(&SearchQueryParams {
+            q: q.trim(),
+            limit,
+        })
+        .send()
+        .await
+        .map_err(|e| format!("request failed: {e}"))?;
+
+    if !response.status().is_success() {
+        return Err(relay_error_message(response).await);
+    }
+
+    response
+        .json::<SearchResponse>()
+        .await
+        .map_err(|e| format!("parse failed: {e}"))
+}
+
+#[tauri::command]
+async fn get_event(event_id: String, state: tauri::State<'_, AppState>) -> Result<String, String> {
+    let request = build_authed_request(
+        &state.http_client,
+        &format!("/api/events/{event_id}"),
+        &state,
+    )
+    .await?;
+    let response = request
+        .send()
+        .await
+        .map_err(|e| format!("request failed: {e}"))?;
+
+    if !response.status().is_success() {
+        return Err(relay_error_message(response).await);
+    }
+
+    response.text().await.map_err(|e| format!("parse failed: {e}"))
+}
+
 #[cfg_attr(mobile, tauri::mobile_entry_point)]
 pub fn run() {
     let app_state = AppState {
@@ -299,6 +374,8 @@ pub fn run() {
             get_channels,
             create_channel,
             get_feed,
+            search_messages,
+            get_event,
         ])
         .run(tauri::generate_context!())
         .expect("error while running tauri application");
diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx
index 6777975bc4..4fef4199e8 100644
--- a/desktop/src/app/AppShell.tsx
+++ b/desktop/src/app/AppShell.tsx
@@ -11,19 +11,44 @@ import { HomeView } from "@/features/home/ui/HomeView";
 import {
   useChannelMessagesQuery,
   useChannelSubscription,
+  mergeMessages,
   useSendMessageMutation,
 } from "@/features/messages/hooks";
 import { formatTimelineMessages } from "@/features/messages/lib/formatTimelineMessages";
 import { MessageComposer } from "@/features/messages/ui/MessageComposer";
 import { MessageTimeline } from "@/features/messages/ui/MessageTimeline";
+import { SearchDialog } from "@/features/search/ui/SearchDialog";
 import { AppSidebar } from "@/features/sidebar/ui/AppSidebar";
+import { getEventById } from "@/shared/api/tauri";
 import { useIdentityQuery } from "@/shared/api/hooks";
+import type { RelayEvent, SearchHit } from "@/shared/api/types";
 import { SidebarInset, SidebarProvider } from "@/shared/ui/sidebar";
 
 type AppView = "home" | "channel";
 
+function createSearchAnchorEvent(hit: SearchHit): RelayEvent {
+  return {
+    id: hit.eventId,
+    pubkey: hit.pubkey,
+    created_at: hit.createdAt,
+    kind: hit.kind,
+    tags: [["e", hit.channelId]],
+    content: hit.content,
+    sig: "",
+  };
+}
+
 export function AppShell() {
   const [selectedView, setSelectedView] = React.useState<AppView>("home");
+  const [isSearchOpen, setIsSearchOpen] = React.useState(false);
+  const [searchAnchor, setSearchAnchor] = React.useState<SearchHit | null>(
+    null,
+  );
+  const [searchAnchorChannelId, setSearchAnchorChannelId] = React.useState<
+    string | null
+  >(null);
+  const [searchAnchorEvent, setSearchAnchorEvent] =
+    React.useState<RelayEvent | null>(null);
   const identityQuery = useIdentityQuery();
   const homeFeedQuery = useHomeFeedQuery();
   const channelsQuery = useChannelsQuery();
@@ -49,15 +74,33 @@ export function AppShell() {
     () => new Set(channels.map((channel) => channel.id)),
     [channels],
   );
+  const resolvedMessages = React.useMemo(() => {
+    const currentMessages = messagesQuery.data ?? [];
+
+    if (
+      !activeChannel ||
+      !searchAnchorEvent ||
+      searchAnchorChannelId !== activeChannel.id
+    ) {
+      return currentMessages;
+    }
+
+    return mergeMessages(currentMessages, searchAnchorEvent);
+  }, [
+    activeChannel,
+    messagesQuery.data,
+    searchAnchorChannelId,
+    searchAnchorEvent,
+  ]);
 
   const timelineMessages = React.useMemo(
     () =>
       formatTimelineMessages(
-        messagesQuery.data ?? [],
+        resolvedMessages,
         activeChannel,
         identityQuery.data?.pubkey,
       ),
-    [activeChannel, identityQuery.data?.pubkey, messagesQuery.data],
+    [activeChannel, identityQuery.data?.pubkey, resolvedMessages],
   );
 
   const channelDescription = activeChannel
@@ -67,6 +110,46 @@ export function AppShell() {
     : "Connect to the relay to browse channels and read messages.";
   const contentPaneKey =
     selectedView === "home" ? "home" : `channel:${activeChannel?.id ?? "none"}`;
+  const isTimelineLoading =
+    messagesQuery.isLoading && resolvedMessages.length === 0;
+
+  const handleOpenChannel = React.useCallback(
+    (channelId: string) => {
+      React.startTransition(() => {
+        setSelectedChannelId(channelId);
+        setSelectedView("channel");
+      });
+    },
+    [setSelectedChannelId],
+  );
+
+  const handleOpenSearchResult = React.useCallback(
+    (hit: SearchHit) => {
+      setSearchAnchor(hit);
+      setSearchAnchorChannelId(hit.channelId);
+      setSearchAnchorEvent(createSearchAnchorEvent(hit));
+      handleOpenChannel(hit.channelId);
+
+      void getEventById(hit.eventId)
+        .then((event) => {
+          setSearchAnchorEvent((current) => {
+            if (current?.id !== hit.eventId) {
+              return current;
+            }
+
+            return event;
+          });
+        })
+        .catch((error) => {
+          console.error(
+            "Failed to load search result event",
+            hit.eventId,
+            error,
+          );
+        });
+    },
+    [handleOpenChannel],
+  );
 
   return (
     <SidebarProvider className="h-dvh overflow-hidden overscroll-none">
@@ -93,6 +176,9 @@ export function AppShell() {
             setSelectedView("channel");
           });
         }}
+        onOpenSearch={() => {
+          setIsSearchOpen(true);
+        }}
         onSelectHome={() => {
           React.startTransition(() => {
             setSelectedView("home");
@@ -100,12 +186,7 @@ export function AppShell() {
 
           void homeFeedQuery.refetch();
         }}
-        onSelectChannel={(channelId) => {
-          React.startTransition(() => {
-            setSelectedChannelId(channelId);
-            setSelectedView("channel");
-          });
-        }}
+        onSelectChannel={handleOpenChannel}
         selectedChannelId={selectedChannel?.id ?? null}
         selectedView={selectedView}
       />
@@ -141,12 +222,7 @@ export function AppShell() {
               feed={homeFeedQuery.data}
               isLoading={homeFeedQuery.isLoading}
               isRefreshing={homeFeedQuery.isRefetching}
-              onOpenChannel={(channelId) => {
-                React.startTransition(() => {
-                  setSelectedChannelId(channelId);
-                  setSelectedView("channel");
-                });
-              }}
+              onOpenChannel={handleOpenChannel}
               onRefresh={() => {
                 void homeFeedQuery.refetch();
               }}
@@ -166,8 +242,19 @@ export function AppShell() {
                       : "No messages yet"
                     : "No channel selected"
                 }
-                isLoading={messagesQuery.isLoading}
+                isLoading={isTimelineLoading}
+                key={activeChannel?.id ?? "no-channel"}
                 messages={timelineMessages}
+                onTargetReached={(messageId) => {
+                  setSearchAnchor((current) =>
+                    current?.eventId === messageId ? null : current,
+                  );
+                }}
+                targetMessageId={
+                  activeChannel && searchAnchor?.channelId === activeChannel.id
+                    ? searchAnchor.eventId
+                    : null
+                }
               />
               <MessageComposer
                 channelName={activeChannel?.name ?? "channel"}
@@ -177,6 +264,7 @@ export function AppShell() {
                   sendMessageMutation.isPending
                 }
                 isSending={sendMessageMutation.isPending}
+                key={activeChannel?.id ?? "no-channel"}
                 onSend={async (content) => {
                   await sendMessageMutation.mutateAsync(content);
                 }}
@@ -191,6 +279,13 @@ export function AppShell() {
             </>
           )}
         </div>
+
+        <SearchDialog
+          channels={channels}
+          onOpenResult={handleOpenSearchResult}
+          onOpenChange={setIsSearchOpen}
+          open={isSearchOpen}
+        />
       </SidebarInset>
     </SidebarProvider>
   );
diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts
index 77138adb90..45a56f294b 100644
--- a/desktop/src/features/messages/hooks.ts
+++ b/desktop/src/features/messages/hooks.ts
@@ -10,7 +10,7 @@ type MessageQueryContext = {
   queryKey: readonly ["channel-messages", string];
 };
 
-function mergeMessages(
+export function mergeMessages(
   current: RelayEvent[],
   incoming: RelayEvent,
 ): RelayEvent[] {
diff --git a/desktop/src/features/messages/types.ts b/desktop/src/features/messages/types.ts
index a1c3d1744a..4b4290b25a 100644
--- a/desktop/src/features/messages/types.ts
+++ b/desktop/src/features/messages/types.ts
@@ -6,4 +6,5 @@ export type TimelineMessage = {
   body: string;
   accent?: boolean;
   pending?: boolean;
+  highlighted?: boolean;
 };
diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx
index 5f60f88923..453978ca6b 100644
--- a/desktop/src/features/messages/ui/MessageTimeline.tsx
+++ b/desktop/src/features/messages/ui/MessageTimeline.tsx
@@ -13,6 +13,8 @@ type MessageTimelineProps = {
   isLoading?: boolean;
   emptyTitle?: string;
   emptyDescription?: string;
+  targetMessageId?: string | null;
+  onTargetReached?: (messageId: string) => void;
 };
 
 const BOTTOM_THRESHOLD_PX = 72;
@@ -33,7 +35,14 @@ function MessageRow({ message }: { message: TimelineMessage }) {
     .toUpperCase();
 
   return (
-    <article className="flex gap-3" data-testid="message-row">
+    <article
+      className={cn(
+        "flex gap-3 rounded-2xl px-2 py-2 transition-colors",
+        message.highlighted ? "bg-primary/10 ring-1 ring-primary/30" : "",
+      )}
+      data-message-id={message.id}
+      data-testid="message-row"
+    >
       <div
         className={cn(
           "flex h-9 w-9 shrink-0 items-center justify-center rounded-xl text-xs font-semibold shadow-sm",
@@ -94,6 +103,8 @@ export function MessageTimeline({
   isLoading = false,
   emptyTitle = "No messages yet",
   emptyDescription = "Send the first message to start the thread.",
+  targetMessageId = null,
+  onTargetReached,
 }: MessageTimelineProps) {
   const timelineRef = React.useRef<HTMLDivElement>(null);
   const contentRef = React.useRef<HTMLDivElement>(null);
@@ -107,7 +118,11 @@ export function MessageTimeline({
   const lockedScrollTopRef = React.useRef<number | null>(null);
   const previousLastMessageIdRef = React.useRef<string | undefined>(undefined);
   const previousMessageCountRef = React.useRef(0);
+  const handledTargetMessageIdRef = React.useRef<string | null>(null);
   const [isAtBottom, setIsAtBottom] = React.useState(true);
+  const [highlightedMessageId, setHighlightedMessageId] = React.useState<
+    string | null
+  >(null);
   const [newMessageCount, setNewMessageCount] = React.useState(0);
   const latestMessage =
     messages.length > 0 ? messages[messages.length - 1] : undefined;
@@ -352,6 +367,54 @@ export function MessageTimeline({
     previousMessageCountRef.current = messages.length;
   }, [isLoading, latestMessage, messages.length, scrollToBottom]);
 
+  React.useEffect(() => {
+    if (!targetMessageId) {
+      handledTargetMessageIdRef.current = null;
+      setHighlightedMessageId(null);
+      return;
+    }
+
+    if (handledTargetMessageIdRef.current === targetMessageId || isLoading) {
+      return;
+    }
+
+    const timeline = timelineRef.current;
+    if (!timeline) {
+      return;
+    }
+
+    const targetElement = timeline.querySelector<HTMLElement>(
+      `[data-message-id="${targetMessageId}"]`,
+    );
+    if (!targetElement) {
+      return;
+    }
+
+    handledTargetMessageIdRef.current = targetMessageId;
+    shouldStickToBottomRef.current = false;
+    isAtBottomRef.current = false;
+    isProgrammaticBottomScrollRef.current = false;
+    targetElement.scrollIntoView({
+      block: "center",
+      behavior: "smooth",
+    });
+    previousScrollTopRef.current = timeline.scrollTop;
+    setIsAtBottom(false);
+    setHighlightedMessageId(targetMessageId);
+    setNewMessageCount(0);
+    onTargetReached?.(targetMessageId);
+
+    const timeout = window.setTimeout(() => {
+      setHighlightedMessageId((current) =>
+        current === targetMessageId ? null : current,
+      );
+    }, 2_000);
+
+    return () => {
+      window.clearTimeout(timeout);
+    };
+  }, [isLoading, onTargetReached, targetMessageId]);
+
   return (
     <div className="relative flex-1 min-h-0">
       <div
@@ -393,7 +456,13 @@ export function MessageTimeline({
 
           {!isLoading
             ? messages.map((message) => (
-                <MessageRow key={message.id} message={message} />
+                <MessageRow
+                  key={message.id}
+                  message={{
+                    ...message,
+                    highlighted: message.id === highlightedMessageId,
+                  }}
+                />
               ))
             : null}
           <div aria-hidden className="h-px" ref={bottomAnchorRef} />
diff --git a/desktop/src/features/search/hooks.ts b/desktop/src/features/search/hooks.ts
new file mode 100644
index 0000000000..ebbc5ad02a
--- /dev/null
+++ b/desktop/src/features/search/hooks.ts
@@ -0,0 +1,27 @@
+import { useQuery } from "@tanstack/react-query";
+
+import { searchMessages } from "@/shared/api/tauri";
+
+export function useSearchMessagesQuery(
+  query: string,
+  options?: {
+    enabled?: boolean;
+    limit?: number;
+  },
+) {
+  const trimmedQuery = query.trim();
+  const enabled = options?.enabled ?? true;
+  const limit = options?.limit ?? 12;
+
+  return useQuery({
+    queryKey: ["search-messages", trimmedQuery, limit],
+    queryFn: () =>
+      searchMessages({
+        q: trimmedQuery,
+        limit,
+      }),
+    enabled: enabled && trimmedQuery.length >= 2,
+    staleTime: 30_000,
+    gcTime: 5 * 60 * 1_000,
+  });
+}
diff --git a/desktop/src/features/search/ui/SearchDialog.tsx b/desktop/src/features/search/ui/SearchDialog.tsx
new file mode 100644
index 0000000000..87f30c5fce
--- /dev/null
+++ b/desktop/src/features/search/ui/SearchDialog.tsx
@@ -0,0 +1,352 @@
+import * as React from "react";
+import {
+  ArrowRight,
+  Command,
+  FileText,
+  Hash,
+  LoaderCircle,
+  MessagesSquare,
+  Search,
+  type LucideIcon,
+} from "lucide-react";
+
+import { useSearchMessagesQuery } from "@/features/search/hooks";
+import type { Channel, SearchHit } from "@/shared/api/types";
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogHeader,
+  DialogTitle,
+} from "@/shared/ui/dialog";
+import { Input } from "@/shared/ui/input";
+import { Skeleton } from "@/shared/ui/skeleton";
+
+const MIN_QUERY_LENGTH = 2;
+
+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 request";
+    default:
+      return "Message";
+  }
+}
+
+function truncateContent(content: string) {
+  const trimmed = content.trim();
+  if (trimmed.length === 0) {
+    return "No message body.";
+  }
+
+  if (trimmed.length <= 180) {
+    return trimmed;
+  }
+
+  return `${trimmed.slice(0, 177)}...`;
+}
+
+function formatRelativeTime(unixSeconds: number) {
+  const diff = Math.floor(Date.now() / 1_000) - unixSeconds;
+
+  if (diff < 60) {
+    return "just now";
+  }
+
+  if (diff < 60 * 60) {
+    return `${Math.floor(diff / 60)}m ago`;
+  }
+
+  if (diff < 60 * 60 * 24) {
+    return `${Math.floor(diff / (60 * 60))}h ago`;
+  }
+
+  return new Intl.DateTimeFormat("en-US", {
+    month: "short",
+    day: "numeric",
+    hour: "numeric",
+    minute: "2-digit",
+  }).format(new Date(unixSeconds * 1_000));
+}
+
+function SearchState({
+  icon: Icon,
+  title,
+  description,
+}: {
+  icon: LucideIcon;
+  title: string;
+  description: string;
+}) {
+  return (
+    <div className="flex flex-col items-center justify-center px-6 py-16 text-center">
+      <div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
+        <Icon className="h-5 w-5" />
+      </div>
+      <p className="mt-4 text-base font-semibold tracking-tight">{title}</p>
+      <p className="mt-2 max-w-md text-sm text-muted-foreground">
+        {description}
+      </p>
+    </div>
+  );
+}
+
+function SearchLoadingState() {
+  return (
+    <div className="space-y-3 px-3 py-3" data-testid="search-loading">
+      {["first", "second", "third"].map((row) => (
+        <div
+          className="rounded-2xl border border-border/80 bg-card/60 p-4"
+          key={row}
+        >
+          <Skeleton className="h-4 w-32" />
+          <Skeleton className="mt-3 h-4 w-full" />
+          <Skeleton className="mt-2 h-4 w-3/4" />
+        </div>
+      ))}
+    </div>
+  );
+}
+
+type SearchDialogProps = {
+  channels: Channel[];
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  onOpenResult: (hit: SearchHit) => void;
+};
+
+export function SearchDialog({
+  channels,
+  open,
+  onOpenChange,
+  onOpenResult,
+}: SearchDialogProps) {
+  const [query, setQuery] = React.useState("");
+  const [selectedIndex, setSelectedIndex] = React.useState(0);
+  const deferredQuery = React.useDeferredValue(query.trim());
+  const inputRef = React.useRef<HTMLInputElement>(null);
+  const channelLookup = React.useMemo(
+    () => new Map(channels.map((channel) => [channel.id, channel])),
+    [channels],
+  );
+
+  const searchQuery = useSearchMessagesQuery(deferredQuery, {
+    enabled: open,
+    limit: 12,
+  });
+
+  const results = searchQuery.data?.hits ?? [];
+
+  const openResult = React.useCallback(
+    (hit: SearchHit) => {
+      onOpenChange(false);
+      onOpenResult(hit);
+    },
+    [onOpenChange, onOpenResult],
+  );
+
+  React.useEffect(() => {
+    function handleKeyDown(event: KeyboardEvent) {
+      if (
+        event.key.toLowerCase() !== "k" ||
+        !(event.metaKey || event.ctrlKey) ||
+        event.altKey ||
+        event.shiftKey
+      ) {
+        return;
+      }
+
+      event.preventDefault();
+      onOpenChange(true);
+    }
+
+    window.addEventListener("keydown", handleKeyDown);
+    return () => {
+      window.removeEventListener("keydown", handleKeyDown);
+    };
+  }, [onOpenChange]);
+
+  React.useEffect(() => {
+    if (!open) {
+      setQuery("");
+      setSelectedIndex(0);
+      return;
+    }
+
+    const timeout = window.setTimeout(() => {
+      inputRef.current?.focus();
+      inputRef.current?.select();
+    }, 0);
+
+    return () => {
+      window.clearTimeout(timeout);
+    };
+  }, [open]);
+
+  React.useEffect(() => {
+    setSelectedIndex((current) => {
+      if (results.length === 0) {
+        return 0;
+      }
+
+      return Math.min(current, results.length - 1);
+    });
+  }, [results]);
+
+  const selectedHit = results[selectedIndex];
+
+  return (
+    <Dialog onOpenChange={onOpenChange} open={open}>
+      <DialogContent
+        className="gap-0 overflow-hidden p-0"
+        data-testid="search-dialog"
+      >
+        <DialogHeader className="border-b border-border/80 px-6 py-5">
+          <DialogTitle className="flex items-center gap-3">
+            <span className="flex h-10 w-10 items-center justify-center rounded-2xl bg-primary text-primary-foreground shadow-sm">
+              <Search className="h-4 w-4" />
+            </span>
+            Search
+          </DialogTitle>
+          <DialogDescription>
+            Full-text search across accessible channels.
+          </DialogDescription>
+          <div className="mt-4 flex items-center gap-3 rounded-2xl border border-input bg-card px-3 py-3 shadow-sm">
+            <Search className="h-4 w-4 text-muted-foreground" />
+            <Input
+              className="h-auto border-0 bg-transparent px-0 py-0 text-base shadow-none focus-visible:ring-0"
+              data-testid="search-input"
+              onChange={(event) => {
+                setQuery(event.target.value);
+                setSelectedIndex(0);
+              }}
+              onKeyDown={(event) => {
+                if (event.key === "ArrowDown" && results.length > 0) {
+                  event.preventDefault();
+                  setSelectedIndex((current) =>
+                    Math.min(current + 1, results.length - 1),
+                  );
+                  return;
+                }
+
+                if (event.key === "ArrowUp" && results.length > 0) {
+                  event.preventDefault();
+                  setSelectedIndex((current) => Math.max(current - 1, 0));
+                  return;
+                }
+
+                if (
+                  event.key === "Enter" &&
+                  !event.nativeEvent.isComposing &&
+                  selectedHit
+                ) {
+                  event.preventDefault();
+                  openResult(selectedHit);
+                }
+              }}
+              placeholder="Search messages, approvals, and forum posts"
+              ref={inputRef}
+              value={query}
+            />
+            <div className="hidden items-center gap-1 rounded-lg border border-border bg-background px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-muted-foreground sm:flex">
+              <Command className="h-3 w-3" />K
+            </div>
+          </div>
+        </DialogHeader>
+
+        <div className="max-h-[60vh] overflow-y-auto">
+          {deferredQuery.length < MIN_QUERY_LENGTH ? (
+            <SearchState
+              description="Type at least two characters to search the relay-backed history for streams, forums, DMs, approvals, and agent updates."
+              icon={MessagesSquare}
+              title="Search message history"
+            />
+          ) : searchQuery.isLoading ? (
+            <SearchLoadingState />
+          ) : searchQuery.error instanceof Error ? (
+            <SearchState
+              description={searchQuery.error.message}
+              icon={LoaderCircle}
+              title="Search unavailable"
+            />
+          ) : results.length === 0 ? (
+            <SearchState
+              description="Try a different keyword, channel name, or phrase from the message body."
+              icon={Search}
+              title="No matches found"
+            />
+          ) : (
+            <div className="p-3" data-testid="search-results">
+              <div className="mb-3 flex items-center justify-between px-2 text-xs font-semibold uppercase tracking-[0.16em] text-muted-foreground">
+                <span>{searchQuery.data?.found ?? results.length} results</span>
+                <span>Enter to open</span>
+              </div>
+
+              <div className="space-y-2">
+                {results.map((hit, index) => {
+                  const channel = channelLookup.get(hit.channelId);
+
+                  return (
+                    <button
+                      className={
+                        index === selectedIndex
+                          ? "w-full rounded-2xl border border-primary/30 bg-primary/10 px-4 py-4 text-left shadow-sm outline-none transition-colors"
+                          : "w-full rounded-2xl border border-border/80 bg-card/60 px-4 py-4 text-left shadow-sm outline-none transition-colors hover:border-primary/20 hover:bg-accent"
+                      }
+                      data-testid={`search-result-${hit.eventId}`}
+                      key={hit.eventId}
+                      onClick={() => openResult(hit)}
+                      onMouseEnter={() => setSelectedIndex(index)}
+                      type="button"
+                    >
+                      <div className="flex items-start gap-3">
+                        <div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-2xl bg-secondary text-secondary-foreground">
+                          {channel?.channelType === "forum" ? (
+                            <FileText className="h-4 w-4" />
+                          ) : (
+                            <Hash className="h-4 w-4" />
+                          )}
+                        </div>
+
+                        <div className="min-w-0 flex-1">
+                          <div className="flex flex-wrap items-center gap-2">
+                            <p className="text-sm font-semibold tracking-tight">
+                              {hit.channelName}
+                            </p>
+                            <p className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.16em] text-muted-foreground">
+                              {describeSearchHit(hit)}
+                            </p>
+                            <p className="ml-auto whitespace-nowrap text-xs text-muted-foreground">
+                              {formatRelativeTime(hit.createdAt)}
+                            </p>
+                          </div>
+                          <p className="mt-2 text-sm leading-6 text-foreground">
+                            {truncateContent(hit.content)}
+                          </p>
+                        </div>
+
+                        <ArrowRight className="mt-1 h-4 w-4 shrink-0 text-muted-foreground" />
+                      </div>
+                    </button>
+                  );
+                })}
+              </div>
+            </div>
+          )}
+        </div>
+
+        <div className="border-t border-border/80 bg-card/50 px-6 py-3 text-xs text-muted-foreground">
+          Search is relay-backed and scoped to channels you can access.
+        </div>
+      </DialogContent>
+    </Dialog>
+  );
+}
diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx
index 519a02a229..a2cff35f20 100644
--- a/desktop/src/features/sidebar/ui/AppSidebar.tsx
+++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx
@@ -1,4 +1,4 @@
-import { CircleDot, FileText, Hash, Home, Plus } from "lucide-react";
+import { CircleDot, FileText, Hash, Home, Plus, Search } from "lucide-react";
 import * as React from "react";
 
 import type { Channel } from "@/shared/api/types";
@@ -14,7 +14,6 @@ import {
   SidebarGroupContent,
   SidebarGroupLabel,
   SidebarHeader,
-  SidebarInput,
   SidebarMenu,
   SidebarMenuButton,
   SidebarMenuItem,
@@ -34,6 +33,7 @@ type AppSidebarProps = {
     name: string;
     description?: string;
   }) => Promise<void>;
+  onOpenSearch: () => void;
   onSelectHome: () => void;
   onSelectChannel: (channelId: string) => void;
 };
@@ -227,37 +227,25 @@ export function AppSidebar({
   selectedChannelId,
   selectedView,
   onCreateChannel,
+  onOpenSearch,
   onSelectHome,
   onSelectChannel,
 }: AppSidebarProps) {
   const skeletonRows = ["first", "second", "third", "fourth", "fifth", "sixth"];
-  const [query, setQuery] = React.useState("");
   const [isCreateOpen, setIsCreateOpen] = React.useState(false);
   const [draftName, setDraftName] = React.useState("");
   const [draftDescription, setDraftDescription] = React.useState("");
   const [createErrorMessage, setCreateErrorMessage] = React.useState<
     string | undefined
   >();
-  const deferredQuery = React.useDeferredValue(query.trim().toLowerCase());
   const createInputRef = React.useRef<HTMLInputElement>(null);
-
-  const filteredChannels = React.useMemo(() => {
-    if (!deferredQuery) {
-      return channels;
-    }
-
-    return channels.filter((channel) =>
-      channel.name.toLowerCase().includes(deferredQuery),
-    );
-  }, [channels, deferredQuery]);
-
-  const streamChannels = filteredChannels.filter(
+  const streamChannels = channels.filter(
     (channel) => channel.channelType === "stream",
   );
-  const forumChannels = filteredChannels.filter(
+  const forumChannels = channels.filter(
     (channel) => channel.channelType === "forum",
   );
-  const directMessages = filteredChannels.filter(
+  const directMessages = channels.filter(
     (channel) => channel.channelType === "dm",
   );
 
@@ -314,13 +302,22 @@ export function AppSidebar({
             </p>
           </div>
         </div>
-        <div className="flex items-center gap-2">
-          <SidebarInput
-            onChange={(event) => setQuery(event.target.value)}
-            placeholder="Jump to channel"
-            value={query}
-          />
-        </div>
+        <Button
+          className="w-full justify-between rounded-xl border border-sidebar-border/80 bg-sidebar-accent/60 px-3 text-sidebar-foreground/80 shadow-sm hover:bg-sidebar-accent hover:text-sidebar-foreground"
+          data-testid="open-search"
+          onClick={onOpenSearch}
+          size="sm"
+          type="button"
+          variant="ghost"
+        >
+          <span className="flex items-center gap-2">
+            <Search className="h-4 w-4" />
+            Search messages
+          </span>
+          <span className="rounded-md border border-sidebar-border/80 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-sidebar-foreground/60">
+            Cmd K
+          </span>
+        </Button>
       </SidebarHeader>
 
       <SidebarSeparator />
@@ -416,9 +413,9 @@ export function AppSidebar({
           </>
         ) : null}
 
-        {!isLoading && filteredChannels.length === 0 ? (
+        {!isLoading && channels.length === 0 ? (
           <div className="px-3 py-2 text-sm text-sidebar-foreground/70">
-            No channels match that filter.
+            No channels available yet.
           </div>
         ) : null}
 
diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts
index 113d45f7df..76b8ac06fa 100644
--- a/desktop/src/shared/api/tauri.ts
+++ b/desktop/src/shared/api/tauri.ts
@@ -8,6 +8,8 @@ import type {
   HomeFeedResponse,
   Identity,
   RelayEvent,
+  SearchMessagesInput,
+  SearchMessagesResponse,
 } from "@/shared/api/types";
 
 type RawIdentity = {
@@ -50,6 +52,22 @@ type RawHomeFeedResponse = {
   };
 };
 
+type RawSearchHit = {
+  event_id: string;
+  content: string;
+  kind: number;
+  pubkey: string;
+  channel_id: string;
+  channel_name: string;
+  created_at: number;
+  score: number;
+};
+
+type RawSearchResponse = {
+  hits: RawSearchHit[];
+  found: number;
+};
+
 function fromRawChannel(channel: RawChannel): Channel {
   return {
     id: channel.id,
@@ -75,6 +93,19 @@ function fromRawFeedItem(item: RawFeedItem) {
   };
 }
 
+function fromRawSearchHit(hit: RawSearchHit) {
+  return {
+    eventId: hit.event_id,
+    content: hit.content,
+    kind: hit.kind,
+    pubkey: hit.pubkey,
+    channelId: hit.channel_id,
+    channelName: hit.channel_name,
+    createdAt: hit.created_at,
+    score: hit.score,
+  };
+}
+
 export async function getIdentity(): Promise<Identity> {
   const identity = await invoke<RawIdentity>("get_identity");
 
@@ -120,6 +151,22 @@ export async function getHomeFeed(
   };
 }
 
+export async function searchMessages(
+  input: SearchMessagesInput,
+): Promise<SearchMessagesResponse> {
+  const response = await invoke<RawSearchResponse>("search_messages", input);
+
+  return {
+    hits: response.hits.map(fromRawSearchHit),
+    found: response.found,
+  };
+}
+
+export async function getEventById(eventId: string): Promise<RelayEvent> {
+  const eventJson = await invoke<string>("get_event", { eventId });
+  return JSON.parse(eventJson) as RelayEvent;
+}
+
 export async function signRelayEvent(input: {
   kind: number;
   content: string;
diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts
index add116a47c..2d6a6d6f4a 100644
--- a/desktop/src/shared/api/types.ts
+++ b/desktop/src/shared/api/types.ts
@@ -74,3 +74,24 @@ export type GetHomeFeedInput = {
   limit?: number;
   types?: string;
 };
+
+export type SearchMessagesInput = {
+  q: string;
+  limit?: number;
+};
+
+export type SearchHit = {
+  eventId: string;
+  content: string;
+  kind: number;
+  pubkey: string;
+  channelId: string;
+  channelName: string;
+  createdAt: number;
+  score: number;
+};
+
+export type SearchMessagesResponse = {
+  hits: SearchHit[];
+  found: number;
+};
diff --git a/desktop/src/shared/ui/dialog.tsx b/desktop/src/shared/ui/dialog.tsx
new file mode 100644
index 0000000000..59db928441
--- /dev/null
+++ b/desktop/src/shared/ui/dialog.tsx
@@ -0,0 +1,97 @@
+"use client";
+
+import * as React from "react";
+import * as DialogPrimitive from "@radix-ui/react-dialog";
+import { X } from "lucide-react";
+
+import { cn } from "@/shared/lib/cn";
+
+const Dialog = DialogPrimitive.Root;
+const DialogTrigger = DialogPrimitive.Trigger;
+const DialogPortal = DialogPrimitive.Portal;
+const DialogClose = DialogPrimitive.Close;
+
+const DialogOverlay = React.forwardRef<
+  React.ElementRef<typeof DialogPrimitive.Overlay>,
+  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
+>(({ className, ...props }, ref) => (
+  <DialogPrimitive.Overlay
+    className={cn(
+      "fixed inset-0 z-50 bg-black/60 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
+      className,
+    )}
+    ref={ref}
+    {...props}
+  />
+));
+DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
+
+const DialogContent = React.forwardRef<
+  React.ElementRef<typeof DialogPrimitive.Content>,
+  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
+>(({ className, children, ...props }, ref) => (
+  <DialogPortal>
+    <DialogOverlay />
+    <DialogPrimitive.Content
+      className={cn(
+        "fixed left-1/2 top-[12vh] z-50 grid w-[calc(100vw-2rem)] max-w-2xl -translate-x-1/2 gap-4 rounded-3xl border border-border bg-background p-6 shadow-2xl duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
+        className,
+      )}
+      ref={ref}
+      {...props}
+    >
+      {children}
+      <DialogPrimitive.Close className="absolute right-4 top-4 rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus:outline-none focus:ring-1 focus:ring-ring">
+        <X className="h-4 w-4" />
+        <span className="sr-only">Close</span>
+      </DialogPrimitive.Close>
+    </DialogPrimitive.Content>
+  </DialogPortal>
+));
+DialogContent.displayName = DialogPrimitive.Content.displayName;
+
+const DialogHeader = ({
+  className,
+  ...props
+}: React.HTMLAttributes<HTMLDivElement>) => (
+  <div
+    className={cn("flex flex-col space-y-2 text-left", className)}
+    {...props}
+  />
+);
+DialogHeader.displayName = "DialogHeader";
+
+const DialogTitle = React.forwardRef<
+  React.ElementRef<typeof DialogPrimitive.Title>,
+  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
+>(({ className, ...props }, ref) => (
+  <DialogPrimitive.Title
+    className={cn("text-lg font-semibold tracking-tight", className)}
+    ref={ref}
+    {...props}
+  />
+));
+DialogTitle.displayName = DialogPrimitive.Title.displayName;
+
+const DialogDescription = React.forwardRef<
+  React.ElementRef<typeof DialogPrimitive.Description>,
+  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
+>(({ className, ...props }, ref) => (
+  <DialogPrimitive.Description
+    className={cn("text-sm text-muted-foreground", className)}
+    ref={ref}
+    {...props}
+  />
+));
+DialogDescription.displayName = DialogPrimitive.Description.displayName;
+
+export {
+  Dialog,
+  DialogClose,
+  DialogContent,
+  DialogDescription,
+  DialogHeader,
+  DialogPortal,
+  DialogTitle,
+  DialogTrigger,
+};
diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts
index b46cd48522..455bae496a 100644
--- a/desktop/src/testing/e2eBridge.ts
+++ b/desktop/src/testing/e2eBridge.ts
@@ -52,6 +52,22 @@ type RawHomeFeedResponse = {
   };
 };
 
+type RawSearchHit = {
+  event_id: string;
+  content: string;
+  kind: number;
+  pubkey: string;
+  channel_id: string;
+  channel_name: string;
+  created_at: number;
+  score: number;
+};
+
+type RawSearchResponse = {
+  hits: RawSearchHit[];
+  found: number;
+};
+
 type WsHandler = (message: unknown) => void;
 
 type MockSocket = {
@@ -62,6 +78,7 @@ type MockSocket = {
 declare global {
   interface Window {
     __SPROUT_E2E__?: E2eConfig;
+    __SPROUT_E2E_COMMANDS__?: string[];
   }
 }
 
@@ -493,6 +510,139 @@ async function handleGetFeed(
   return response.json();
 }
 
+async function handleSearchMessages(
+  args: {
+    q: string;
+    limit?: number;
+  },
+  config: E2eConfig | undefined,
+): Promise<RawSearchResponse> {
+  const identity = getIdentity(config);
+  if (!identity) {
+    const query = args.q.trim().toLowerCase();
+    const limit = args.limit ?? 20;
+    const now = Math.floor(Date.now() / 1000);
+
+    const mockHits: RawSearchHit[] = [
+      {
+        event_id: "mock-general-welcome",
+        content: "Welcome to #general",
+        kind: 40001,
+        pubkey: DEFAULT_MOCK_IDENTITY.pubkey,
+        channel_id: "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
+        channel_name: "general",
+        created_at: now - 60,
+        score: 8.5,
+      },
+      {
+        event_id: "mock-engineering-shipped",
+        content: "Engineering shipped the desktop build.",
+        kind: 40001,
+        pubkey:
+          "bb22a5299220cad76ffd46190ccbeede8ab5dc260faa28b6e5a2cb31b9aff260",
+        channel_id: "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9",
+        channel_name: "engineering",
+        created_at: now - 42 * 60,
+        score: 7.2,
+      },
+      {
+        event_id: "mock-forum-release-thread",
+        content: "Release checklist: async feedback thread.",
+        kind: 45001,
+        pubkey:
+          "953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f",
+        channel_id: "a27e1ee9-76a6-5bdf-a5d5-1d85610dad11",
+        channel_name: "watercooler",
+        created_at: now - 90 * 60,
+        score: 5.8,
+      },
+    ];
+
+    const hits = mockHits
+      .filter((hit) => {
+        if (!query) {
+          return true;
+        }
+
+        return (
+          hit.content.toLowerCase().includes(query) ||
+          hit.channel_name.toLowerCase().includes(query)
+        );
+      })
+      .slice(0, limit);
+
+    return {
+      hits,
+      found: hits.length,
+    };
+  }
+
+  const url = new URL("/api/search", getRelayHttpUrl(config));
+  url.searchParams.set("q", args.q);
+  if (args.limit !== undefined) {
+    url.searchParams.set("limit", String(args.limit));
+  }
+
+  const response = await fetch(url, {
+    headers: {
+      "X-Pubkey": identity.pubkey,
+    },
+  });
+  await assertOk(response);
+  return response.json();
+}
+
+async function handleGetEvent(
+  args: {
+    eventId: string;
+  },
+  config: E2eConfig | undefined,
+) {
+  const identity = getIdentity(config);
+  if (!identity) {
+    const knownEvents: RelayEvent[] = [
+      ...Array.from(mockMessages.values()).flat(),
+      {
+        id: "mock-engineering-shipped",
+        pubkey:
+          "bb22a5299220cad76ffd46190ccbeede8ab5dc260faa28b6e5a2cb31b9aff260",
+        created_at: Math.floor(Date.now() / 1000) - 42 * 60,
+        kind: 40001,
+        tags: [["e", "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9"]],
+        content: "Engineering shipped the desktop build.",
+        sig: "mocksig".repeat(20).slice(0, 128),
+      },
+      {
+        id: "mock-forum-release-thread",
+        pubkey:
+          "953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f",
+        created_at: Math.floor(Date.now() / 1000) - 90 * 60,
+        kind: 45001,
+        tags: [["e", "a27e1ee9-76a6-5bdf-a5d5-1d85610dad11"]],
+        content: "Release checklist: async feedback thread.",
+        sig: "mocksig".repeat(20).slice(0, 128),
+      },
+    ];
+    const event = knownEvents.find((item) => item.id === args.eventId);
+    if (!event) {
+      throw new Error(`Event not found: ${args.eventId}`);
+    }
+
+    return JSON.stringify(event);
+  }
+
+  const response = await fetch(
+    `${getRelayHttpUrl(config)}/api/events/${args.eventId}`,
+    {
+      headers: {
+        "X-Pubkey": identity.pubkey,
+      },
+    },
+  );
+  await assertOk(response);
+  return JSON.stringify(await response.json());
+}
+
 async function connectRealSocket(args: { url?: string; onMessage: unknown }) {
   const wsId = nextSocketId++;
   const ws = new WebSocket(args.url ?? DEFAULT_RELAY_WS_URL);
@@ -657,9 +807,11 @@ export function maybeInstallE2eTauriMocks() {
   }
 
   mockWindows("main");
+  window.__SPROUT_E2E_COMMANDS__ = [];
   mockIPC(async (command, payload) => {
     const activeConfig = getConfig();
     const identity = getIdentity(activeConfig);
+    window.__SPROUT_E2E_COMMANDS__?.push(command);
 
     switch (command) {
       case "get_identity":
@@ -685,6 +837,16 @@ export function maybeInstallE2eTauriMocks() {
           payload as Parameters<typeof handleCreateChannel>[0],
           activeConfig,
         );
+      case "search_messages":
+        return handleSearchMessages(
+          payload as Parameters<typeof handleSearchMessages>[0],
+          activeConfig,
+        );
+      case "get_event":
+        return handleGetEvent(
+          payload as Parameters<typeof handleGetEvent>[0],
+          activeConfig,
+        );
       case "sign_event":
         if (identity) {
           return JSON.stringify(
diff --git a/desktop/tests/e2e/smoke.spec.ts b/desktop/tests/e2e/smoke.spec.ts
index ab0325e9cd..0a5fa84ee5 100644
--- a/desktop/tests/e2e/smoke.spec.ts
+++ b/desktop/tests/e2e/smoke.spec.ts
@@ -91,6 +91,33 @@ test("opens a mocked channel from the home feed", async ({ page }) => {
   );
 });
 
+test("opens relay-backed search from the sidebar and loads the exact result", async ({
+  page,
+}) => {
+  await page.goto("/");
+
+  await expect(page.getByTestId("open-search")).toBeVisible();
+  await page.keyboard.press(
+    process.platform === "darwin" ? "Meta+K" : "Control+K",
+  );
+  await expect(page.getByTestId("search-dialog")).toBeVisible();
+
+  await page.getByTestId("search-input").fill("shipped");
+  await expect(page.getByTestId("search-results")).toContainText(
+    "Engineering shipped the desktop build.",
+  );
+
+  await page
+    .getByTestId("search-results")
+    .getByText("Engineering shipped the desktop build.")
+    .click();
+
+  await expect(page.getByTestId("chat-title")).toHaveText("engineering");
+  await expect(page.getByTestId("message-timeline")).toContainText(
+    "Engineering shipped the desktop build.",
+  );
+});
+
 test("replaces the channel pane when switching channels", async ({ page }) => {
   await page.goto("/");
 
