From e1ee6eb83765330742b2d2ece0d1d2f4dbac3846 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Fri, 7 Aug 2026 19:11:35 -0400 Subject: [PATCH 01/12] ENG-2109 Create node search modal with ranked results and preview Add the discourse node search surface: a Modal hosting a React root, a result list ranked by the QueryEngine functions from ENG-2108, and a Markdown preview of the active result. Register it as "Open node search" with no default hotkey, so users bind their own and we avoid colliding with core or community bindings. Highlight matched substrings with Obsidian's renderResults, passing the same string that was scored. Using the platform renderer rather than hand-rolled markup means highlights inherit theme styling, which is the code path that produced the equivalent Roam bug. Open with every node listed in title order rather than an empty prompt, so the modal doubles as a node browser. Model candidate loading as a discriminated union covering loading, ready, empty and error; the fetch is synchronous today, but semantic search will make it a network call and threading those states through later costs far more than carrying them now. Co-Authored-By: Claude Opus 5 --- .../src/components/NodeSearchModal.tsx | 353 ++++++++++++++++++ apps/obsidian/src/styles/style.css | 16 + apps/obsidian/src/utils/registerCommands.ts | 12 + 3 files changed, 381 insertions(+) create mode 100644 apps/obsidian/src/components/NodeSearchModal.tsx diff --git a/apps/obsidian/src/components/NodeSearchModal.tsx b/apps/obsidian/src/components/NodeSearchModal.tsx new file mode 100644 index 000000000..b46325857 --- /dev/null +++ b/apps/obsidian/src/components/NodeSearchModal.tsx @@ -0,0 +1,353 @@ +import { + App, + Component, + MarkdownRenderer, + Modal, + Notice, + renderResults, + TFile, + type SearchResult, +} from "obsidian"; +import { + StrictMode, + useEffect, + useMemo, + useRef, + useState, + type KeyboardEvent, + type ReactElement, +} from "react"; +import { createRoot, Root } from "react-dom/client"; +import type DiscourseGraphPlugin from "~/index"; +import { + QueryEngine, + rankDiscourseNodesByTitle, + type DiscourseNodeCandidate, + type RankedDiscourseNode, +} from "~/services/QueryEngine"; + +const MAX_VISIBLE_RESULTS = 50; +const SEARCH_DEBOUNCE_MS = 250; + +/** + * Loading and error are unreachable today, since `getDiscourseNodeCandidates` is + * synchronous and swallows Datacore failures. They exist because semantic search + * (F12) queries Supabase over the network, and threading those states through + * every render branch later costs far more than carrying them now. + */ +type CandidateState = + | { status: "loading" } + | { status: "ready"; candidates: DiscourseNodeCandidate[] } + | { status: "error"; message: string }; + +type SearchResultRow = RankedDiscourseNode & { + nodeTypeName: string; + authorName: string; +}; + +/** + * A local note is authored by whoever is using the vault; only imported nodes + * carry an `authorId`, and resolving that to a display name is deferred to v1+. + */ +const resolveAuthorName = (app: App, file: TFile): string => { + const frontmatter = app.metadataCache.getFileCache(file)?.frontmatter as + | Record + | undefined; + return frontmatter?.authorId === undefined ? "You" : "Unknown"; +}; + +const formatTimestamp = (epochMs: number): string => + new Date(epochMs).toLocaleString(undefined, { + dateStyle: "medium", + timeStyle: "short", + }); + +const PreviewPane = ({ + app, + result, +}: { + app: App; + result: SearchResultRow | undefined; +}): ReactElement => { + const containerRef = useRef(null); + const [content, setContent] = useState(null); + + const file = result?.file; + + useEffect(() => { + if (!file) { + setContent(null); + return; + } + let cancelled = false; + void app.vault.cachedRead(file).then((text) => { + if (!cancelled) setContent(text); + }); + return () => { + cancelled = true; + }; + }, [app, file]); + + useEffect(() => { + const container = containerRef.current; + if (!container || !file || content === null) return; + + container.empty(); + const component = new Component(); + void MarkdownRenderer.render( + app, + content.trim() || "This note is empty.", + container, + file.path, + component, + ); + + return () => { + component.unload(); + container.empty(); + }; + }, [app, file, content]); + + if (!result || !file) { + return ( +
+ Select a result to preview it. +
+ ); + } + + return ( +
+
+
{result.title}
+
+ {`Created ${formatTimestamp(file.stat.ctime)} · Modified ${formatTimestamp( + file.stat.mtime, + )} · ${result.authorName}`} +
+
+
+
+ ); +}; + +/** + * `renderResults` slices `title` using the offsets in `match`, so it must be + * handed the exact string that was scored. It also applies the theme's own + * highlight styling, which is why matches are not marked up by hand. + */ +const HighlightedTitle = ({ + title, + match, +}: { + title: string; + match: SearchResult; +}): ReactElement => { + const titleRef = useRef(null); + + useEffect(() => { + const container = titleRef.current; + if (!container) return; + container.empty(); + renderResults(container, title, match); + return () => container.empty(); + }, [title, match]); + + return
; +}; + +const ResultList = ({ + results, + activeIndex, + onActivate, +}: { + results: SearchResultRow[]; + activeIndex: number; + onActivate: (index: number) => void; +}): ReactElement => { + const listRef = useRef(null); + + useEffect(() => { + const active = listRef.current?.children[activeIndex]; + active?.scrollIntoView({ block: "nearest" }); + }, [activeIndex]); + + return ( +
+ {results.map((result, index) => ( +
onActivate(index)} + className={`border-modifier-border cursor-pointer border-b px-3 py-2 ${ + index === activeIndex ? "bg-modifier-hover" : "" + }`} + > + +
{result.nodeTypeName}
+
+ ))} +
+ ); +}; + +const NodeSearch = ({ + plugin, +}: { + plugin: DiscourseGraphPlugin; +}): ReactElement => { + const { app } = plugin; + const [candidateState, setCandidateState] = useState({ + status: "loading", + }); + const [query, setQuery] = useState(""); + const [debouncedQuery, setDebouncedQuery] = useState(""); + const [activeIndex, setActiveIndex] = useState(0); + const inputRef = useRef(null); + + const nodeTypeNames = useMemo(() => { + const names = new Map(); + for (const nodeType of plugin.settings.nodeTypes) { + names.set(nodeType.id, nodeType.name); + } + return names; + }, [plugin.settings.nodeTypes]); + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + // The fetch is synchronous today, so there is nothing to await or cancel yet. + // Effects run after paint, so the loading state still renders for a frame; when + // F12 makes this a network call, only this body changes. + useEffect(() => { + try { + const candidates = new QueryEngine(app).getDiscourseNodeCandidates(); + setCandidateState({ status: "ready", candidates }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unexpected error"; + new Notice(`Could not load discourse nodes: ${message}`); + setCandidateState({ status: "error", message }); + } + }, [app]); + + useEffect(() => { + const timeout = window.setTimeout( + () => setDebouncedQuery(query), + SEARCH_DEBOUNCE_MS, + ); + return () => window.clearTimeout(timeout); + }, [query]); + + const results = useMemo(() => { + if (candidateState.status !== "ready") return []; + return rankDiscourseNodesByTitle({ + candidates: candidateState.candidates, + query: debouncedQuery, + }) + .slice(0, MAX_VISIBLE_RESULTS) + .map((result) => ({ + ...result, + nodeTypeName: nodeTypeNames.get(result.nodeTypeId) ?? "Unknown type", + authorName: resolveAuthorName(app, result.file), + })); + }, [app, candidateState, debouncedQuery, nodeTypeNames]); + + useEffect(() => { + setActiveIndex(0); + }, [results]); + + const moveActiveIndex = (delta: number) => { + if (!results.length) return; + setActiveIndex((current) => { + const next = current + delta; + if (next < 0) return 0; + if (next > results.length - 1) return results.length - 1; + return next; + }); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return; + // Otherwise the caret jumps to the start or end of the query. + event.preventDefault(); + moveActiveIndex(event.key === "ArrowDown" ? 1 : -1); + }; + + return ( +
+ setQuery(event.target.value)} + onKeyDown={handleKeyDown} + className="w-full" + /> +
+
+ {candidateState.status === "loading" && ( +
Loading discourse nodes…
+ )} + {candidateState.status === "error" && ( +
+ Could not load discourse nodes. {candidateState.message} +
+ )} + {candidateState.status === "ready" && results.length === 0 && ( +
No results
+ )} + {candidateState.status === "ready" && results.length > 0 && ( + + )} +
+ +
+
+ ); +}; + +export class NodeSearchModal extends Modal { + private plugin: DiscourseGraphPlugin; + private root: Root | null = null; + + constructor(app: App, plugin: DiscourseGraphPlugin) { + super(app); + this.plugin = plugin; + } + + onOpen() { + const { contentEl, modalEl } = this; + modalEl.addClass("dg-node-search-modal"); + contentEl.empty(); + this.root = createRoot(contentEl); + this.root.render( + + + , + ); + } + + onClose() { + if (this.root) { + this.root.unmount(); + this.root = null; + } + this.contentEl.empty(); + } +} diff --git a/apps/obsidian/src/styles/style.css b/apps/obsidian/src/styles/style.css index 66e243fe6..489d82d81 100644 --- a/apps/obsidian/src/styles/style.css +++ b/apps/obsidian/src/styles/style.css @@ -3898,3 +3898,19 @@ kbd.tlui-kbd { background-color: var(--background-secondary); } } + +/* The default modal is too narrow for a result list beside a preview pane. + Responsive layout is an explicit non-goal, so this is a desktop-only size. */ +.dg-node-search-modal { + width: 900px; + max-width: 90vw; + height: 600px; + max-height: 80vh; +} + +.dg-node-search-modal .modal-content { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} diff --git a/apps/obsidian/src/utils/registerCommands.ts b/apps/obsidian/src/utils/registerCommands.ts index f72544360..256caec00 100644 --- a/apps/obsidian/src/utils/registerCommands.ts +++ b/apps/obsidian/src/utils/registerCommands.ts @@ -3,6 +3,7 @@ import type DiscourseGraphPlugin from "~/index"; import { NodeTypeModal } from "~/components/NodeTypeModal"; import ModifyNodeModal from "~/components/ModifyNodeModal"; import { BulkIdentifyDiscourseNodesModal } from "~/components/BulkIdentifyDiscourseNodesModal"; +import { NodeSearchModal } from "~/components/NodeSearchModal"; import { ImportNodesModal } from "~/components/ImportNodesModal"; import { FeedbackModal } from "~/components/FeedbackModal"; import { convertPageToDiscourseNode, createDiscourseNode } from "./createNode"; @@ -137,6 +138,17 @@ export const registerCommands = (plugin: DiscourseGraphPlugin) => { }, }); + plugin.addCommand({ + id: "open-node-search", + name: "Open node search", + // No default hotkey: users bind their own, and we avoid colliding with core + // or community bindings. + hotkeys: [], + callback: () => { + new NodeSearchModal(plugin.app, plugin).open(); + }, + }); + plugin.addCommand({ id: "import-nodes-from-another-space", name: "Import nodes from another space", From 59de16afb73864753cd73bfe90e4df5c8d3271bb Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Fri, 7 Aug 2026 20:05:54 -0400 Subject: [PATCH 02/12] ENG-2109 Use the search highlight colour for matched substrings renderResults applies Obsidian's suggestion highlight, which is styled for the quick switcher rather than for search. Point it at --text-highlight-bg instead, the variable behind the yellow in Obsidian's own search view, so matches read the same way there, here, and in the Roam implementation. Target the span element rather than Obsidian's internal class name: renderResults wraps matched ranges in spans and leaves unmatched text as bare text nodes, so every span inside the title is a match, and the rule survives a class rename. Co-Authored-By: Claude Opus 5 --- apps/obsidian/src/components/NodeSearchModal.tsx | 7 ++++++- apps/obsidian/src/styles/style.css | 14 ++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/apps/obsidian/src/components/NodeSearchModal.tsx b/apps/obsidian/src/components/NodeSearchModal.tsx index b46325857..22518dd5d 100644 --- a/apps/obsidian/src/components/NodeSearchModal.tsx +++ b/apps/obsidian/src/components/NodeSearchModal.tsx @@ -156,7 +156,12 @@ const HighlightedTitle = ({ return () => container.empty(); }, [title, match]); - return
; + return ( +
+ ); }; const ResultList = ({ diff --git a/apps/obsidian/src/styles/style.css b/apps/obsidian/src/styles/style.css index 489d82d81..2079f9e0f 100644 --- a/apps/obsidian/src/styles/style.css +++ b/apps/obsidian/src/styles/style.css @@ -3914,3 +3914,17 @@ kbd.tlui-kbd { height: 100%; overflow: hidden; } + +/* renderResults wraps matched ranges in spans and leaves unmatched text as bare + text nodes, so every span in here is a match. Targeting the element rather + than Obsidian's internal class keeps this working if that class is renamed. + + Obsidian styles these with the suggestion highlight, which is not the yellow + used by its own search view; --text-highlight-bg is that yellow, and stays + theme-aware rather than hardcoding a colour. */ +.dg-node-search-modal .dg-search-result-title span { + background-color: var(--text-highlight-bg); + color: inherit; + border-radius: var(--radius-s); + padding: 0 1px; +} From 73e812b7d9d5a54ae4fb8f40b44df34b6578410c Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Sat, 8 Aug 2026 13:30:14 -0400 Subject: [PATCH 03/12] Keep preview text paired with its file to avoid stale render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The preview pane read the newly selected note asynchronously while `content` still held the previous note's text, so the render effect fired once with the new file's path and the old file's body — the header showed one note while the pane rendered another. Co-Authored-By: Claude Opus 5 --- apps/obsidian/src/components/NodeSearchModal.tsx | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/apps/obsidian/src/components/NodeSearchModal.tsx b/apps/obsidian/src/components/NodeSearchModal.tsx index 22518dd5d..4d57b8d79 100644 --- a/apps/obsidian/src/components/NodeSearchModal.tsx +++ b/apps/obsidian/src/components/NodeSearchModal.tsx @@ -70,18 +70,22 @@ const PreviewPane = ({ result: SearchResultRow | undefined; }): ReactElement => { const containerRef = useRef(null); - const [content, setContent] = useState(null); + // The text is kept with the file it came from so the pane never renders one + // note's body under another note's title while the next read is in flight. + const [loaded, setLoaded] = useState<{ file: TFile; text: string } | null>( + null, + ); const file = result?.file; useEffect(() => { if (!file) { - setContent(null); + setLoaded(null); return; } let cancelled = false; void app.vault.cachedRead(file).then((text) => { - if (!cancelled) setContent(text); + if (!cancelled) setLoaded({ file, text }); }); return () => { cancelled = true; @@ -90,13 +94,13 @@ const PreviewPane = ({ useEffect(() => { const container = containerRef.current; - if (!container || !file || content === null) return; + if (!container || !file || loaded?.file !== file) return; container.empty(); const component = new Component(); void MarkdownRenderer.render( app, - content.trim() || "This note is empty.", + loaded.text.trim() || "This note is empty.", container, file.path, component, @@ -106,7 +110,7 @@ const PreviewPane = ({ component.unload(); container.empty(); }; - }, [app, file, content]); + }, [app, file, loaded]); if (!result || !file) { return ( From 68917849072f9476c2211541f2723cd2009dae42 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Sat, 8 Aug 2026 13:41:31 -0400 Subject: [PATCH 04/12] Show node type as a badge and resolve author names from the shared cache Follows the Roam result row: the node type is a rounded badge of the first three letters, inline before the title, reusing the colors the editor already paints discourse tags with so a type reads the same in both places. Author names now resolve through `plugin.settings.userNames`, which `fetchUserNames` fills with one query for every person in the vault's spaces. The modal refreshes it at most once per open, and only when an imported node is actually missing a name, so nothing queries per result. Resolution also moved to the selected result, which is the only one whose author is displayed. Co-Authored-By: Claude Opus 5 --- .../src/components/NodeSearchModal.tsx | 150 +++++++++++++++--- apps/obsidian/src/utils/nodeTypeBadge.ts | 43 +++++ apps/obsidian/src/utils/typeUtils.ts | 9 +- 3 files changed, 176 insertions(+), 26 deletions(-) create mode 100644 apps/obsidian/src/utils/nodeTypeBadge.ts diff --git a/apps/obsidian/src/components/NodeSearchModal.tsx b/apps/obsidian/src/components/NodeSearchModal.tsx index 4d57b8d79..b3cc00a71 100644 --- a/apps/obsidian/src/components/NodeSearchModal.tsx +++ b/apps/obsidian/src/components/NodeSearchModal.tsx @@ -25,6 +25,14 @@ import { type DiscourseNodeCandidate, type RankedDiscourseNode, } from "~/services/QueryEngine"; +import { + getNodeTypeBadge, + UNKNOWN_NODE_TYPE_BADGE, + type NodeTypeBadge, +} from "~/utils/nodeTypeBadge"; +import { fetchUserNames } from "~/utils/importNodes"; +import { getLoggedInClient } from "~/utils/supabaseContext"; +import { formatUserName } from "~/utils/typeUtils"; const MAX_VISIBLE_RESULTS = 50; const SEARCH_DEBOUNCE_MS = 250; @@ -40,20 +48,89 @@ type CandidateState = | { status: "ready"; candidates: DiscourseNodeCandidate[] } | { status: "error"; message: string }; +type NodeTypeDisplay = { + name: string; + badge: NodeTypeBadge; +}; + +const UNKNOWN_NODE_TYPE: NodeTypeDisplay = { + name: "Unknown type", + badge: UNKNOWN_NODE_TYPE_BADGE, +}; + type SearchResultRow = RankedDiscourseNode & { - nodeTypeName: string; - authorName: string; + nodeType: NodeTypeDisplay; }; -/** - * A local note is authored by whoever is using the vault; only imported nodes - * carry an `authorId`, and resolving that to a display name is deferred to v1+. - */ -const resolveAuthorName = (app: App, file: TFile): string => { +const getFrontmatterAuthorId = (app: App, file: TFile): number | undefined => { const frontmatter = app.metadataCache.getFileCache(file)?.frontmatter as | Record | undefined; - return frontmatter?.authorId === undefined ? "You" : "Unknown"; + const authorId = frontmatter?.authorId; + return typeof authorId === "number" ? authorId : undefined; +}; + +/** + * A local note is authored by whoever is using the vault; only imported nodes + * carry an `authorId`. The lookup is synchronous because `useAuthorNames` has + * already fetched every name; an id with no cached name degrades to `user ` + * rather than blocking the preview on a request. + */ +const resolveAuthorName = ({ + app, + file, + userNames, +}: { + app: App; + file: TFile; + userNames: Record; +}): string => { + const authorId = getFrontmatterAuthorId(app, file); + if (authorId === undefined) return "You"; + return formatUserName(userNames, authorId); +}; + +/** + * `fetchUserNames` returns every person in the vault's spaces in a single query + * and persists them, so names resolve during render with a map lookup. It runs + * at most once per modal open, and only when an imported node is actually + * missing a name — resolving per result or per selection would fire a request + * per author for data this one request already covers. + */ +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 authorId !== undefined && !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 => @@ -65,9 +142,11 @@ const formatTimestamp = (epochMs: number): string => const PreviewPane = ({ app, result, + authorName, }: { app: App; result: SearchResultRow | undefined; + authorName: string; }): ReactElement => { const containerRef = useRef(null); // The text is kept with the file it came from so the pane never renders one @@ -127,7 +206,7 @@ const PreviewPane = ({
{`Created ${formatTimestamp(file.stat.ctime)} · Modified ${formatTimestamp( file.stat.mtime, - )} · ${result.authorName}`} + )} · ${authorName}`}
); }; @@ -197,12 +276,22 @@ const ResultList = ({ role="option" aria-selected={index === activeIndex} onClick={() => onActivate(index)} - className={`border-modifier-border cursor-pointer border-b px-3 py-2 ${ + className={`border-modifier-border flex cursor-pointer items-center gap-2 border-b px-3 py-2 ${ index === activeIndex ? "bg-modifier-hover" : "" }`} > + + {result.nodeType.badge.text} + -
{result.nodeTypeName}
))}
@@ -222,13 +311,17 @@ const NodeSearch = ({ const [debouncedQuery, setDebouncedQuery] = useState(""); const [activeIndex, setActiveIndex] = useState(0); const inputRef = useRef(null); - - const nodeTypeNames = useMemo(() => { - const names = new Map(); - for (const nodeType of plugin.settings.nodeTypes) { - names.set(nodeType.id, nodeType.name); - } - return names; + const userNames = useAuthorNames({ app, plugin, candidateState }); + + const nodeTypesById = useMemo(() => { + const byId = new Map(); + plugin.settings.nodeTypes.forEach((nodeType, nodeIndex) => { + byId.set(nodeType.id, { + name: nodeType.name, + badge: getNodeTypeBadge({ nodeType, nodeIndex }), + }); + }); + return byId; }, [plugin.settings.nodeTypes]); useEffect(() => { @@ -267,10 +360,21 @@ const NodeSearch = ({ .slice(0, MAX_VISIBLE_RESULTS) .map((result) => ({ ...result, - nodeTypeName: nodeTypeNames.get(result.nodeTypeId) ?? "Unknown type", - authorName: resolveAuthorName(app, result.file), + nodeType: nodeTypesById.get(result.nodeTypeId) ?? UNKNOWN_NODE_TYPE, })); - }, [app, candidateState, debouncedQuery, nodeTypeNames]); + }, [candidateState, debouncedQuery, nodeTypesById]); + + const activeResult = results[activeIndex]; + + // Only the preview shows an author, so resolving the active result costs one + // lookup per selection instead of one per row on every keystroke. + const authorName = useMemo( + () => + activeResult + ? resolveAuthorName({ app, file: activeResult.file, userNames }) + : "", + [app, activeResult, userNames], + ); useEffect(() => { setActiveIndex(0); @@ -325,7 +429,7 @@ const NodeSearch = ({ /> )}
- +
); diff --git a/apps/obsidian/src/utils/nodeTypeBadge.ts b/apps/obsidian/src/utils/nodeTypeBadge.ts new file mode 100644 index 000000000..74f240064 --- /dev/null +++ b/apps/obsidian/src/utils/nodeTypeBadge.ts @@ -0,0 +1,43 @@ +import { DiscourseNode } from "~/types"; +import { getNodeTagColors } from "./colorUtils"; + +const BADGE_TEXT_LENGTH = 3; + +export type NodeTypeBadge = { + text: string; + backgroundColor: string; + textColor: string; +}; + +/** + * Mirrors Roam's `formatBadgeText` so a node type abbreviates to the same three + * letters in both apps. The tag wins over the name because it is the string + * users already see on the node itself. + */ +export const formatNodeTypeBadgeText = (source: string): string => + source.replace(/^#+/, "").trim().slice(0, BADGE_TEXT_LENGTH).toUpperCase(); + +/** + * Reuses the colors the editor already paints discourse tags with, so the same + * node type reads identically in a tag and in a search result. + */ +export const getNodeTypeBadge = ({ + nodeType, + nodeIndex, +}: { + nodeType: DiscourseNode; + nodeIndex: number; +}): NodeTypeBadge => ({ + text: formatNodeTypeBadgeText(nodeType.tag?.trim() || nodeType.name), + ...getNodeTagColors(nodeType, nodeIndex), +}); + +/** + * `nodeTypeId` comes from file frontmatter, so it can outlive the node type it + * names — deleted types and notes imported from another vault both land here. + */ +export const UNKNOWN_NODE_TYPE_BADGE: NodeTypeBadge = { + text: "?", + backgroundColor: "var(--background-modifier-hover)", + textColor: "var(--text-muted)", +}; diff --git a/apps/obsidian/src/utils/typeUtils.ts b/apps/obsidian/src/utils/typeUtils.ts index 9540ea81f..19808ed81 100644 --- a/apps/obsidian/src/utils/typeUtils.ts +++ b/apps/obsidian/src/utils/typeUtils.ts @@ -89,9 +89,12 @@ export const getAndFormatImportSource = ( return formatImportSource(importInfo.spaceUri || "", spaceNames); }; +export const formatUserName = ( + userNames: Record | undefined, + id: number, +): string => (userNames || {})[id] || `user ${id}`; + export const getUserNameById = ( plugin: DiscourseGraphPlugin, id: number, -): string => { - return (plugin.settings.userNames || {})[id] || `user ${id}`; -}; +): string => formatUserName(plugin.settings.userNames, id); From 33fb1fcba9b745523c1384c016e73996b69bb5a5 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Sun, 9 Aug 2026 13:19:26 -0400 Subject: [PATCH 05/12] Tighten comments on the node type badge and author name cache Co-Authored-By: Claude Opus 5 --- .../src/components/NodeSearchModal.tsx | 21 +++++++------------ apps/obsidian/src/utils/nodeTypeBadge.ts | 15 ++++--------- 2 files changed, 12 insertions(+), 24 deletions(-) diff --git a/apps/obsidian/src/components/NodeSearchModal.tsx b/apps/obsidian/src/components/NodeSearchModal.tsx index b3cc00a71..48e71d2ef 100644 --- a/apps/obsidian/src/components/NodeSearchModal.tsx +++ b/apps/obsidian/src/components/NodeSearchModal.tsx @@ -71,10 +71,8 @@ const getFrontmatterAuthorId = (app: App, file: TFile): number | undefined => { }; /** - * A local note is authored by whoever is using the vault; only imported nodes - * carry an `authorId`. The lookup is synchronous because `useAuthorNames` has - * already fetched every name; an id with no cached name degrades to `user ` - * rather than blocking the preview on a request. + * Only imported nodes carry an `authorId`; a local note is the vault owner's. + * `useAuthorNames` has already cached the names, so this stays synchronous. */ const resolveAuthorName = ({ app, @@ -91,11 +89,9 @@ const resolveAuthorName = ({ }; /** - * `fetchUserNames` returns every person in the vault's spaces in a single query - * and persists them, so names resolve during render with a map lookup. It runs - * at most once per modal open, and only when an imported node is actually - * missing a name — resolving per result or per selection would fire a request - * per author for data this one request already covers. + * `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, @@ -149,8 +145,8 @@ const PreviewPane = ({ authorName: string; }): ReactElement => { const containerRef = useRef(null); - // The text is kept with the file it came from so the pane never renders one - // note's body under another note's title while the next read is in flight. + // 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, ); @@ -366,8 +362,7 @@ const NodeSearch = ({ const activeResult = results[activeIndex]; - // Only the preview shows an author, so resolving the active result costs one - // lookup per selection instead of one per row on every keystroke. + // Only the preview shows an author, so resolve the selection, not all 50 rows. const authorName = useMemo( () => activeResult diff --git a/apps/obsidian/src/utils/nodeTypeBadge.ts b/apps/obsidian/src/utils/nodeTypeBadge.ts index 74f240064..b07d6dff1 100644 --- a/apps/obsidian/src/utils/nodeTypeBadge.ts +++ b/apps/obsidian/src/utils/nodeTypeBadge.ts @@ -10,17 +10,13 @@ export type NodeTypeBadge = { }; /** - * Mirrors Roam's `formatBadgeText` so a node type abbreviates to the same three - * letters in both apps. The tag wins over the name because it is the string - * users already see on the node itself. + * Mirrors Roam's `formatBadgeText`. The tag wins over the name because it is + * the string users already see on the node. */ export const formatNodeTypeBadgeText = (source: string): string => source.replace(/^#+/, "").trim().slice(0, BADGE_TEXT_LENGTH).toUpperCase(); -/** - * Reuses the colors the editor already paints discourse tags with, so the same - * node type reads identically in a tag and in a search result. - */ +/** Reuses the editor's tag colors so a node type reads the same everywhere. */ export const getNodeTypeBadge = ({ nodeType, nodeIndex, @@ -32,10 +28,7 @@ export const getNodeTypeBadge = ({ ...getNodeTagColors(nodeType, nodeIndex), }); -/** - * `nodeTypeId` comes from file frontmatter, so it can outlive the node type it - * names — deleted types and notes imported from another vault both land here. - */ +/** `nodeTypeId` comes from frontmatter, so it can outlive the type it names. */ export const UNKNOWN_NODE_TYPE_BADGE: NodeTypeBadge = { text: "?", backgroundColor: "var(--background-modifier-hover)", From dd75cb4d2a1747c418695408d287be63d0d5f8ad Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Sun, 9 Aug 2026 13:38:34 -0400 Subject: [PATCH 06/12] Cycle the node type palette and correct author fallbacks getNodeTagColors clamped any index past the twelfth node type to 0, so every type beyond the palette length shared one color. Cycling spreads them instead. This also changes existing tag colors for vaults with more than twelve types. Author resolution now distinguishes the two cases the scope doc separates: no authorId means the note is local ("You"), while an authorId that cannot be resolved from settings or Supabase stays "Unknown" rather than claiming local authorship. A non-numeric authorId counts as present-but-unresolvable. Co-Authored-By: Claude Opus 5 --- .../src/components/NodeSearchModal.tsx | 25 ++++++++++++------- apps/obsidian/src/utils/colorUtils.ts | 6 ++--- apps/obsidian/src/utils/typeUtils.ts | 9 +++---- 3 files changed, 22 insertions(+), 18 deletions(-) diff --git a/apps/obsidian/src/components/NodeSearchModal.tsx b/apps/obsidian/src/components/NodeSearchModal.tsx index 48e71d2ef..788aed794 100644 --- a/apps/obsidian/src/components/NodeSearchModal.tsx +++ b/apps/obsidian/src/components/NodeSearchModal.tsx @@ -32,7 +32,6 @@ import { } from "~/utils/nodeTypeBadge"; import { fetchUserNames } from "~/utils/importNodes"; import { getLoggedInClient } from "~/utils/supabaseContext"; -import { formatUserName } from "~/utils/typeUtils"; const MAX_VISIBLE_RESULTS = 50; const SEARCH_DEBOUNCE_MS = 250; @@ -62,17 +61,22 @@ type SearchResultRow = RankedDiscourseNode & { nodeType: NodeTypeDisplay; }; -const getFrontmatterAuthorId = (app: App, file: TFile): number | undefined => { +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; - const authorId = frontmatter?.authorId; - return typeof authorId === "number" ? authorId : undefined; + return frontmatter?.authorId; }; /** - * Only imported nodes carry an `authorId`; a local note is the vault owner's. - * `useAuthorNames` has already cached the names, so this stays synchronous. + * "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, @@ -84,8 +88,9 @@ const resolveAuthorName = ({ userNames: Record; }): string => { const authorId = getFrontmatterAuthorId(app, file); - if (authorId === undefined) return "You"; - return formatUserName(userNames, authorId); + if (authorId === undefined || authorId === null) return LOCAL_AUTHOR_NAME; + if (typeof authorId !== "number") return UNRESOLVED_AUTHOR_NAME; + return userNames[authorId] ?? UNRESOLVED_AUTHOR_NAME; }; /** @@ -110,7 +115,9 @@ const useAuthorNames = ({ const isMissingName = (candidate: DiscourseNodeCandidate): boolean => { const authorId = getFrontmatterAuthorId(app, candidate.file); - return authorId !== undefined && !plugin.settings.userNames?.[authorId]; + return ( + typeof authorId === "number" && !plugin.settings.userNames?.[authorId] + ); }; if (!candidateState.candidates.some(isMissingName)) return; diff --git a/apps/obsidian/src/utils/colorUtils.ts b/apps/obsidian/src/utils/colorUtils.ts index 091667fa9..68757c389 100644 --- a/apps/obsidian/src/utils/colorUtils.ts +++ b/apps/obsidian/src/utils/colorUtils.ts @@ -42,8 +42,9 @@ export const getNodeTagColors = ( ): { backgroundColor: string; textColor: string } => { const customColor = nodeType.color || ""; - const safeIndex = - nodeIndex >= 0 && nodeIndex < COLOR_ARRAY.length ? nodeIndex : 0; + // Cycling keeps the 13th node type onwards spread across the palette; clamping + // them to index 0 made every type past the twelfth share one color. + const safeIndex = nodeIndex >= 0 ? nodeIndex % COLOR_ARRAY.length : 0; const paletteColorKey = COLOR_ARRAY[safeIndex]; const paletteColor = paletteColorKey ? COLOR_PALETTE[paletteColorKey] @@ -55,7 +56,6 @@ export const getNodeTagColors = ( return { backgroundColor, textColor }; }; - export const getAllDiscourseNodeColors = ( nodeTypes: DiscourseNode[], ): Array<{ diff --git a/apps/obsidian/src/utils/typeUtils.ts b/apps/obsidian/src/utils/typeUtils.ts index 19808ed81..9540ea81f 100644 --- a/apps/obsidian/src/utils/typeUtils.ts +++ b/apps/obsidian/src/utils/typeUtils.ts @@ -89,12 +89,9 @@ export const getAndFormatImportSource = ( return formatImportSource(importInfo.spaceUri || "", spaceNames); }; -export const formatUserName = ( - userNames: Record | undefined, - id: number, -): string => (userNames || {})[id] || `user ${id}`; - export const getUserNameById = ( plugin: DiscourseGraphPlugin, id: number, -): string => formatUserName(plugin.settings.userNames, id); +): string => { + return (plugin.settings.userNames || {})[id] || `user ${id}`; +}; From bd14efd578cbfb628037b988d2a07514a34107eb Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Sun, 9 Aug 2026 13:43:57 -0400 Subject: [PATCH 07/12] ENG-2109 Navigate results by keyboard from anywhere in the modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the arrow-key handler from the search input to the modal container, so navigation keeps working when focus moves elsewhere inside the modal, and so result actions have one place to live when they arrive. Mirrors the Roam dialog, which binds its handler at the same level. Activate rows on hover as well as click, again matching Roam. Suppress the mouseenter that fires when scrolling drags a row under a stationary cursor — that is the list moving, not the user choosing, and honouring it makes arrow keys jump back a row. Prevent the default on mousedown so clicking a result never pulls focus out of the input. Co-Authored-By: Claude Opus 5 --- apps/obsidian/src/components/NodeSearchModal.tsx | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/apps/obsidian/src/components/NodeSearchModal.tsx b/apps/obsidian/src/components/NodeSearchModal.tsx index 788aed794..0cd552fb5 100644 --- a/apps/obsidian/src/components/NodeSearchModal.tsx +++ b/apps/obsidian/src/components/NodeSearchModal.tsx @@ -260,10 +260,14 @@ const ResultList = ({ onActivate: (index: number) => void; }): ReactElement => { const listRef = useRef(null); + const pointerMovedRef = useRef(false); useEffect(() => { const active = listRef.current?.children[activeIndex]; active?.scrollIntoView({ block: "nearest" }); + // Scrolling drags rows under a stationary cursor, and the mouseenter that + // fires is not a choice. Ignore hover until the pointer actually moves. + pointerMovedRef.current = false; }, [activeIndex]); return ( @@ -271,6 +275,7 @@ const ResultList = ({ ref={listRef} role="listbox" aria-label="Discourse node search results" + onMouseMove={() => (pointerMovedRef.current = true)} className="flex-1 overflow-y-auto" > {results.map((result, index) => ( @@ -278,7 +283,11 @@ const ResultList = ({ key={result.file.path} role="option" aria-selected={index === activeIndex} + onMouseEnter={() => pointerMovedRef.current && onActivate(index)} onClick={() => onActivate(index)} + // Keeps focus in the search input, so the keyboard path stays live + // after a click. + onMouseDown={(event) => event.preventDefault()} className={`border-modifier-border flex cursor-pointer items-center gap-2 border-b px-3 py-2 ${ index === activeIndex ? "bg-modifier-hover" : "" }`} @@ -392,7 +401,7 @@ const NodeSearch = ({ }); }; - const handleKeyDown = (event: KeyboardEvent) => { + const handleKeyDown = (event: KeyboardEvent) => { if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return; // Otherwise the caret jumps to the start or end of the query. event.preventDefault(); @@ -400,14 +409,15 @@ const NodeSearch = ({ }; return ( -
+ // Bound here rather than on the input so navigation survives focus moving + // elsewhere in the modal, and so result actions have one place to live. +
setQuery(event.target.value)} - onKeyDown={handleKeyDown} className="w-full" />
From 032083d60d3f24b5d48e0e87429242a2bf3c4ea5 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Sun, 9 Aug 2026 13:49:31 -0400 Subject: [PATCH 08/12] ENG-2109 Derive the badge from the title when the node type is gone A "?" chip told the reader nothing except that something was wrong. Roam handles the same case by storing the type's label on each result at index time and falling back to that; we have no stored label, but node formats are `PREFIX - {content}`, so the title still carries the prefix the badge would have shown. A note whose type was deleted, or imported from a differently configured vault, now reads QUE or CLM instead of ?. Omit the chip entirely when the title has no prefix either. Abbreviating the note's own words would produce a confident-looking label that says nothing about its type, which is worse than no label. Co-Authored-By: Claude Opus 5 --- .../src/components/NodeSearchModal.tsx | 39 ++++++++++--------- apps/obsidian/src/utils/nodeTypeBadge.ts | 29 +++++++++++--- 2 files changed, 44 insertions(+), 24 deletions(-) diff --git a/apps/obsidian/src/components/NodeSearchModal.tsx b/apps/obsidian/src/components/NodeSearchModal.tsx index 0cd552fb5..d3a8dd5e0 100644 --- a/apps/obsidian/src/components/NodeSearchModal.tsx +++ b/apps/obsidian/src/components/NodeSearchModal.tsx @@ -27,7 +27,7 @@ import { } from "~/services/QueryEngine"; import { getNodeTypeBadge, - UNKNOWN_NODE_TYPE_BADGE, + getFallbackNodeTypeBadge, type NodeTypeBadge, } from "~/utils/nodeTypeBadge"; import { fetchUserNames } from "~/utils/importNodes"; @@ -49,12 +49,8 @@ type CandidateState = type NodeTypeDisplay = { name: string; - badge: NodeTypeBadge; -}; - -const UNKNOWN_NODE_TYPE: NodeTypeDisplay = { - name: "Unknown type", - badge: UNKNOWN_NODE_TYPE_BADGE, + /** Null when neither the config nor the title says what type this is. */ + badge: NodeTypeBadge | null; }; type SearchResultRow = RankedDiscourseNode & { @@ -292,17 +288,19 @@ const ResultList = ({ index === activeIndex ? "bg-modifier-hover" : "" }`} > - - {result.nodeType.badge.text} - + {result.nodeType.badge && ( + + {result.nodeType.badge.text} + + )}
))} @@ -372,7 +370,10 @@ const NodeSearch = ({ .slice(0, MAX_VISIBLE_RESULTS) .map((result) => ({ ...result, - nodeType: nodeTypesById.get(result.nodeTypeId) ?? UNKNOWN_NODE_TYPE, + nodeType: nodeTypesById.get(result.nodeTypeId) ?? { + name: "Unknown type", + badge: getFallbackNodeTypeBadge(result.title), + }, })); }, [candidateState, debouncedQuery, nodeTypesById]); diff --git a/apps/obsidian/src/utils/nodeTypeBadge.ts b/apps/obsidian/src/utils/nodeTypeBadge.ts index b07d6dff1..8f36e0a24 100644 --- a/apps/obsidian/src/utils/nodeTypeBadge.ts +++ b/apps/obsidian/src/utils/nodeTypeBadge.ts @@ -28,9 +28,28 @@ export const getNodeTypeBadge = ({ ...getNodeTagColors(nodeType, nodeIndex), }); -/** `nodeTypeId` comes from frontmatter, so it can outlive the type it names. */ -export const UNKNOWN_NODE_TYPE_BADGE: NodeTypeBadge = { - text: "?", - backgroundColor: "var(--background-modifier-hover)", - textColor: "var(--text-muted)", +/** + * `nodeTypeId` comes from frontmatter, so it can outlive the type it names — + * a deleted type, or a note imported from a vault configured differently. + * + * Roam covers this by storing the type's label on the result when it indexes, and + * falling back to that. We have no such label, but node formats are + * `PREFIX - {content}`, so the title still carries the prefix the badge would have + * shown. Returns null when the title has no prefix either: an abbreviation of the + * note's own words would say nothing about its type. + */ +export const getFallbackNodeTypeBadge = ( + title: string, +): NodeTypeBadge | null => { + const [prefix, ...rest] = title.split(" - "); + if (!rest.length || !prefix) return null; + + const text = formatNodeTypeBadgeText(prefix); + if (!text) return null; + + return { + text, + backgroundColor: "var(--background-modifier-hover)", + textColor: "var(--text-muted)", + }; }; From 948e909fdc554a11c9ba44ee07071b5ea9b58819 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Sun, 9 Aug 2026 13:53:00 -0400 Subject: [PATCH 09/12] ENG-2109 Keep colorUtils out of this change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The palette-cycling fix is a real one — past the twelfth node type every type collapsed to a single colour — but it is a behaviour change to a util shared with the editor, and nothing in the search modal needs it: this vault has nine node types, so clamping and cycling agree. Reverted here so the search PR stays to the search surface; worth its own change. Also drop the badge comments that restated their code, keeping the one that explains what Roam does differently. Co-Authored-By: Claude Opus 5 --- apps/obsidian/src/utils/colorUtils.ts | 5 ++--- apps/obsidian/src/utils/nodeTypeBadge.ts | 18 +++++------------- 2 files changed, 7 insertions(+), 16 deletions(-) diff --git a/apps/obsidian/src/utils/colorUtils.ts b/apps/obsidian/src/utils/colorUtils.ts index 68757c389..a1ed9503c 100644 --- a/apps/obsidian/src/utils/colorUtils.ts +++ b/apps/obsidian/src/utils/colorUtils.ts @@ -42,9 +42,8 @@ export const getNodeTagColors = ( ): { backgroundColor: string; textColor: string } => { const customColor = nodeType.color || ""; - // Cycling keeps the 13th node type onwards spread across the palette; clamping - // them to index 0 made every type past the twelfth share one color. - const safeIndex = nodeIndex >= 0 ? nodeIndex % COLOR_ARRAY.length : 0; + const safeIndex = + nodeIndex >= 0 && nodeIndex < COLOR_ARRAY.length ? nodeIndex : 0; const paletteColorKey = COLOR_ARRAY[safeIndex]; const paletteColor = paletteColorKey ? COLOR_PALETTE[paletteColorKey] diff --git a/apps/obsidian/src/utils/nodeTypeBadge.ts b/apps/obsidian/src/utils/nodeTypeBadge.ts index 8f36e0a24..570d81f02 100644 --- a/apps/obsidian/src/utils/nodeTypeBadge.ts +++ b/apps/obsidian/src/utils/nodeTypeBadge.ts @@ -9,14 +9,9 @@ export type NodeTypeBadge = { textColor: string; }; -/** - * Mirrors Roam's `formatBadgeText`. The tag wins over the name because it is - * the string users already see on the node. - */ export const formatNodeTypeBadgeText = (source: string): string => source.replace(/^#+/, "").trim().slice(0, BADGE_TEXT_LENGTH).toUpperCase(); -/** Reuses the editor's tag colors so a node type reads the same everywhere. */ export const getNodeTypeBadge = ({ nodeType, nodeIndex, @@ -29,14 +24,11 @@ export const getNodeTypeBadge = ({ }); /** - * `nodeTypeId` comes from frontmatter, so it can outlive the type it names — - * a deleted type, or a note imported from a vault configured differently. - * - * Roam covers this by storing the type's label on the result when it indexes, and - * falling back to that. We have no such label, but node formats are - * `PREFIX - {content}`, so the title still carries the prefix the badge would have - * shown. Returns null when the title has no prefix either: an abbreviation of the - * note's own words would say nothing about its type. + * Mirrors Roam, which stores each node type's label on the search result at index + * time and falls back to it when the type can no longer be resolved. We have no + * stored label, but node formats are `PREFIX - {content}`, so the title still + * carries the prefix the badge would have shown. Null when it does not: an + * abbreviation of the note's own words would say nothing about its type. */ export const getFallbackNodeTypeBadge = ( title: string, From ab1f1db1266a70ef4c8a2a4951a530ebcb05f935 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Sun, 9 Aug 2026 13:53:54 -0400 Subject: [PATCH 10/12] ENG-2109 Restore colorUtils byte-for-byte The revert left a whitespace-only diff: the pre-commit formatter collapsed a double blank line the file already had. Committing without it so colorUtils drops out of this PR entirely rather than appearing as a one-line change. Co-Authored-By: Claude Opus 5 --- apps/obsidian/src/utils/colorUtils.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/obsidian/src/utils/colorUtils.ts b/apps/obsidian/src/utils/colorUtils.ts index a1ed9503c..091667fa9 100644 --- a/apps/obsidian/src/utils/colorUtils.ts +++ b/apps/obsidian/src/utils/colorUtils.ts @@ -55,6 +55,7 @@ export const getNodeTagColors = ( return { backgroundColor, textColor }; }; + export const getAllDiscourseNodeColors = ( nodeTypes: DiscourseNode[], ): Array<{ From 24388a875fb34fcf177cf1092f22dffa3195a871 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Sun, 9 Aug 2026 14:00:54 -0400 Subject: [PATCH 11/12] ENG-2109 Drop the remaining explanatory comments Co-Authored-By: Claude Opus 5 --- apps/obsidian/src/components/NodeSearchModal.tsx | 11 ----------- apps/obsidian/src/styles/style.css | 7 ------- apps/obsidian/src/utils/nodeTypeBadge.ts | 7 ------- apps/obsidian/src/utils/registerCommands.ts | 2 -- 4 files changed, 27 deletions(-) diff --git a/apps/obsidian/src/components/NodeSearchModal.tsx b/apps/obsidian/src/components/NodeSearchModal.tsx index d3a8dd5e0..048f5c3c0 100644 --- a/apps/obsidian/src/components/NodeSearchModal.tsx +++ b/apps/obsidian/src/components/NodeSearchModal.tsx @@ -36,12 +36,6 @@ import { getLoggedInClient } from "~/utils/supabaseContext"; const MAX_VISIBLE_RESULTS = 50; const SEARCH_DEBOUNCE_MS = 250; -/** - * Loading and error are unreachable today, since `getDiscourseNodeCandidates` is - * synchronous and swallows Datacore failures. They exist because semantic search - * (F12) queries Supabase over the network, and threading those states through - * every render branch later costs far more than carrying them now. - */ type CandidateState = | { status: "loading" } | { status: "ready"; candidates: DiscourseNodeCandidate[] } @@ -216,11 +210,6 @@ const PreviewPane = ({ ); }; -/** - * `renderResults` slices `title` using the offsets in `match`, so it must be - * handed the exact string that was scored. It also applies the theme's own - * highlight styling, which is why matches are not marked up by hand. - */ const HighlightedTitle = ({ title, match, diff --git a/apps/obsidian/src/styles/style.css b/apps/obsidian/src/styles/style.css index 2079f9e0f..63949fec5 100644 --- a/apps/obsidian/src/styles/style.css +++ b/apps/obsidian/src/styles/style.css @@ -3915,13 +3915,6 @@ kbd.tlui-kbd { overflow: hidden; } -/* renderResults wraps matched ranges in spans and leaves unmatched text as bare - text nodes, so every span in here is a match. Targeting the element rather - than Obsidian's internal class keeps this working if that class is renamed. - - Obsidian styles these with the suggestion highlight, which is not the yellow - used by its own search view; --text-highlight-bg is that yellow, and stays - theme-aware rather than hardcoding a colour. */ .dg-node-search-modal .dg-search-result-title span { background-color: var(--text-highlight-bg); color: inherit; diff --git a/apps/obsidian/src/utils/nodeTypeBadge.ts b/apps/obsidian/src/utils/nodeTypeBadge.ts index 570d81f02..a4ad3f258 100644 --- a/apps/obsidian/src/utils/nodeTypeBadge.ts +++ b/apps/obsidian/src/utils/nodeTypeBadge.ts @@ -23,13 +23,6 @@ export const getNodeTypeBadge = ({ ...getNodeTagColors(nodeType, nodeIndex), }); -/** - * Mirrors Roam, which stores each node type's label on the search result at index - * time and falls back to it when the type can no longer be resolved. We have no - * stored label, but node formats are `PREFIX - {content}`, so the title still - * carries the prefix the badge would have shown. Null when it does not: an - * abbreviation of the note's own words would say nothing about its type. - */ export const getFallbackNodeTypeBadge = ( title: string, ): NodeTypeBadge | null => { diff --git a/apps/obsidian/src/utils/registerCommands.ts b/apps/obsidian/src/utils/registerCommands.ts index 256caec00..de3e2eae6 100644 --- a/apps/obsidian/src/utils/registerCommands.ts +++ b/apps/obsidian/src/utils/registerCommands.ts @@ -141,8 +141,6 @@ export const registerCommands = (plugin: DiscourseGraphPlugin) => { plugin.addCommand({ id: "open-node-search", name: "Open node search", - // No default hotkey: users bind their own, and we avoid colliding with core - // or community bindings. hotkeys: [], callback: () => { new NodeSearchModal(plugin.app, plugin).open(); From da528fbc3020ab09d5559d9b5de558235d00faa0 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Mon, 10 Aug 2026 11:19:19 -0400 Subject: [PATCH 12/12] Clamp the active index while results are being replaced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A narrowing query rebuilds `results` before the reset effect runs, so the old index could point past the new list for one render — blanking the preview and leaving no row highlighted. Clamping at render covers that frame; the effect still resets the state so arrow keys continue from the top. Co-Authored-By: Claude Opus 5 --- apps/obsidian/src/components/NodeSearchModal.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/obsidian/src/components/NodeSearchModal.tsx b/apps/obsidian/src/components/NodeSearchModal.tsx index 048f5c3c0..4735ea142 100644 --- a/apps/obsidian/src/components/NodeSearchModal.tsx +++ b/apps/obsidian/src/components/NodeSearchModal.tsx @@ -366,7 +366,11 @@ const NodeSearch = ({ })); }, [candidateState, debouncedQuery, nodeTypesById]); - const activeResult = results[activeIndex]; + // A narrowing query rebuilds `results` before the effect below can reset the + // state, so the old index can point past the new list for one render. Clamping + // here keeps the preview and the highlighted row from blanking for that frame. + const activeIndexInRange = activeIndex < results.length ? activeIndex : 0; + const activeResult = results[activeIndexInRange]; // Only the preview shows an author, so resolve the selection, not all 50 rows. const authorName = useMemo( @@ -426,7 +430,7 @@ const NodeSearch = ({ {candidateState.status === "ready" && results.length > 0 && ( )}