logancyang_obsidian-copilot/src/main.ts
Zero Liu c67c4fc200
fix(popout): document ownership and selection listener fixes (W4/9) (#2406)
* fix(popout): document ownership and selection listener fixes (W4/9)

Fifth of nine workspaces splitting #2397. Real popout-window
correctness fixes — semantic choices about which window owns a DOM
operation, plus selection-listener captured-document fixes.

- document → activeDocument where a generic active-window lookup is
  correct (SourcesModal, ChatViewLayout, ItemView/Notice mocks,
  TabContext, ChatInput keydown listener, CopilotView keyboard
  observer, TypeaheadMenuPortal, AddImageModal, and ?? document
  fallbacks across draggable-modal, menu-command-modal,
  CustomCommandChatModal, use-draggable, use-resizable, and
  QuickAskOverlay).
- document → root.ownerDocument in ChatSingleMessage's
  linkInlineCitations so DOM nodes end up in the citation's window;
  same pattern in the processMessage streaming block via
  contentRef.current.ownerDocument.
- New getEditorDocument(editor) helper in BasePillNode. createDOM and
  exportDOM now accept the LexicalEditor; pills create DOM in the
  editor's window (correct popout behavior). All 6 pill subclasses
  updated to match the new signature.
- main.ts: selectionListenerDocument captured at listener registration;
  remove uses the same document (fixes leak when activeDocument has
  drifted before unload).
- chatSelectionHighlightController.ts: getActiveViewOfType(MarkdownView)
  replaces the brittle activeLeaf type comparison.
- Test setup: (window as any).activeDocument = window.document in
  ChatSingleMessage.test; window.document.createElement in
  AgentPrompt.test Notice mock.

Behavior fix: chat in a popout window now creates DOM nodes in the
popout's document, not the focused window's document.

W0, W1, and #2402 already merged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(popout): prefer element.doc/.win over ownerDocument and activeDocument

Switch popout-window-sensitive call sites to Obsidian's `.doc` / `.win`
augmentations so DOM ops, listeners, portals, and positioning follow the
element's actual owner window. Recreate Lexical root on view migration to
rebind input handling after a leaf moves between windows. Document the
decision order in AGENTS.md and polyfill `Node.doc` / `Node.win` in
jest.setup.js so tests still run under jsdom.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* improve image upload implementation

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 21:28:04 -07:00

942 lines
32 KiB
TypeScript

import { BrevilabsClient } from "@/LLMProviders/brevilabsClient";
import ProjectManager from "@/LLMProviders/projectManager";
import {
CustomModel,
getCurrentProject,
setSelectedTextContexts,
getSelectedTextContexts,
} from "@/aiParams";
import { NoteSelectedTextContext, SelectedTextContext } from "@/types/message";
import { registerCommands } from "@/commands";
import CopilotView from "@/components/CopilotView";
import { APPLY_VIEW_TYPE, ApplyView } from "@/components/composer/ApplyView";
import { LoadChatHistoryModal } from "@/components/modals/LoadChatHistoryModal";
import { registerContextMenu } from "@/commands/contextMenu";
import { CustomCommandRegister } from "@/commands/customCommandRegister";
import { migrateCommands, suggestDefaultCommands } from "@/commands/migrator";
import { migrateSystemPromptsFromSettings } from "@/system-prompts/migration";
import { SystemPromptRegister } from "@/system-prompts/systemPromptRegister";
import { ProjectRegister } from "@/projects/projectRegister";
import { ABORT_REASON, CHAT_VIEWTYPE, DEFAULT_OPEN_AREA, EVENT_NAMES } from "@/constants";
import { ChatManager } from "@/core/ChatManager";
import { MessageRepository } from "@/core/MessageRepository";
import { encryptAllKeys } from "@/encryptionService";
import { logError, logInfo, logWarn } from "@/logger";
import { logFileManager } from "@/logFileManager";
import { UserMemoryManager } from "@/memory/UserMemoryManager";
import { clearRecordedPromptPayload } from "@/LLMProviders/chainRunner/utils/promptPayloadRecorder";
import { checkIsPlusUser, refreshSelfHostModeValidation } from "@/plusUtils";
import {
getWebViewerService,
startActiveWebTabTracking,
} from "@/services/webViewerService/webViewerServiceSingleton";
import { WebSelectionTracker } from "@/services/webViewerService/webViewerServiceSelection";
import VectorStoreManager from "@/search/vectorStoreManager";
import { CopilotSettingTab } from "@/settings/SettingsPage";
import {
getModelKeyFromModel,
getSettings,
sanitizeSettings,
setSettings,
subscribeToSettingsChange,
} from "@/settings/model";
import { ChatUIState } from "@/state/ChatUIState";
import { VaultDataManager } from "@/state/vaultDataAtoms";
import { FileParserManager } from "@/tools/FileParserManager";
import { initializeBuiltinTools } from "@/tools/builtinTools";
import {
ChatSelectionHighlightController,
hideChatSelectionHighlight,
QuickAskController,
SelectionHighlight,
} from "@/editor";
import {
Editor,
MarkdownView,
Menu,
Notice,
Platform,
Plugin,
TFile,
WorkspaceLeaf,
} from "obsidian";
import { ChatHistoryItem } from "@/components/chat-components/ChatHistoryPopover";
import {
extractChatDate,
extractChatLastAccessedAtMs,
extractChatTitle,
filterChatHistoryFiles,
} from "@/utils/chatHistoryUtils";
import { RecentUsageManager } from "@/utils/recentUsageManager";
import { listMarkdownFiles, patchFrontmatter, resolveFileByPath } from "@/utils/vaultAdapterUtils";
import { v4 as uuidv4 } from "uuid";
// Removed unused FileTrackingState interface
export default class CopilotPlugin extends Plugin {
// Plugin components
projectManager: ProjectManager;
brevilabsClient: BrevilabsClient;
userMessageHistory: string[] = [];
vectorStoreManager: VectorStoreManager;
fileParserManager: FileParserManager;
customCommandRegister: CustomCommandRegister;
systemPromptRegister: SystemPromptRegister;
projectRegister: ProjectRegister;
settingsUnsubscriber?: () => void;
chatUIState: ChatUIState;
userMemoryManager: UserMemoryManager;
quickAskController: QuickAskController;
chatSelectionHighlightController: ChatSelectionHighlightController;
private selectionDebounceTimer?: number;
private selectionChangeHandler?: () => void;
private selectionListenerDocument?: Document;
private lastSelectionSignature?: string;
private webSelectionTracker?: WebSelectionTracker;
private readonly chatHistoryLastAccessedAtManager = new RecentUsageManager<string>();
async onload(): Promise<void> {
await this.loadSettings();
this.settingsUnsubscriber = subscribeToSettingsChange(async (prev, next) => {
if (next.enableEncryption) {
await this.saveData(await encryptAllKeys(next));
} else {
await this.saveData(next);
}
registerCommands(this, prev, next);
});
this.addSettingTab(new CopilotSettingTab(this.app, this));
// Core plugin initialization
// Initialize built-in tools with vault access
initializeBuiltinTools(this.app.vault);
// Initialize BrevilabsClient
this.brevilabsClient = BrevilabsClient.getInstance();
this.brevilabsClient.setPluginVersion(this.manifest.version);
checkIsPlusUser();
refreshSelfHostModeValidation();
// Initialize ProjectManager
this.projectManager = ProjectManager.getInstance(this.app, this);
// Always construct VectorStoreManager; it internally no-ops when semantic search is disabled
this.vectorStoreManager = VectorStoreManager.getInstance();
// Initialize VaultDataManager for centralized vault data (notes, folders, tags)
// Note: VaultDataManager tracks ALL data; hooks filter based on parameters
const vaultDataManager = VaultDataManager.getInstance();
vaultDataManager.initialize();
// Initialize FileParserManager early with other core services
this.fileParserManager = new FileParserManager(this.brevilabsClient, this.app.vault);
// Initialize ChatUIState with new architecture
const messageRepo = new MessageRepository();
const chainManager = this.projectManager.getCurrentChainManager();
const chatManager = new ChatManager(messageRepo, chainManager, this.fileParserManager, this);
this.chatUIState = new ChatUIState(chatManager);
// Initialize UserMemoryManager
this.userMemoryManager = new UserMemoryManager(this.app);
// Initialize QuickAskController and register CM6 extension
this.quickAskController = new QuickAskController(this);
this.registerEditorExtension(this.quickAskController.createExtension());
// Initialize Chat selection highlight controller
this.chatSelectionHighlightController = new ChatSelectionHighlightController(this, {
closeQuickAskOnChatFocus: false,
});
this.chatSelectionHighlightController.initialize();
// Single source of truth for Active Web Tab ({activeWebTab}) state
// Preserves activeWebTab when switching to Chat view
// Only run on desktop - Web Viewer is not available on mobile
if (Platform.isDesktopApp) {
const { activeLeafRef, layoutRef } = startActiveWebTabTracking(this.app, {
preserveOnViewTypes: [CHAT_VIEWTYPE],
});
this.registerEvent(activeLeafRef);
this.registerEvent(layoutRef);
}
this.registerView(CHAT_VIEWTYPE, (leaf: WorkspaceLeaf) => new CopilotView(leaf, this));
this.registerView(APPLY_VIEW_TYPE, (leaf: WorkspaceLeaf) => new ApplyView(leaf));
this.initActiveLeafChangeHandler();
this.addRibbonIcon("message-square", "Open Copilot Chat", (evt: MouseEvent) => {
this.activateView();
});
registerCommands(this, undefined, getSettings());
// Tool initialization is now handled automatically in CopilotPlusChainRunner and AutonomousAgentChainRunner
this.registerEvent(
this.app.workspace.on("editor-menu", (menu: Menu) => {
registerContextMenu(menu, this.app);
})
);
this.registerEvent(
this.app.workspace.on("active-leaf-change", (leaf) => {
// Delegate to chat selection highlight controller
this.chatSelectionHighlightController.handleActiveLeafChange(leaf ?? null);
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);
}
}
}
})
);
this.customCommandRegister = new CustomCommandRegister(this, this.app.vault);
this.systemPromptRegister = new SystemPromptRegister(this, this.app.vault);
this.projectRegister = new ProjectRegister(this.app.vault);
this.app.workspace.onLayoutReady(() => {
// Reason: projects must initialize after vault file tree is indexed (onLayoutReady),
// not in onload(). Otherwise getAbstractFileByPath() returns null for non-hidden
// folders and the adapter fallback creates synthetic TFiles that crash vault.read().
// This matches the system-prompts initialization pattern.
this.projectRegister.initialize().catch((error) => {
logError("[Projects] ProjectRegister initialization failed", error);
new Notice("Failed to load projects. Check console for details.");
});
// Initialize custom commands
this.customCommandRegister.initialize().then(migrateCommands).then(suggestDefaultCommands);
// Initialize system prompts (independent from custom commands)
this.systemPromptRegister
.initialize()
.then(() => migrateSystemPromptsFromSettings(this.app.vault));
});
// Initialize automatic selection handler
this.initSelectionHandler();
// Initialize web selection watcher (Desktop only)
this.initWebSelectionWatcher();
}
async onunload() {
// Clear all persistent selection highlights before unload
// This prevents "stuck" highlights after hot reload (dev environment)
this.clearAllPersistentSelectionHighlights();
// Cleanup chat selection highlight controller
this.chatSelectionHighlightController?.cleanup();
if (this.projectManager) {
this.projectManager.onunload();
}
// Cleanup VaultDataManager event listeners
const vaultDataManager = VaultDataManager.getInstance();
vaultDataManager.cleanup();
this.customCommandRegister.cleanup();
this.systemPromptRegister.cleanup();
this.projectRegister.cleanup();
this.settingsUnsubscriber?.();
// Cleanup selection handler
this.cleanupSelectionHandler();
this.cleanupWebSelectionWatcher();
this.clearSelectionContext();
// Cleanup Web Viewer state tracking (webview event listeners)
try {
const webViewerService = getWebViewerService(this.app);
webViewerService.stopActiveWebTabTracking();
} catch {
// Ignore errors if service not available
}
// Best-effort flush of log file
await logFileManager.flush();
logInfo("Copilot plugin unloaded");
}
/**
* Clear all persistent selection highlights across all Markdown editors.
* Called during plugin unload to prevent "stuck" highlights after hot reload.
*/
private clearAllPersistentSelectionHighlights(): void {
try {
const leaves = this.app.workspace.getLeavesOfType("markdown");
for (const leaf of leaves) {
const view = leaf.view;
if (!(view instanceof MarkdownView)) continue;
const cm = view.editor?.cm;
if (cm) {
SelectionHighlight.hide(cm);
hideChatSelectionHighlight(cm);
}
}
} catch (error) {
logWarn("Failed to clear persistent selection highlights:", error);
}
}
updateUserMessageHistory(newMessage: string) {
this.userMessageHistory = [...this.userMessageHistory, newMessage];
}
async autosaveCurrentChat() {
if (getSettings().autosaveChat) {
const chatView = this.app.workspace.getLeavesOfType(CHAT_VIEWTYPE)[0]?.view as CopilotView;
if (chatView) {
await chatView.saveChat();
}
}
}
async processText(
editor: Editor,
eventType: string,
eventSubtype?: string,
checkSelectedText = true
) {
const selectedText = await editor.getSelection();
const isChatWindowActive = this.app.workspace.getLeavesOfType(CHAT_VIEWTYPE).length > 0;
if (!isChatWindowActive) {
await this.activateView();
}
// Without the timeout, the view is not yet active
setTimeout(() => {
const activeCopilotView = this.app.workspace
.getLeavesOfType(CHAT_VIEWTYPE)
.find((leaf) => leaf.view instanceof CopilotView)?.view as CopilotView;
if (activeCopilotView && (!checkSelectedText || selectedText)) {
const event = new CustomEvent(eventType, { detail: { selectedText, eventSubtype } });
activeCopilotView.eventTarget.dispatchEvent(event);
}
}, 0);
}
processSelection(editor: Editor, eventType: string, eventSubtype?: string) {
this.processText(editor, eventType, eventSubtype);
}
emitChatIsVisible() {
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.CHAT_IS_VISIBLE);
activeCopilotView.eventTarget.dispatchEvent(event);
}
}
initActiveLeafChangeHandler() {
this.registerEvent(
this.app.workspace.on("active-leaf-change", (leaf) => {
if (!leaf) {
return;
}
if (leaf.getViewState().type === CHAT_VIEWTYPE) {
this.emitChatIsVisible();
}
})
);
}
/**
* Initialize automatic text selection handler
* Listens to selectionchange events and automatically adds selected text to chat context
*/
initSelectionHandler() {
this.selectionChangeHandler = () => {
// Clear existing debounce timer
if (this.selectionDebounceTimer) {
window.clearTimeout(this.selectionDebounceTimer);
}
// Debounce selection changes to avoid excessive triggers
this.selectionDebounceTimer = window.setTimeout(() => {
this.handleSelectionChange();
}, 500);
};
// Capture the document at registration so removal targets the same one
// (activeDocument can change if the user focuses a popout window).
this.selectionListenerDocument = activeDocument;
this.selectionListenerDocument.addEventListener("selectionchange", this.selectionChangeHandler);
}
/**
* Clean up selection handler on plugin unload
*/
cleanupSelectionHandler() {
if (this.selectionDebounceTimer) {
window.clearTimeout(this.selectionDebounceTimer);
}
if (this.selectionChangeHandler && this.selectionListenerDocument) {
this.selectionListenerDocument.removeEventListener(
"selectionchange",
this.selectionChangeHandler
);
}
this.selectionListenerDocument = undefined;
}
/**
* Clears the auto-selected text context if one was previously captured
*/
private clearSelectionContext() {
setSelectedTextContexts([]);
}
/**
* Clears the auto-selected web text context for a specific URL.
* Preserves contexts from other sourceTypes and other URLs.
*/
private clearWebSelectionContextForUrl(url: string): void {
const current = getSelectedTextContexts();
const next = current.filter((c) => c.sourceType !== "web" || c.url !== url);
if (next.length === current.length) {
return;
}
setSelectedTextContexts(next);
}
/**
* Stores the provided selection as the active selected text context.
* Only keeps the latest selection - note and web selections are mutually exclusive.
*/
private setSelectionContext(context: SelectedTextContext) {
setSelectedTextContexts([context]);
}
/**
* Handle text selection changes
* Only processes selections from markdown editors
*/
handleSelectionChange() {
// Check if auto-inclusion is enabled
const settings = getSettings();
if (!settings.autoAddSelectionToContext) {
return;
}
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
if (!activeView || !activeView.editor) {
return;
}
const editor = activeView.editor;
const activeFile = this.app.workspace.getActiveFile();
// Get selection range first to validate it exists
const selectionRange = editor.listSelections()[0];
if (!selectionRange) {
return;
}
// Compute selection signature to avoid redundant updates
const signature = activeFile
? `${activeFile.path}:${selectionRange.anchor.line}:${selectionRange.anchor.ch}:${selectionRange.head.line}:${selectionRange.head.ch}`
: "";
// Skip if selection hasn't changed
if (signature === this.lastSelectionSignature) {
return;
}
this.lastSelectionSignature = signature;
const selectedText = editor.getSelection();
// If selection is empty, clear note-type contexts
if (!selectedText || !selectedText.trim()) {
const currentContexts = getSelectedTextContexts();
const nonNoteContexts = currentContexts.filter((ctx) => ctx.sourceType !== "note");
if (currentContexts.length !== nonNoteContexts.length) {
setSelectedTextContexts(nonNoteContexts);
}
return;
}
if (!activeFile) {
return;
}
const anchorLine = selectionRange.anchor.line + 1;
const headLine = selectionRange.head.line + 1;
const startLine = Math.min(anchorLine, headLine);
const endLine = Math.max(anchorLine, headLine);
// Create selected text context
const selectedTextContext: NoteSelectedTextContext = {
id: uuidv4(),
content: selectedText,
sourceType: "note",
noteTitle: activeFile.basename,
notePath: activeFile.path,
startLine,
endLine,
};
this.setSelectionContext(selectedTextContext);
}
/**
* Initialize web selection watcher for auto-adding web tab selections.
* Desktop only - uses WebSelectionTracker with self-scheduling pattern.
*/
initWebSelectionWatcher() {
// Only run on desktop
if (!Platform.isDesktopApp) {
return;
}
const webViewerService = getWebViewerService(this.app);
this.webSelectionTracker = new WebSelectionTracker({
intervalMs: 500,
emptySelectionDebounceCount: 2,
isEnabled: () => getSettings().autoAddSelectionToContext,
getLeaf: () => webViewerService.getActiveLeaf() ?? webViewerService.getLastActiveLeaf(),
getActiveLeaf: () => webViewerService.getActiveLeaf(),
onSelectionChange: (context) => {
// Use symmetric update strategy via setSelectionContext
this.setSelectionContext(context);
},
onSelectionClear: ({ url }) => {
this.clearWebSelectionContextForUrl(url);
},
});
this.webSelectionTracker.start();
}
/**
* Clean up web selection watcher
*/
cleanupWebSelectionWatcher() {
this.webSelectionTracker?.stop();
this.webSelectionTracker = undefined;
}
/**
* Suppress the current web selection so it won't be auto-captured again until it changes or is cleared.
* Called by UI when user removes web selection or starts a new chat.
* @param url - Optional URL to suppress (prevents leaf-binding issues when lastActiveLeaf has changed)
*/
suppressCurrentWebSelection(url?: string): void {
if (url && url.trim()) {
this.webSelectionTracker?.suppressSelectionForUrl(url);
return;
}
this.webSelectionTracker?.suppressCurrentSelection();
}
private getCurrentEditorOrDummy(): Editor {
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
return {
getSelection: () => {
const selection = activeView?.editor?.getSelection();
if (selection) return selection;
// Default to the entire active file if no selection
const activeFile = this.app.workspace.getActiveFile();
return activeFile ? this.app.vault.read(activeFile) : "";
},
replaceSelection: activeView?.editor?.replaceSelection.bind(activeView.editor) || (() => {}),
} as Partial<Editor> as Editor;
}
processCustomPrompt(eventType: string, customPrompt: string) {
const editor = this.getCurrentEditorOrDummy();
this.processText(editor, eventType, customPrompt, false);
}
toggleView() {
const leaves = this.app.workspace.getLeavesOfType(CHAT_VIEWTYPE);
if (leaves.length > 0) {
this.deactivateView();
} else {
this.activateView();
}
}
async activateView(): Promise<void> {
const leaves = this.app.workspace.getLeavesOfType(CHAT_VIEWTYPE);
if (leaves.length === 0) {
if (getSettings().defaultOpenArea === DEFAULT_OPEN_AREA.VIEW) {
await this.app.workspace.getRightLeaf(false).setViewState({
type: CHAT_VIEWTYPE,
active: true,
});
} else {
await this.app.workspace.getLeaf(true).setViewState({
type: CHAT_VIEWTYPE,
active: true,
});
}
} else {
this.app.workspace.revealLeaf(leaves[0]);
}
// Small delay to ensure React component is ready to receive the focus event
setTimeout(() => {
this.emitChatIsVisible();
}, 50);
}
async deactivateView() {
this.app.workspace.detachLeavesOfType(CHAT_VIEWTYPE);
}
async loadSettings() {
const savedSettings = await this.loadData();
const sanitizedSettings = sanitizeSettings(savedSettings);
setSettings(sanitizedSettings);
}
mergeActiveModels(
existingActiveModels: CustomModel[],
builtInModels: CustomModel[]
): CustomModel[] {
const modelMap = new Map<string, CustomModel>();
// Create a unique key for each model, it's model (name + provider)
// Add or update existing models in the map
existingActiveModels.forEach((model) => {
const key = getModelKeyFromModel(model);
const existingModel = modelMap.get(key);
if (existingModel) {
// If it's a built-in model, preserve the built-in status
modelMap.set(key, {
...model,
isBuiltIn: existingModel.isBuiltIn || model.isBuiltIn,
});
} else {
modelMap.set(key, model);
}
});
return Array.from(modelMap.values());
}
async loadCopilotChatHistory() {
const chatFiles = await this.getChatHistoryFiles();
if (chatFiles.length === 0) {
new Notice("No chat history found.");
return;
}
new LoadChatHistoryModal(
this.app,
chatFiles,
this.chatHistoryLastAccessedAtManager,
this.loadChatHistory.bind(this)
).open();
}
async getChatHistoryFiles(): Promise<TFile[]> {
const folderFiles = await listMarkdownFiles(this.app, getSettings().defaultSaveFolder);
if (folderFiles.length === 0) return [];
const currentProject = getCurrentProject();
// Reason: pass all files to filterChatHistoryFiles which checks frontmatter projectId.
// A prefix prefilter would miss renamed or legacy files that still have correct frontmatter.
return filterChatHistoryFiles(this.app, folderFiles, currentProject?.id);
}
async getChatHistoryItems(): Promise<ChatHistoryItem[]> {
const files = await this.getChatHistoryFiles();
return files.map((file) => {
const createdAt = extractChatDate(file);
const persistedLastAccessedAtMs = extractChatLastAccessedAtMs(file);
// Use effective last used time (prefers in-memory value for immediate UI updates)
const effectiveLastAccessedAtMs =
this.chatHistoryLastAccessedAtManager.getEffectiveLastUsedAt(
file.path,
persistedLastAccessedAtMs ?? createdAt.getTime()
);
const lastAccessedAt = new Date(effectiveLastAccessedAtMs);
return {
id: file.path,
title: extractChatTitle(file),
createdAt,
lastAccessedAt,
};
});
}
/**
* Record that a chat history file was accessed by updating its `lastAccessedAt`
* YAML frontmatter field (epoch ms), with in-memory tracking and throttled persistence.
*
* Memory is always updated immediately (for UI sorting), but disk writes are throttled and monotonic.
*/
private async touchChatHistoryLastAccessedAt(file: TFile): Promise<void> {
try {
// Always update memory for immediate UI feedback
this.chatHistoryLastAccessedAtManager.touch(file.path);
// Check if we should persist to disk (throttled)
const persistedLastAccessedAtMs = extractChatLastAccessedAtMs(file);
const timestampToPersist = this.chatHistoryLastAccessedAtManager.shouldPersist(
file.path,
persistedLastAccessedAtMs
);
if (timestampToPersist === null) {
return;
}
let persistedAtMs = timestampToPersist;
if (
this.app.fileManager?.processFrontMatter &&
this.app.vault.getAbstractFileByPath(file.path) != null
) {
await this.app.fileManager.processFrontMatter(file, (frontmatter) => {
// Monotonic protection: ensure we never write an older timestamp
const existingValue = Number(frontmatter.lastAccessedAt);
const existingAtMs =
Number.isFinite(existingValue) && existingValue > 0 ? existingValue : 0;
persistedAtMs = Math.max(existingAtMs, timestampToPersist);
if (existingAtMs === persistedAtMs) {
return;
}
frontmatter.lastAccessedAt = persistedAtMs;
});
} else {
await patchFrontmatter(this.app, file.path, { lastAccessedAt: persistedAtMs });
}
// Mark persistence successful for throttling purposes
this.chatHistoryLastAccessedAtManager.markPersisted(file.path, persistedAtMs);
} catch (error) {
logWarn(`[CopilotPlugin] Failed to update chat lastAccessedAt for ${file.path}`, error);
}
}
/**
* Get the chat history last accessed at manager for use in sorting.
* This allows UI components to use in-memory values for immediate feedback.
*/
getChatHistoryLastAccessedAtManager(): RecentUsageManager<string> {
return this.chatHistoryLastAccessedAtManager;
}
async loadChatHistory(file: TFile) {
// First autosave the current chat if the setting is enabled
await this.autosaveCurrentChat();
// Check if the Copilot view is already active
const existingView = this.app.workspace.getLeavesOfType(CHAT_VIEWTYPE)[0];
if (!existingView) {
// Only activate the view if it's not already open
this.activateView();
}
// Load messages using ChatUIState (which now uses ChatPersistenceManager internally)
await this.chatUIState.loadChatHistory(file);
// Touch "lastAccessedAt" timestamp (throttled to avoid frequent writes)
void this.touchChatHistoryLastAccessedAt(file);
// Update the view
const copilotView = (existingView || this.app.workspace.getLeavesOfType(CHAT_VIEWTYPE)[0])
?.view as CopilotView;
if (copilotView) {
copilotView.updateView();
}
}
async loadChatById(fileId: string): Promise<void> {
const file = await resolveFileByPath(this.app, fileId);
if (file) {
await this.loadChatHistory(file);
} else {
throw new Error("Chat file not found.");
}
}
async openChatSourceFile(fileId: string): Promise<void> {
const file = this.app.vault.getAbstractFileByPath(fileId);
if (file instanceof TFile) {
await this.app.workspace.getLeaf(true).openFile(file);
} else if (await this.app.vault.adapter.exists(fileId)) {
new Notice(
"Cannot open source files from hidden directories. To open chat notes in the editor, save them to a non-hidden folder in settings."
);
} else {
throw new Error("Chat file not found.");
}
}
async updateChatTitle(fileId: string, newTitle: string): Promise<void> {
const file = this.app.vault.getAbstractFileByPath(fileId);
if (file instanceof TFile) {
await this.app.fileManager.processFrontMatter(file, (frontmatter) => {
frontmatter.topic = newTitle;
});
// Wait for metadata cache to update with improved error handling
// This ensures that subsequent calls to extractChatTitle will get the updated data
await new Promise<void>((resolve) => {
const handler = (updatedFile: TFile) => {
if (updatedFile.path === fileId) {
this.app.metadataCache.off("changed", handler);
clearTimeout(timeoutId);
resolve();
}
};
this.app.metadataCache.on("changed", handler);
// Fallback timeout with shorter duration and better error handling
const timeoutId = setTimeout(() => {
this.app.metadataCache.off("changed", handler);
// Don't reject, just resolve - the frontmatter update might have worked
// even if we didn't catch the event
resolve();
}, 500); // Reduced timeout for better performance
});
new Notice("Chat title updated.");
} else if (await resolveFileByPath(this.app, fileId)) {
await patchFrontmatter(this.app, fileId, { topic: newTitle.trim() });
new Notice("Chat title updated.");
} else {
throw new Error("Chat file not found.");
}
}
async deleteChatHistory(fileId: string): Promise<void> {
const file = this.app.vault.getAbstractFileByPath(fileId);
if (file instanceof TFile) {
await this.app.vault.delete(file);
new Notice("Chat deleted.");
} else if (await this.app.vault.adapter.exists(fileId)) {
await this.app.vault.adapter.remove(fileId);
new Notice("Chat deleted.");
} else {
throw new Error("Chat file not found.");
}
}
async handleNewChat() {
clearRecordedPromptPayload();
await logFileManager.clear();
// Analyze chat messages for memory if enabled
if (getSettings().enableRecentConversations) {
try {
// Get the current chat model from the chain manager
const chainManager = this.projectManager.getCurrentChainManager();
const chatModel = chainManager.chatModelManager.getChatModel();
this.userMemoryManager.addRecentConversation(this.chatUIState.getMessages(), chatModel);
} catch (error) {
logInfo("Failed to analyze chat messages for memory:", error);
}
}
// First autosave the current chat if the setting is enabled
await this.autosaveCurrentChat();
// Abort any ongoing streams before clearing chat
const existingView = this.app.workspace.getLeavesOfType(CHAT_VIEWTYPE)[0];
if (existingView) {
const copilotView = existingView.view as CopilotView;
// Dispatch abort event to stop any ongoing streams
const abortEvent = new CustomEvent(EVENT_NAMES.ABORT_STREAM, {
detail: { reason: ABORT_REASON.NEW_CHAT },
});
copilotView.eventTarget.dispatchEvent(abortEvent);
}
// Clear messages through ChatUIState (which also clears chain memory)
this.chatUIState.clearMessages();
// Update view if it exists
if (existingView) {
const copilotView = existingView.view as CopilotView;
copilotView.updateView();
} else {
// If view doesn't exist, open it
await this.activateView();
}
// Note: UI-specific state like includeActiveNote setting is handled in the Chat component
// This ensures proper separation of concerns between plugin logic and UI state
}
async newChat() {
// Just delegate to the shared method
await this.handleNewChat();
}
async customSearchDB(query: string, salientTerms: string[], textWeight: number): Promise<any[]> {
const settings = getSettings();
// Run FilterRetriever for guaranteed title/tag matches
const { FilterRetriever } = await import("@/search/v3/FilterRetriever");
const { mergeFilterAndSearchResults } = await import("@/search/v3/mergeResults");
const filterRetriever = new FilterRetriever(this.app, {
salientTerms: salientTerms,
maxK: 20,
});
const filterDocs = await filterRetriever.getRelevantDocuments(query);
// Run main retriever for scored results
const retriever = settings.enableSemanticSearchV3
? new (await import("@/search/v3/MergedSemanticRetriever")).MergedSemanticRetriever(
this.app,
{
minSimilarityScore: 0.3,
maxK: 20,
salientTerms: salientTerms,
textWeight: textWeight,
returnAll: false,
}
)
: new (await import("@/search/v3/TieredLexicalRetriever")).TieredLexicalRetriever(this.app, {
minSimilarityScore: 0.3,
maxK: 20,
salientTerms: salientTerms,
textWeight: textWeight,
returnAll: false,
useRerankerThreshold: undefined,
});
const searchDocs = await retriever.getRelevantDocuments(query);
const { filterResults, searchResults } = mergeFilterAndSearchResults(filterDocs, searchDocs);
const allDocs = [...filterResults, ...searchResults];
return allDocs.map((doc) => ({
content: doc.pageContent,
metadata: doc.metadata,
}));
}
}