From d8dfaa6c6ba363667bc21c2c2e7939dedf53e0fe Mon Sep 17 00:00:00 2001 From: Zero Liu Date: Mon, 20 Jul 2026 21:25:59 -0400 Subject: [PATCH] feat(agent-home): surface Relevant Notes as a shelf tab (#2668) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Revert "Revert "feat(agent-home): surface Relevant Notes as a home-shelf tab …" This reverts commit 982a2462f4b043eeaa23bcda1d26c767caccfee4. * fix(agent-home): size relevant notes shelf correctly * fix(agent-home): address relevant notes review feedback * test(relevant-notes): cover shelf navigation leaf selection * fix(relevant-notes): always open notes in new tabs --- docs/chat-interface.md | 2 +- jest.config.js | 4 + src/agentMode/ui/AgentHome.tsx | 43 ++++++- src/agentMode/ui/AgentHomeShelf.test.tsx | 105 ++++++++++------ src/agentMode/ui/AgentHomeShelf.tsx | 3 +- src/agentMode/ui/AgentHomeTab.tsx | 4 +- src/agentMode/ui/CopilotAgentView.tsx | 3 + .../ui/RelevantNotesShelfPanel.test.tsx | 34 ++++++ src/agentMode/ui/RelevantNotesShelfPanel.tsx | 69 +++++++++++ src/agentMode/ui/homeShelfPrefs.test.ts | 94 +++++++++++++++ src/agentMode/ui/homeShelfPrefs.ts | 53 ++++++++ .../ui/useRelevantNotesPaneOpen.test.tsx | 38 ++++++ src/agentMode/ui/useRelevantNotesPaneOpen.ts | 19 +++ src/components/CopilotView.tsx | 3 + src/components/RelevantNotesView.tsx | 19 ++- .../chat-components/RelevantNotes.test.tsx | 114 ++++++++++++++++++ .../chat-components/RelevantNotes.tsx | 46 +++---- src/main.ts | 16 --- .../registerActiveLeafChangeBridge.test.ts | 46 +++++++ src/utils/registerActiveLeafChangeBridge.ts | 23 ++++ 20 files changed, 640 insertions(+), 98 deletions(-) create mode 100644 src/agentMode/ui/RelevantNotesShelfPanel.test.tsx create mode 100644 src/agentMode/ui/RelevantNotesShelfPanel.tsx create mode 100644 src/agentMode/ui/homeShelfPrefs.test.ts create mode 100644 src/agentMode/ui/homeShelfPrefs.ts create mode 100644 src/agentMode/ui/useRelevantNotesPaneOpen.test.tsx create mode 100644 src/agentMode/ui/useRelevantNotesPaneOpen.ts create mode 100644 src/components/chat-components/RelevantNotes.test.tsx create mode 100644 src/utils/registerActiveLeafChangeBridge.test.ts create mode 100644 src/utils/registerActiveLeafChangeBridge.ts diff --git a/docs/chat-interface.md b/docs/chat-interface.md index 0839185b..470b5a7c 100644 --- a/docs/chat-interface.md +++ b/docs/chat-interface.md @@ -141,7 +141,7 @@ When starting a new chat, Copilot may show suggested prompts based on your activ 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**. -Relevant Notes lives in its own pane. Open it from the command palette: **Open Relevant Notes**. The pane tracks whichever note you're viewing. +On Agent Home, Relevant Notes appears as a shelf tab when the dedicated pane is closed. Notes opened from this shelf use a separate tab so Agent Home and its chat stay available. You can also select **Open pane** in the shelf or run **Open Relevant Notes** from the command palette to keep Relevant Notes beside your conversation. While that pane is open, the duplicate Agent Home tab is hidden. Both surfaces track whichever note you're viewing. ## Saving a Chat Manually diff --git a/jest.config.js b/jest.config.js index fa7b91d1..d634ac13 100644 --- a/jest.config.js +++ b/jest.config.js @@ -13,6 +13,10 @@ module.exports = { // jsdom; Jest can't parse ESM without extra config, so point at the CJS // build it ships under dist/. "^yaml$": "/node_modules/yaml/dist/index.js", + // @orama/orama resolves to its ESM "browser" entry under jsdom, which Jest + // can't parse; point at the CJS build it ships under dist/commonjs/ (same + // reason as yaml above). + "^@orama/orama$": "/node_modules/@orama/orama/dist/commonjs/index.js", "^@agentclientprotocol/sdk$": "/__mocks__/@agentclientprotocol/sdk.js", "^@anthropic-ai/claude-agent-sdk$": "/__mocks__/@anthropic-ai/claude-agent-sdk.js", // react-resizable-panels is ESM-only with no CJS build to point at; stub it. diff --git a/src/agentMode/ui/AgentHome.tsx b/src/agentMode/ui/AgentHome.tsx index 8d8cfbc4..8e085f3d 100644 --- a/src/agentMode/ui/AgentHome.tsx +++ b/src/agentMode/ui/AgentHome.tsx @@ -14,7 +14,14 @@ import { AgentWelcomeCard } from "@/agentMode/ui/AgentWelcomeCard"; import { CopilotBrandIcon } from "@/agentMode/ui/CopilotBrandIcon"; import { AgentHomeShelf, type AgentHomeShelfSection } from "@/agentMode/ui/AgentHomeShelf"; import { GlobalRecentChatsSection } from "@/agentMode/ui/GlobalRecentChatsSection"; +import { + getHomeShelfTab, + setHomeShelfTab, + HOME_SHELF_TAB_STORAGE_KEY, +} from "@/agentMode/ui/homeShelfPrefs"; import { ProjectPickerList } from "@/agentMode/ui/ProjectPickerList"; +import { RelevantNotesShelfPanel } from "@/agentMode/ui/RelevantNotesShelfPanel"; +import { useRelevantNotesPaneOpen } from "@/agentMode/ui/useRelevantNotesPaneOpen"; import { useAgentChatRuntimeState } from "@/agentMode/ui/hooks/useAgentChatRuntimeState"; import { useAgentHistoryControls } from "@/agentMode/ui/hooks/useAgentHistoryControls"; import { useAgentInputDrafts } from "@/agentMode/ui/hooks/useAgentInputDrafts"; @@ -45,7 +52,7 @@ import { getProjectLandingCaptureSignature } from "@/projects/projectContextSign import { getCachedProjectRecordById, useProjects } from "@/projects/state"; import { getSettings, settingsStore, updateSetting, useSettingsValue } from "@/settings/model"; import { useAtomValue } from "jotai"; -import { Files, Folder, MessageSquare } from "lucide-react"; +import { FileSearch, Files, Folder, MessageSquare } from "lucide-react"; import { Notice } from "obsidian"; import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; @@ -86,6 +93,7 @@ const AgentHomeInternal: React.FC = ({ const settings = useSettingsValue(); const eventTarget = useContext(EventTargetContext); const chatInput = useChatInput(); + const isRelevantNotesPaneOpen = useRelevantNotesPaneOpen(app); // Place the caret in the composer when the agent view opens so the user can // type immediately. AgentHome only mounts once preload settles and a session @@ -508,9 +516,17 @@ const AgentHomeInternal: React.FC = ({ // instance-level UI memory: wherever the user left the global shelf, they // return to it (entering a project from the Projects tab and backing out // lands on Projects again instead of snapping to Recent Chats). AgentHome - // stays mounted across those switches, so the state survives. null = nothing - // picked yet → the shelf resolves to its first selectable tab. - const [globalShelfTab, setGlobalShelfTab] = useState(null); + // stays mounted across those switches, so the state survives. On top of that + // in-instance memory we seed from (and write back to) device-local storage so + // the choice also survives a full remount / reload. null = nothing picked yet + // → the shelf resolves to its first selectable tab. + const [globalShelfTab, setGlobalShelfTabState] = useState(() => + getHomeShelfTab(HOME_SHELF_TAB_STORAGE_KEY) + ); + const setGlobalShelfTab = useCallback((id: string) => { + setGlobalShelfTabState(id); + setHomeShelfTab(HOME_SHELF_TAB_STORAGE_KEY, id); + }, []); // Chip-shelf sections for the landing. Each body renders lazily (only the open // section is mounted), so these render closures are cheap to recreate. @@ -534,6 +550,23 @@ const AgentHomeInternal: React.FC = ({ /> ), }, + // Hidden while the dedicated Relevant Notes pane is open, so the shelf + // doesn't duplicate a surface the user already has pinned alongside chat. + ...(isRelevantNotesPaneOpen + ? [] + : [ + { + id: "relevant-notes", + icon: , + title: "Relevant Notes", + renderBody: () => ( + void plugin.activateRelevantNotesView()} + onAddToChat={(text) => void plugin.insertTextIntoActiveChat(text)} + /> + ), + }, + ]), { id: "projects", icon: , @@ -566,6 +599,8 @@ const AgentHomeInternal: React.FC = ({ handleLoadChatHistory, runningChatIds, attentionChatIds, + isRelevantNotesPaneOpen, + plugin, ] ); diff --git a/src/agentMode/ui/AgentHomeShelf.test.tsx b/src/agentMode/ui/AgentHomeShelf.test.tsx index 502fe4ec..e0866493 100644 --- a/src/agentMode/ui/AgentHomeShelf.test.tsx +++ b/src/agentMode/ui/AgentHomeShelf.test.tsx @@ -46,48 +46,73 @@ const projectsDisabled: AgentHomeShelfSection = { renderBody: () =>
PROJECTS BODY
, }; -describe("AgentHomeShelf with a disabled section", () => { - it("activates the first selectable section, not the disabled one", () => { - renderShelf([chats, projectsDisabled]); - expect(screen.queryByText("CHATS BODY")).not.toBeNull(); - // The disabled section's body never mounts. - expect(screen.queryByText("PROJECTS BODY")).toBeNull(); - }); +describe("AgentHomeShelf", () => { + describe("AgentHomeShelf()", () => { + it("activates the first selectable section when another section is disabled", () => { + renderShelf([chats, projectsDisabled]); + expect(screen.queryByText("CHATS BODY")).not.toBeNull(); + expect(screen.queryByText("PROJECTS BODY")).toBeNull(); + }); - it("marks the disabled tab aria-disabled and hides its count", () => { - renderShelf([chats, projectsDisabled]); - const projectsTab = screen.getByRole("tab", { name: /Projects/ }); - expect(projectsTab.getAttribute("aria-disabled")).toBe("true"); - expect(projectsTab.textContent ?? "").not.toContain("5"); - }); + it("marks a disabled tab aria-disabled and hides its count", () => { + renderShelf([chats, projectsDisabled]); + const projectsTab = screen.getByRole("tab", { name: /Projects/ }); + expect(projectsTab.getAttribute("aria-disabled")).toBe("true"); + expect(projectsTab.textContent ?? "").not.toContain("5"); + }); - it("does not activate the disabled tab on click", () => { - renderShelf([chats, projectsDisabled]); - fireEvent.click(screen.getByRole("tab", { name: /Projects/ })); - expect(screen.queryByText("PROJECTS BODY")).toBeNull(); - expect(screen.queryByText("CHATS BODY")).not.toBeNull(); + it("keeps the current section active when a disabled tab is clicked", () => { + renderShelf([chats, projectsDisabled]); + fireEvent.click(screen.getByRole("tab", { name: /Projects/ })); + expect(screen.queryByText("PROJECTS BODY")).toBeNull(); + expect(screen.queryByText("CHATS BODY")).not.toBeNull(); + }); + + it("renders the parent-selected section in controlled mode", () => { + renderShelf([chats, projectsEnabled], { activeSectionId: "projects" }); + expect(screen.queryByText("PROJECTS BODY")).not.toBeNull(); + expect(screen.queryByText("CHATS BODY")).toBeNull(); + }); + + it("falls back to the first selectable section when controlled mode has no selection", () => { + renderShelf([chats, projectsEnabled], { activeSectionId: null }); + expect(screen.queryByText("CHATS BODY")).not.toBeNull(); + }); + + it("reports controlled clicks without switching sections until the parent updates", () => { + const onSectionSelect = jest.fn(); + renderShelf([chats, projectsEnabled], { activeSectionId: "chats", onSectionSelect }); + fireEvent.click(screen.getByRole("tab", { name: /Projects/ })); + expect(onSectionSelect).toHaveBeenCalledWith("projects"); + expect(screen.queryByText("CHATS BODY")).not.toBeNull(); + expect(screen.queryByText("PROJECTS BODY")).toBeNull(); + }); + + it("hides the count badge when the count is undefined", () => { + renderShelf([withCount(undefined)]); + expect(screen.getByRole("tab", { name: /Recent Chats/ }).textContent ?? "").not.toMatch(/\d/); + }); + + it("hides the count badge when the count is zero", () => { + renderShelf([withCount(0)]); + expect(screen.getByRole("tab", { name: /Recent Chats/ }).textContent ?? "").not.toContain( + "0" + ); + }); + + it("shows the count badge when the count is positive", () => { + renderShelf([withCount(3)]); + expect(screen.getByRole("tab", { name: /Recent Chats/ }).textContent ?? "").toContain("3"); + }); }); }); -describe("AgentHomeShelf controlled mode", () => { - it("renders the parent-selected section's body", () => { - renderShelf([chats, projectsEnabled], { activeSectionId: "projects" }); - expect(screen.queryByText("PROJECTS BODY")).not.toBeNull(); - expect(screen.queryByText("CHATS BODY")).toBeNull(); - }); - - it("falls back to the first selectable section when nothing is picked yet (null)", () => { - renderShelf([chats, projectsEnabled], { activeSectionId: null }); - expect(screen.queryByText("CHATS BODY")).not.toBeNull(); - }); - - it("reports clicks via onSectionSelect instead of switching on its own", () => { - const onSectionSelect = jest.fn(); - renderShelf([chats, projectsEnabled], { activeSectionId: "chats", onSectionSelect }); - fireEvent.click(screen.getByRole("tab", { name: /Projects/ })); - expect(onSectionSelect).toHaveBeenCalledWith("projects"); - // Controlled: the body only changes when the parent updates the prop. - expect(screen.queryByText("CHATS BODY")).not.toBeNull(); - expect(screen.queryByText("PROJECTS BODY")).toBeNull(); - }); -}); +function withCount(count?: number): AgentHomeShelfSection { + return { + id: "chats", + icon: , + title: "Recent Chats", + count, + renderBody: () =>
CHATS BODY
, + }; +} diff --git a/src/agentMode/ui/AgentHomeShelf.tsx b/src/agentMode/ui/AgentHomeShelf.tsx index 9032c367..c092096a 100644 --- a/src/agentMode/ui/AgentHomeShelf.tsx +++ b/src/agentMode/ui/AgentHomeShelf.tsx @@ -18,7 +18,8 @@ export interface AgentHomeShelfSection { /** Leading type icon for the tab. */ icon: React.ReactNode; title: string; - count: number; + /** Optional tally shown next to the title; the badge renders only when > 0. */ + count?: number; /** Rendered into the panel while this tab is the selected one. */ renderBody: () => React.ReactNode; /** diff --git a/src/agentMode/ui/AgentHomeTab.tsx b/src/agentMode/ui/AgentHomeTab.tsx index ae8edb5a..83f92ed7 100644 --- a/src/agentMode/ui/AgentHomeTab.tsx +++ b/src/agentMode/ui/AgentHomeTab.tsx @@ -9,7 +9,7 @@ interface AgentHomeTabProps { /** Leading type icon (sized by the caller, typically `tw-size-4`). */ icon: React.ReactNode; title: string; - count: number; + count?: number; /** Whether this tab is the selected one. */ active: boolean; onClick: () => void; @@ -68,7 +68,7 @@ export const AgentHomeTab = memo(function AgentHomeTab({ > {icon} {title} - {!disabled && ( + {!disabled && typeof count === "number" && count > 0 && ( {count} diff --git a/src/agentMode/ui/CopilotAgentView.tsx b/src/agentMode/ui/CopilotAgentView.tsx index 40acb292..0aa12825 100644 --- a/src/agentMode/ui/CopilotAgentView.tsx +++ b/src/agentMode/ui/CopilotAgentView.tsx @@ -3,6 +3,7 @@ import { attachChatViewLayoutObservers } from "@/components/chat-components/atta import { CHAT_AGENT_VIEWTYPE, COPILOT_AGENT_ICON_ID } from "@/constants"; import { ChatViewEventTarget, EventTargetContext } from "@/context"; import CopilotPlugin from "@/main"; +import { registerActiveLeafChangeBridge } from "@/utils/registerActiveLeafChangeBridge"; import { mountPluginViewRoot, type PluginViewRootHandle } from "@/utils/react/mountPluginViewRoot"; import * as Tooltip from "@radix-ui/react-tooltip"; import { ItemView, WorkspaceLeaf } from "obsidian"; @@ -46,6 +47,8 @@ export default class CopilotAgentView extends ItemView { const observers = attachChatViewLayoutObservers(this.containerEl); this.disposeLayoutObservers = observers.dispose; + registerActiveLeafChangeBridge(this, this.eventTarget); + this.registerEvent( this.app.workspace.on("layout-change", () => { window.requestAnimationFrame(() => observers.rebindDrawerObserver()); diff --git a/src/agentMode/ui/RelevantNotesShelfPanel.test.tsx b/src/agentMode/ui/RelevantNotesShelfPanel.test.tsx new file mode 100644 index 00000000..2c6bb67f --- /dev/null +++ b/src/agentMode/ui/RelevantNotesShelfPanel.test.tsx @@ -0,0 +1,34 @@ +import { RelevantNotesShelfPanel } from "@/agentMode/ui/RelevantNotesShelfPanel"; +import { render, screen } from "@testing-library/react"; +import React from "react"; + +jest.mock("@/agentMode/ui/homeShelfPrefs", () => ({ + dismissPopOutHint: jest.fn(), + isPopOutHintDismissed: () => true, +})); + +jest.mock("@/components/chat-components/RelevantNotes", () => ({ + RelevantNotes: ({ className }: { className?: string }) => ( +
+ ), +})); + +describe("RelevantNotesShelfPanel", () => { + describe("RelevantNotesShelfPanel()", () => { + it("fills the Agent Home shelf and reserves vertical spacing for empty states", () => { + const { container } = render( + + ); + + const panel = container.firstElementChild as HTMLElement; + const relevantNotes = screen.getByTestId("relevant-notes"); + const content = relevantNotes.parentElement as HTMLElement; + + expect(panel.classList.contains("tw-flex-1")).toBe(true); + expect(panel.classList.contains("tw-min-h-0")).toBe(true); + expect(content.classList.contains("tw-flex")).toBe(true); + expect(content.classList.contains("tw-flex-col")).toBe(true); + expect(relevantNotes.className).toContain("[&>[data-relevant-notes-empty-state]]:tw-py-6"); + }); + }); +}); diff --git a/src/agentMode/ui/RelevantNotesShelfPanel.tsx b/src/agentMode/ui/RelevantNotesShelfPanel.tsx new file mode 100644 index 00000000..3791cc5b --- /dev/null +++ b/src/agentMode/ui/RelevantNotesShelfPanel.tsx @@ -0,0 +1,69 @@ +import { dismissPopOutHint, isPopOutHintDismissed } from "@/agentMode/ui/homeShelfPrefs"; +import { RelevantNotes } from "@/components/chat-components/RelevantNotes"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { ExternalLink, X } from "lucide-react"; +import React, { useState } from "react"; + +interface RelevantNotesShelfPanelProps { + /** Open the dedicated Relevant Notes pane (the shelf is transient). */ + onPopOut: () => void; + /** Insert text (a `[[wikilink]]`) into the target chat input. */ + onAddToChat: (text: string) => void; +} + +/** + * Relevant Notes rendered inside the Agent Home shelf. The shelf is transient + * (it collapses as you chat), so a one-time, dismissible hint nudges the user + * toward the dedicated pane that persists alongside the conversation. Assumes + * the surrounding agent view already provides AppContext + EventTargetContext. + */ +export function RelevantNotesShelfPanel({ onPopOut, onAddToChat }: RelevantNotesShelfPanelProps) { + const [hintDismissed, setHintDismissed] = useState(() => isPopOutHintDismissed()); + + const handleDismiss = () => { + dismissPopOutHint(); + setHintDismissed(true); + }; + + return ( +
+ {!hintDismissed && ( +
+ + Open Relevant Notes in its own pane to keep it while you chat. + + + +
+ )} +
+ [data-relevant-notes-empty-state]]:tw-py-6")} + onAddToChat={onAddToChat} + /> +
+
+ ); +} diff --git a/src/agentMode/ui/homeShelfPrefs.test.ts b/src/agentMode/ui/homeShelfPrefs.test.ts new file mode 100644 index 00000000..ebc8b33f --- /dev/null +++ b/src/agentMode/ui/homeShelfPrefs.test.ts @@ -0,0 +1,94 @@ +import { + dismissPopOutHint, + getHomeShelfTab, + isPopOutHintDismissed, + setHomeShelfTab, +} from "@/agentMode/ui/homeShelfPrefs"; +import { logWarn } from "@/logger"; + +jest.mock("@/logger", () => ({ + logWarn: jest.fn(), +})); + +const mockLogWarn = logWarn as jest.MockedFunction; + +describe("homeShelfPrefs", () => { + afterEach(() => { + jest.restoreAllMocks(); + window.localStorage.clear(); + }); + + describe("getHomeShelfTab()", () => { + it("returns null when no tab has been stored", () => { + expect(getHomeShelfTab("shelf-key")).toBeNull(); + }); + + it("returns the stored tab id", () => { + window.localStorage.setItem("shelf-key", "projects"); + + expect(getHomeShelfTab("shelf-key")).toBe("projects"); + }); + + it("returns null when storage cannot be read", () => { + jest.spyOn(Storage.prototype, "getItem").mockImplementation(() => { + throw new Error("storage disabled"); + }); + + expect(getHomeShelfTab("shelf-key")).toBeNull(); + }); + }); + + describe("setHomeShelfTab()", () => { + it("persists the selected tab id", () => { + setHomeShelfTab("shelf-key", "projects"); + + expect(window.localStorage.getItem("shelf-key")).toBe("projects"); + }); + + it("does not throw when storage cannot be written", () => { + jest.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new Error("storage disabled"); + }); + + expect(() => setHomeShelfTab("shelf-key", "projects")).not.toThrow(); + expect(mockLogWarn).toHaveBeenCalledWith( + "Failed to persist home shelf tab", + expect.any(Error) + ); + }); + }); + + describe("isPopOutHintDismissed()", () => { + it("defaults to false when no dismissal has been stored", () => { + expect(isPopOutHintDismissed()).toBe(false); + }); + + it("returns false when storage cannot be read", () => { + jest.spyOn(Storage.prototype, "getItem").mockImplementation(() => { + throw new Error("storage disabled"); + }); + + expect(isPopOutHintDismissed()).toBe(false); + }); + }); + + describe("dismissPopOutHint()", () => { + it("persists the dismissed state", () => { + dismissPopOutHint(); + + expect(isPopOutHintDismissed()).toBe(true); + }); + + it("does not throw when storage cannot be written", () => { + jest.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new Error("storage disabled"); + }); + + expect(() => dismissPopOutHint()).not.toThrow(); + expect(mockLogWarn).toHaveBeenCalledWith( + "Failed to persist pop-out hint dismissal", + expect.any(Error) + ); + }); + }); +}); diff --git a/src/agentMode/ui/homeShelfPrefs.ts b/src/agentMode/ui/homeShelfPrefs.ts new file mode 100644 index 00000000..7e80c9bc --- /dev/null +++ b/src/agentMode/ui/homeShelfPrefs.ts @@ -0,0 +1,53 @@ +/** + * Device-local UI preferences for the Agent Home shelf, persisted in + * `window.localStorage` (which Obsidian never syncs). Mirrors the storage idiom + * in `deviceId.ts`: every access is wrapped in try/catch, reads return a safe + * default on failure, and writes swallow errors so a broken-storage device + * (disabled / restricted) never crashes the UI — it just loses persistence. + * + * These are intentionally device-local: which shelf tab you last viewed and + * whether you dismissed the pop-out hint are per-device UI state, not content + * that should ride a synced `data.json` to your other devices. + */ + +import { logWarn } from "@/logger"; + +export const HOME_SHELF_TAB_STORAGE_KEY = "copilot:home-shelf-tab:v1"; +const POPOUT_HINT_DISMISSED_KEY = "copilot:relevant-notes-popout-hint-dismissed:v1"; + +/** Read the last-selected shelf tab id, or `null` when unset or storage is unusable. */ +export function getHomeShelfTab(storageKey: string): string | null { + try { + const value = window.localStorage.getItem(storageKey); + return value && value.length > 0 ? value : null; + } catch { + return null; + } +} + +/** Persist the selected shelf tab id; no-op if storage is unusable. */ +export function setHomeShelfTab(storageKey: string, id: string): void { + try { + window.localStorage.setItem(storageKey, id); + } catch (e) { + logWarn("Failed to persist home shelf tab", e); + } +} + +/** Whether the user dismissed the Relevant Notes pop-out hint; defaults to false. */ +export function isPopOutHintDismissed(): boolean { + try { + return window.localStorage.getItem(POPOUT_HINT_DISMISSED_KEY) === "true"; + } catch { + return false; + } +} + +/** Mark the Relevant Notes pop-out hint dismissed; no-op if storage is unusable. */ +export function dismissPopOutHint(): void { + try { + window.localStorage.setItem(POPOUT_HINT_DISMISSED_KEY, "true"); + } catch (e) { + logWarn("Failed to persist pop-out hint dismissal", e); + } +} diff --git a/src/agentMode/ui/useRelevantNotesPaneOpen.test.tsx b/src/agentMode/ui/useRelevantNotesPaneOpen.test.tsx new file mode 100644 index 00000000..b29e04f1 --- /dev/null +++ b/src/agentMode/ui/useRelevantNotesPaneOpen.test.tsx @@ -0,0 +1,38 @@ +import { useRelevantNotesPaneOpen } from "@/agentMode/ui/useRelevantNotesPaneOpen"; +import { RELEVANT_NOTES_VIEWTYPE } from "@/constants"; +import { act, renderHook } from "@testing-library/react"; +import type { App, EventRef, WorkspaceLeaf } from "obsidian"; + +describe("useRelevantNotesPaneOpen", () => { + describe("useRelevantNotesPaneOpen()", () => { + it("tracks the initial pane state, layout changes, and removes its listener on unmount", () => { + let leaves = [{} as WorkspaceLeaf]; + let layoutChange: (() => void) | undefined; + const ref = {} as EventRef; + const workspace = { + getLeavesOfType: jest.fn(() => leaves), + on: jest.fn((_name: string, callback: () => void) => { + layoutChange = callback; + return ref; + }), + offref: jest.fn(), + }; + const app = { workspace } as unknown as App; + + const { result, unmount } = renderHook(() => useRelevantNotesPaneOpen(app)); + + expect(result.current).toBe(true); + expect(workspace.getLeavesOfType).toHaveBeenCalledWith(RELEVANT_NOTES_VIEWTYPE); + expect(workspace.on).toHaveBeenCalledWith("layout-change", expect.any(Function)); + + leaves = []; + act(() => layoutChange?.()); + + expect(result.current).toBe(false); + + unmount(); + + expect(workspace.offref).toHaveBeenCalledWith(ref); + }); + }); +}); diff --git a/src/agentMode/ui/useRelevantNotesPaneOpen.ts b/src/agentMode/ui/useRelevantNotesPaneOpen.ts new file mode 100644 index 00000000..48870816 --- /dev/null +++ b/src/agentMode/ui/useRelevantNotesPaneOpen.ts @@ -0,0 +1,19 @@ +import { RELEVANT_NOTES_VIEWTYPE } from "@/constants"; +import type { App } from "obsidian"; +import { useEffect, useState } from "react"; + +/** True while a dedicated Relevant Notes pane leaf is open; updates on layout-change. */ +export function useRelevantNotesPaneOpen(app: App): boolean { + const [open, setOpen] = useState( + () => app.workspace.getLeavesOfType(RELEVANT_NOTES_VIEWTYPE).length > 0 + ); + useEffect(() => { + // Re-sync on mount in case the workspace changed between the lazy-init read + // and this effect running (e.g. a layout restored in the same tick). + const update = () => setOpen(app.workspace.getLeavesOfType(RELEVANT_NOTES_VIEWTYPE).length > 0); + update(); + const ref = app.workspace.on("layout-change", update); + return () => app.workspace.offref(ref); + }, [app]); + return open; +} diff --git a/src/components/CopilotView.tsx b/src/components/CopilotView.tsx index 8904fcab..bfac535e 100644 --- a/src/components/CopilotView.tsx +++ b/src/components/CopilotView.tsx @@ -4,6 +4,7 @@ import { CHAT_VIEWTYPE } from "@/constants"; import { ChatViewEventTarget, EventTargetContext } from "@/context"; import CopilotPlugin from "@/main"; import { FileParserManager } from "@/tools/FileParserManager"; +import { registerActiveLeafChangeBridge } from "@/utils/registerActiveLeafChangeBridge"; import { mountPluginViewRoot, type PluginViewRootHandle } from "@/utils/react/mountPluginViewRoot"; import * as Tooltip from "@radix-ui/react-tooltip"; import { ItemView, Platform, WorkspaceLeaf } from "obsidian"; @@ -56,6 +57,8 @@ export default class CopilotView extends ItemView { this.setupMobileKeyboardObserver(); this.setupDrawerHideObserver(); + registerActiveLeafChangeBridge(this, this.eventTarget); + // Reason: The view can move between containers (e.g. editor tab → drawer) // without onOpen firing again. Re-bind the drawer observer on layout changes // so it always watches the correct drawer element. diff --git a/src/components/RelevantNotesView.tsx b/src/components/RelevantNotesView.tsx index 3430ab69..5dfe6050 100644 --- a/src/components/RelevantNotesView.tsx +++ b/src/components/RelevantNotesView.tsx @@ -1,9 +1,10 @@ import { RelevantNotes } from "@/components/chat-components/RelevantNotes"; -import { EVENT_NAMES, RELEVANT_NOTES_VIEWTYPE } from "@/constants"; +import { 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 { registerActiveLeafChangeBridge } from "@/utils/registerActiveLeafChangeBridge"; +import { ItemView, WorkspaceLeaf } from "obsidian"; import * as React from "react"; import { Root } from "react-dom/client"; @@ -12,9 +13,9 @@ import { Root } from "react-dom/client"; * * `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. + * event on this view's own `eventTarget`. This view bridges that event itself + * via `registerActiveLeafChangeBridge` since no other component feeds its + * `eventTarget`. */ export default class RelevantNotesView extends ItemView { private root: Root | null = null; @@ -49,13 +50,7 @@ export default class RelevantNotesView extends ItemView { 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)); - } - }) - ); + registerActiveLeafChangeBridge(this, this.eventTarget); } private renderView(): void { diff --git a/src/components/chat-components/RelevantNotes.test.tsx b/src/components/chat-components/RelevantNotes.test.tsx new file mode 100644 index 00000000..09d2cec5 --- /dev/null +++ b/src/components/chat-components/RelevantNotes.test.tsx @@ -0,0 +1,114 @@ +/* eslint-disable @eslint-react/hooks-extra/no-unnecessary-use-prefix -- Mock exports must preserve production hook names. */ +import { RelevantNotes } from "@/components/chat-components/RelevantNotes"; +import { useActiveFile } from "@/hooks/useActiveFile"; +import { findRelevantNotes } from "@/search/findRelevantNotes"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { TFile } from "obsidian"; +import React from "react"; + +const mockOpenFile = jest.fn().mockResolvedValue(undefined); +const mockGetLeaf = jest.fn(() => ({ openFile: mockOpenFile })); +const mockApp = { + vault: { + getAbstractFileByPath: jest.fn(), + cachedRead: jest.fn().mockResolvedValue(""), + }, + workspace: { + getLeaf: mockGetLeaf, + }, +}; + +jest.mock("@/aiParams", () => ({ + useIndexingProgress: () => [{ isActive: false, indexedCount: 0, totalFiles: 0 }], +})); + +jest.mock("@/components/modals/SemanticSearchToggleModal", () => ({ + SemanticSearchToggleModal: jest.fn(), +})); + +jest.mock("@/context", () => ({ + useApp: () => mockApp, +})); + +jest.mock("@/hooks/useActiveFile", () => ({ + useActiveFile: jest.fn(), +})); + +jest.mock("@/hooks/useNoteDrag", () => ({ + useNoteDrag: () => jest.fn(), +})); + +jest.mock("@/miyo/miyoUtils", () => ({ + shouldUseMiyo: () => false, +})); + +jest.mock("@/search/findRelevantNotes", () => ({ + findRelevantNotes: jest.fn(), +})); + +jest.mock("@/search/indexSignal", () => ({ + onIndexChanged: () => jest.fn(), +})); + +jest.mock("@/search/searchUtils", () => ({ + getMatchingPatterns: () => ({ inclusions: [], exclusions: [] }), + shouldIndexFile: () => true, +})); + +jest.mock("@/search/vectorStoreManager", () => ({ + __esModule: true, + default: { + getInstance: () => ({ + hasIndex: jest.fn().mockResolvedValue(true), + indexVaultToVectorStore: jest.fn().mockResolvedValue(undefined), + }), + }, +})); + +jest.mock("@/settings/model", () => ({ + getSettings: () => ({ enableSemanticSearchV3: true }), + updateSetting: jest.fn(), + useSettingsValue: () => ({ qaInclusions: "", qaExclusions: "" }), +})); + +const mockUseActiveFile = useActiveFile as jest.MockedFunction; +const mockFindRelevantNotes = findRelevantNotes as jest.MockedFunction; + +function makeMarkdownFile(path: string): TFile { + const MockTFile = TFile as unknown as new (path: string) => TFile; + return new MockTFile(path); +} + +describe("RelevantNotes", () => { + beforeEach(() => { + jest.clearAllMocks(); + const sourceFile = makeMarkdownFile("Source.md"); + const targetFile = makeMarkdownFile("Target.md"); + mockUseActiveFile.mockReturnValue(sourceFile); + mockApp.vault.getAbstractFileByPath.mockImplementation((path: string) => + path === targetFile.path ? targetFile : sourceFile + ); + mockFindRelevantNotes.mockResolvedValue([ + { + note: { path: targetFile.path, title: targetFile.basename }, + metadata: { + score: 0.8, + similarityScore: 0.8, + hasOutgoingLinks: false, + hasBacklinks: false, + }, + }, + ]); + }); + + describe("RelevantNotes()", () => { + it("opens a result in a new leaf", async () => { + render(); + + fireEvent.click(await screen.findByText("Target")); + + expect(mockGetLeaf).toHaveBeenCalledWith(true); + expect(mockOpenFile).toHaveBeenCalledWith(expect.objectContaining({ path: "Target.md" })); + }); + }); +}); diff --git a/src/components/chat-components/RelevantNotes.tsx b/src/components/chat-components/RelevantNotes.tsx index 2a6e4cbd..da0ab050 100644 --- a/src/components/chat-components/RelevantNotes.tsx +++ b/src/components/chat-components/RelevantNotes.tsx @@ -139,7 +139,7 @@ function RelevantNoteHoverCard({ }: { note: RelevantNoteEntry; onAddToChat: () => void; - onNavigateToNote: (openInNewLeaf: boolean) => void; + onNavigateToNote: () => void; children: React.ReactNode; }) { const app = useApp(); @@ -242,7 +242,7 @@ function RelevantNoteHoverCard({