}>
@@ -45,9 +45,9 @@ function EmptyStateContent(props: { reason: string }) {
function InitialContent() {
return (
<>
-
- Type a query above to search through your liked and bookmarked posts.
+ Type a query above to search through the posts you liked or bookmarked.
>
@@ -56,13 +56,15 @@ function InitialContent() {
function KeyboardShortcuts() {
return (
-
-
- / Focus search from anywhere
-
-
- Tab Cycle search modes
-
+
+
+ /
+ Focus search from anywhere
+
+
+ Tab
+ Cycle search modes
+
);
}
@@ -82,7 +84,9 @@ function NoSyncContent() {
return (
<>
No posts synced yet
-
Sync your liked and bookmarked posts to enable local search.
+
+ Sync your liked and bookmarked posts to build the local index for keyword and semantic search.
+
>
);
}
diff --git a/src/components/search/SearchPanel.test.tsx b/src/components/search/SearchPanel.test.tsx
index fb7365e..22c019e 100644
--- a/src/components/search/SearchPanel.test.tsx
+++ b/src/components/search/SearchPanel.test.tsx
@@ -6,10 +6,16 @@ const searchPostsMock = vi.hoisted(() => vi.fn());
const searchPostsNetworkMock = vi.hoisted(() => vi.fn());
const getSyncStatusMock = vi.hoisted(() => vi.fn());
const syncPostsMock = vi.hoisted(() => vi.fn());
+const getEmbeddingsConfigMock = vi.hoisted(() => vi.fn());
+const prepareEmbeddingsModelMock = vi.hoisted(() => vi.fn());
+const setEmbeddingsEnabledMock = vi.hoisted(() => vi.fn());
vi.mock(
"$/lib/api/search",
() => ({
+ getEmbeddingsConfig: getEmbeddingsConfigMock,
+ prepareEmbeddingsModel: prepareEmbeddingsModelMock,
+ setEmbeddingsEnabled: setEmbeddingsEnabledMock,
searchPosts: searchPostsMock,
searchPostsNetwork: searchPostsNetworkMock,
getSyncStatus: getSyncStatusMock,
@@ -26,20 +32,38 @@ describe("SearchPanel", () => {
searchPostsNetworkMock.mockReset();
getSyncStatusMock.mockReset();
syncPostsMock.mockReset();
+ getEmbeddingsConfigMock.mockReset();
+ prepareEmbeddingsModelMock.mockReset();
+ setEmbeddingsEnabledMock.mockReset();
getSyncStatusMock.mockResolvedValue([]);
syncPostsMock.mockResolvedValue({
did: "did:plc:test",
source: "like",
- post_count: 100,
- last_synced_at: "2026-03-29T12:00:00.000Z",
+ postCount: 100,
+ lastSyncedAt: "2026-03-29T12:00:00.000Z",
});
+ getEmbeddingsConfigMock.mockResolvedValue({
+ enabled: true,
+ modelName: "nomic-embed-text-v1.5",
+ dimensions: 768,
+ downloaded: true,
+ downloadActive: false,
+ });
+ prepareEmbeddingsModelMock.mockResolvedValue({
+ enabled: true,
+ modelName: "nomic-embed-text-v1.5",
+ dimensions: 768,
+ downloaded: true,
+ downloadActive: false,
+ });
+ setEmbeddingsEnabledMock.mockResolvedValue(void 0);
});
it("renders the search panel with initial state", async () => {
render(() =>
);
- expect(await screen.findByPlaceholderText("Search posts...")).toBeInTheDocument();
+ expect(await screen.findByPlaceholderText("Search your saved & liked posts...")).toBeInTheDocument();
expect(screen.getByText("Network")).toBeInTheDocument();
expect(screen.getByText("Keyword")).toBeInTheDocument();
expect(screen.getByText("Semantic")).toBeInTheDocument();
@@ -70,7 +94,7 @@ describe("SearchPanel", () => {
render(() =>
);
- const input = await screen.findByPlaceholderText("Search posts...");
+ const input = await screen.findByPlaceholderText("Search your saved & liked posts...");
fireEvent.input(input, { target: { value: "test query" } });
vi.advanceTimersByTime(350);
@@ -79,42 +103,45 @@ describe("SearchPanel", () => {
expect(searchPostsNetworkMock).toHaveBeenCalledWith("test query", "top", 25);
});
- expect(await screen.findByText("Test post content")).toBeInTheDocument();
+ expect(await screen.findByText(/post content/i)).toBeInTheDocument();
});
it("performs local search in keyword mode", async () => {
+ getSyncStatusMock.mockResolvedValue([{ did: "did:plc:test", source: "like", postCount: 12, lastSyncedAt: null }]);
searchPostsMock.mockResolvedValue([{
uri: "at://test",
cid: "cid-1",
- author_did: "did:plc:test",
- author_handle: "test.bsky.social",
+ authorDid: "did:plc:test",
+ authorHandle: "test.bsky.social",
text: "Local test post",
- created_at: "2026-03-29T12:00:00.000Z",
- indexed_at: "2026-03-29T12:00:00.000Z",
+ createdAt: "2026-03-29T12:00:00.000Z",
source: "like" as const,
+ score: 1,
+ keywordMatch: true,
+ semanticMatch: false,
}]);
render(() =>
);
const keywordButton = screen.getByRole("button", { name: /keyword/i });
fireEvent.click(keywordButton);
+ expect(keywordButton).toHaveAttribute("aria-pressed", "true");
- const input = await screen.findByPlaceholderText("Search posts...");
+ const input = screen.getByPlaceholderText("Search your saved & liked posts...");
fireEvent.input(input, { target: { value: "test query" } });
- vi.advanceTimersByTime(350);
-
- await waitFor(() => {
- expect(searchPostsMock).toHaveBeenCalledWith("test query", "keyword", 50);
- });
+ await vi.advanceTimersByTimeAsync(350);
+ await Promise.resolve();
+ await Promise.resolve();
- expect(await screen.findByText("Local test post")).toBeInTheDocument();
+ expect(searchPostsMock).toHaveBeenCalledWith("test query", "keyword", 50);
+ expect(screen.getByText("Liked")).toBeInTheDocument();
});
it("cycles through modes with Tab key", async () => {
render(() =>
);
- const input = await screen.findByPlaceholderText("Search posts...");
+ const input = await screen.findByPlaceholderText("Search your saved & liked posts...");
input.focus();
fireEvent.keyDown(input, { key: "Tab" });
@@ -134,7 +161,7 @@ describe("SearchPanel", () => {
render(() =>
);
- const input = await screen.findByPlaceholderText("Search posts...");
+ const input = await screen.findByPlaceholderText("Search your saved & liked posts...");
fireEvent.input(input, { target: { value: "test" } });
vi.advanceTimersByTime(350);
@@ -152,7 +179,7 @@ describe("SearchPanel", () => {
render(() =>
);
- const input = await screen.findByPlaceholderText("Search posts...");
+ const input = await screen.findByPlaceholderText("Search your saved & liked posts...");
fireEvent.input(input, { target: { value: "test" } });
vi.advanceTimersByTime(350);
@@ -162,6 +189,7 @@ describe("SearchPanel", () => {
});
it("shows empty state when no results found", async () => {
+ getSyncStatusMock.mockResolvedValue([{ did: "did:plc:test", source: "like", postCount: 12, lastSyncedAt: null }]);
searchPostsMock.mockResolvedValue([]);
render(() =>
);
@@ -169,7 +197,7 @@ describe("SearchPanel", () => {
const keywordButton = screen.getByRole("button", { name: /keyword/i });
fireEvent.click(keywordButton);
- const input = await screen.findByPlaceholderText("Search posts...");
+ const input = await screen.findByPlaceholderText("Search your saved & liked posts...");
fireEvent.input(input, { target: { value: "nonexistent" } });
vi.advanceTimersByTime(350);
diff --git a/src/components/search/SearchPanel.tsx b/src/components/search/SearchPanel.tsx
index 67a656a..42ada02 100644
--- a/src/components/search/SearchPanel.tsx
+++ b/src/components/search/SearchPanel.tsx
@@ -1,16 +1,22 @@
import { Icon, SearchModeIcon } from "$/components/shared/Icon";
import {
+ type EmbeddingsConfig,
+ getEmbeddingsConfig,
type LocalPostResult,
type NetworkSearchResult,
type SearchMode,
searchPosts,
searchPostsNetwork,
+ type SyncStatus,
} from "$/lib/api/search";
+import { formatRelativeTime } from "$/lib/feeds";
import type { ActiveSession } from "$/lib/types";
import { normalizeError } from "$/lib/utils/text";
import * as logger from "@tauri-apps/plugin-log";
import { createEffect, createMemo, createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js";
import { Motion, Presence } from "solid-motionone";
+import { PostCount } from "../shared/PostCount";
+import { EmbeddingsSettings } from "./EmbeddingsSettings";
import { SearchEmptyState } from "./SearchEmptyState";
import { SearchResultCard } from "./SearchResultCard";
import { SyncStatusPanel } from "./SyncStatusPanel";
@@ -19,7 +25,7 @@ const MODES: SearchMode[] = ["network", "keyword", "semantic", "hybrid"];
function ModeLabel(props: { mode: SearchMode }) {
return (
-
+
Network
@@ -42,14 +48,43 @@ export function SearchPanel(props: SearchPanelProps) {
const [error, setError] = createSignal(null);
const [resultCount, setResultCount] = createSignal(0);
const [hasSearched, setHasSearched] = createSignal(false);
+ const [syncStatus, setSyncStatus] = createSignal([]);
+ const [embeddingsConfig, setEmbeddingsConfig] = createSignal(null);
let searchInputRef: HTMLInputElement | undefined;
let debounceTimer: ReturnType | undefined;
const isLocalMode = createMemo(() => mode() !== "network");
+ const semanticEnabled = createMemo(() => embeddingsConfig()?.enabled ?? true);
+ const totalIndexedPosts = createMemo(() => syncStatus().reduce((sum, status) => sum + (status.postCount ?? 0), 0));
+ const hasLocalPosts = createMemo(() => totalIndexedPosts() > 0);
+ const lastSync = createMemo(() => {
+ const timestamps = syncStatus().map((status) => status.lastSyncedAt).filter(Boolean) as string[];
+ if (timestamps.length === 0) {
+ return null;
+ }
+
+ return formatRelativeTime(timestamps.toSorted((left, right) => right.localeCompare(left))[0]);
+ });
+ const cycleModes = createMemo(() => MODES.filter((candidate) => candidate !== "semantic" || semanticEnabled()));
+
+ async function loadEmbeddingsConfig() {
+ try {
+ setEmbeddingsConfig(await getEmbeddingsConfig());
+ } catch (err) {
+ logger.error("failed to load embeddings config", { keyValues: { error: normalizeError(err) } });
+ }
+ }
async function performSearch(searchQuery: string, searchMode: SearchMode) {
if (!searchQuery.trim()) {
+ clearResults();
+ return;
+ }
+
+ if (searchMode === "semantic" && !semanticEnabled()) {
+ setError("Semantic search is disabled. Re-enable embeddings to use this mode.");
+ setHasSearched(true);
setResults([]);
setNetworkResults(null);
setResultCount(0);
@@ -63,22 +98,36 @@ export function SearchPanel(props: SearchPanelProps) {
if (searchMode === "network") {
const response = await searchPostsNetwork(searchQuery, "top", 25);
setNetworkResults(response);
+ setResults([]);
setResultCount(response.posts.length);
} else {
const response = await searchPosts(searchQuery, searchMode, 50);
setResults(response);
+ setNetworkResults(null);
setResultCount(response.length);
}
setHasSearched(true);
} catch (err) {
const errorMsg = normalizeError(err);
setError(errorMsg);
+ setResults([]);
+ setNetworkResults(null);
+ setResultCount(0);
+ setHasSearched(true);
logger.error("search failed", { keyValues: { query: searchQuery, mode: searchMode, error: errorMsg } });
} finally {
setLoading(false);
}
}
+ function clearResults() {
+ setResults([]);
+ setNetworkResults(null);
+ setResultCount(0);
+ setError(null);
+ setHasSearched(false);
+ }
+
function handleInput(value: string) {
setQuery(value);
clearTimeout(debounceTimer);
@@ -88,25 +137,28 @@ export function SearchPanel(props: SearchPanelProps) {
}
function handleModeChange(newMode: SearchMode) {
+ if (newMode === "semantic" && !semanticEnabled()) {
+ return;
+ }
+
setMode(newMode);
if (query().trim()) {
void performSearch(query(), newMode);
+ } else {
+ setError(null);
}
}
function cycleMode() {
- const currentIndex = MODES.indexOf(mode());
- const nextIndex = (currentIndex + 1) % MODES.length;
- handleModeChange(MODES[nextIndex]);
+ const availableModes = cycleModes();
+ const currentIndex = availableModes.indexOf(mode());
+ const nextIndex = (currentIndex + 1) % availableModes.length;
+ handleModeChange(availableModes[nextIndex] ?? availableModes[0] ?? "network");
}
function clearSearch() {
setQuery("");
- setResults([]);
- setNetworkResults(null);
- setResultCount(0);
- setHasSearched(false);
- setError(null);
+ clearResults();
searchInputRef?.focus();
}
@@ -120,7 +172,7 @@ export function SearchPanel(props: SearchPanelProps) {
}
function handleGlobalKeyDown(event: KeyboardEvent) {
- if (event.key === "/" || ((event.metaKey || event.ctrlKey) && event.key === "f")) {
+ if (event.key === "/" || ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "f")) {
const target = event.target as HTMLElement;
if (target.tagName !== "INPUT" && target.tagName !== "TEXTAREA") {
event.preventDefault();
@@ -131,39 +183,62 @@ export function SearchPanel(props: SearchPanelProps) {
onMount(() => {
document.addEventListener("keydown", handleGlobalKeyDown);
+ void loadEmbeddingsConfig();
+
onCleanup(() => {
document.removeEventListener("keydown", handleGlobalKeyDown);
clearTimeout(debounceTimer);
});
});
+ createEffect(() => {
+ if (mode() === "semantic" && !semanticEnabled()) {
+ setMode("keyword");
+ }
+ });
+
return (
-
- {
- searchInputRef = el;
- }}
- onKeyDown={handleKeyDown}
- onClear={clearSearch} />
-
-
-
-
-
+
+
+ {
+ searchInputRef = element;
+ }}
+ onKeyDown={handleKeyDown}
+ onClear={clearSearch} />
+
+
+
+
+
+
);
}
@@ -172,6 +247,7 @@ function SearchHeader(
error: string | null;
hasSearched: boolean;
inputRef: (el: HTMLInputElement) => void;
+ lastSync: string | null;
loading: boolean;
mode: SearchMode;
onClear: () => void;
@@ -180,10 +256,12 @@ function SearchHeader(
onQueryChange: (value: string) => void;
query: string;
resultCount: number;
+ semanticEnabled: boolean;
+ totalIndexedPosts: number;
},
) {
return (
-