diff --git a/apps/obsidian/src/components/NodeSearchFooter.tsx b/apps/obsidian/src/components/NodeSearchFooter.tsx
new file mode 100644
index 000000000..2778c840f
--- /dev/null
+++ b/apps/obsidian/src/components/NodeSearchFooter.tsx
@@ -0,0 +1,74 @@
+import { type ReactElement } from "react";
+import { getHintKeys, type HintKey } from "~/utils/keyboardHints";
+
+type NodeSearchFooterProps = {
+ canAct: boolean;
+ onClose: () => void;
+ onOpenInNewTab: () => void;
+ onOpenInSplit: () => void;
+};
+
+type FooterActionProps = {
+ disabled?: boolean;
+ keys: HintKey[];
+ label: string;
+ onClick: () => void;
+};
+
+const KeyHints = ({ keys }: { keys: HintKey[] }): ReactElement => (
+ <>
+ {getHintKeys(keys).map((symbol) => (
+
+ {symbol}
+
+ ))}
+ >
+);
+
+const FooterAction = ({
+ disabled = false,
+ keys,
+ label,
+ onClick,
+}: FooterActionProps): ReactElement => (
+
+);
+
+// Sits in Obsidian's `prompt-instructions` container for its type and spacing.
+// Obsidian centres that row for the narrow quick switcher; this footer spans a
+// full-width result list, so the actions start at its left edge instead.
+export const NodeSearchFooter = ({
+ canAct,
+ onClose,
+ onOpenInNewTab,
+ onOpenInSplit,
+}: NodeSearchFooterProps): ReactElement => (
+
+
+
+ {/* The Escape key itself is handled by Obsidian's modal scope; this button
+ is the pointer equivalent, so every footer item responds to a click. */}
+
+
+);
diff --git a/apps/obsidian/src/components/NodeSearchModal.tsx b/apps/obsidian/src/components/NodeSearchModal.tsx
new file mode 100644
index 000000000..e9d527e29
--- /dev/null
+++ b/apps/obsidian/src/components/NodeSearchModal.tsx
@@ -0,0 +1,540 @@
+import {
+ App,
+ Component,
+ MarkdownRenderer,
+ Modal,
+ Notice,
+ renderResults,
+ TFile,
+ type SearchResult,
+} from "obsidian";
+import {
+ StrictMode,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type KeyboardEvent,
+ type MouseEvent,
+ type ReactElement,
+} from "react";
+import { createRoot, Root } from "react-dom/client";
+import type DiscourseGraphPlugin from "~/index";
+import { NodeSearchFooter } from "~/components/NodeSearchFooter";
+import {
+ openFileInNewLeaf,
+ openFileInNewTab,
+} from "~/components/canvas/utils/openFileUtils";
+import {
+ QueryEngine,
+ rankDiscourseNodesByTitle,
+ type DiscourseNodeCandidate,
+ type RankedDiscourseNode,
+} from "~/services/QueryEngine";
+import {
+ getNodeTypeBadge,
+ getFallbackNodeTypeBadge,
+ type NodeTypeBadge,
+} from "~/utils/nodeTypeBadge";
+import { fetchUserNames } from "~/utils/importNodes";
+import { getLoggedInClient } from "~/utils/supabaseContext";
+
+const MAX_VISIBLE_RESULTS = 50;
+const SEARCH_DEBOUNCE_MS = 250;
+
+type CandidateState =
+ | { status: "loading" }
+ | { status: "ready"; candidates: DiscourseNodeCandidate[] }
+ | { status: "error"; message: string };
+
+type NodeTypeDisplay = {
+ name: string;
+ /** Null when neither the config nor the title says what type this is. */
+ badge: NodeTypeBadge | null;
+};
+
+type SearchResultRow = RankedDiscourseNode & {
+ nodeType: NodeTypeDisplay;
+};
+
+const LOCAL_AUTHOR_NAME = "You";
+const UNRESOLVED_AUTHOR_NAME = "Unknown";
+
+/** Frontmatter is untyped, so the raw value is narrowed by each caller. */
+const getFrontmatterAuthorId = (app: App, file: TFile): unknown => {
+ const frontmatter = app.metadataCache.getFileCache(file)?.frontmatter as
+ | Record
+ | undefined;
+ return frontmatter?.authorId;
+};
+
+/**
+ * "You" belongs only to a note with no `authorId` at all — every note in an
+ * unsynced vault. An id that is present but unresolvable stays "Unknown" rather
+ * than claiming local authorship. `useAuthorNames` has already cached the
+ * names, so this stays synchronous.
+ */
+const resolveAuthorName = ({
+ app,
+ file,
+ userNames,
+}: {
+ app: App;
+ file: TFile;
+ userNames: Record;
+}): string => {
+ const authorId = getFrontmatterAuthorId(app, file);
+ if (authorId === undefined || authorId === null) return LOCAL_AUTHOR_NAME;
+ if (typeof authorId !== "number") return UNRESOLVED_AUTHOR_NAME;
+ return userNames[authorId] ?? UNRESOLVED_AUTHOR_NAME;
+};
+
+/**
+ * `fetchUserNames` returns every person in the vault's spaces in one query, so
+ * this refreshes once per open when a name is missing rather than querying per
+ * author.
+ */
+const useAuthorNames = ({
+ app,
+ plugin,
+ candidateState,
+}: {
+ app: App;
+ plugin: DiscourseGraphPlugin;
+ candidateState: CandidateState;
+}): Record => {
+ const [userNames, setUserNames] = useState(plugin.settings.userNames ?? {});
+
+ useEffect(() => {
+ if (candidateState.status !== "ready") return;
+ if (!plugin.settings.syncModeEnabled) return;
+
+ const isMissingName = (candidate: DiscourseNodeCandidate): boolean => {
+ const authorId = getFrontmatterAuthorId(app, candidate.file);
+ return (
+ typeof authorId === "number" && !plugin.settings.userNames?.[authorId]
+ );
+ };
+ if (!candidateState.candidates.some(isMissingName)) return;
+
+ let cancelled = false;
+ void (async () => {
+ const client = await getLoggedInClient(plugin);
+ if (!client || cancelled) return;
+ await fetchUserNames(plugin, client);
+ if (!cancelled) setUserNames(plugin.settings.userNames ?? {});
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [app, plugin, candidateState]);
+
+ return userNames;
+};
+
+const formatTimestamp = (epochMs: number): string =>
+ new Date(epochMs).toLocaleString(undefined, {
+ dateStyle: "medium",
+ timeStyle: "short",
+ });
+
+const PreviewPane = ({
+ app,
+ result,
+ authorName,
+}: {
+ app: App;
+ result: SearchResultRow | undefined;
+ authorName: string;
+}): ReactElement => {
+ const containerRef = useRef(null);
+ // Paired with its file so an in-flight read can't put one note's body under
+ // another note's title.
+ const [loaded, setLoaded] = useState<{ file: TFile; text: string } | null>(
+ null,
+ );
+
+ const file = result?.file;
+
+ useEffect(() => {
+ if (!file) {
+ setLoaded(null);
+ return;
+ }
+ let cancelled = false;
+ void app.vault.cachedRead(file).then((text) => {
+ if (!cancelled) setLoaded({ file, text });
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [app, file]);
+
+ useEffect(() => {
+ const container = containerRef.current;
+ if (!container || !file || loaded?.file !== file) return;
+
+ container.empty();
+ const component = new Component();
+ void MarkdownRenderer.render(
+ app,
+ loaded.text.trim() || "This note is empty.",
+ container,
+ file.path,
+ component,
+ );
+
+ return () => {
+ component.unload();
+ container.empty();
+ };
+ }, [app, file, loaded]);
+
+ if (!result || !file) {
+ return (
+