diff --git a/README.md b/README.md index 5783f010..81fbfec2 100644 --- a/README.md +++ b/README.md @@ -192,7 +192,7 @@ Type `/` to use Command Palette in chat window. #### **Relevant Notes: notes suggestions based on semantic similarity and links** -Appears automatically when there's useful related content and links. +Open it from the command palette (**Open Relevant Notes**) to get its own pane that surfaces related content and links for whatever note you're viewing. Use it to quickly reference past research, ideas, or decisions—no need to search or switch tabs. diff --git a/docs/chat-interface.md b/docs/chat-interface.md index 74fe11ac..0839185b 100644 --- a/docs/chat-interface.md +++ b/docs/chat-interface.md @@ -9,19 +9,24 @@ The Copilot chat panel is the main way you interact with AI in Obsidian. This gu Copilot offers four modes. You can switch between them using the mode selector at the top of the chat panel. ### Chat + General-purpose conversation. Good for writing, brainstorming, summarizing, or any task where you want to talk to an AI. Your currently open note and selected text are automatically included as context. ### Vault QA (Basic) + Ask questions about your vault content. Copilot uses lexical search (keyword matching) to find relevant notes and passes them as context to the AI. No indexing required. Good for quick questions about your notes. ### Copilot Plus + The most powerful mode. Requires a [Copilot Plus](copilot-plus-and-self-host.md) license. Combines Chat and Vault QA with an autonomous agent that can: + - Search your vault and the web - Read and edit notes - Remember things across conversations - Use a growing set of tools automatically ### Projects (alpha) + Focused workspaces with their own context, model, system prompt, and isolated chat history. Useful for keeping separate AI conversations per project. See [Projects](projects.md) for details. --- @@ -45,6 +50,7 @@ Copilot adds the note's content to your message as context in the background. Th ### User Message Buttons Each message you send has action buttons that appear on hover: + - **Edit** — Modify your prompt. Press Enter to re-send the edited message to the AI. - **Copy** — Copy the message text to clipboard - **Delete** — Remove this message from the conversation @@ -52,6 +58,7 @@ Each message you send has action buttons that appear on hover: ### AI Message Buttons Each AI response has action buttons: + - **Insert at cursor** — Insert the AI's response at your cursor position in the active note - **Replace at cursor** — Replace the selected text in your note with the AI's response - **Copy** — Copy the response to clipboard @@ -77,6 +84,7 @@ The filename template controls how saved chats are named. The default is: ``` Where: + - `{$topic}` — An AI-generated title (or the first few words of your first message if AI titles are off) - `{$date}` — Date in YYYY-MM-DD format - `{$time}` — Time in HH-MM-SS format @@ -90,6 +98,7 @@ When **Generate AI chat title on save** is enabled (default), Copilot asks the A ### Loading Previous Chats Click the **clock/history icon** in the chat panel toolbar to open the Chat History list. You can: + - Browse previous conversations - Click a conversation to load it and continue from where you left off - Delete conversations you no longer need @@ -130,9 +139,9 @@ When starting a new chat, Copilot may show suggested prompts based on your activ ## Relevant Notes -Copilot can display a list of notes related to your currently active note in the chat panel. This helps surface notes you might want to reference without manually searching. +Copilot can display a list of notes related to your currently active note. This helps surface notes you might want to reference without manually searching. Each note can be opened, dragged in as a wikilink, or sent to the open chat with **Add to Chat**. -Enable in **Settings → Copilot → Basic → Relevant Notes** (on by default). +Relevant Notes lives in its own pane. Open it from the command palette: **Open Relevant Notes**. The pane tracks whichever note you're viewing. ## Saving a Chat Manually @@ -143,6 +152,7 @@ If autosave is off, or you want to save mid-conversation, click the **Save Chat ## New Chat Behavior Click the **pencil/new chat icon** to start a fresh conversation. This: + 1. Saves the current conversation (if autosave is enabled) 2. Clears the chat window 3. Resets the context to your currently active note diff --git a/docs/models-and-parameters.md b/docs/models-and-parameters.md index f9068d9a..f7c1c078 100644 --- a/docs/models-and-parameters.md +++ b/docs/models-and-parameters.md @@ -98,7 +98,7 @@ If you change embedding models, you must rebuild the vault index because the old - **Vault QA mode** — Uses embeddings to find relevant notes by meaning - **Semantic Search** — The "Enable Semantic Search" toggle in QA settings -- **Relevant Notes** — Shows semantically similar notes in the sidebar +- **Relevant Notes** — Shows semantically similar notes in its own pane (command palette: **Open Relevant Notes**) --- diff --git a/src/agentMode/ui/AgentHome.tsx b/src/agentMode/ui/AgentHome.tsx index 051767eb..2db6c125 100644 --- a/src/agentMode/ui/AgentHome.tsx +++ b/src/agentMode/ui/AgentHome.tsx @@ -13,8 +13,9 @@ import { useAgentModePicker } from "@/agentMode/ui/useAgentModePicker"; import { useSessionBackendDescriptor } from "@/agentMode/ui/useBackendDescriptor"; import type { AgentChatBackend } from "@/agentMode/session/AgentChatBackend"; import type { AgentSessionManager } from "@/agentMode/session/AgentSessionManager"; -import { AppContext } from "@/context"; -import { ChatInputProvider } from "@/context/ChatInputContext"; +import { EVENT_NAMES } from "@/constants"; +import { AppContext, ChatViewEventTarget, EventTargetContext } from "@/context"; +import { ChatInputProvider, useChatInput } from "@/context/ChatInputContext"; import { useChatFileDrop } from "@/hooks/useChatFileDrop"; import { logError } from "@/logger"; import type CopilotPlugin from "@/main"; @@ -56,6 +57,27 @@ const AgentHomeInternal: React.FC = ({ const appContext = useContext(AppContext); const app = plugin.app || appContext; const settings = useSettingsValue(); + const eventTarget = useContext(EventTargetContext); + const chatInput = useChatInput(); + + // Insert text routed from outside the chat (e.g. the Relevant Notes pane's + // "Add to Chat") into the active session's composer. The bus latches text + // queued before this listener attaches, so a freshly-opened view still + // receives it on mount. + useEffect(() => { + const bus = eventTarget instanceof ChatViewEventTarget ? eventTarget : null; + const handleInsertText = (e: Event) => { + bus?.consumePendingInsertText(); + const text = (e as CustomEvent<{ text?: string }>).detail?.text; + if (typeof text === "string") chatInput.insertTextWithPills(text, true); + }; + eventTarget?.addEventListener(EVENT_NAMES.INSERT_TEXT_TO_CHAT, handleInsertText); + const pending = bus?.consumePendingInsertText(); + if (typeof pending === "string") chatInput.insertTextWithPills(pending, true); + return () => { + eventTarget?.removeEventListener(EVENT_NAMES.INSERT_TEXT_TO_CHAT, handleInsertText); + }; + }, [eventTarget, chatInput]); const { messages, diff --git a/src/agentMode/ui/CopilotAgentView.tsx b/src/agentMode/ui/CopilotAgentView.tsx index bcf56c1e..da0f2225 100644 --- a/src/agentMode/ui/CopilotAgentView.tsx +++ b/src/agentMode/ui/CopilotAgentView.tsx @@ -2,7 +2,7 @@ import { AgentModeChat } from "@/agentMode/ui/AgentModeChat"; import { attachChatViewLayoutObservers } from "@/components/chat-components/attachChatViewLayoutObservers"; import { ChatViewLayout } from "@/components/chat-components/ChatViewLayout"; import { CHAT_AGENT_VIEWTYPE, COPILOT_AGENT_ICON_ID } from "@/constants"; -import { EventTargetContext } from "@/context"; +import { ChatViewEventTarget, EventTargetContext } from "@/context"; import CopilotPlugin from "@/main"; import { createPluginRoot } from "@/utils/react/createPluginRoot"; import * as Tooltip from "@radix-ui/react-tooltip"; @@ -15,7 +15,7 @@ export default class CopilotAgentView extends ItemView { private handleSaveAsNote: (() => Promise) | null = null; private layout: ChatViewLayout | null = null; private disposeLayoutObservers: (() => void) | null = null; - eventTarget: EventTarget; + eventTarget: ChatViewEventTarget; constructor( leaf: WorkspaceLeaf, @@ -23,7 +23,7 @@ export default class CopilotAgentView extends ItemView { ) { super(leaf); this.app = plugin.app; - this.eventTarget = new EventTarget(); + this.eventTarget = new ChatViewEventTarget(); this.plugin = plugin; } diff --git a/src/commands/index.ts b/src/commands/index.ts index 899fc1fa..be487f80 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -118,6 +118,10 @@ export function registerCommands(plugin: CopilotPlugin) { await plugin.activateView(); }); + addCommand(plugin, COMMAND_IDS.OPEN_RELEVANT_NOTES_VIEW, async () => { + await plugin.activateRelevantNotesView(); + }); + addCommand(plugin, COMMAND_IDS.NEW_CHAT, async () => { clearRecordedPromptPayload(); await plugin.newChat(); diff --git a/src/components/Chat.tsx b/src/components/Chat.tsx index f89cb631..cbb5d6c2 100644 --- a/src/components/Chat.tsx +++ b/src/components/Chat.tsx @@ -31,7 +31,7 @@ import { RESTRICTION_MESSAGES, USER_SENDER, } from "@/constants"; -import { AppContext, EventTargetContext } from "@/context"; +import { AppContext, ChatViewEventTarget, EventTargetContext } from "@/context"; import { ChatInputProvider, useChatInput } from "@/context/ChatInputContext"; import { useChatManager } from "@/hooks/useChatManager"; import { useChatFileDrop } from "@/hooks/useChatFileDrop"; @@ -644,6 +644,24 @@ const ChatInternal: React.FC { + const bus = eventTarget instanceof ChatViewEventTarget ? eventTarget : null; + const handleInsertText = (e: Event) => { + bus?.consumePendingInsertText(); + const text = (e as CustomEvent<{ text?: string }>).detail?.text; + if (typeof text === "string") chatInput.insertTextWithPills(text, true); + }; + eventTarget?.addEventListener(EVENT_NAMES.INSERT_TEXT_TO_CHAT, handleInsertText); + const pending = bus?.consumePendingInsertText(); + if (typeof pending === "string") chatInput.insertTextWithPills(pending, true); + return () => { + eventTarget?.removeEventListener(EVENT_NAMES.INSERT_TEXT_TO_CHAT, handleInsertText); + }; + }, [eventTarget, chatInput]); + const handleDelete = useCallback( async (messageIndex: number) => { const messageToDelete = chatHistory[messageIndex]; diff --git a/src/components/CopilotView.tsx b/src/components/CopilotView.tsx index 7764df34..53207637 100644 --- a/src/components/CopilotView.tsx +++ b/src/components/CopilotView.tsx @@ -2,7 +2,7 @@ import ChainManager from "@/LLMProviders/chainManager"; import Chat from "@/components/Chat"; import { ChatViewLayout } from "@/components/chat-components/ChatViewLayout"; import { CHAT_VIEWTYPE } from "@/constants"; -import { EventTargetContext } from "@/context"; +import { ChatViewEventTarget, EventTargetContext } from "@/context"; import CopilotPlugin from "@/main"; import { FileParserManager } from "@/tools/FileParserManager"; import { createPluginRoot } from "@/utils/react/createPluginRoot"; @@ -24,7 +24,7 @@ export default class CopilotView extends ItemView { private layout: ChatViewLayout | null = null; private lastDrawerEl: HTMLElement | null = null; private windowMigrationDestroy: (() => void) | null = null; - eventTarget: EventTarget; + eventTarget: ChatViewEventTarget; constructor( leaf: WorkspaceLeaf, @@ -33,7 +33,7 @@ export default class CopilotView extends ItemView { super(leaf); this.app = plugin.app; this.fileParserManager = plugin.fileParserManager; - this.eventTarget = new EventTarget(); + this.eventTarget = new ChatViewEventTarget(); this.plugin = plugin; } diff --git a/src/components/RelevantNotesView.tsx b/src/components/RelevantNotesView.tsx new file mode 100644 index 00000000..3430ab69 --- /dev/null +++ b/src/components/RelevantNotesView.tsx @@ -0,0 +1,79 @@ +import { RelevantNotes } from "@/components/chat-components/RelevantNotes"; +import { EVENT_NAMES, RELEVANT_NOTES_VIEWTYPE } from "@/constants"; +import { EventTargetContext } from "@/context"; +import CopilotPlugin from "@/main"; +import { createPluginRoot } from "@/utils/react/createPluginRoot"; +import { ItemView, MarkdownView, WorkspaceLeaf } from "obsidian"; +import * as React from "react"; +import { Root } from "react-dom/client"; + +/** + * Standalone pane for the Relevant Notes panel, isolated from the chat views. + * + * `RelevantNotes` reads the active file via `useActiveFile`, which seeds from + * the current active file on mount and then updates on the `ACTIVE_LEAF_CHANGE` + * event on this view's own `eventTarget`. The plugin's global handler dispatches + * that event only to the legacy chat view, so this view feeds its own + * `eventTarget` (mirroring that handler's condition) on subsequent leaf changes. + */ +export default class RelevantNotesView extends ItemView { + private root: Root | null = null; + eventTarget: EventTarget; + + constructor( + leaf: WorkspaceLeaf, + private plugin: CopilotPlugin + ) { + super(leaf); + this.app = plugin.app; + this.eventTarget = new EventTarget(); + } + + getViewType(): string { + return RELEVANT_NOTES_VIEWTYPE; + } + + getIcon(): string { + return "files"; + } + + getTitle(): string { + return "Copilot Relevant Notes"; + } + + getDisplayText(): string { + return "Copilot Relevant Notes"; + } + + async onOpen(): Promise { + this.root = createPluginRoot(this.containerEl.children[1], this.app); + this.renderView(); + + this.registerEvent( + this.app.workspace.on("active-leaf-change", (leaf) => { + if (leaf?.view instanceof MarkdownView && leaf.view.file) { + this.eventTarget.dispatchEvent(new CustomEvent(EVENT_NAMES.ACTIVE_LEAF_CHANGE)); + } + }) + ); + } + + private renderView(): void { + if (!this.root) return; + + this.root.render( + +
+ void this.plugin.insertTextIntoActiveChat(text)} /> +
+
+ ); + } + + async onClose(): Promise { + if (this.root) { + this.root.unmount(); + this.root = null; + } + } +} diff --git a/src/components/chat-components/ChatControls.tsx b/src/components/chat-components/ChatControls.tsx index cc47751e..55d21235 100644 --- a/src/components/chat-components/ChatControls.tsx +++ b/src/components/chat-components/ChatControls.tsx @@ -20,7 +20,6 @@ import { CheckCircle, ChevronDown, Download, - FileText, History, LibraryBig, MessageCirclePlus, @@ -395,19 +394,6 @@ export function ChatControls({ - { - e.preventDefault(); - updateSetting("showRelevantNotes", !settings.showRelevantNotes); - }} - > -
- - Relevant Note -
- -
{ diff --git a/src/components/chat-components/ChatMessages.tsx b/src/components/chat-components/ChatMessages.tsx index 7868b938..8fa7509d 100644 --- a/src/components/chat-components/ChatMessages.tsx +++ b/src/components/chat-components/ChatMessages.tsx @@ -1,6 +1,5 @@ import { BottomLoadingIndicator } from "@/components/chat-components/BottomLoadingIndicator"; import ChatSingleMessage from "@/components/chat-components/ChatSingleMessage"; -import { RelevantNotes } from "@/components/chat-components/RelevantNotes"; import { SuggestedPrompts } from "@/components/chat-components/SuggestedPrompts"; import { USER_SENDER } from "@/constants"; import { useChatScrolling } from "@/hooks/useChatScrolling"; @@ -48,9 +47,6 @@ const ChatMessages = memo( if (!chatHistory.filter((message) => message.isVisible).length && !currentAiMessage) { return (
- {showHelperComponents && settings.showRelevantNotes && ( - - )} {showHelperComponents && settings.showSuggestedPrompts && ( )} @@ -61,9 +57,6 @@ const ChatMessages = memo( return (
- {showHelperComponents && settings.showRelevantNotes && ( - - )}
{text}; +/** Map a 0–1 similarity score directly to the meter fill width (70% → 70%). */ +function meterWidth(score: number): string { + return `${Math.max(0, Math.min(100, score * 100))}%`; } -function RelevantNote({ +/** Color-grade the meter: stronger matches lean fully into the theme accent. */ +function meterColor(score: number): string { + const pct = score * 100; + const k = Math.max(0, Math.min(1, (pct - 30) / 45)); + return `color-mix(in srgb, var(--interactive-accent) ${Math.round(40 + 60 * k)}%, var(--text-faint))`; +} + +function RelevanceMeter({ score, className }: { score: number; className?: string }) { + return ( +
+
+
+ ); +} + +function LinkBadge({ icon, label }: { icon: React.ReactNode; label: string }) { + return ( + + {icon} + + ); +} + +function RelevantNoteHoverCard({ note, onAddToChat, onNavigateToNote, + children, }: { note: RelevantNoteEntry; onAddToChat: () => void; onNavigateToNote: (openInNewLeaf: boolean) => void; + children: React.ReactNode; }) { const app = useApp(); - const [isOpen, setIsOpen] = useState(false); + const [open, setOpen] = useState(false); const [fileContent, setFileContent] = useState(null); - const handleDragStart = useNoteDrag(); + const similarity = note.metadata.similarityScore; const loadContent = useCallback(async () => { - if (fileContent) return; // Don't load if we already have content + if (fileContent) return; // Don't reload once cached const file = app.vault.getAbstractFileByPath(note.note.path); if (file instanceof TFile) { const content = await app.vault.cachedRead(file); @@ -128,39 +162,119 @@ function RelevantNote({ } } - // Take first 1000 characters as preview - setFileContent(cleanContent.slice(0, 1000) + (cleanContent.length > 1000 ? "..." : "")); + setFileContent(cleanContent); } }, [app, fileContent, note.note.path]); useEffect(() => { - if (isOpen) { + if (open) { void loadContent(); } - }, [isOpen, loadContent]); + }, [open, loadContent]); return ( - -
- - -
- + + +
setOpen(true)} onMouseLeave={() => setOpen(false)}> + {children} +
+
+ setOpen(true)} + onMouseLeave={() => setOpen(false)} + onOpenAutoFocus={(e) => e.preventDefault()} + className="tw-flex tw-w-fit tw-min-w-72 tw-max-w-96 tw-flex-col tw-gap-3 tw-overflow-hidden tw-p-3" + > +
+ {note.note.title} + + + {note.note.path} +
-
+ {fileContent && ( +

+ {fileContent} +

+ )} + + {similarity != null && ( +
+ Similarity + + + {(similarity * 100).toFixed(1)}% + +
+ )} + + {(note.metadata.hasOutgoingLinks || note.metadata.hasBacklinks) && ( +
+ {note.metadata.hasOutgoingLinks && ( + + + Outgoing links + + )} + {note.metadata.hasBacklinks && ( + + + Backlinks + + )} +
+ )} + +
+ + +
+ + + ); +} + +function RelevantNoteRow({ + note, + onAddToChat, + onNavigateToNote, +}: { + note: RelevantNoteEntry; + onAddToChat: () => void; + onNavigateToNote: (openInNewLeaf: boolean) => void; +}) { + const app = useApp(); + const handleDragStart = useNoteDrag(); + const similarity = note.metadata.similarityScore; + + return ( + +
+ - - - - - Add to Chat - -
- - -
-
- {note.note.path} +
- {fileContent && ( -
- {fileContent} -
- )}
-
- {note.metadata.similarityScore != null && ( -
- Similarity: {(note.metadata.similarityScore * 100).toFixed(1)}% -
- )} - {note.metadata.hasOutgoingLinks && ( -
- - Outgoing links -
- )} - {note.metadata.hasBacklinks && ( -
- - Backlinks -
- )} -
-
- + {similarity != null && } +
+ ); } -function RelevantNotePopover({ - note, - onAddToChat, - onNavigateToNote, - children, +function RelevantNotesToolbar({ + activeFileName, + isBuilding, + onBuild, }: { - note: RelevantNoteEntry; - onAddToChat: () => void; - onNavigateToNote: (openInNewLeaf: boolean) => void; - children: React.ReactNode; + activeFileName: string | undefined; + isBuilding: boolean; + onBuild: () => void; }) { return ( - - {children} - - {note.note.title} - {note.note.path} -
- - -
-
-
+
+
+ Relevant to + {activeFileName ? ( + + + {activeFileName} + + ) : ( + + )} +
+ +
+ ); +} + +function BuildOverlay({ indexedCount, totalFiles }: { indexedCount: number; totalFiles: number }) { + const progress = totalFiles > 0 ? Math.round((indexedCount / totalFiles) * 100) : 0; + return ( +
+ + Indexing your vault + + {totalFiles > 0 && ( + + {indexedCount} / {totalFiles} notes embedded + + )} +
); } export const RelevantNotes = memo( - ({ className, defaultOpen = false }: { className?: string; defaultOpen?: boolean }) => { + ({ + className, + onAddToChat, + }: { + className?: string; + /** Insert text (a `[[wikilink]]`) into the target chat input. */ + onAddToChat: (text: string) => void; + }) => { 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 [indexingState] = useIndexingProgress(); + const settings = useSettingsValue(); + + // The active note itself is excluded from the index (by the QA + // inclusion/exclusion settings or an internal exclusion), so no relevant + // notes can ever be computed for it — surface that instead of a build + // prompt or a bare "none found". + const isActiveFileExcluded = useMemo(() => { + if (!activeFile) return false; + const { inclusions, exclusions } = getMatchingPatterns({ + inclusions: settings.qaInclusions, + exclusions: settings.qaExclusions, + }); + return !shouldIndexFile(app, activeFile, inclusions, exclusions); + }, [app, activeFile, settings.qaInclusions, settings.qaExclusions]); const navigateToNote = (notePath: string, openInNewLeaf = false) => { const file = app.vault.getAbstractFileByPath(notePath); if (file instanceof TFile) { @@ -293,15 +436,7 @@ export const RelevantNotes = memo( } }; 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); - } + onAddToChat(`[[${prompt}]]`); }; const handleBuildIndex = async () => { @@ -333,100 +468,91 @@ export const RelevantNotes = memo( }; return ( -
- -
-
- Relevant Notes - -
-
- {hasIndex ? ( - - - - - Reindex Current Note - - ) : ( - - )} - {relevantNotes.length > 0 && ( - - - - )} +
+ {isActiveFileExcluded && ( +
+
+
+ +
+
+ + This note is excluded + + + It falls outside your semantic index settings, so related notes can't be + shown here. Adjust inclusions or exclusions in Copilot settings to include it. + +
- {relevantNotes.length === 0 && hasIndex && ( -
- No relevant notes found -
- )} - {!isOpen && relevantNotes.length > 0 && ( -
- {relevantNotes.map((note) => ( - addToChat(note.note.title)} - onNavigateToNote={(openInNewLeaf: boolean) => - navigateToNote(note.note.path, openInNewLeaf) - } + )} + + {!isActiveFileExcluded && !hasIndex && ( +
+
+
+ +
+
+ + No semantic index yet + + + {"Build it once to surface notes related to whatever you're writing."} + +
+
+ +
- )} - -
- {relevantNotes.map((note) => ( - addToChat(note.note.title)} - onNavigateToNote={(openInNewLeaf: boolean) => - navigateToNote(note.note.path, openInNewLeaf) - } +
+ )} + + {!isActiveFileExcluded && hasIndex && ( + <> + void handleBuildIndex()} + /> +
+
+ {relevantNotes.length === 0 ? ( +
+ No relevant notes found +
+ ) : ( +
+ {relevantNotes.map((note) => ( + addToChat(note.note.title)} + onNavigateToNote={(openInNewLeaf: boolean) => + navigateToNote(note.note.path, openInNewLeaf) + } + /> + ))} +
+ )} +
+ {indexingState.isActive && ( + - ))} + )}
-
- + + )}
); } diff --git a/src/constants.ts b/src/constants.ts index 0713ae89..8ce1dcc5 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -8,6 +8,7 @@ export const BREVILABS_API_BASE_URL = "https://api.brevilabs.com/v1"; export const BREVILABS_MODELS_BASE_URL = "https://models.brevilabs.com/v1"; export const CHAT_VIEWTYPE = "copilot-chat-view"; export const CHAT_AGENT_VIEWTYPE = "copilot-agent-chat-view"; +export const RELEVANT_NOTES_VIEWTYPE = "copilot-relevant-notes-view"; // Custom Obsidian icon for Agent Mode surfaces (view tab, ribbon, commands). // The v4 monochrome brand mark, normalized from its source viewBox "4 4 152 127" @@ -805,6 +806,7 @@ export const COMMAND_IDS = { NEW_AGENT_CHAT: "new-agent-chat", OPEN_COPILOT_CHAT_WINDOW: "chat-open-window", OPEN_AGENT_CHAT_WINDOW: "agent-chat-open-window", + OPEN_RELEVANT_NOTES_VIEW: "open-relevant-notes-view", SEARCH_ORAMA_DB: "copilot-search-orama-db", TOGGLE_COPILOT_CHAT_WINDOW: "chat-toggle-window", TOGGLE_AGENT_CHAT_WINDOW: "agent-chat-toggle-window", @@ -836,6 +838,7 @@ export const COMMAND_NAMES: Record = { [COMMAND_IDS.NEW_AGENT_CHAT]: "New Copilot Agent Chat", [COMMAND_IDS.OPEN_COPILOT_CHAT_WINDOW]: "Open Copilot Chat Window", [COMMAND_IDS.OPEN_AGENT_CHAT_WINDOW]: "Open Copilot Agent Chat Window", + [COMMAND_IDS.OPEN_RELEVANT_NOTES_VIEW]: "Open Relevant Notes", [COMMAND_IDS.SEARCH_ORAMA_DB]: "Search semantic index (debug)", [COMMAND_IDS.TOGGLE_COPILOT_CHAT_WINDOW]: "Toggle Copilot Chat Window", [COMMAND_IDS.TOGGLE_AGENT_CHAT_WINDOW]: "Toggle Copilot Agent Chat Window", @@ -860,6 +863,7 @@ export const COMMAND_ICONS: Partial> = { [COMMAND_IDS.NEW_AGENT_CHAT]: COPILOT_AGENT_ICON_ID, [COMMAND_IDS.OPEN_COPILOT_CHAT_WINDOW]: "message-square", [COMMAND_IDS.OPEN_AGENT_CHAT_WINDOW]: COPILOT_AGENT_ICON_ID, + [COMMAND_IDS.OPEN_RELEVANT_NOTES_VIEW]: "files", [COMMAND_IDS.TOGGLE_COPILOT_CHAT_WINDOW]: "message-square", [COMMAND_IDS.TOGGLE_AGENT_CHAT_WINDOW]: COPILOT_AGENT_ICON_ID, [COMMAND_IDS.LOAD_COPILOT_CHAT_CONVERSATION]: "history", @@ -979,7 +983,6 @@ export const DEFAULT_SETTINGS: CopilotSettings = { embeddingBatchSize: 16, disableIndexOnMobile: true, showSuggestedPrompts: true, - showRelevantNotes: true, numPartitions: 1, lexicalSearchRamLimit: 100, // Default 100 MB promptUsageTimestamps: {}, @@ -1056,6 +1059,7 @@ export const EVENT_NAMES = { CHAT_IS_VISIBLE: "chat-is-visible", ACTIVE_LEAF_CHANGE: "active-leaf-change", ABORT_STREAM: "abort-stream", + INSERT_TEXT_TO_CHAT: "insert-text-to-chat", }; export enum ABORT_REASON { diff --git a/src/context.ts b/src/context.ts index 26dccbf5..104c5267 100644 --- a/src/context.ts +++ b/src/context.ts @@ -1,9 +1,34 @@ +import { EVENT_NAMES } from "@/constants"; import { App } from "obsidian"; import * as React from "react"; // App context export const AppContext = React.createContext(undefined); +/** + * Per-chat-view event bus. Beyond plain pub/sub it *latches* a queued + * "insert text" payload so a consumer that subscribes after the text was + * queued — e.g. a chat view still mounting when the Relevant Notes pane routes + * a wikilink to it — still receives it. This removes any dependence on a + * freshly-opened view's mount timing (previously papered over with a setTimeout). + */ +export class ChatViewEventTarget extends EventTarget { + private pendingInsertText: string | null = null; + + /** Queue text for the chat input and notify any already-attached listener. */ + queueInsertText(text: string): void { + this.pendingInsertText = text; + this.dispatchEvent(new CustomEvent(EVENT_NAMES.INSERT_TEXT_TO_CHAT, { detail: { text } })); + } + + /** Take and clear the latched text; returns null once it has been consumed. */ + consumePendingInsertText(): string | null { + const text = this.pendingInsertText; + this.pendingInsertText = null; + return text; + } +} + // Event target context export const EventTargetContext = React.createContext(undefined); diff --git a/src/context/ChatInputContext.tsx b/src/context/ChatInputContext.tsx index 24abc10f..d9dda240 100644 --- a/src/context/ChatInputContext.tsx +++ b/src/context/ChatInputContext.tsx @@ -1,4 +1,12 @@ -import React, { createContext, useContext, useCallback, useMemo, useState } from "react"; +import React, { + createContext, + useContext, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { INSERT_TEXT_WITH_PILLS_COMMAND } from "@/components/chat-components/utils/lexicalTextUtils"; import { LexicalEditor } from "lexical"; @@ -32,6 +40,10 @@ interface ChatInputProviderProps { export function ChatInputProvider({ children }: ChatInputProviderProps): JSX.Element { const [editor, setEditor] = useState(null); const [focusHandler, setFocusHandler] = useState<(() => void) | null>(null); + // Text requested before the Lexical editor has mounted (e.g. routed in while + // the chat view is still opening). Held here and flushed once the editor + // registers, so insertion never depends on mount timing. + const pendingInsertRef = useRef<{ text: string; enableURLPills: boolean } | null>(null); const registerEditor = useCallback((editorInstance: LexicalEditor) => { setEditor(editorInstance); @@ -48,11 +60,24 @@ export function ChatInputProvider({ children }: ChatInputProviderProps): JSX.Ele text, options: { enableURLPills, insertAtSelection: true }, }); + } else { + pendingInsertRef.current = { text, enableURLPills }; } }, [editor] ); + // Flush any text buffered before the editor was ready. + useEffect(() => { + if (!editor || !pendingInsertRef.current) return; + const { text, enableURLPills } = pendingInsertRef.current; + pendingInsertRef.current = null; + editor.dispatchCommand(INSERT_TEXT_WITH_PILLS_COMMAND, { + text, + options: { enableURLPills, insertAtSelection: true }, + }); + }, [editor]); + const focusInput = useCallback(() => { if (focusHandler) { focusHandler(); diff --git a/src/hooks/useActiveFile.ts b/src/hooks/useActiveFile.ts index 8e2e8d3f..ce194ba1 100644 --- a/src/hooks/useActiveFile.ts +++ b/src/hooks/useActiveFile.ts @@ -5,7 +5,10 @@ import { useContext, useEffect, useState } from "react"; export function useActiveFile() { const app = useApp(); - const [activeFile, setActiveFile] = useState(null); + // Seed from the current active file so a freshly-mounted consumer (e.g. the + // Relevant Notes pane) renders for the open note immediately, rather than + // waiting on an ACTIVE_LEAF_CHANGE event that only fires on later switches. + const [activeFile, setActiveFile] = useState(() => app.workspace.getActiveFile()); const eventTarget = useContext(EventTargetContext); useEffect(() => { diff --git a/src/main.ts b/src/main.ts index e5f74e99..aa91ea27 100644 --- a/src/main.ts +++ b/src/main.ts @@ -26,6 +26,7 @@ import { import { NoteSelectedTextContext, SelectedTextContext } from "@/types/message"; import { registerCommands } from "@/commands"; import CopilotView from "@/components/CopilotView"; +import RelevantNotesView from "@/components/RelevantNotesView"; import { APPLY_VIEW_TYPE, ApplyView } from "@/components/composer/ApplyView"; import { LoadChatHistoryModal } from "@/components/modals/LoadChatHistoryModal"; @@ -43,6 +44,7 @@ import { COPILOT_AGENT_ICON_SVG, DEFAULT_OPEN_AREA, EVENT_NAMES, + RELEVANT_NOTES_VIEWTYPE, } from "@/constants"; import { ChatManager } from "@/core/ChatManager"; import { MessageRepository } from "@/core/MessageRepository"; @@ -306,6 +308,10 @@ export default class CopilotPlugin extends Plugin { this.safeRegisterView(CHAT_VIEWTYPE, (leaf: WorkspaceLeaf) => new CopilotView(leaf, this)); this.safeRegisterView(APPLY_VIEW_TYPE, (leaf: WorkspaceLeaf) => new ApplyView(leaf)); + this.safeRegisterView( + RELEVANT_NOTES_VIEWTYPE, + (leaf: WorkspaceLeaf) => new RelevantNotesView(leaf, this) + ); if (!Platform.isMobile) { this.safeRegisterView( CHAT_AGENT_VIEWTYPE, @@ -790,13 +796,12 @@ export default class CopilotPlugin extends Plugin { } /** - * The "add … to chat context" commands write into a shared atom that both chat - * views render, so this only picks which chat to bring into focus: - * - both chat views open → the one focused most recently + * Which chat view "add … to chat" actions should target: + * - both chat views open → the one focused most recently (`lastActiveChatViewType`) * - exactly one open → that one - * - none open → the agent chat when available, else the legacy chat + * - none open → the agent chat when usable, else the legacy chat */ - async activateChatViewForContext(): Promise { + private pickContextChatViewType(): typeof CHAT_VIEWTYPE | typeof CHAT_AGENT_VIEWTYPE { const agentUsable = this.canUseAgentView(); const agentOpen = agentUsable && this.app.workspace.getLeavesOfType(CHAT_AGENT_VIEWTYPE).length > 0; @@ -810,8 +815,16 @@ export default class CopilotPlugin extends Plugin { } else { useAgent = agentUsable; } + return useAgent ? CHAT_AGENT_VIEWTYPE : CHAT_VIEWTYPE; + } - if (useAgent) { + /** + * The "add … to chat context" commands write into a shared atom that both chat + * views render, so this only picks which chat to bring into focus (see + * `pickContextChatViewType`). + */ + async activateChatViewForContext(): Promise { + if (this.pickContextChatViewType() === CHAT_AGENT_VIEWTYPE) { await this.activateAgentView(); } else { await this.activateView(); @@ -841,6 +854,38 @@ export default class CopilotPlugin extends Plugin { this.app.workspace.detachLeavesOfType(CHAT_AGENT_VIEWTYPE); } + async activateRelevantNotesView(): Promise { + return this.openOrRevealView(RELEVANT_NOTES_VIEWTYPE); + } + + /** + * Insert text (a `[[wikilink]]` from the Relevant Notes pane) into the chat view + * the user last focused (see `pickContextChatViewType`), opening that view if none + * is open. Routes via the target view's `eventTarget`, the same seam + * `processText`/`emitChatIsVisible` use, so the standalone pane never needs the + * chat's Lexical editor directly. + */ + async insertTextIntoActiveChat(text: string): Promise { + const viewType = this.pickContextChatViewType(); + let leaf = this.app.workspace.getLeavesOfType(viewType)[0] ?? null; + if (!leaf) { + if (viewType === CHAT_AGENT_VIEWTYPE) { + await this.activateAgentView(); + } else { + await this.activateView(); + } + leaf = this.app.workspace.getLeavesOfType(viewType)[0] ?? null; + } + if (!leaf) return; + + this.app.workspace.revealLeaf(leaf); + const view = leaf.view as CopilotView | CopilotAgentView; + // The bus latches the text if the view's React tree hasn't mounted its + // listener yet, so a freshly-opened view drains it on mount — delivery no + // longer depends on guessing how long mounting takes. + view.eventTarget.queueInsertText(text); + } + async newAgentChat(): Promise { const manager = this.requireAgentView(); if (!manager) return; diff --git a/src/search/findRelevantNotes.test.ts b/src/search/findRelevantNotes.test.ts index 6aba5a10..a88b47f2 100644 --- a/src/search/findRelevantNotes.test.ts +++ b/src/search/findRelevantNotes.test.ts @@ -190,6 +190,26 @@ describe("findRelevantNotes", () => { expect(mockSearchRelated).not.toHaveBeenCalled(); }); + it("ranks by similarity only — a backlink does not boost a lower-similarity note above a higher one", async () => { + mockGetDocumentsByPath.mockResolvedValue([ + { id: "chunk-1", path: "source.md", content: "chunk one", embedding: [0.1, 0.2] }, + ]); + mockGetDb.mockResolvedValue({ db: "orama" }); + // alpha (0.6) has no links; beta (0.58) is backlinked. The old merged-score + // ranking boosted beta above alpha despite its lower similarity. + mockGetDocsByEmbedding.mockResolvedValueOnce([ + { score: 0.6, document: { path: "alpha.md" } }, + { score: 0.58, document: { path: "beta.md" } }, + ]); + mockedGetBacklinkedNotes.mockReturnValue([createMarkdownFile("beta.md")]); + + const result = await findRelevantNotes({ app: window.app, filePath: "source.md" }); + + expect(result.map((entry) => entry.note.path)).toEqual(["alpha.md", "beta.md"]); + expect(result[0].metadata.similarityScore).toBe(0.6); + expect(result.find((entry) => entry.note.path === "beta.md")?.metadata.hasBacklinks).toBe(true); + }); + it("uses Miyo when shouldUseMiyoForRelevantNotes is true (enableMiyo=true and valid self-host)", async () => { mockedShouldUseMiyo.mockReturnValue(true); mockedGetSettings.mockReturnValue({ diff --git a/src/search/findRelevantNotes.ts b/src/search/findRelevantNotes.ts index ead81a3e..34d69c1a 100644 --- a/src/search/findRelevantNotes.ts +++ b/src/search/findRelevantNotes.ts @@ -16,8 +16,6 @@ import { InternalTypedDocument, Orama, Result } from "@orama/orama"; import { App, TFile } from "obsidian"; const MAX_K = 20; -const ORIGINAL_WEIGHT = 0.7; -const LINKS_WEIGHT = 0.3; /** * Determine whether Miyo-backed relevant-note scoring should be used. @@ -252,38 +250,6 @@ function getNoteLinks(app: App, file: TFile) { return resultMap; } -/** - * Merge semantic similarity scores with note link heuristics. - * - * @param similarityScoreMap - Semantic score map. - * @param noteLinks - Outgoing/backlink flags map. - * @returns Combined score map used for ranking. - */ -function mergeScoreMaps( - similarityScoreMap: Map, - noteLinks: Map -) { - const mergedMap = new Map(); - const totalWeight = ORIGINAL_WEIGHT + LINKS_WEIGHT; - for (const [key, value] of similarityScoreMap) { - mergedMap.set(key, (value * ORIGINAL_WEIGHT) / totalWeight); - } - for (const [key, value] of noteLinks) { - let score = 0; - if (value.links && value.backlinks) { - score = LINKS_WEIGHT; - } else if (value.links) { - // If the note only has outgoing or incoming links, give it a 80% links - // weight. - score = LINKS_WEIGHT * 0.8; - } else if (value.backlinks) { - score = LINKS_WEIGHT * 0.8; - } - mergedMap.set(key, (mergedMap.get(key) ?? 0) + score); - } - return mergedMap; -} - export type RelevantNoteEntry = { note: { path: string; @@ -319,33 +285,36 @@ export async function findRelevantNotes({ const similarityScoreMap = await calculateSimilarityScore(app, filePath); const noteLinks = getNoteLinks(app, file); - const mergedScoreMap = mergeScoreMaps(similarityScoreMap, noteLinks); - const sortedHits = Array.from(mergedScoreMap.entries()).sort((a, b) => { - const aPath = a[0]; - const bPath = b[0]; - const aCategory = getSimilarityCategory(similarityScoreMap.get(aPath) ?? 0); - const bCategory = getSimilarityCategory(similarityScoreMap.get(bPath) ?? 0); - if (aCategory !== bCategory) { - return bCategory - aCategory; - } - - return b[1] - a[1]; + // Rank purely by semantic similarity so the displayed percentages stay + // monotonic down the list. Linked/backlinked notes still appear, but a link + // never boosts ranking: link-only notes have no similarity score and sort to + // the bottom (they render without a meter in the UI). + const candidatePaths = new Set([...similarityScoreMap.keys(), ...noteLinks.keys()]); + candidatePaths.delete(filePath); + const sortedPaths = Array.from(candidatePaths).sort((aPath, bPath) => { + const aScore = similarityScoreMap.get(aPath); + const bScore = similarityScoreMap.get(bPath); + if (aScore == null && bScore == null) return 0; + if (aScore == null) return 1; + if (bScore == null) return -1; + return bScore - aScore; }); - return sortedHits - .map(([path, score]) => { + return sortedPaths + .map((path) => { const file = app.vault.getAbstractFileByPath(path); if (!(file instanceof TFile) || file.extension !== "md") { return null; } + const similarityScore = similarityScoreMap.get(path); return { note: { path, title: file.basename, }, metadata: { - score, - similarityScore: similarityScoreMap.get(path), + score: similarityScore ?? 0, + similarityScore, hasOutgoingLinks: noteLinks.get(path)?.links ?? false, hasBacklinks: noteLinks.get(path)?.backlinks ?? false, }, @@ -353,14 +322,3 @@ export async function findRelevantNotes({ }) .filter((entry) => entry !== null); } - -/** - * Gets the similarity category for the given score. - * @param score - The score to get the similarity category for. - * @returns The similarity category. 1 is low, 2 is medium, 3 is high. - */ -export function getSimilarityCategory(score: number): number { - if (score > 0.7) return 3; - if (score > 0.55) return 2; - return 1; -} diff --git a/src/settings/model.ts b/src/settings/model.ts index 746305ff..c34ac550 100644 --- a/src/settings/model.ts +++ b/src/settings/model.ts @@ -121,7 +121,6 @@ export interface CopilotSettings { defaultSendShortcut: SEND_SHORTCUT; disableIndexOnMobile: boolean; showSuggestedPrompts: boolean; - showRelevantNotes: boolean; numPartitions: number; defaultConversationNoteName: string; // undefined means never checked diff --git a/src/settings/v2/components/BasicSettings.tsx b/src/settings/v2/components/BasicSettings.tsx index 0741985e..96ac8f53 100644 --- a/src/settings/v2/components/BasicSettings.tsx +++ b/src/settings/v2/components/BasicSettings.tsx @@ -266,14 +266,6 @@ export const BasicSettings: React.FC = () => { checked={settings.showSuggestedPrompts} onCheckedChange={(checked) => updateSetting("showSuggestedPrompts", checked)} /> - - updateSetting("showRelevantNotes", checked)} - />
diff --git a/src/styles/tailwind.css b/src/styles/tailwind.css index 52fcccb0..f27f5abd 100644 --- a/src/styles/tailwind.css +++ b/src/styles/tailwind.css @@ -30,6 +30,13 @@ If your plugin does not need CSS, delete this file. border-top: 1px solid var(--background-modifier-border); } +/* Relevance meter fill. Width and color are score-driven, so they arrive as + per-element CSS variables rather than static utilities. */ +.copilot-relevance-meter-fill { + width: var(--relevance-meter-fill, 0%); + background: var(--relevance-meter-color, var(--text-faint)); +} + .button-container { display: flex; justify-content: space-between;