Add native desktop spotlight search

This commit is contained in:
Sreeram Sreedhar 2026-06-22 16:58:17 -07:00
parent ce8c960586
commit 53c10e2126
8 changed files with 506 additions and 13 deletions

View file

@ -14,9 +14,10 @@ import {
SendHorizontal,
Sparkles,
} from "lucide-react"
import { useMemo, useState } from "react"
import { useEffect, useMemo, useState } from "react"
import { listDocuments, type DocumentWithMemories } from "@/lib/api"
import type { SearchResult } from "@/lib/search"
import { OPEN_MEMORY_EVENT, type SpotlightMemory } from "@/lib/spotlight"
import { SearchCommand, useCommandK } from "@/components/search-command"
type MemoryPreview = {
@ -51,6 +52,27 @@ export default function DashboardPage() {
}, [documentsQuery.isPending, totalCount])
const visibleDocuments = documents.slice(0, 5)
useEffect(() => {
let unlisten: (() => void) | undefined
import("@tauri-apps/api/event")
.then(({ listen }) =>
listen<SpotlightMemory>(OPEN_MEMORY_EVENT, (event) => {
setSelectedMemory(spotlightMemoryToPreview(event.payload))
}),
)
.then((handler) => {
unlisten = handler
})
.catch(() => {
unlisten = undefined
})
return () => {
unlisten?.()
}
}, [])
return (
<div className="flex min-h-full flex-col px-4 pb-10 md:px-6">
<div className="mx-auto flex min-h-[calc(100vh-8.5rem)] w-full max-w-5xl flex-col justify-center py-8">
@ -273,6 +295,19 @@ function searchResultToPreview(result: SearchResult): MemoryPreview {
}
}
function spotlightMemoryToPreview(memory: SpotlightMemory): MemoryPreview {
return {
id: memory.id,
title: memory.title,
summary: memory.summary,
content: memory.content,
raw: memory.raw,
url: memory.url,
type: memory.type,
createdAt: memory.createdAt,
}
}
function formatDate(value: string | Date) {
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",

View file

@ -1,23 +1,198 @@
"use client"
import { Search } from "lucide-react"
import { useQuery } from "@tanstack/react-query"
import { FileText, Loader2, Search, Sparkles } from "lucide-react"
import { type KeyboardEvent, useEffect, useMemo, useRef, useState } from "react"
import { searchMemories, type SearchResult } from "@/lib/search"
import {
hideSpotlight,
openSpotlightResult,
SPOTLIGHT_SHOWN_EVENT,
type SpotlightMemory,
} from "@/lib/spotlight"
// Phase 1 stub for the frameless spotlight window. Phase 5 wires the Rust
// global-shortcut, window show/hide on blur/Esc, input focus on show, and real
// /v3/search results.
export default function SpotlightPage() {
const inputRef = useRef<HTMLInputElement>(null)
const [query, setQuery] = useState("")
const [activeIndex, setActiveIndex] = useState(0)
const trimmedQuery = query.trim()
const searchQuery = useQuery({
queryKey: ["spotlight-search", trimmedQuery],
queryFn: () => searchMemories(trimmedQuery),
enabled: trimmedQuery.length > 0,
staleTime: 30 * 1000,
})
const results = useMemo(
() => searchQuery.data?.results.slice(0, 6) ?? [],
[searchQuery.data?.results],
)
useEffect(() => {
let unlisten: (() => void) | undefined
const focusInput = () => {
setTimeout(() => inputRef.current?.focus(), 0)
}
focusInput()
import("@tauri-apps/api/event")
.then(({ listen }) => listen(SPOTLIGHT_SHOWN_EVENT, focusInput))
.then((handler) => {
unlisten = handler
})
.catch(() => {
unlisten = undefined
})
return () => {
unlisten?.()
}
}, [])
function onKeyDown(event: KeyboardEvent<HTMLInputElement>) {
if (event.key === "Escape") {
event.preventDefault()
void hideSpotlight()
return
}
if (event.key === "ArrowDown") {
event.preventDefault()
setActiveIndex((index) => Math.min(index + 1, results.length - 1))
return
}
if (event.key === "ArrowUp") {
event.preventDefault()
setActiveIndex((index) => Math.max(index - 1, 0))
return
}
if (event.key === "Enter" && results[activeIndex]) {
event.preventDefault()
void openResult(results[activeIndex])
}
}
async function openResult(result: SearchResult) {
await openSpotlightResult(searchResultToSpotlightMemory(result))
setQuery("")
}
return (
<div className="flex h-screen items-start justify-center bg-transparent p-3">
<div className="w-full overflow-hidden rounded-xl border border-border/60 bg-popover/95 shadow-2xl backdrop-blur">
<div className="flex items-center gap-3 px-4 py-3">
<Search className="size-4 shrink-0 text-muted-foreground" />
<div className="w-full overflow-hidden rounded-2xl border border-white/[0.10] bg-[#090D14]/96 shadow-[0_26px_90px_rgba(0,0,0,0.44),inset_1px_1px_1px_rgba(255,255,255,0.06)] backdrop-blur-2xl">
<div className="flex items-center gap-3 border-white/[0.06] border-b px-4 py-3">
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-[#4BA0FA]/12 text-[#8BC6FF]">
<Search className="size-4" />
</div>
<input
aria-label="Search your memories"
placeholder="Search your memories…"
className="w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
ref={inputRef}
aria-label="Ask or search your memories"
value={query}
onChange={(event) => {
setQuery(event.target.value)
setActiveIndex(0)
}}
onKeyDown={onKeyDown}
placeholder="Ask or search your memories..."
className="min-w-0 flex-1 bg-transparent text-base text-white outline-none placeholder:text-[#6F7885]"
/>
<div className="hidden items-center gap-1.5 rounded-full border border-white/[0.06] bg-white/[0.03] px-2.5 py-1 text-[11px] text-fg-subtle sm:flex">
<Sparkles className="size-3 text-[#4BA0FA]" />
Spotlight
</div>
</div>
<div className="min-h-[220px] p-2">
{!trimmedQuery ? (
<div className="flex h-[220px] flex-col items-center justify-center text-center">
<p className="font-medium text-fg-primary text-sm">
Search by meaning, title, or question.
</p>
<p className="mt-1 text-[12px] text-fg-subtle">
Press Enter to open a result in the main window.
</p>
</div>
) : null}
{searchQuery.isFetching ? (
<div className="flex items-center gap-2 px-3 py-2.5 text-fg-subtle text-sm">
<Loader2 className="size-4 animate-spin" />
Searching...
</div>
) : null}
{searchQuery.isError ? (
<div className="px-3 py-2.5 text-destructive text-sm">
Search failed. Check your token and API URL.
</div>
) : null}
{trimmedQuery && !searchQuery.isFetching && results.length === 0 ? (
<div className="px-3 py-2.5 text-fg-subtle text-sm">
No results found.
</div>
) : null}
<ul className="space-y-0.5">
{results.map((result, index) => {
const title = result.title ?? result.documentId
const preview =
result.summary ??
result.content ??
result.chunks.find((chunk) => chunk.isRelevant)?.content
const active = index === activeIndex
return (
<li key={result.documentId}>
<button
type="button"
onMouseEnter={() => setActiveIndex(index)}
onClick={() => void openResult(result)}
className={[
"group flex w-full items-start gap-3 rounded-xl px-3 py-2.5 text-left transition-colors",
active ? "bg-white/[0.07]" : "hover:bg-white/[0.04]",
].join(" ")}
>
<span className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-lg bg-surface-card ring-1 ring-surface-border">
<FileText className="size-3.5 text-fg-subtle" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium text-fg-primary text-sm">
{title}
</span>
{preview ? (
<span className="mt-0.5 line-clamp-2 block text-[12px] text-fg-subtle leading-relaxed">
{preview}
</span>
) : null}
</span>
</button>
</li>
)
})}
</ul>
</div>
</div>
</div>
)
}
function searchResultToSpotlightMemory(result: SearchResult): SpotlightMemory {
return {
id: result.documentId,
title: result.title,
summary: result.summary,
content:
result.content ??
result.chunks.find((chunk) => chunk.isRelevant)?.content ??
null,
type: result.type,
createdAt: normalizeDate(result.createdAt),
}
}
function normalizeDate(value: string | Date) {
return value instanceof Date ? value.toISOString() : value
}

View file

@ -0,0 +1,29 @@
"use client"
import { invoke } from "@tauri-apps/api/core"
export type SpotlightMemory = {
id: string
title: string | null
summary?: string | null
content?: string | null
raw?: string | null
url?: string | null
type?: string | null
createdAt: string
}
export const OPEN_MEMORY_EVENT = "nav:open-memory"
export const SPOTLIGHT_SHOWN_EVENT = "spotlight:shown"
export function showSpotlight() {
return invoke("spotlight_show")
}
export function hideSpotlight() {
return invoke("spotlight_hide")
}
export function openSpotlightResult(memory: SpotlightMemory) {
return invoke("spotlight_open_result", { memory })
}

View file

@ -724,6 +724,16 @@ dependencies = [
"typeid",
]
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "fastrand"
version = "2.4.1"
@ -993,6 +1003,16 @@ dependencies = [
"version_check",
]
[[package]]
name = "gethostname"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
dependencies = [
"rustix",
"windows-link 0.2.1",
]
[[package]]
name = "getrandom"
version = "0.2.17"
@ -1116,6 +1136,24 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "global-hotkey"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c386b0a4a70cb2d39fffd74480f985b6f0bfbcb934b6a6b6b7e630e448f242e"
dependencies = [
"crossbeam-channel",
"keyboard-types",
"objc2",
"objc2-app-kit",
"once_cell",
"serde",
"thiserror 2.0.18",
"windows-sys 0.59.0",
"x11rb",
"xkeysym",
]
[[package]]
name = "gobject-sys"
version = "0.18.0"
@ -1683,6 +1721,12 @@ dependencies = [
"libc",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litemap"
version = "0.8.2"
@ -2598,6 +2642,19 @@ dependencies = [
"semver",
]
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags 2.13.0",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.41"
@ -3061,6 +3118,7 @@ dependencies = [
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-global-shortcut",
]
[[package]]
@ -3298,6 +3356,37 @@ dependencies = [
"tauri-utils",
]
[[package]]
name = "tauri-plugin"
version = "2.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020"
dependencies = [
"anyhow",
"glob",
"plist",
"schemars 0.8.22",
"serde",
"serde_json",
"tauri-utils",
"walkdir",
]
[[package]]
name = "tauri-plugin-global-shortcut"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4dd9f4c5136c09cd962da0c86dc4accd4666db2ea591cf16e6597435843bd2b"
dependencies = [
"global-hotkey",
"log",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
]
[[package]]
name = "tauri-runtime"
version = "2.11.3"
@ -4672,6 +4761,29 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "x11rb"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
dependencies = [
"gethostname",
"rustix",
"x11rb-protocol",
]
[[package]]
name = "x11rb-protocol"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
[[package]]
name = "xkeysym"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
[[package]]
name = "yoke"
version = "0.8.3"

View file

@ -20,3 +20,4 @@ keyring = "3"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri-plugin-global-shortcut = "2"

View file

@ -2,6 +2,6 @@
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Core capabilities granted to the main window.",
"windows": ["main"],
"windows": ["main", "spotlight"],
"permissions": ["core:default", "core:window:allow-start-dragging"]
}

View file

@ -1,4 +1,5 @@
mod auth;
mod spotlight;
use serde::Serialize;
@ -43,15 +44,42 @@ async fn auth_whoami() -> Result<auth::AuthSession, String> {
auth::whoami().await
}
#[tauri::command]
fn spotlight_show(app: tauri::AppHandle) -> Result<(), String> {
spotlight::show(&app).map_err(|error| error.to_string())
}
#[tauri::command]
fn spotlight_hide(app: tauri::AppHandle) -> Result<(), String> {
spotlight::hide(&app).map_err(|error| error.to_string())
}
#[tauri::command]
fn spotlight_open_result(
app: tauri::AppHandle,
memory: spotlight::SpotlightMemory,
) -> Result<(), String> {
spotlight::open_result(&app, memory).map_err(|error| error.to_string())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
.setup(|app| {
spotlight::create_window(app)?;
spotlight::register_shortcut(app);
Ok(())
})
.invoke_handler(tauri::generate_handler![
app_info,
auth_store_token,
auth_get_token,
auth_clear,
auth_whoami
auth_whoami,
spotlight_show,
spotlight_hide,
spotlight_open_result
])
// Bootstrap failure is unrecoverable (no window, no app), so we abort
// loudly here. This is the one sanctioned `expect` — see roadmap quality bar.

View file

@ -0,0 +1,113 @@
use serde::{Deserialize, Serialize};
use tauri::{
App, AppHandle, Emitter, Manager, WebviewUrl, WebviewWindow, WebviewWindowBuilder,
WindowEvent,
};
use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState};
const MAIN_LABEL: &str = "main";
const SPOTLIGHT_LABEL: &str = "spotlight";
const OPEN_MEMORY_EVENT: &str = "nav:open-memory";
const SHOWN_EVENT: &str = "spotlight:shown";
#[derive(Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SpotlightMemory {
id: String,
title: Option<String>,
summary: Option<String>,
content: Option<String>,
raw: Option<String>,
url: Option<String>,
#[serde(rename = "type")]
memory_type: Option<String>,
created_at: String,
}
pub fn shortcut() -> Shortcut {
Shortcut::new(Some(Modifiers::SUPER | Modifiers::SHIFT), Code::KeyM)
}
pub fn create_window(app: &App) -> tauri::Result<WebviewWindow> {
if let Some(window) = app.get_webview_window(SPOTLIGHT_LABEL) {
return Ok(window);
}
let window = WebviewWindowBuilder::new(
app,
SPOTLIGHT_LABEL,
WebviewUrl::App("spotlight/".into()),
)
.title("Supermemory Spotlight")
.inner_size(760.0, 420.0)
.min_inner_size(560.0, 220.0)
.decorations(false)
.always_on_top(true)
.skip_taskbar(true)
.visible(false)
.center()
.build()?;
let window_for_blur = window.clone();
window.on_window_event(move |event| {
if matches!(event, WindowEvent::Focused(false)) {
let _ = window_for_blur.hide();
}
});
Ok(window)
}
pub fn register_shortcut(app: &App) {
let result = app
.global_shortcut()
.on_shortcut(shortcut(), |app, shortcut, event| {
if event.state == ShortcutState::Pressed && shortcut.matches(
Modifiers::SUPER | Modifiers::SHIFT,
Code::KeyM,
) {
let _ = toggle(app);
}
});
if let Err(error) = result {
eprintln!("failed to register Supermemory spotlight shortcut: {error}");
}
}
pub fn toggle(app: &AppHandle) -> tauri::Result<()> {
let window = spotlight_window(app)?;
if window.is_visible()? {
window.hide()
} else {
show(app)
}
}
pub fn show(app: &AppHandle) -> tauri::Result<()> {
let window = spotlight_window(app)?;
window.center()?;
window.show()?;
window.set_focus()?;
window.emit(SHOWN_EVENT, ())?;
Ok(())
}
pub fn hide(app: &AppHandle) -> tauri::Result<()> {
spotlight_window(app)?.hide()
}
pub fn open_result(app: &AppHandle, memory: SpotlightMemory) -> tauri::Result<()> {
if let Some(main) = app.get_webview_window(MAIN_LABEL) {
main.show()?;
main.set_focus()?;
main.emit(OPEN_MEMORY_EVENT, memory)?;
}
hide(app)
}
fn spotlight_window(app: &AppHandle) -> tauri::Result<WebviewWindow> {
app.get_webview_window(SPOTLIGHT_LABEL)
.ok_or_else(|| tauri::Error::WindowNotFound)
}