Revert "feat(agent-home): surface Relevant Notes as a home-shelf tab (#2639)" (#2667)

This reverts commit 7366499229.
This commit is contained in:
Zero Liu 2026-07-07 10:00:26 +08:00 committed by GitHub
parent 7366499229
commit 982a2462f4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 35 additions and 246 deletions

View file

@ -13,10 +13,6 @@ module.exports = {
// jsdom; Jest can't parse ESM without extra config, so point at the CJS
// build it ships under dist/.
"^yaml$": "<rootDir>/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$": "<rootDir>/node_modules/@orama/orama/dist/commonjs/index.js",
"^@agentclientprotocol/sdk$": "<rootDir>/__mocks__/@agentclientprotocol/sdk.js",
"^@anthropic-ai/claude-agent-sdk$": "<rootDir>/__mocks__/@anthropic-ai/claude-agent-sdk.js",
// react-resizable-panels is ESM-only with no CJS build to point at; stub it.

View file

@ -13,14 +13,7 @@ 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";
@ -51,7 +44,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 { FileSearch, Files, Folder, MessageSquare } from "lucide-react";
import { Files, Folder, MessageSquare } from "lucide-react";
import { Notice } from "obsidian";
import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
@ -92,7 +85,6 @@ const AgentHomeInternal: React.FC<AgentHomeProps> = ({
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
@ -515,17 +507,9 @@ const AgentHomeInternal: React.FC<AgentHomeProps> = ({
// 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. 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<string | null>(() =>
getHomeShelfTab(HOME_SHELF_TAB_STORAGE_KEY)
);
const setGlobalShelfTab = useCallback((id: string) => {
setGlobalShelfTabState(id);
setHomeShelfTab(HOME_SHELF_TAB_STORAGE_KEY, id);
}, []);
// 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<string | null>(null);
// Chip-shelf sections for the landing. Each body renders lazily (only the open
// section is mounted), so these render closures are cheap to recreate.
@ -549,23 +533,6 @@ const AgentHomeInternal: React.FC<AgentHomeProps> = ({
/>
),
},
// 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: <FileSearch className="tw-size-4" />,
title: "Relevant Notes",
renderBody: () => (
<RelevantNotesShelfPanel
onPopOut={() => void plugin.activateRelevantNotesView()}
onAddToChat={(text) => void plugin.insertTextIntoActiveChat(text)}
/>
),
},
]),
{
id: "projects",
icon: <Folder className="tw-size-4" />,
@ -598,8 +565,6 @@ const AgentHomeInternal: React.FC<AgentHomeProps> = ({
handleLoadChatHistory,
runningChatIds,
attentionChatIds,
isRelevantNotesPaneOpen,
plugin,
]
);

View file

@ -91,28 +91,3 @@ describe("AgentHomeShelf controlled mode", () => {
expect(screen.queryByText("PROJECTS BODY")).toBeNull();
});
});
describe("AgentHomeTab count badge (via the shelf)", () => {
const withCount = (count?: number): AgentHomeShelfSection => ({
id: "chats",
icon: <span />,
title: "Recent Chats",
count,
renderBody: () => <div>CHATS BODY</div>,
});
it("shows no badge when count is undefined (e.g. the Relevant Notes tab)", () => {
renderShelf([withCount(undefined)]);
expect(screen.getByRole("tab", { name: /Recent Chats/ }).textContent ?? "").not.toMatch(/\d/);
});
it("shows no badge when count is 0", () => {
renderShelf([withCount(0)]);
expect(screen.getByRole("tab", { name: /Recent Chats/ }).textContent ?? "").not.toContain("0");
});
it("shows the badge when count is positive", () => {
renderShelf([withCount(3)]);
expect(screen.getByRole("tab", { name: /Recent Chats/ }).textContent ?? "").toContain("3");
});
});

View file

@ -18,8 +18,7 @@ export interface AgentHomeShelfSection {
/** Leading type icon for the tab. */
icon: React.ReactNode;
title: string;
/** Optional tally shown next to the title; the badge renders only when > 0. */
count?: number;
count: number;
/** Rendered into the panel while this tab is the selected one. */
renderBody: () => React.ReactNode;
/**

View file

@ -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({
>
<span className="tw-flex tw-shrink-0 tw-items-center tw-text-muted">{icon}</span>
<span>{title}</span>
{!disabled && typeof count === "number" && count > 0 && (
{!disabled && (
<span className={cn("tw-text-ui-smaller", active ? "tw-text-muted" : "tw-text-faint")}>
{count}
</span>

View file

@ -3,7 +3,6 @@ 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";
@ -47,8 +46,6 @@ 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());

View file

@ -1,66 +0,0 @@
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 (
<div className={cn("tw-flex tw-size-full tw-flex-col tw-overflow-hidden")}>
{!hintDismissed && (
<div
className={cn(
"tw-flex tw-shrink-0 tw-items-center tw-gap-2 tw-rounded-md tw-border tw-border-solid",
"tw-border-border tw-bg-secondary tw-px-2 tw-py-1.5 tw-text-ui-smaller tw-text-muted"
)}
>
<span className="tw-min-w-0 tw-flex-1">
Open Relevant Notes in its own pane to keep it while you chat.
</span>
<Button
variant="ghost2"
size="fit"
className={cn("tw-shrink-0 tw-text-muted", "hover:tw-text-normal")}
onClick={onPopOut}
>
<ExternalLink className="tw-size-3" />
Open pane
</Button>
<Button
variant="ghost2"
size="fit"
className={cn("tw-shrink-0 tw-text-muted", "hover:tw-text-normal")}
onClick={handleDismiss}
aria-label="Dismiss hint"
>
<X className="tw-size-3" />
</Button>
</div>
)}
<div className={cn("tw-min-h-0 tw-flex-1")}>
<RelevantNotes onAddToChat={onAddToChat} />
</div>
</div>
);
}

View file

@ -1,53 +0,0 @@
/**
* 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);
}
}

View file

@ -1,19 +0,0 @@
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;
}

View file

@ -4,7 +4,6 @@ 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";
@ -57,8 +56,6 @@ 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.

View file

@ -1,10 +1,9 @@
import { RelevantNotes } from "@/components/chat-components/RelevantNotes";
import { RELEVANT_NOTES_VIEWTYPE } from "@/constants";
import { EVENT_NAMES, RELEVANT_NOTES_VIEWTYPE } from "@/constants";
import { EventTargetContext } from "@/context";
import CopilotPlugin from "@/main";
import { createPluginRoot } from "@/utils/react/createPluginRoot";
import { registerActiveLeafChangeBridge } from "@/utils/registerActiveLeafChangeBridge";
import { ItemView, WorkspaceLeaf } from "obsidian";
import { ItemView, MarkdownView, WorkspaceLeaf } from "obsidian";
import * as React from "react";
import { Root } from "react-dom/client";
@ -13,9 +12,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`. This view bridges that event itself
* via `registerActiveLeafChangeBridge` since no other component feeds its
* `eventTarget`.
* 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;
@ -50,7 +49,13 @@ export default class RelevantNotesView extends ItemView {
this.root = createPluginRoot(this.containerEl.children[1], this.app);
this.renderView();
registerActiveLeafChangeBridge(this, this.eventTarget);
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 {

View file

@ -373,6 +373,22 @@ export default class CopilotPlugin extends Plugin {
if (activeViewType === CHAT_VIEWTYPE || activeViewType === CHAT_AGENT_VIEWTYPE) {
this.lastActiveChatViewType = activeViewType;
}
if (leaf && leaf.view instanceof MarkdownView) {
const file = leaf.view.file;
if (file) {
// Note: File tracking and real-time reindexing removed for simplicity
// Semantic search indexes are rebuilt manually or on startup as needed
const activeCopilotView = this.app.workspace
.getLeavesOfType(CHAT_VIEWTYPE)
.find((leaf) => leaf.view instanceof CopilotView)?.view as CopilotView;
if (activeCopilotView) {
const event = new CustomEvent(EVENT_NAMES.ACTIVE_LEAF_CHANGE);
activeCopilotView.eventTarget.dispatchEvent(event);
}
}
}
})
);

View file

@ -1,23 +0,0 @@
import { EVENT_NAMES } from "@/constants";
import { ItemView, MarkdownView } from "obsidian";
/**
* Bridge Obsidian's workspace `active-leaf-change` onto a view-local
* `eventTarget` so React consumers (via `useActiveFile`) re-read the active
* file when the user switches notes.
*
* Each chat-ish view self-owns this bridge rather than relying on a single
* plugin-level dispatch: `useActiveFile` listens on the view's own
* `eventTarget`, so a view that never feeds its target stays frozen at its
* mount seed. Registering through the view's `registerEvent` ties the listener
* to the view lifecycle (auto-unregistered on close).
*/
export function registerActiveLeafChangeBridge(view: ItemView, eventTarget: EventTarget): void {
view.registerEvent(
view.app.workspace.on("active-leaf-change", (leaf) => {
if (leaf?.view instanceof MarkdownView && leaf.view.file) {
eventTarget.dispatchEvent(new CustomEvent(EVENT_NAMES.ACTIVE_LEAF_CHANGE));
}
})
);
}