logancyang_obsidian-copilot/src/components/chat-components/RelevantNotes.tsx
Zero Liu 13beaf133b
chore(lint): fix no-array-index-key and other React lint warnings (#2453)
* chore(lint): fix no-array-index-key and other React lint warnings

Re-enable @eslint-react/no-array-index-key, replace index keys with stable
ids where possible (capability enum, action label, file paths, computed
coords), and add per-line eslint-disable with rationale where index is
intentional (diff parts, append-only steps, parsed markdown, duplicate-
tolerant context badges).

Also addresses adjacent React lint warnings: useMemo for context provider
values, stable module-scope defaults for empty array props, lazy useState
initializers, and explicit type="button" on non-submit buttons. Surface
no-direct-set-state-in-use-effect as a warning for follow-up triage.

* fix: dedupe selected image files
2026-05-14 02:19:38 -07:00

434 lines
16 KiB
TypeScript

import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { useApp } from "@/context";
import { HelpTooltip } from "@/components/ui/help-tooltip";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useChatInput } from "@/context/ChatInputContext";
import { useActiveFile } from "@/hooks/useActiveFile";
import { useNoteDrag } from "@/hooks/useNoteDrag";
import { cn } from "@/lib/utils";
import { logError, logWarn } from "@/logger";
import { SemanticSearchToggleModal } from "@/components/modals/SemanticSearchToggleModal";
import { shouldUseMiyo } from "@/miyo/miyoUtils";
import {
findRelevantNotes,
getSimilarityCategory,
RelevantNoteEntry,
} from "@/search/findRelevantNotes";
import { onIndexChanged } from "@/search/indexSignal";
import {
ArrowRight,
ChevronDown,
ChevronRight,
ChevronUp,
FileInput,
FileOutput,
PlusCircle,
RefreshCcw,
} from "lucide-react";
import { Notice, TFile } from "obsidian";
import React, { memo, useCallback, useEffect, useState } from "react";
function useRelevantNotes(refresher: number) {
const [relevantNotes, setRelevantNotes] = useState<RelevantNoteEntry[]>([]);
const [signalTick, setSignalTick] = useState(0);
const activeFile = useActiveFile();
useEffect(() => onIndexChanged(() => setSignalTick((t) => t + 1)), []);
useEffect(() => {
async function fetchNotes() {
if (!activeFile?.path) return;
try {
const notes = await findRelevantNotes({ filePath: activeFile.path });
setRelevantNotes(notes);
} catch (error) {
logWarn("Failed to fetch relevant notes", error);
setRelevantNotes([]);
}
}
void fetchNotes();
}, [activeFile?.path, refresher, signalTick]);
return relevantNotes;
}
function useHasIndex(notePath: string, refresher: number) {
const [hasIndex, setHasIndex] = useState(true);
const [signalTick, setSignalTick] = useState(0);
useEffect(() => onIndexChanged(() => setSignalTick((t) => t + 1)), []);
useEffect(() => {
if (!notePath) return;
async function fetchHasIndex() {
try {
const VectorStoreManager = (await import("@/search/vectorStoreManager")).default;
const { getSettings } = await import("@/settings/model");
const settings = getSettings();
const useMiyo = shouldUseMiyo(settings);
if (useMiyo) {
const isEmpty = await VectorStoreManager.getInstance().isIndexEmpty();
setHasIndex(!isEmpty);
return;
}
const has = await VectorStoreManager.getInstance().hasIndex(notePath);
setHasIndex(has);
} catch {
setHasIndex(false);
}
}
void fetchHasIndex();
}, [notePath, refresher, signalTick]);
return hasIndex;
}
function SimilarityBadge({ score }: { score: number }) {
const category = getSimilarityCategory(score);
let text = "🔴";
if (category === 2) text = "🟠";
if (category === 3) text = "🟢";
return <span className="tw-text-sm">{text}</span>;
}
function RelevantNote({
note,
onAddToChat,
onNavigateToNote,
}: {
note: RelevantNoteEntry;
onAddToChat: () => void;
onNavigateToNote: (openInNewLeaf: boolean) => void;
}) {
const app = useApp();
const [isOpen, setIsOpen] = useState(false);
const [fileContent, setFileContent] = useState<string | null>(null);
const handleDragStart = useNoteDrag();
const loadContent = useCallback(async () => {
if (fileContent) return; // Don't load if we already have content
const file = app.vault.getAbstractFileByPath(note.note.path);
if (file instanceof TFile) {
const content = await app.vault.cachedRead(file);
// Remove YAML frontmatter if it exists
let cleanContent = content;
if (content.startsWith("---")) {
const endOfFrontmatter = content.indexOf("---", 3);
if (endOfFrontmatter !== -1) {
cleanContent = content.slice(endOfFrontmatter + 3).trim();
}
}
// Take first 1000 characters as preview
setFileContent(cleanContent.slice(0, 1000) + (cleanContent.length > 1000 ? "..." : ""));
}
}, [app, fileContent, note.note.path]);
useEffect(() => {
if (isOpen) {
void loadContent();
}
}, [isOpen, loadContent]);
return (
<Collapsible
open={isOpen}
onOpenChange={setIsOpen}
className="tw-rounded-md tw-border tw-border-solid tw-border-border"
>
<div className={cn("tw-flex tw-items-center tw-justify-between tw-gap-2 tw-p-2")}>
<Button variant="ghost2" size="icon" className="tw-shrink-0" asChild>
<CollapsibleTrigger>
<ChevronRight
className={cn("tw-size-4 tw-transition-transform tw-duration-200", {
"rotate-90": isOpen,
})}
/>
</CollapsibleTrigger>
</Button>
<div className="tw-flex tw-shrink-0 tw-items-center tw-gap-2">
<SimilarityBadge score={note.metadata.similarityScore ?? 0} />
</div>
<div className="tw-flex-1 tw-overflow-hidden">
<a
draggable
onDragStart={(e) => {
const file = app.vault.getAbstractFileByPath(note.note.path);
if (file instanceof TFile) {
handleDragStart(e, file);
}
}}
onClick={(e) => {
e.preventDefault();
const openInNewLeaf = e.metaKey || e.ctrlKey;
onNavigateToNote(openInNewLeaf);
}}
onAuxClick={(e) => {
if (e.button === 1) {
// Middle click
e.preventDefault();
onNavigateToNote(true);
}
}}
className="tw-block tw-w-full tw-truncate tw-text-sm tw-font-bold tw-text-normal"
title={`${note.note.title} - drag to insert wikilink`}
>
{note.note.title}
</a>
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost2" size="icon" onClick={onAddToChat} className="tw-shrink-0">
<PlusCircle className="tw-size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Add to Chat</TooltipContent>
</Tooltip>
</div>
<CollapsibleContent>
<div className="tw-border-[0px] tw-border-t tw-border-solid tw-border-border tw-px-4 tw-py-2">
<div className="tw-whitespace-pre-wrap tw-text-wrap tw-break-all tw-text-xs tw-text-muted tw-opacity-75">
{note.note.path}
</div>
{fileContent && (
<div className="tw-overflow-hidden tw-whitespace-pre-wrap tw-border-t tw-border-border tw-pb-4 tw-pt-2 tw-text-xs tw-text-normal">
{fileContent}
</div>
)}
</div>
<div className="tw-flex tw-items-center tw-gap-4 tw-border-[0px] tw-border-t tw-border-solid tw-border-border tw-px-4 tw-py-2 tw-text-xs tw-text-muted">
{note.metadata.similarityScore != null && (
<div className="tw-flex tw-items-center tw-gap-1">
<span>Similarity: {(note.metadata.similarityScore * 100).toFixed(1)}%</span>
</div>
)}
{note.metadata.hasOutgoingLinks && (
<div className="tw-flex tw-items-center tw-gap-1">
<FileOutput className="tw-size-4" />
<span>Outgoing links</span>
</div>
)}
{note.metadata.hasBacklinks && (
<div className="tw-flex tw-items-center tw-gap-1">
<FileInput className="tw-size-4" />
<span>Backlinks</span>
</div>
)}
</div>
</CollapsibleContent>
</Collapsible>
);
}
function RelevantNotePopover({
note,
onAddToChat,
onNavigateToNote,
children,
}: {
note: RelevantNoteEntry;
onAddToChat: () => void;
onNavigateToNote: (openInNewLeaf: boolean) => void;
children: React.ReactNode;
}) {
return (
<Popover key={note.note.path}>
<PopoverTrigger asChild>{children}</PopoverTrigger>
<PopoverContent className="tw-flex tw-w-fit tw-min-w-72 tw-max-w-96 tw-flex-col tw-gap-2 tw-overflow-hidden">
<span className="tw-text-sm tw-text-normal">{note.note.title}</span>
<span className="tw-text-xs tw-text-muted">{note.note.path}</span>
<div className="tw-flex tw-gap-2">
<button
type="button"
onClick={onAddToChat}
className="tw-inline-flex tw-items-center tw-gap-2 tw-border tw-border-solid tw-border-border !tw-bg-transparent !tw-shadow-none hover:!tw-bg-interactive-hover"
>
Add to Chat <PlusCircle className="tw-size-4" />
</button>
<button
type="button"
onClick={(e) => {
const openInNewLeaf = e.metaKey || e.ctrlKey;
onNavigateToNote(openInNewLeaf);
}}
className="tw-inline-flex tw-items-center tw-gap-2 tw-border tw-border-solid tw-border-border !tw-bg-transparent !tw-shadow-none hover:!tw-bg-interactive-hover"
>
Navigate to Note <ArrowRight className="tw-size-4" />
</button>
</div>
</PopoverContent>
</Popover>
);
}
export const RelevantNotes = memo(
({ className, defaultOpen = false }: { className?: string; defaultOpen?: boolean }) => {
const app = useApp();
const [refresher, setRefresher] = useState(0);
const [isOpen, setIsOpen] = useState(defaultOpen);
const relevantNotes = useRelevantNotes(refresher);
const activeFile = useActiveFile();
const chatInput = useChatInput();
const hasIndex = useHasIndex(activeFile?.path ?? "", refresher);
const handleDragStart = useNoteDrag();
const navigateToNote = (notePath: string, openInNewLeaf = false) => {
const file = app.vault.getAbstractFileByPath(notePath);
if (file instanceof TFile) {
const leaf = app.workspace.getLeaf(openInNewLeaf);
void leaf.openFile(file).catch((err) => logError("openFile failed", err));
}
};
const addToChat = (prompt: string) => {
chatInput.insertTextWithPills(`[[${prompt}]]`, true);
};
const refreshIndex = async () => {
if (activeFile) {
const VectorStoreManager = (await import("@/search/vectorStoreManager")).default;
await VectorStoreManager.getInstance().reindexFile(activeFile);
new Notice(`Refreshed index for ${activeFile.basename}`);
setRefresher(refresher + 1);
}
};
const handleBuildIndex = async () => {
const { getSettings, updateSetting } = await import("@/settings/model");
const settings = getSettings();
if (!settings.enableSemanticSearchV3) {
// Semantic search is off — show confirmation modal (same as settings page)
new SemanticSearchToggleModal(
app,
async () => {
updateSetting("enableSemanticSearchV3", true);
const VectorStoreManager = (await import("@/search/vectorStoreManager")).default;
await VectorStoreManager.getInstance().indexVaultToVectorStore(false, {
userInitiated: true,
});
setRefresher(refresher + 1);
},
true // enabling
).open();
} else {
// Semantic search is on but index missing — build it
const VectorStoreManager = (await import("@/search/vectorStoreManager")).default;
await VectorStoreManager.getInstance().indexVaultToVectorStore(false, {
userInitiated: true,
});
setRefresher(refresher + 1);
}
};
return (
<div
className={cn(
"tw-w-full tw-border tw-border-solid tw-border-transparent tw-border-b-border tw-pb-2",
className
)}
>
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<div className="tw-flex tw-items-center tw-justify-between tw-pb-2 tw-pl-1">
<div className="tw-flex tw-flex-1 tw-items-center tw-gap-2">
<span className="tw-font-semibold tw-text-normal">Relevant Notes</span>
<HelpTooltip
content="Relevance is a combination of semantic similarity and links. Requires semantic search setting on."
contentClassName="tw-w-64"
buttonClassName="tw-size-4 tw-text-muted"
/>
</div>
<div className="tw-flex tw-items-center">
{hasIndex ? (
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost2" size="icon" onClick={() => void refreshIndex()}>
<RefreshCcw className="tw-size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Reindex Current Note</TooltipContent>
</Tooltip>
) : (
<Button variant="secondary" size="sm" onClick={() => void handleBuildIndex()}>
Build Index
</Button>
)}
{relevantNotes.length > 0 && (
<CollapsibleTrigger asChild>
<Button variant="ghost2" size="icon">
{isOpen ? (
<ChevronUp className="tw-size-5" />
) : (
<ChevronDown className="tw-size-5" />
)}
</Button>
</CollapsibleTrigger>
)}
</div>
</div>
{relevantNotes.length === 0 && hasIndex && (
<div className="tw-flex tw-max-h-12 tw-flex-wrap tw-items-center tw-gap-x-2 tw-gap-y-1 tw-overflow-y-hidden tw-px-1">
<span className="tw-text-xs tw-text-muted">No relevant notes found</span>
</div>
)}
{!isOpen && relevantNotes.length > 0 && (
<div className="tw-flex tw-max-h-6 tw-flex-wrap tw-gap-x-2 tw-gap-y-1 tw-overflow-y-hidden tw-px-1">
{relevantNotes.map((note) => (
<RelevantNotePopover
key={note.note.path}
note={note}
onAddToChat={() => addToChat(note.note.title)}
onNavigateToNote={(openInNewLeaf: boolean) =>
navigateToNote(note.note.path, openInNewLeaf)
}
>
<Badge
variant="outline"
key={note.note.path}
draggable
onDragStart={(e) => {
const file = app.vault.getAbstractFileByPath(note.note.path);
if (file instanceof TFile) {
handleDragStart(e, file);
}
}}
className="tw-max-w-40 tw-text-xs tw-text-muted hover:tw-cursor-pointer hover:tw-bg-interactive-hover"
title={`${note.note.title} - drag to insert wikilink`}
>
<span className="tw-truncate">{note.note.title}</span>
</Badge>
</RelevantNotePopover>
))}
</div>
)}
<CollapsibleContent>
<div className="tw-flex tw-max-h-screen tw-flex-col tw-gap-2 tw-overflow-y-auto tw-px-1 tw-py-2">
{relevantNotes.map((note) => (
<RelevantNote
note={note}
key={note.note.path}
onAddToChat={() => addToChat(note.note.title)}
onNavigateToNote={(openInNewLeaf: boolean) =>
navigateToNote(note.note.path, openInNewLeaf)
}
/>
))}
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
}
);
RelevantNotes.displayName = "RelevantNotes";