From d6368d7f0fb08730a8064a0b01e30504a40c2e88 Mon Sep 17 00:00:00 2001 From: Midway65 Date: Fri, 3 Apr 2026 20:49:07 -0700 Subject: [PATCH 01/64] fix(chat-input): correct auto-resize min/max heights and measurement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - minHeight 48→72px (desktop) and 64px (mobile) to match CSS values - maxHeight 120→200px (desktop) and 160px (mobile) to match CSS values - Replace class-toggle height measurement with style.removeProperty() for reliable scrollHeight reads on contenteditable divs Fixes BUG-01: input box shrinking unexpectedly while typing. Co-Authored-By: Claude Sonnet 4.6 --- src/ui/chat/components/ChatInput.ts | 288 ++++++++++++++-------------- 1 file changed, 144 insertions(+), 144 deletions(-) diff --git a/src/ui/chat/components/ChatInput.ts b/src/ui/chat/components/ChatInput.ts index 1a51c5e63..5256fec55 100644 --- a/src/ui/chat/components/ChatInput.ts +++ b/src/ui/chat/components/ChatInput.ts @@ -11,14 +11,14 @@ import { ReferenceExtractor, ReferenceMetadata } from '../utils/ReferenceExtract import { MessageEnhancement } from './suggesters/base/SuggesterInterfaces'; import { isMobile, isIOS } from '../../../utils/platform'; -export class ChatInput { - private element: HTMLElement | null = null; - private inputElement: HTMLElement | null = null; - private sendButton: HTMLButtonElement | null = null; - private isLoading = false; - private isPreSendCompacting = false; - private hasConversation = false; - private suggesters: SuggesterInstances | null = null; +export class ChatInput { + private element: HTMLElement | null = null; + private inputElement: HTMLElement | null = null; + private sendButton: HTMLButtonElement | null = null; + private isLoading = false; + private isPreSendCompacting = false; + private hasConversation = false; + private suggesters: SuggesterInstances | null = null; constructor( private container: HTMLElement, @@ -39,18 +39,18 @@ export class ChatInput { /** * Set loading state */ - setLoading(loading: boolean): void { - this.isLoading = loading; - this.updateUI(); - } - - /** - * Set pre-send compaction state. - */ - setPreSendCompacting(compacting: boolean): void { - this.isPreSendCompacting = compacting; - this.updateUI(); - } + setLoading(loading: boolean): void { + this.isLoading = loading; + this.updateUI(); + } + + /** + * Set pre-send compaction state. + */ + setPreSendCompacting(compacting: boolean): void { + this.isPreSendCompacting = compacting; + this.updateUI(); + } /** * Set conversation state (whether a conversation is active) @@ -102,11 +102,11 @@ export class ChatInput { } }; - // Auto-resize on input - const inputHandler = () => { - this.autoResizeInput(); - this.updateUI(); - }; + // Auto-resize on input + const inputHandler = () => { + this.autoResizeInput(); + this.updateUI(); + }; // iOS: Scroll input into view when keyboard opens const focusHandler = () => { @@ -128,24 +128,24 @@ export class ChatInput { inputWrapper.addClass('chat-input-mobile'); } - // Send button - embedded inside the input wrapper (bottom-right) - // Uses Obsidian's clickable-icon class for proper icon sizing - this.sendButton = inputWrapper.createEl('button', { - cls: 'chat-send-button clickable-icon' - }); + // Send button - embedded inside the input wrapper (bottom-right) + // Uses Obsidian's clickable-icon class for proper icon sizing + this.sendButton = inputWrapper.createEl('button', { + cls: 'chat-send-button clickable-icon' + }); // Add send icon using Obsidian's setIcon setIcon(this.sendButton, 'arrow-up'); this.sendButton.setAttribute('aria-label', 'Send message'); - const sendClickHandler = () => { - this.handleSendOrStop(); - }; - this.component!.registerDomEvent(this.sendButton, 'click', sendClickHandler); - - // Initialize suggesters if app is available - if (this.app && this.inputElement) { - this.suggesters = initializeSuggesters(this.app, this.inputElement, this.component); + const sendClickHandler = () => { + this.handleSendOrStop(); + }; + this.component!.registerDomEvent(this.sendButton, 'click', sendClickHandler); + + // Initialize suggesters if app is available + if (this.app && this.inputElement) { + this.suggesters = initializeSuggesters(this.app, this.inputElement, this.component); } this.element = this.container; @@ -155,16 +155,16 @@ export class ChatInput { /** * Handle send or stop based on current state */ - private handleSendOrStop(): void { - const actuallyLoading = this.isLoading || this.getLoadingState(); - const hasPendingInput = this.hasPendingInput(); - - if (actuallyLoading && !hasPendingInput) { - // Stop generation - if (this.onStopGeneration) { - this.onStopGeneration(); - } - } else { + private handleSendOrStop(): void { + const actuallyLoading = this.isLoading || this.getLoadingState(); + const hasPendingInput = this.hasPendingInput(); + + if (actuallyLoading && !hasPendingInput) { + // Stop generation + if (this.onStopGeneration) { + this.onStopGeneration(); + } + } else { // Send message this.handleSendMessage(); } @@ -213,16 +213,16 @@ export class ChatInput { private autoResizeInput(): void { if (!this.inputElement) return; - // Reset height to auto to get the correct scrollHeight - this.inputElement.addClass('chat-input-auto-height'); + // Match CSS values exactly: desktop min 72px / max 200px, mobile min 64px / max 160px + const minHeight = isMobile() ? 64 : 72; + const maxHeight = isMobile() ? 160 : 200; - // Set height limits - matches CSS min/max heights - const minHeight = 48; - const maxHeight = 120; - const newHeight = Math.min(Math.max(this.inputElement.scrollHeight, minHeight), maxHeight); + // Remove explicit height so scrollHeight reflects the natural content height. + // This is more reliable than the class-toggle approach for contenteditable divs, + // which can read a stale scrollHeight before the browser has reflowed. + this.inputElement.style.removeProperty('height'); - // Remove auto-height class and set specific height - this.inputElement.removeClass('chat-input-auto-height'); + const newHeight = Math.min(Math.max(this.inputElement.scrollHeight, minHeight), maxHeight); this.inputElement.style.setProperty('height', newHeight + 'px'); // Enable scrolling if content exceeds max height @@ -238,78 +238,78 @@ export class ChatInput { /** * Update UI based on current state */ - private updateUI(): void { - if (!this.sendButton || !this.inputElement) return; - - const actuallyLoading = this.isLoading || this.getLoadingState(); - const hasConversation = this.getHasConversation ? this.getHasConversation() : this.hasConversation; - const hasPendingInput = this.hasPendingInput(); - this.inputElement.setAttribute('aria-busy', this.isPreSendCompacting ? 'true' : 'false'); - - if (this.isPreSendCompacting) { - this.container.addClass('chat-input-compacting'); - } else { - this.container.removeClass('chat-input-compacting'); - } - - if (!hasConversation) { - // No conversation selected - disable everything - this.sendButton.disabled = true; - this.sendButton.classList.remove('stop-mode'); - this.sendButton.classList.add('disabled-mode'); - this.sendButton.empty(); - setIcon(this.sendButton, 'arrow-up'); - this.sendButton.setAttribute('aria-label', 'No conversation selected'); - this.inputElement.contentEditable = 'false'; - this.inputElement.setAttribute('data-placeholder', 'Select or create a conversation to begin'); - } else if (this.isPreSendCompacting) { - this.sendButton.disabled = true; - this.sendButton.classList.remove('stop-mode'); - this.sendButton.classList.add('disabled-mode'); - this.sendButton.empty(); - setIcon(this.sendButton, 'arrow-up'); - this.sendButton.setAttribute('aria-label', 'Compacting context before sending'); - this.inputElement.contentEditable = 'false'; - this.inputElement.setAttribute('data-placeholder', 'Compacting context before sending...'); - } else if (actuallyLoading) { - // Keep the input active so a new message can interrupt the current turn. - this.sendButton.disabled = false; - this.sendButton.empty(); - this.inputElement.contentEditable = 'true'; - - if (hasPendingInput) { - this.sendButton.classList.remove('stop-mode'); - this.sendButton.classList.remove('disabled-mode'); - setIcon(this.sendButton, 'arrow-up'); - this.sendButton.setAttribute('aria-label', 'Interrupt and send message'); - this.inputElement.setAttribute('data-placeholder', 'Send a steering message...'); - } else { - this.sendButton.classList.add('stop-mode'); - this.sendButton.classList.remove('disabled-mode'); - setIcon(this.sendButton, 'square'); - this.sendButton.setAttribute('aria-label', 'Stop generation'); - this.inputElement.setAttribute('data-placeholder', 'Type to interrupt, or stop generation'); - } - } else { - // Show normal send button - this.sendButton.disabled = false; - this.sendButton.classList.remove('stop-mode'); - this.sendButton.classList.remove('disabled-mode'); + private updateUI(): void { + if (!this.sendButton || !this.inputElement) return; + + const actuallyLoading = this.isLoading || this.getLoadingState(); + const hasConversation = this.getHasConversation ? this.getHasConversation() : this.hasConversation; + const hasPendingInput = this.hasPendingInput(); + this.inputElement.setAttribute('aria-busy', this.isPreSendCompacting ? 'true' : 'false'); + + if (this.isPreSendCompacting) { + this.container.addClass('chat-input-compacting'); + } else { + this.container.removeClass('chat-input-compacting'); + } + + if (!hasConversation) { + // No conversation selected - disable everything + this.sendButton.disabled = true; + this.sendButton.classList.remove('stop-mode'); + this.sendButton.classList.add('disabled-mode'); + this.sendButton.empty(); + setIcon(this.sendButton, 'arrow-up'); + this.sendButton.setAttribute('aria-label', 'No conversation selected'); + this.inputElement.contentEditable = 'false'; + this.inputElement.setAttribute('data-placeholder', 'Select or create a conversation to begin'); + } else if (this.isPreSendCompacting) { + this.sendButton.disabled = true; + this.sendButton.classList.remove('stop-mode'); + this.sendButton.classList.add('disabled-mode'); + this.sendButton.empty(); + setIcon(this.sendButton, 'arrow-up'); + this.sendButton.setAttribute('aria-label', 'Compacting context before sending'); + this.inputElement.contentEditable = 'false'; + this.inputElement.setAttribute('data-placeholder', 'Compacting context before sending...'); + } else if (actuallyLoading) { + // Keep the input active so a new message can interrupt the current turn. + this.sendButton.disabled = false; + this.sendButton.empty(); + this.inputElement.contentEditable = 'true'; + + if (hasPendingInput) { + this.sendButton.classList.remove('stop-mode'); + this.sendButton.classList.remove('disabled-mode'); + setIcon(this.sendButton, 'arrow-up'); + this.sendButton.setAttribute('aria-label', 'Interrupt and send message'); + this.inputElement.setAttribute('data-placeholder', 'Send a steering message...'); + } else { + this.sendButton.classList.add('stop-mode'); + this.sendButton.classList.remove('disabled-mode'); + setIcon(this.sendButton, 'square'); + this.sendButton.setAttribute('aria-label', 'Stop generation'); + this.inputElement.setAttribute('data-placeholder', 'Type to interrupt, or stop generation'); + } + } else { + // Show normal send button + this.sendButton.disabled = false; + this.sendButton.classList.remove('stop-mode'); + this.sendButton.classList.remove('disabled-mode'); this.sendButton.empty(); setIcon(this.sendButton, 'arrow-up'); this.sendButton.setAttribute('aria-label', 'Send message'); this.inputElement.contentEditable = 'true'; - this.inputElement.setAttribute('data-placeholder', 'Type your message...'); - } - } - - private hasPendingInput(): boolean { - if (!this.inputElement) { - return false; - } - - return ContentEditableHelper.getPlainText(this.inputElement).trim().length > 0; - } + this.inputElement.setAttribute('data-placeholder', 'Type your message...'); + } + } + + private hasPendingInput(): boolean { + if (!this.inputElement) { + return false; + } + + return ContentEditableHelper.getPlainText(this.inputElement).trim().length > 0; + } /** * Focus the input @@ -323,13 +323,13 @@ export class ChatInput { /** * Clear the input */ - clear(): void { - if (this.inputElement) { - ContentEditableHelper.clear(this.inputElement); - this.autoResizeInput(); - this.updateUI(); - } - } + clear(): void { + if (this.inputElement) { + ContentEditableHelper.clear(this.inputElement); + this.autoResizeInput(); + this.updateUI(); + } + } /** * Get current input value @@ -341,13 +341,13 @@ export class ChatInput { /** * Set input value */ - setValue(value: string): void { - if (this.inputElement) { - ContentEditableHelper.setPlainText(this.inputElement, value); - this.autoResizeInput(); - this.updateUI(); - } - } + setValue(value: string): void { + if (this.inputElement) { + ContentEditableHelper.setPlainText(this.inputElement, value); + this.autoResizeInput(); + this.updateUI(); + } + } /** * Get message enhancer (for accessing enhancements before sending) @@ -373,9 +373,9 @@ export class ChatInput { this.suggesters.cleanup(); this.suggesters = null; } - - this.element = null; - this.inputElement = null; - this.sendButton = null; - } -} + + this.element = null; + this.inputElement = null; + this.sendButton = null; + } +} From 4aebd253edb71dc0a290cf8a7036607336ff5ef2 Mon Sep 17 00:00:00 2001 From: Midway65 Date: Fri, 3 Apr 2026 20:49:42 -0700 Subject: [PATCH 02/64] fix(accordion): replace full DOM rebuild with targeted in-place updates Instead of calling content.empty() and re-rendering all steps on every refresh(), reconcile existing DOM elements with current state: - Existing steps are updated in place (status class, text, meta, result) - New steps are appended only when first seen - Reasoning items follow the same pattern Eliminates layout thrashing during active tool execution. Fixes BUG-02: accordion formatting shifting on every update. Co-Authored-By: Claude Sonnet 4.6 --- .../components/ProgressiveToolAccordion.ts | 90 +++++++++++++++++-- 1 file changed, 84 insertions(+), 6 deletions(-) diff --git a/src/ui/chat/components/ProgressiveToolAccordion.ts b/src/ui/chat/components/ProgressiveToolAccordion.ts index f85514287..6a510c187 100644 --- a/src/ui/chat/components/ProgressiveToolAccordion.ts +++ b/src/ui/chat/components/ProgressiveToolAccordion.ts @@ -212,23 +212,101 @@ export class ProgressiveToolAccordion { } text.textContent = formatToolGroupHeader(this.displayGroup); - this.renderGroupContent(content); + this.updateGroupContent(content); } - private renderGroupContent(content: HTMLElement): void { - content.empty(); - + /** + * Targeted content update — reconciles existing DOM with current state + * instead of wiping and rebuilding on every refresh call. + * New steps are appended; existing steps are updated in place. + */ + private updateGroupContent(content: HTMLElement): void { if (!this.displayGroup) { return; } if (this.displayGroup.kind === 'reasoning') { - this.renderReasoningItem(content, this.displayGroup.steps[0]); + const step = this.displayGroup.steps[0]; + if (!step) { + return; + } + const existing = content.querySelector('.reasoning-item') as HTMLElement | null; + if (!existing) { + this.renderReasoningItem(content, step); + } else { + this.updateReasoningItem(existing, step); + } return; } for (const step of this.displayGroup.steps) { - this.renderStepItem(content, step); + const existing = content.querySelector(`[data-tool-id="${step.id}"]`) as HTMLElement | null; + if (!existing) { + this.renderStepItem(content, step); + } else { + this.updateStepItem(existing, step); + } + } + } + + private updateReasoningItem(item: HTMLElement, step: ToolDisplayStep): void { + item.className = `progressive-tool-item reasoning-item tool-${step.status}`; + + const meta = item.querySelector('.tool-meta') as HTMLElement | null; + if (meta) { + if (step.status === 'streaming' || step.status === 'executing') { + meta.textContent = 'thinking...'; + meta.addClass('reasoning-streaming'); + } else { + meta.textContent = ''; + meta.removeClass('reasoning-streaming'); + } + } + + const reasoningContent = item.querySelector('[data-reasoning-content]') as HTMLElement | null; + if (reasoningContent) { + reasoningContent.textContent = typeof step.result === 'string' ? step.result : ''; + } + + const existingIndicator = item.querySelector('.reasoning-streaming-indicator'); + if (step.status === 'streaming' || step.status === 'executing') { + if (!existingIndicator) { + const section = item.querySelector('.reasoning-content-section'); + if (section) { + const indicator = section.createDiv('reasoning-streaming-indicator'); + indicator.textContent = '⋯'; + } + } + } else { + existingIndicator?.remove(); + } + } + + private updateStepItem(item: HTMLElement, step: ToolDisplayStep): void { + item.className = `progressive-tool-item tool-${step.status}`; + + const name = item.querySelector('.tool-name') as HTMLElement | null; + if (name) { + name.textContent = step.displayName || formatToolStepLabel(step, this.getTenseForStep(step)); + } + + const meta = item.querySelector('.tool-meta') as HTMLElement | null; + if (meta) { + this.updateExecutionMeta(meta, step); + } + + if (step.status === 'completed' && step.result !== undefined) { + const resultSection = item.querySelector(`[data-result-section="${step.id}"]`) as HTMLElement | null; + if (resultSection && resultSection.hasClass('progressive-accordion-hidden')) { + this.renderResultSection(resultSection, step); + } + } + + if (step.status === 'failed' && step.error) { + const errorSection = item.querySelector(`[data-error-section="${step.id}"]`) as HTMLElement | null; + if (errorSection && errorSection.hasClass('progressive-accordion-hidden')) { + this.renderErrorSection(errorSection, step.error); + } } } From 7e26b3b53ad3196502f62b4168e0854ab7d4d66d Mon Sep 17 00:00:00 2001 From: Midway65 Date: Fri, 3 Apr 2026 20:49:53 -0700 Subject: [PATCH 03/64] fix(chat-view): remove dead isRetry variable in handleStreamingUpdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The variable was computed but never used. Retry functionality is fully handled by MessageAlternativeService — this was leftover from a planned but unimplemented streaming differentiation path. Fixes BUG-04. Co-Authored-By: Claude Sonnet 4.6 --- src/ui/chat/ChatView.ts | 134 +++++++++++++++++++--------------------- 1 file changed, 65 insertions(+), 69 deletions(-) diff --git a/src/ui/chat/ChatView.ts b/src/ui/chat/ChatView.ts index 8d4c0155b..2e75391ae 100644 --- a/src/ui/chat/ChatView.ts +++ b/src/ui/chat/ChatView.ts @@ -44,30 +44,30 @@ import { ChatLayoutBuilder, ChatLayoutElements } from './builders/ChatLayoutBuil import { ChatEventBinder } from './utils/ChatEventBinder'; // Ingest UI -import { IngestEventBinder } from '../../agents/ingestManager/ui/IngestEventBinder'; -import { IngestProgressBanner } from '../../agents/ingestManager/ui/IngestProgressBanner'; -import { IngestConfirmModal, IngestConfirmOptions } from '../../agents/ingestManager/ui/IngestConfirmModal'; -import type { IngestProgress, IngestToolResult } from '../../agents/ingestManager/types'; -import { ACCEPTED_AUDIO_EXTENSIONS } from '../../agents/ingestManager/types'; -import { - getIngestCapabilityOptions, - IngestCapabilityOptions -} from '../../agents/ingestManager/tools/services/IngestCapabilityService'; - -// Utils -import { ReferenceMetadata } from './utils/ReferenceExtractor'; -import { CHAT_VIEW_TYPES } from '../../constants/branding'; -import { getNexusPlugin } from '../../utils/pluginLocator'; +import { IngestEventBinder } from '../../agents/ingestManager/ui/IngestEventBinder'; +import { IngestProgressBanner } from '../../agents/ingestManager/ui/IngestProgressBanner'; +import { IngestConfirmModal, IngestConfirmOptions } from '../../agents/ingestManager/ui/IngestConfirmModal'; +import type { IngestProgress, IngestToolResult } from '../../agents/ingestManager/types'; +import { ACCEPTED_AUDIO_EXTENSIONS } from '../../agents/ingestManager/types'; +import { + getIngestCapabilityOptions, + IngestCapabilityOptions +} from '../../agents/ingestManager/tools/services/IngestCapabilityService'; + +// Utils +import { ReferenceMetadata } from './utils/ReferenceExtractor'; +import { CHAT_VIEW_TYPES } from '../../constants/branding'; +import { getNexusPlugin } from '../../utils/pluginLocator'; // Nexus Lifecycle import { getWebLLMLifecycleManager } from '../../services/llm/adapters/webllm/WebLLMLifecycleManager'; // Subagent infrastructure (delegated to SubagentController) -import type { AgentManager } from '../../services/AgentManager'; -import type { DirectToolExecutor } from '../../services/chat/DirectToolExecutor'; -import type { PromptManagerAgent } from '../../agents/promptManager/promptManager'; -import type { HybridStorageAdapter } from '../../database/adapters/HybridStorageAdapter'; -import { LLMProviderManager } from '../../services/llm/providers/ProviderManager'; +import type { AgentManager } from '../../services/AgentManager'; +import type { DirectToolExecutor } from '../../services/chat/DirectToolExecutor'; +import type { PromptManagerAgent } from '../../agents/promptManager/promptManager'; +import type { HybridStorageAdapter } from '../../database/adapters/HybridStorageAdapter'; +import { LLMProviderManager } from '../../services/llm/providers/ProviderManager'; // Branch UI components import { BranchHeader, BranchViewContext } from './components/BranchHeader'; @@ -500,12 +500,12 @@ export class ChatView extends ItemView { /** * Initialize ingest drag-and-drop UI and progress banner */ - private initializeIngestUI(): void { - const plugin = getNexusPlugin(this.app); - if (!plugin || plugin.settings?.settings?.enableIngestion === false) return; - - // Progress banner (always visible container, banners appear inside on ingest) - this.ingestProgressBanner = new IngestProgressBanner( + private initializeIngestUI(): void { + const plugin = getNexusPlugin(this.app); + if (!plugin || plugin.settings?.settings?.enableIngestion === false) return; + + // Progress banner (always visible container, banners appear inside on ingest) + this.ingestProgressBanner = new IngestProgressBanner( this.layoutElements.ingestBannerContainer ); @@ -524,19 +524,19 @@ export class ChatView extends ItemView { /** * Handle files dropped onto the chat view for ingestion */ - private async handleIngestFiles(files: FileList): Promise { - const plugin = getNexusPlugin(this.app); - if (!plugin) return; - if (plugin.settings?.settings?.enableIngestion === false) { - new Notice('Ingestion is disabled in settings.'); - return; - } - - const settings = plugin.settings?.settings; - const llmSettings = settings?.llmProviders; - const ingestCapabilities = await this.getIngestCapabilities(); - - for (let i = 0; i < files.length; i++) { + private async handleIngestFiles(files: FileList): Promise { + const plugin = getNexusPlugin(this.app); + if (!plugin) return; + if (plugin.settings?.settings?.enableIngestion === false) { + new Notice('Ingestion is disabled in settings.'); + return; + } + + const settings = plugin.settings?.settings; + const llmSettings = settings?.llmProviders; + const ingestCapabilities = await this.getIngestCapabilities(); + + for (let i = 0; i < files.length; i++) { const file = files[i]; const ext = file.name.substring(file.name.lastIndexOf('.')).toLowerCase(); @@ -563,13 +563,13 @@ export class ChatView extends ItemView { filePath: vaultPath, fileType, defaultPdfMode: (llmSettings?.defaultPdfMode as 'text' | 'vision') || 'text', - defaultOcrProvider: llmSettings?.defaultOcrModel?.provider, - defaultOcrModel: llmSettings?.defaultOcrModel?.model, - defaultTranscriptionProvider: llmSettings?.defaultTranscriptionModel?.provider, - defaultTranscriptionModel: llmSettings?.defaultTranscriptionModel?.model, - ocrProviders: ingestCapabilities.ocrProviders, - transcriptionProviders: ingestCapabilities.transcriptionProviders - }; + defaultOcrProvider: llmSettings?.defaultOcrModel?.provider, + defaultOcrModel: llmSettings?.defaultOcrModel?.model, + defaultTranscriptionProvider: llmSettings?.defaultTranscriptionModel?.provider, + defaultTranscriptionModel: llmSettings?.defaultTranscriptionModel?.model, + ocrProviders: ingestCapabilities.ocrProviders, + transcriptionProviders: ingestCapabilities.transcriptionProviders + }; const modal = new IngestConfirmModal(this.app, confirmOptions); const result = await modal.prompt(); @@ -658,26 +658,26 @@ export class ChatView extends ItemView { } } - private async getIngestCapabilities(): Promise { - const plugin = getNexusPlugin(this.app); - if (plugin?.settings?.settings?.enableIngestion === false) { - return { - ocrProviders: [], - transcriptionProviders: [] - }; - } - const llmSettings = plugin?.settings?.settings?.llmProviders; - - if (!llmSettings) { - return { - ocrProviders: [], - transcriptionProviders: [] - }; - } - - const providerManager = new LLMProviderManager(llmSettings, this.app.vault); - return getIngestCapabilityOptions(providerManager); - } + private async getIngestCapabilities(): Promise { + const plugin = getNexusPlugin(this.app); + if (plugin?.settings?.settings?.enableIngestion === false) { + return { + ocrProviders: [], + transcriptionProviders: [] + }; + } + const llmSettings = plugin?.settings?.settings?.llmProviders; + + if (!llmSettings) { + return { + ocrProviders: [], + transcriptionProviders: [] + }; + } + + const providerManager = new LLMProviderManager(llmSettings, this.app.vault); + return getIngestCapabilityOptions(providerManager); + } /** * Initialize subagent infrastructure via SubagentController @@ -956,10 +956,6 @@ export class ChatView extends ItemView { } private handleStreamingUpdate(messageId: string, content: string, isComplete: boolean, isIncremental?: boolean): void { - const currentConversation = this.conversationManager?.getCurrentConversation(); - const message = currentConversation?.messages.find((m) => m.id === messageId); - const isRetry = message && message.branches && message.branches.length > 0; - if (isIncremental) { this.streamingController.updateStreamingChunk(messageId, content); } else if (isComplete) { From 607ec5819c56ea85e87c4f0bdfb49b9fb6fc9a46 Mon Sep 17 00:00:00 2001 From: Midway65 Date: Fri, 3 Apr 2026 20:54:15 -0700 Subject: [PATCH 04/64] remove beta warning banner from chat window Removed the experimental/beta warning banner that appeared on every chat open. The chat window is stable enough for daily use and the warning was more distracting than informative. Removed: createWarningBanner() method, its call site, all associated CSS classes (.chat-experimental-warning and children, .chat-warning-banner-fadeout). Settings-tab warning styles untouched. Co-Authored-By: Claude Sonnet 4.6 --- src/ui/chat/builders/ChatLayoutBuilder.ts | 27 ---------------- styles.css | 39 ----------------------- 2 files changed, 66 deletions(-) diff --git a/src/ui/chat/builders/ChatLayoutBuilder.ts b/src/ui/chat/builders/ChatLayoutBuilder.ts index 424adea23..963f451b8 100644 --- a/src/ui/chat/builders/ChatLayoutBuilder.ts +++ b/src/ui/chat/builders/ChatLayoutBuilder.ts @@ -7,7 +7,6 @@ * - Building header with hamburger, title, and settings buttons * - Creating message display, input, and context containers * - Building sidebar with conversation list - * - Auto-hiding experimental warning banner * * Used by ChatView to build the initial DOM structure, * following the Builder pattern for complex UI construction. @@ -43,9 +42,6 @@ export class ChatLayoutBuilder { const chatLayout = container.createDiv('chat-layout'); const mainContainer = chatLayout.createDiv('chat-main'); - // Experimental warning banner - this.createWarningBanner(mainContainer); - // Header const { chatTitle, hamburgerButton, settingsButton } = this.createHeader(mainContainer); @@ -127,29 +123,6 @@ export class ChatLayoutBuilder { return overlay; } - /** - * Create experimental warning banner with auto-hide - */ - private static createWarningBanner(container: HTMLElement): void { - const warningBanner = container.createDiv('chat-experimental-warning'); - - warningBanner.createEl('span', { cls: 'warning-icon', text: '⚠️' }); - warningBanner.createEl('span', { cls: 'warning-text', text: 'Experimental Feature: Nexus Chat is in beta.' }); - const link = warningBanner.createEl('a', { cls: 'warning-link', text: 'Report issues' }); - link.href = 'https://github.com/ProfSynapse/nexus/issues'; - link.target = '_blank'; - link.rel = 'noopener noreferrer'; - warningBanner.createEl('span', { cls: 'warning-text', text: '• Use at your own risk' }); - - // Auto-hide warning after 5 seconds - setTimeout(() => { - warningBanner.addClass('chat-warning-banner-fadeout'); - setTimeout(() => { - warningBanner.addClass('chat-loading-overlay-hidden'); - }, 500); - }, 5000); - } - /** * Create chat header with hamburger, title, and settings */ diff --git a/styles.css b/styles.css index 0b8ef23e3..81938ecc3 100644 --- a/styles.css +++ b/styles.css @@ -1774,39 +1774,6 @@ /* EXPERIMENTAL WARNING STYLES */ /* =============================== */ -/* ChatView Experimental Warning Banner */ -.chat-experimental-warning { - background: var(--background-modifier-error); - border: 1px solid var(--background-modifier-error-border, var(--background-modifier-border)); - border-radius: 6px; - padding: 8px 12px; - margin-bottom: 12px; - display: flex; - align-items: center; - gap: 8px; - font-size: 0.9em; - flex-wrap: wrap; -} - -.chat-experimental-warning .warning-icon { - font-size: 1.1em; - color: var(--text-warning); -} - -.chat-experimental-warning .warning-text { - color: var(--text-on-accent); -} - -.chat-experimental-warning .warning-link { - color: var(--text-accent); - text-decoration: underline; - font-weight: 500; -} - -.chat-experimental-warning .warning-link:hover { - color: var(--text-accent-hover); -} - /* Settings Tab Experimental Warning Styles */ .experimental-warning-container { background: var(--background-modifier-error); @@ -7083,12 +7050,6 @@ body.is-mobile .chat-loading-overlay { width: 0%; } -/* Warning Banner Fade Out */ -.chat-warning-banner-fadeout { - opacity: 0; - transition: opacity 0.5s ease-out; -} - /* Input Auto-Resize Styles */ .chat-input-auto-height { height: auto; From 72abed8a57b7e06250f1e038da10d1a5a4c9e832 Mon Sep 17 00:00:00 2001 From: Midway65 Date: Fri, 3 Apr 2026 21:25:43 -0700 Subject: [PATCH 05/64] =?UTF-8?q?add=20Chat=20Action=20Buttons=20=E2=80=94?= =?UTF-8?q?=20Copy,=20Insert,=20Append,=20Create=20File=20pill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a MessageActionBar pill below every completed AI message that has text content. Moves the copy action out of the bubble header and into the pill alongside Insert at cursor, Append to active note, and Create new file. CreateFileModal handles naming, folder selection (default 00-inbox), and optional open-after-save. Pill fades to 35% opacity at rest and full opacity on hover. - src/ui/chat/components/CreateFileModal.ts (new) - src/ui/chat/components/MessageActionBar.ts (new) - src/ui/chat/components/MessageBubble.ts — appendActionBar, cleanupActionBar, remove header copy button for assistant messages - src/ui/chat/components/factories/ToolBubbleFactory.ts — remove copy button from createTextBubble, drop onCopy/showCopyFeedback params - styles.css — message-action-bar and message-action-bar-btn styles Co-Authored-By: Claude Sonnet 4.6 --- src/ui/chat/components/CreateFileModal.ts | 119 +++++ src/ui/chat/components/MessageActionBar.ts | 104 ++++ src/ui/chat/components/MessageBubble.ts | 480 +++++++++--------- .../components/factories/ToolBubbleFactory.ts | 25 +- styles.css | 56 ++ 5 files changed, 532 insertions(+), 252 deletions(-) create mode 100644 src/ui/chat/components/CreateFileModal.ts create mode 100644 src/ui/chat/components/MessageActionBar.ts diff --git a/src/ui/chat/components/CreateFileModal.ts b/src/ui/chat/components/CreateFileModal.ts new file mode 100644 index 000000000..842f080e7 --- /dev/null +++ b/src/ui/chat/components/CreateFileModal.ts @@ -0,0 +1,119 @@ +/** + * CreateFileModal - Modal for creating a new vault file from chat content + * Location: /src/ui/chat/components/CreateFileModal.ts + * + * Presents a filename field, folder path (defaulting to 00-inbox), and an + * open-after-save toggle. Creates the folder if it does not exist, guards + * against duplicate filenames, and opens the created file on request. + * + * Used by MessageActionBar when the user clicks "Create new file". + */ + +import { App, Modal, Notice, Setting, normalizePath } from 'obsidian'; + +export class CreateFileModal extends Modal { + private filename = ''; + private folderPath = '00-inbox'; + private openAfterSave = true; + private readonly content: string; + + constructor(app: App, content: string) { + super(app); + this.content = content; + } + + onOpen(): void { + const { contentEl } = this; + + contentEl.createEl('h2', { text: 'Create new file' }); + + // File name input + let filenameInput: HTMLInputElement; + new Setting(contentEl) + .setName('File name') + .addText(text => { + text.setPlaceholder('Note name') + .onChange(value => { this.filename = value; }); + filenameInput = text.inputEl; + }); + + // Folder path input + new Setting(contentEl) + .setName('Folder') + .setDesc('Folder path within your vault') + .addText(text => { + text.setValue(this.folderPath) + .onChange(value => { this.folderPath = value; }); + }); + + // Open after save toggle + new Setting(contentEl) + .setName('Open after saving') + .addToggle(toggle => { + toggle.setValue(this.openAfterSave) + .onChange(value => { this.openAfterSave = value; }); + }); + + // Action buttons + new Setting(contentEl) + .addButton(button => { + button.setButtonText('Create') + .setCta() + .onClick(() => this.handleCreate()); + }) + .addButton(button => { + button.setButtonText('Cancel') + .onClick(() => this.close()); + }); + + // Focus filename input on open + setTimeout(() => { filenameInput?.focus(); }, 50); + } + + private async handleCreate(): Promise { + // Strip .md suffix and trim whitespace + let name = this.filename.trim(); + if (name.toLowerCase().endsWith('.md')) { + name = name.slice(0, -3).trim(); + } + + if (!name) { + new Notice('Please enter a file name.'); + return; + } + + const folder = this.folderPath.trim() || '00-inbox'; + const filePath = normalizePath(`${folder}/${name}.md`); + + // Guard against duplicate + if (this.app.vault.getFileByPath(filePath)) { + new Notice(`File already exists: ${filePath}`); + return; + } + + try { + await this.ensureFolder(folder); + const file = await this.app.vault.create(filePath, this.content); + new Notice(`Created: ${name}.md`); + this.close(); + + if (this.openAfterSave) { + await this.app.workspace.getLeaf().openFile(file); + } + } catch (err) { + console.error('[CreateFileModal] Error creating file:', err); + new Notice(`Failed to create file: ${String(err)}`); + } + } + + private async ensureFolder(folderPath: string): Promise { + const normalized = normalizePath(folderPath); + if (!this.app.vault.getAbstractFileByPath(normalized)) { + await this.app.vault.createFolder(normalized); + } + } + + onClose(): void { + this.contentEl.empty(); + } +} diff --git a/src/ui/chat/components/MessageActionBar.ts b/src/ui/chat/components/MessageActionBar.ts new file mode 100644 index 000000000..0bd87b878 --- /dev/null +++ b/src/ui/chat/components/MessageActionBar.ts @@ -0,0 +1,104 @@ +/** + * MessageActionBar - Action pill shown below completed AI message bubbles + * Location: /src/ui/chat/components/MessageActionBar.ts + * + * Renders four action buttons: Copy, Insert at cursor, Append to active note, + * and Create new file. Appears only on completed assistant messages that have + * non-empty text content. Fades to 35% opacity at rest and full opacity on hover. + * + * Used by MessageBubble.appendActionBar() after a message reaches completed state. + */ + +import { App, Component, MarkdownView, Notice, setIcon } from 'obsidian'; +import { CreateFileModal } from './CreateFileModal'; + +export class MessageActionBar extends Component { + private element: HTMLElement | null = null; + + constructor( + private readonly content: string, + private readonly app: App + ) { + super(); + } + + /** + * Build and return the pill element. Call once — the element is stored and + * returned by getElement() for later DOM removal. + */ + createElement(): HTMLElement { + const bar = document.createElement('div'); + bar.addClass('message-action-bar'); + + this.addButton(bar, 'copy', 'Copy message', () => this.handleCopy(bar)); + this.addButton(bar, 'file-input', 'Insert at cursor', () => this.handleInsert()); + this.addButton(bar, 'file-plus-2', 'Append to active note', () => this.handleAppend()); + this.addButton(bar, 'file-plus', 'Create new file', () => this.handleCreate()); + + this.element = bar; + return bar; + } + + getElement(): HTMLElement | null { + return this.element; + } + + // ─── Private helpers ──────────────────────────────────────────────────────── + + private addButton( + parent: HTMLElement, + icon: string, + title: string, + handler: () => void + ): void { + const btn = parent.createEl('button', { + cls: 'message-action-bar-btn clickable-icon', + attr: { title, 'aria-label': title } + }); + setIcon(btn, icon); + this.registerDomEvent(btn, 'click', handler); + } + + private handleCopy(bar: HTMLElement): void { + navigator.clipboard.writeText(this.content).then(() => { + const btn = bar.querySelector('[title="Copy message"]'); + if (btn instanceof HTMLElement) { + this.showCopyFeedback(btn); + } + }).catch(err => { + console.error('[MessageActionBar] Copy failed:', err); + new Notice('Copy failed.'); + }); + } + + private showCopyFeedback(button: HTMLElement): void { + setIcon(button, 'check'); + setTimeout(() => { + setIcon(button, 'copy'); + }, 1500); + } + + private handleInsert(): void { + const view = this.app.workspace.getActiveViewOfType(MarkdownView); + if (!view) return; + view.editor.replaceSelection(this.content); + } + + private async handleAppend(): Promise { + const view = this.app.workspace.getActiveViewOfType(MarkdownView); + if (!view?.file) return; + + const timestamp = new Date().toLocaleString(); + const separator = `\n\n---\n*Appended from Nexus Chat — ${timestamp}*\n\n`; + + await this.app.vault.process(view.file, (fileContent) => { + return fileContent + separator + this.content; + }); + + new Notice('Appended to note.'); + } + + private handleCreate(): void { + new CreateFileModal(this.app, this.content).open(); + } +} diff --git a/src/ui/chat/components/MessageBubble.ts b/src/ui/chat/components/MessageBubble.ts index e59244fff..f54145955 100644 --- a/src/ui/chat/components/MessageBubble.ts +++ b/src/ui/chat/components/MessageBubble.ts @@ -13,17 +13,18 @@ import { ConversationMessage } from '../../../types/chat/ChatTypes'; import { ProgressiveToolAccordion } from './ProgressiveToolAccordion'; import { MessageBranchNavigator, MessageBranchNavigatorEvents } from './MessageBranchNavigator'; +import { MessageActionBar } from './MessageActionBar'; import { setIcon, Component, App } from 'obsidian'; // Extracted classes import { ReferenceBadgeRenderer } from './renderers/ReferenceBadgeRenderer'; -import { ToolBubbleFactory } from './factories/ToolBubbleFactory'; -import { ToolEventParser } from '../utils/ToolEventParser'; -import { normalizeToolCallForDisplay } from '../utils/toolDisplayNormalizer'; -import { MessageContentRenderer } from './renderers/MessageContentRenderer'; -import { MessageEditController } from '../controllers/MessageEditController'; +import { ToolBubbleFactory } from './factories/ToolBubbleFactory'; +import { ToolEventParser } from '../utils/ToolEventParser'; +import { normalizeToolCallForDisplay } from '../utils/toolDisplayNormalizer'; +import { MessageContentRenderer } from './renderers/MessageContentRenderer'; +import { MessageEditController } from '../controllers/MessageEditController'; -export class MessageBubble extends Component { +export class MessageBubble extends Component { private element: HTMLElement | null = null; private loadingInterval: any = null; private progressiveToolAccordions: Map = new Map(); @@ -31,6 +32,7 @@ export class MessageBubble extends Component { private toolBubbleElement: HTMLElement | null = null; private textBubbleElement: HTMLElement | null = null; private imageBubbleElement: HTMLElement | null = null; + private actionBar: MessageActionBar | null = null; constructor( private message: ConversationMessage, @@ -45,17 +47,17 @@ export class MessageBubble extends Component { super(); } - /** - * Create the message bubble element - * For assistant messages with toolCalls or reasoning, returns a fragment containing tool bubble + text bubble - */ - createElement(): HTMLElement { - const activeToolCalls = this.getActiveToolCalls(this.message); - const activeReasoning = this.getActiveReasoning(this.message); - const showToolBubble = this.getRenderMode(this.message) === 'group'; - const activeContent = this.getActiveMessageContent(this.message); - - if (showToolBubble) { + /** + * Create the message bubble element + * For assistant messages with toolCalls or reasoning, returns a fragment containing tool bubble + text bubble + */ + createElement(): HTMLElement { + const activeToolCalls = this.getActiveToolCalls(this.message); + const activeReasoning = this.getActiveReasoning(this.message); + const showToolBubble = this.getRenderMode(this.message) === 'group'; + const activeContent = this.getActiveMessageContent(this.message); + + if (showToolBubble) { const wrapper = document.createElement('div'); wrapper.addClass('message-group'); wrapper.setAttribute('data-message-id', this.message.id); @@ -64,11 +66,11 @@ export class MessageBubble extends Component { const renderMessage: ConversationMessage = { ...this.message, toolCalls: activeToolCalls, reasoning: activeReasoning }; // Create tool bubble using factory - this.toolBubbleElement = ToolBubbleFactory.createToolBubble({ - message: renderMessage, - progressiveToolAccordions: this.progressiveToolAccordions, - component: this - }); + this.toolBubbleElement = ToolBubbleFactory.createToolBubble({ + message: renderMessage, + progressiveToolAccordions: this.progressiveToolAccordions, + component: this + }); wrapper.appendChild(this.toolBubbleElement); // Wire up onViewBranch callback to all accordions @@ -91,12 +93,10 @@ export class MessageBubble extends Component { } // Create text bubble if there's content OR if streaming (need element for StreamingController) - if (this.shouldRenderTextBubble(this.message)) { - this.textBubbleElement = ToolBubbleFactory.createTextBubble( - renderMessage, - (container, content) => this.renderContent(container, content), - this.onCopy, - (button) => this.showCopyFeedback(button), + if (this.shouldRenderTextBubble(this.message)) { + this.textBubbleElement = ToolBubbleFactory.createTextBubble( + renderMessage, + (container, content) => this.renderContent(container, content), this.messageBranchNavigator, this.onMessageAlternativeChanged, this @@ -104,9 +104,9 @@ export class MessageBubble extends Component { wrapper.appendChild(this.textBubbleElement); // Add branch navigator for assistant messages with branches - if (renderMessage.branches && renderMessage.branches.length > 0) { - const actions = this.textBubbleElement.querySelector('.message-actions-external'); - if (actions instanceof HTMLElement) { + if (renderMessage.branches && renderMessage.branches.length > 0) { + const actions = this.textBubbleElement.querySelector('.message-actions-external'); + if (actions instanceof HTMLElement) { const navigatorEvents: MessageBranchNavigatorEvents = { onAlternativeChanged: (messageId, alternativeIndex) => { if (this.onMessageAlternativeChanged) { @@ -117,17 +117,18 @@ export class MessageBubble extends Component { }; this.messageBranchNavigator = new MessageBranchNavigator(actions, navigatorEvents, this); - this.messageBranchNavigator.updateMessage(renderMessage); - } - } - - const contentElement = this.textBubbleElement.querySelector('.message-content'); - if (contentElement instanceof HTMLElement && this.message.isLoading && !activeContent.trim()) { - this.appendLoadingIndicator(contentElement); - } - } - - this.element = wrapper; + this.messageBranchNavigator.updateMessage(renderMessage); + } + } + + const contentElement = this.textBubbleElement.querySelector('.message-content'); + if (contentElement instanceof HTMLElement && this.message.isLoading && !activeContent.trim()) { + this.appendLoadingIndicator(contentElement); + } + } + + this.element = wrapper; + this.appendActionBar(wrapper, this.message); return wrapper; } @@ -178,11 +179,12 @@ export class MessageBubble extends Component { }); this.element = messageContainer; + this.appendActionBar(messageContainer, this.message); return messageContainer; } /** - * Create action buttons (edit, retry, copy, branch navigator) + * Create action buttons (edit, retry, branch navigator) */ private createActionButtons(actions: HTMLElement, bubble: HTMLElement): void { if (this.message.role === 'user') { @@ -221,17 +223,6 @@ export class MessageBubble extends Component { this.onCopy(this.message.id); }); } else { - // Copy button for AI messages - const copyBtn = actions.createEl('button', { - cls: 'message-action-btn clickable-icon', - attr: { title: 'Copy message' } - }); - setIcon(copyBtn, 'copy'); - this.registerDomEvent(copyBtn, 'click', () => { - this.showCopyFeedback(copyBtn); - this.onCopy(this.message.id); - }); - // Message branch navigator for AI messages with branches if (this.message.branches && this.message.branches.length > 0) { const navigatorEvents: MessageBranchNavigatorEvents = { @@ -343,15 +334,15 @@ export class MessageBubble extends Component { /** * Update MessageBubble with new message data */ - updateWithNewMessage(newMessage: ConversationMessage): void { - const previousRenderMode = this.getRenderMode(this.message); - const nextRenderMode = this.getRenderMode(newMessage); - const previousHadTextBubble = this.shouldRenderTextBubble(this.message); - const nextNeedsTextBubble = this.shouldRenderTextBubble(newMessage); - - // Handle progressive accordion transition to static - const activeToolCalls = this.getActiveToolCalls(newMessage); - if (this.progressiveToolAccordions.size > 0 && activeToolCalls) { + updateWithNewMessage(newMessage: ConversationMessage): void { + const previousRenderMode = this.getRenderMode(this.message); + const nextRenderMode = this.getRenderMode(newMessage); + const previousHadTextBubble = this.shouldRenderTextBubble(this.message); + const nextNeedsTextBubble = this.shouldRenderTextBubble(newMessage); + + // Handle progressive accordion transition to static + const activeToolCalls = this.getActiveToolCalls(newMessage); + if (this.progressiveToolAccordions.size > 0 && activeToolCalls) { const hasCompletedTools = activeToolCalls.some(tc => tc.result !== undefined || tc.success !== undefined ); @@ -362,16 +353,16 @@ export class MessageBubble extends Component { this.messageBranchNavigator.updateMessage(newMessage); } return; - } - } - - if (previousRenderMode !== nextRenderMode || previousHadTextBubble !== nextNeedsTextBubble) { - this.message = newMessage; - this.rebuildElement(); - return; - } - - this.message = newMessage; + } + } + + if (previousRenderMode !== nextRenderMode || previousHadTextBubble !== nextNeedsTextBubble) { + this.message = newMessage; + this.rebuildElement(); + return; + } + + this.message = newMessage; // Clear tool accordions and tool bubble when new message has no tool calls (e.g., retry clear) if (!activeToolCalls || activeToolCalls.length === 0) { @@ -409,43 +400,45 @@ export class MessageBubble extends Component { } } - if (!this.element) return; - const contentElement = this.element.querySelector('.message-content'); - if (!(contentElement instanceof HTMLElement)) { - this.rebuildElement(); - return; - } - - contentElement.empty(); + if (!this.element) return; + const contentElement = this.element.querySelector('.message-content'); + if (!(contentElement instanceof HTMLElement)) { + this.rebuildElement(); + return; + } + + contentElement.empty(); const activeContent = this.getActiveMessageContent(newMessage); this.renderContent(contentElement as HTMLElement, activeContent).catch(error => { console.error('[MessageBubble] Error re-rendering content:', error); - }); - - if (newMessage.isLoading && newMessage.role === 'assistant') { - this.appendLoadingIndicator(contentElement); - } - } - - /** - * Handle tool events from MessageManager - */ - handleToolEvent(event: 'detected' | 'updated' | 'started' | 'completed', data: any): void { - const info = ToolEventParser.getToolEventInfo(data, event); - const toolId = info.toolId || info.batchId || info.parentToolCallId || info.stepId; - if (!toolId) { - return; - } - - let accordion = this.progressiveToolAccordions.get(toolId); - - if (!accordion && (event === 'detected' || event === 'started' || event === 'completed')) { - accordion = new ProgressiveToolAccordion(this); - const accordionElement = accordion.createElement(); - - // Wire up onViewBranch callback for subagent navigation - if (this.onViewBranch) { + }); + + if (newMessage.isLoading && newMessage.role === 'assistant') { + this.appendLoadingIndicator(contentElement); + } else if (this.element) { + this.appendActionBar(this.element, newMessage); + } + } + + /** + * Handle tool events from MessageManager + */ + handleToolEvent(event: 'detected' | 'updated' | 'started' | 'completed', data: any): void { + const info = ToolEventParser.getToolEventInfo(data, event); + const toolId = info.toolId || info.batchId || info.parentToolCallId || info.stepId; + if (!toolId) { + return; + } + + let accordion = this.progressiveToolAccordions.get(toolId); + + if (!accordion && (event === 'detected' || event === 'started' || event === 'completed')) { + accordion = new ProgressiveToolAccordion(this); + const accordionElement = accordion.createElement(); + + // Wire up onViewBranch callback for subagent navigation + if (this.onViewBranch) { accordion.setCallbacks({ onViewBranch: this.onViewBranch }); } @@ -458,61 +451,61 @@ export class MessageBubble extends Component { toolContent.appendChild(accordionElement); } - this.progressiveToolAccordions.set(toolId, accordion); - } - - if (!accordion) { - return; - } - - const hasToolMetadata = - Boolean(data?.toolCall) || - Boolean(data?.name) || - Boolean(data?.technicalName) || - Boolean(data?.displayName); - - const isLiveBatchStep = Boolean(info.isBatchStepEvent); - - if (event === 'completed' && !hasToolMetadata) { - accordion.completeTool(toolId, data.result, data.success !== false, data.error); - } else { - const currentGroup = accordion.getDisplayGroup(); - const nextDisplayGroup = isLiveBatchStep - ? normalizeToolCallForDisplay({ - ...data, - id: toolId, - toolId, - parentToolCallId: info.parentToolCallId ?? info.batchId ?? toolId, - batchId: info.batchId ?? toolId, - callIndex: info.callIndex, - totalCalls: info.totalCalls, - strategy: info.strategy, - stepId: info.stepId, - status: info.status - }, currentGroup) - : info.displayGroup; - - const shouldPreserveCurrentBatch = - !isLiveBatchStep && - Boolean(currentGroup) && - currentGroup?.kind === 'batch' && - currentGroup.steps.length > 0 && - nextDisplayGroup.kind === 'batch' && - nextDisplayGroup.steps.length === 0 && - ( - nextDisplayGroup.technicalName === 'useTools' || - nextDisplayGroup.technicalName?.endsWith('.useTools') - ); - - const displayGroup = shouldPreserveCurrentBatch ? currentGroup! : nextDisplayGroup; - - accordion.setDisplayGroup(displayGroup); - } - - if (event === 'completed' && data.success && data.result) { - this.checkAndRenderImageResult(data.result); - } - } + this.progressiveToolAccordions.set(toolId, accordion); + } + + if (!accordion) { + return; + } + + const hasToolMetadata = + Boolean(data?.toolCall) || + Boolean(data?.name) || + Boolean(data?.technicalName) || + Boolean(data?.displayName); + + const isLiveBatchStep = Boolean(info.isBatchStepEvent); + + if (event === 'completed' && !hasToolMetadata) { + accordion.completeTool(toolId, data.result, data.success !== false, data.error); + } else { + const currentGroup = accordion.getDisplayGroup(); + const nextDisplayGroup = isLiveBatchStep + ? normalizeToolCallForDisplay({ + ...data, + id: toolId, + toolId, + parentToolCallId: info.parentToolCallId ?? info.batchId ?? toolId, + batchId: info.batchId ?? toolId, + callIndex: info.callIndex, + totalCalls: info.totalCalls, + strategy: info.strategy, + stepId: info.stepId, + status: info.status + }, currentGroup) + : info.displayGroup; + + const shouldPreserveCurrentBatch = + !isLiveBatchStep && + Boolean(currentGroup) && + currentGroup?.kind === 'batch' && + currentGroup.steps.length > 0 && + nextDisplayGroup.kind === 'batch' && + nextDisplayGroup.steps.length === 0 && + ( + nextDisplayGroup.technicalName === 'useTools' || + nextDisplayGroup.technicalName?.endsWith('.useTools') + ); + + const displayGroup = shouldPreserveCurrentBatch ? currentGroup! : nextDisplayGroup; + + accordion.setDisplayGroup(displayGroup); + } + + if (event === 'completed' && data.success && data.result) { + this.checkAndRenderImageResult(data.result); + } + } /** * Create tool bubble on-demand during streaming @@ -623,73 +616,74 @@ export class MessageBubble extends Component { /** * Get progressive tool accordions for external updates */ - getProgressiveToolAccordions(): Map { - return this.progressiveToolAccordions; - } - - /** - * Determine which DOM structure this message needs. - */ - private getRenderMode(message: ConversationMessage): 'group' | 'standard' { - const activeToolCalls = this.getActiveToolCalls(message); - const hasToolCalls = message.role === 'assistant' && !!activeToolCalls && activeToolCalls.length > 0; - const activeReasoning = this.getActiveReasoning(message); - const hasReasoning = message.role === 'assistant' && !!activeReasoning; - return hasToolCalls || hasReasoning ? 'group' : 'standard'; - } - - /** - * Tool/reasoning messages still need a text bubble while loading so streaming - * updates always have a content container to target. - */ - private shouldRenderTextBubble(message: ConversationMessage): boolean { - if (message.role !== 'assistant') { - return false; - } - - const activeContent = this.getActiveMessageContent(message); - return !!activeContent.trim() || message.state === 'streaming' || !!message.isLoading; - } - - /** - * Replace the current DOM node when the message switches between incompatible - * layouts, such as tool-only -> plain loading bubble during retry. - */ - private rebuildElement(): void { - const previousElement = this.element; - const parentElement = previousElement?.parentElement ?? null; - - this.stopLoadingAnimation(); - this.cleanupProgressiveAccordions(); - - if (this.messageBranchNavigator) { - this.messageBranchNavigator.destroy(); - this.messageBranchNavigator = null; - } - - this.toolBubbleElement = null; - this.textBubbleElement = null; - this.imageBubbleElement = null; - - const nextElement = this.createElement(); - - if (previousElement && parentElement) { - previousElement.replaceWith(nextElement); - } else { - this.element = nextElement; - } - } - - /** - * Render the inline loading indicator used after the initial bubble is on screen. - */ - private appendLoadingIndicator(contentElement: HTMLElement): void { - const loadingDiv = contentElement.createDiv('ai-loading-continuation'); - const loadingSpan = loadingDiv.createEl('span', { cls: 'ai-loading' }); - loadingSpan.appendText('Thinking'); - loadingSpan.createEl('span', { cls: 'dots', text: '...' }); - this.startLoadingAnimation(loadingDiv); - } + getProgressiveToolAccordions(): Map { + return this.progressiveToolAccordions; + } + + /** + * Determine which DOM structure this message needs. + */ + private getRenderMode(message: ConversationMessage): 'group' | 'standard' { + const activeToolCalls = this.getActiveToolCalls(message); + const hasToolCalls = message.role === 'assistant' && !!activeToolCalls && activeToolCalls.length > 0; + const activeReasoning = this.getActiveReasoning(message); + const hasReasoning = message.role === 'assistant' && !!activeReasoning; + return hasToolCalls || hasReasoning ? 'group' : 'standard'; + } + + /** + * Tool/reasoning messages still need a text bubble while loading so streaming + * updates always have a content container to target. + */ + private shouldRenderTextBubble(message: ConversationMessage): boolean { + if (message.role !== 'assistant') { + return false; + } + + const activeContent = this.getActiveMessageContent(message); + return !!activeContent.trim() || message.state === 'streaming' || !!message.isLoading; + } + + /** + * Replace the current DOM node when the message switches between incompatible + * layouts, such as tool-only -> plain loading bubble during retry. + */ + private rebuildElement(): void { + const previousElement = this.element; + const parentElement = previousElement?.parentElement ?? null; + + this.stopLoadingAnimation(); + this.cleanupProgressiveAccordions(); + + if (this.messageBranchNavigator) { + this.messageBranchNavigator.destroy(); + this.messageBranchNavigator = null; + } + + this.toolBubbleElement = null; + this.textBubbleElement = null; + this.imageBubbleElement = null; + this.cleanupActionBar(); + + const nextElement = this.createElement(); + + if (previousElement && parentElement) { + previousElement.replaceWith(nextElement); + } else { + this.element = nextElement; + } + } + + /** + * Render the inline loading indicator used after the initial bubble is on screen. + */ + private appendLoadingIndicator(contentElement: HTMLElement): void { + const loadingDiv = contentElement.createDiv('ai-loading-continuation'); + const loadingSpan = loadingDiv.createEl('span', { cls: 'ai-loading' }); + loadingSpan.appendText('Thinking'); + loadingSpan.createEl('span', { cls: 'dots', text: '...' }); + this.startLoadingAnimation(loadingDiv); + } /** * Get the active content for the message (original or from branch) @@ -794,6 +788,35 @@ export class MessageBubble extends Component { this.progressiveToolAccordions.clear(); } + /** + * Append the action bar pill below the message container for completed + * assistant messages that have non-empty text content. + */ + private appendActionBar(container: HTMLElement, message: ConversationMessage): void { + if (message.role !== 'assistant') return; + if (message.isLoading || message.state === 'streaming') return; + + const activeContent = this.getActiveMessageContent(message); + if (!activeContent.trim()) return; + + // Only create once per message lifecycle — rebuildElement resets this.actionBar + if (this.actionBar !== null) return; + + this.actionBar = new MessageActionBar(activeContent, this.app); + container.appendChild(this.actionBar.createElement()); + } + + /** + * Remove the action bar from the DOM and unload its event handlers. + */ + private cleanupActionBar(): void { + if (!this.actionBar) return; + const el = this.actionBar.getElement(); + if (el) el.remove(); + this.actionBar.unload(); + this.actionBar = null; + } + /** * Cleanup resources. * Calls Component.unload() to auto-clean registerDomEvent/registerInterval handlers. @@ -803,6 +826,7 @@ export class MessageBubble extends Component { cleanup(): void { this.stopLoadingAnimation(); this.cleanupProgressiveAccordions(); + this.cleanupActionBar(); if (this.messageBranchNavigator) { this.messageBranchNavigator.destroy(); diff --git a/src/ui/chat/components/factories/ToolBubbleFactory.ts b/src/ui/chat/components/factories/ToolBubbleFactory.ts index 772d38e4d..18fd03255 100644 --- a/src/ui/chat/components/factories/ToolBubbleFactory.ts +++ b/src/ui/chat/components/factories/ToolBubbleFactory.ts @@ -90,8 +90,6 @@ export class ToolBubbleFactory { static createTextBubble( message: ConversationMessage, renderContentCallback: (content: HTMLElement, text: string) => Promise, - onCopy: (messageId: string) => void, - showCopyFeedback: (button: HTMLElement) => void, messageBranchNavigator: any | null, onMessageAlternativeChanged?: (messageId: string, alternativeIndex: number) => void, component?: Component @@ -104,7 +102,7 @@ export class ToolBubbleFactory { const bubble = messageContainer.createDiv('message-bubble'); // Actions inside the bubble (for sticky positioning) - const actions = bubble.createDiv('message-actions-external'); + bubble.createDiv('message-actions-external'); // Header with bot icon const header = bubble.createDiv('message-header'); @@ -120,29 +118,8 @@ export class ToolBubbleFactory { console.error('[ToolBubbleFactory] Error rendering text bubble content:', error); }); - // Copy button - const copyBtn = actions.createEl('button', { - cls: 'message-action-btn clickable-icon', - attr: { title: 'Copy message' } - }); - setIcon(copyBtn, 'copy'); - const copyHandler = () => { - showCopyFeedback(copyBtn); - onCopy(message.id); - }; - component!.registerDomEvent(copyBtn, 'click', copyHandler); - // Message branch navigator for messages with branches if (message.branches && message.branches.length > 0 && messageBranchNavigator) { - const navigatorEvents = { - onAlternativeChanged: (messageId: string, alternativeIndex: number) => { - if (onMessageAlternativeChanged) { - onMessageAlternativeChanged(messageId, alternativeIndex); - } - }, - onError: (errorMessage: string) => console.error('[ToolBubbleFactory] Branch navigation error:', errorMessage) - }; - messageBranchNavigator.updateMessage(message); } diff --git a/styles.css b/styles.css index 81938ecc3..71b432eaf 100644 --- a/styles.css +++ b/styles.css @@ -7928,3 +7928,59 @@ body.is-mobile .nexus-task-board-shell { min-width: 80px; min-height: 44px; } + +/* ── Message Action Bar (pill below completed AI bubbles) ─────────────────── */ + +.message-action-bar { + display: flex; + flex-direction: row; + gap: 2px; + padding: 2px 6px; + margin-top: 4px; + opacity: 0.35; + transition: opacity 0.15s ease; +} + +.message-action-bar:hover { + opacity: 1; +} + +.message-action-bar-btn { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + background: transparent; + border: none; + border-radius: var(--radius-s); + color: var(--text-muted); + cursor: pointer; + transition: background-color 0.1s ease, color 0.1s ease; +} + +.message-action-bar-btn svg { + width: 12px; + height: 12px; +} + +.message-action-bar-btn:hover { + background: var(--background-modifier-hover); + color: var(--text-normal); +} + +/* Mobile: larger touch targets */ +body.is-mobile .message-action-bar-btn { + width: 36px; + height: 36px; +} + +body.is-mobile .message-action-bar-btn svg { + width: 16px; + height: 16px; +} + +body.is-mobile .message-action-bar { + opacity: 0.85; +} From 176db1eb72937dcadd9eb4821bfbbdc54bd97b05 Mon Sep 17 00:00:00 2001 From: Midway65 Date: Fri, 3 Apr 2026 21:29:40 -0700 Subject: [PATCH 06/64] fix: remove dead params from createTextBubble after copy button removal onMessageAlternativeChanged and component were only used by the copy button logic that was removed in the previous commit. Stale header comment in MessageBubble also updated. Co-Authored-By: Claude Sonnet 4.6 --- src/ui/chat/components/MessageBubble.ts | 6 ++---- src/ui/chat/components/factories/ToolBubbleFactory.ts | 4 +--- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/ui/chat/components/MessageBubble.ts b/src/ui/chat/components/MessageBubble.ts index f54145955..3f3ca64f8 100644 --- a/src/ui/chat/components/MessageBubble.ts +++ b/src/ui/chat/components/MessageBubble.ts @@ -2,7 +2,7 @@ * MessageBubble - Individual message bubble component * Location: /src/ui/chat/components/MessageBubble.ts * - * Renders user/AI messages with copy, retry, and edit actions. + * Renders user/AI messages with retry, edit, and action bar actions. * Delegates rendering responsibilities to specialized classes following SOLID principles. * * Used by MessageDisplay to render individual messages in the chat interface. @@ -97,9 +97,7 @@ export class MessageBubble extends Component { this.textBubbleElement = ToolBubbleFactory.createTextBubble( renderMessage, (container, content) => this.renderContent(container, content), - this.messageBranchNavigator, - this.onMessageAlternativeChanged, - this + this.messageBranchNavigator ); wrapper.appendChild(this.textBubbleElement); diff --git a/src/ui/chat/components/factories/ToolBubbleFactory.ts b/src/ui/chat/components/factories/ToolBubbleFactory.ts index 18fd03255..2ff1cc7b6 100644 --- a/src/ui/chat/components/factories/ToolBubbleFactory.ts +++ b/src/ui/chat/components/factories/ToolBubbleFactory.ts @@ -90,9 +90,7 @@ export class ToolBubbleFactory { static createTextBubble( message: ConversationMessage, renderContentCallback: (content: HTMLElement, text: string) => Promise, - messageBranchNavigator: any | null, - onMessageAlternativeChanged?: (messageId: string, alternativeIndex: number) => void, - component?: Component + messageBranchNavigator: any | null ): HTMLElement { const messageContainer = document.createElement('div'); messageContainer.addClass('message-container'); From cf8dc23172051d1504003a2da5b03fd11fda0265 Mon Sep 17 00:00:00 2001 From: Midway65 Date: Fri, 3 Apr 2026 21:31:02 -0700 Subject: [PATCH 07/64] fix(lint): rename unused bubble param to _bubble in createActionButtons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Satisfies @typescript-eslint/no-unused-vars. Parameter was never used in the function body even before this feature — only surfaced now. Co-Authored-By: Claude Sonnet 4.6 --- src/ui/chat/components/MessageBubble.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/chat/components/MessageBubble.ts b/src/ui/chat/components/MessageBubble.ts index 3f3ca64f8..98f084ee1 100644 --- a/src/ui/chat/components/MessageBubble.ts +++ b/src/ui/chat/components/MessageBubble.ts @@ -184,7 +184,7 @@ export class MessageBubble extends Component { /** * Create action buttons (edit, retry, branch navigator) */ - private createActionButtons(actions: HTMLElement, bubble: HTMLElement): void { + private createActionButtons(actions: HTMLElement, _bubble: HTMLElement): void { if (this.message.role === 'user') { // Edit button for user messages if (this.onEdit) { From 85ea19bde8a432f2ec6cf2f9f0f8cfe00d753812 Mon Sep 17 00:00:00 2001 From: Midway65 Date: Sat, 4 Apr 2026 12:26:26 -0700 Subject: [PATCH 08/64] fix(css): add pointer-events:none to invisible message action pill The .message-actions-external pill had opacity:0 but no pointer-events:none, causing it to silently intercept all mouse clicks over message content. Text selection was broken because the invisible pill sat above the content in the stacking context (z-index 20). Added pointer-events:none to the hidden state and pointer-events:auto to all visible states (hover, mobile, media queries). Co-Authored-By: Claude Sonnet 4.6 --- styles.css | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/styles.css b/styles.css index 71b432eaf..93fbcf213 100644 --- a/styles.css +++ b/styles.css @@ -722,6 +722,7 @@ border-radius: 50px; padding: 0.2rem; opacity: 0; + pointer-events: none; transition: all 0.2s ease; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12); z-index: 10; @@ -754,6 +755,7 @@ .message-container:hover .message-actions-external { opacity: 1; + pointer-events: auto; transform: translateY(0); } @@ -5915,11 +5917,13 @@ body.is-mobile .progressive-tool-header { body.is-mobile .message-actions-external { opacity: 0.85; + pointer-events: auto; transform: translateY(0); } body.is-mobile .message-container:active .message-actions-external { opacity: 1; + pointer-events: auto; } body.is-mobile .conversation-delete { @@ -6084,6 +6088,7 @@ body.is-mobile .chat-loading-overlay { @media (hover: hover) and (pointer: fine) { .message-container:hover .message-actions-external { opacity: 1; + pointer-events: auto; transform: translateY(0); } @@ -6100,6 +6105,7 @@ body.is-mobile .chat-loading-overlay { @media (hover: none) and (pointer: coarse) { .message-actions-external { opacity: 0.85; + pointer-events: auto; } .conversation-delete { From 0222bdb1043e9d45b9f2ebf310d228aefff9d53f Mon Sep 17 00:00:00 2001 From: Midway65 Date: Sat, 4 Apr 2026 12:32:48 -0700 Subject: [PATCH 09/64] fix: move action buttons into existing pill, fix click-blocking The separate bottom pill (message-action-bar) was wrong placement and left the message-actions-external div empty. An empty sticky/z-20 element with no pointer-events:none silently blocked all mouse clicks on message content, breaking text selection. Fix: - MessageActionBar.renderInto(el) populates the existing upper-right .message-actions-external pill instead of creating a new element - MessageActionBar.removeFromContainer() cleans up on rebuild/destroy - appendActionBar() queries .message-actions-external within the container rather than appending a new child after the bubble - Removed .message-action-bar CSS block (no longer needed) - pointer-events:none already in place for the hidden pill state All 4 buttons (Copy, Insert, Append, Create File) now appear in the familiar upper-right corner pill alongside the branch navigator. Co-Authored-By: Claude Sonnet 4.6 --- src/ui/chat/components/MessageActionBar.ts | 63 ++++++++++++---------- src/ui/chat/components/MessageBubble.ts | 16 +++--- styles.css | 55 ------------------- 3 files changed, 45 insertions(+), 89 deletions(-) diff --git a/src/ui/chat/components/MessageActionBar.ts b/src/ui/chat/components/MessageActionBar.ts index 0bd87b878..76f1668b1 100644 --- a/src/ui/chat/components/MessageActionBar.ts +++ b/src/ui/chat/components/MessageActionBar.ts @@ -1,19 +1,24 @@ /** - * MessageActionBar - Action pill shown below completed AI message bubbles + * MessageActionBar - Populates the existing message-actions-external pill * Location: /src/ui/chat/components/MessageActionBar.ts * - * Renders four action buttons: Copy, Insert at cursor, Append to active note, - * and Create new file. Appears only on completed assistant messages that have - * non-empty text content. Fades to 35% opacity at rest and full opacity on hover. + * Renders four action buttons into the caller-supplied container element + * (the existing .message-actions-external pill that sits in the upper-right + * corner of each message bubble). Buttons appear alongside any other pill + * contents (e.g. branch navigator) and use the same message-action-btn + * styling as the original copy button did. * - * Used by MessageBubble.appendActionBar() after a message reaches completed state. + * Only rendered for completed assistant messages with non-empty text content. + * Called by MessageBubble.appendActionBar() after message state transitions + * to complete. */ import { App, Component, MarkdownView, Notice, setIcon } from 'obsidian'; import { CreateFileModal } from './CreateFileModal'; export class MessageActionBar extends Component { - private element: HTMLElement | null = null; + private buttons: HTMLElement[] = []; + private copyButton: HTMLElement | null = null; constructor( private readonly content: string, @@ -23,24 +28,25 @@ export class MessageActionBar extends Component { } /** - * Build and return the pill element. Call once — the element is stored and - * returned by getElement() for later DOM removal. + * Create the four action buttons inside the provided container element. + * The container is the existing .message-actions-external pill — no new + * wrapper is created. Call removeFromContainer() before unload to clean up. */ - createElement(): HTMLElement { - const bar = document.createElement('div'); - bar.addClass('message-action-bar'); - - this.addButton(bar, 'copy', 'Copy message', () => this.handleCopy(bar)); - this.addButton(bar, 'file-input', 'Insert at cursor', () => this.handleInsert()); - this.addButton(bar, 'file-plus-2', 'Append to active note', () => this.handleAppend()); - this.addButton(bar, 'file-plus', 'Create new file', () => this.handleCreate()); - - this.element = bar; - return bar; + renderInto(container: HTMLElement): void { + this.copyButton = this.addButton(container, 'copy', 'Copy message', () => this.handleCopy()); + this.addButton(container, 'file-input', 'Insert at cursor', () => this.handleInsert()); + this.addButton(container, 'file-plus-2', 'Append to active note', () => this.handleAppend()); + this.addButton(container, 'file-plus', 'Create new file', () => this.handleCreate()); } - getElement(): HTMLElement | null { - return this.element; + /** + * Remove all buttons this component added from their parent container. + * Call before unload() to keep the DOM clean. + */ + removeFromContainer(): void { + this.buttons.forEach(btn => btn.remove()); + this.buttons = []; + this.copyButton = null; } // ─── Private helpers ──────────────────────────────────────────────────────── @@ -50,21 +56,20 @@ export class MessageActionBar extends Component { icon: string, title: string, handler: () => void - ): void { + ): HTMLElement { const btn = parent.createEl('button', { - cls: 'message-action-bar-btn clickable-icon', + cls: 'message-action-btn clickable-icon', attr: { title, 'aria-label': title } }); setIcon(btn, icon); this.registerDomEvent(btn, 'click', handler); + this.buttons.push(btn); + return btn; } - private handleCopy(bar: HTMLElement): void { + private handleCopy(): void { navigator.clipboard.writeText(this.content).then(() => { - const btn = bar.querySelector('[title="Copy message"]'); - if (btn instanceof HTMLElement) { - this.showCopyFeedback(btn); - } + if (this.copyButton) this.showCopyFeedback(this.copyButton); }).catch(err => { console.error('[MessageActionBar] Copy failed:', err); new Notice('Copy failed.'); @@ -73,8 +78,10 @@ export class MessageActionBar extends Component { private showCopyFeedback(button: HTMLElement): void { setIcon(button, 'check'); + button.classList.add('copy-success'); setTimeout(() => { setIcon(button, 'copy'); + button.classList.remove('copy-success'); }, 1500); } diff --git a/src/ui/chat/components/MessageBubble.ts b/src/ui/chat/components/MessageBubble.ts index 98f084ee1..96b292e8f 100644 --- a/src/ui/chat/components/MessageBubble.ts +++ b/src/ui/chat/components/MessageBubble.ts @@ -787,8 +787,10 @@ export class MessageBubble extends Component { } /** - * Append the action bar pill below the message container for completed - * assistant messages that have non-empty text content. + * Populate the existing .message-actions-external pill with action buttons + * for completed assistant messages that have non-empty text content. + * Buttons are inserted into the pill that already sits in the upper-right + * corner — no new wrapper element is created. */ private appendActionBar(container: HTMLElement, message: ConversationMessage): void { if (message.role !== 'assistant') return; @@ -800,17 +802,19 @@ export class MessageBubble extends Component { // Only create once per message lifecycle — rebuildElement resets this.actionBar if (this.actionBar !== null) return; + const actionsEl = container.querySelector('.message-actions-external'); + if (!(actionsEl instanceof HTMLElement)) return; + this.actionBar = new MessageActionBar(activeContent, this.app); - container.appendChild(this.actionBar.createElement()); + this.actionBar.renderInto(actionsEl); } /** - * Remove the action bar from the DOM and unload its event handlers. + * Remove action buttons from the pill and unload event handlers. */ private cleanupActionBar(): void { if (!this.actionBar) return; - const el = this.actionBar.getElement(); - if (el) el.remove(); + this.actionBar.removeFromContainer(); this.actionBar.unload(); this.actionBar = null; } diff --git a/styles.css b/styles.css index 93fbcf213..25ece85c0 100644 --- a/styles.css +++ b/styles.css @@ -7935,58 +7935,3 @@ body.is-mobile .nexus-task-board-shell { min-height: 44px; } -/* ── Message Action Bar (pill below completed AI bubbles) ─────────────────── */ - -.message-action-bar { - display: flex; - flex-direction: row; - gap: 2px; - padding: 2px 6px; - margin-top: 4px; - opacity: 0.35; - transition: opacity 0.15s ease; -} - -.message-action-bar:hover { - opacity: 1; -} - -.message-action-bar-btn { - display: flex; - align-items: center; - justify-content: center; - width: 24px; - height: 24px; - padding: 0; - background: transparent; - border: none; - border-radius: var(--radius-s); - color: var(--text-muted); - cursor: pointer; - transition: background-color 0.1s ease, color 0.1s ease; -} - -.message-action-bar-btn svg { - width: 12px; - height: 12px; -} - -.message-action-bar-btn:hover { - background: var(--background-modifier-hover); - color: var(--text-normal); -} - -/* Mobile: larger touch targets */ -body.is-mobile .message-action-bar-btn { - width: 36px; - height: 36px; -} - -body.is-mobile .message-action-bar-btn svg { - width: 16px; - height: 16px; -} - -body.is-mobile .message-action-bar { - opacity: 0.85; -} From 87d8bb2b69ea3e0dbb47e2f7aabb6bef3c9399a0 Mon Sep 17 00:00:00 2001 From: Midway65 Date: Sat, 4 Apr 2026 12:40:40 -0700 Subject: [PATCH 10/64] fix(css): explicit user-select:text on message content + 25% pill opacity Two fixes: 1. .message-content now explicitly sets user-select:text and cursor:text. Obsidian's Electron shell applies user-select:none globally; without an explicit override the content was not text-selectable. Child elements (links, buttons) retain their own cursor/pointer-events. 2. .message-actions-external resting opacity changed from 0 to 0.25 so the pill is always visible as a subtle affordance, becoming fully opaque on hover. Removed stale transform from hover rule. Mobile resting opacity set to 0.75 (was 0.85). Co-Authored-By: Claude Sonnet 4.6 --- styles.css | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/styles.css b/styles.css index 25ece85c0..f8484c78f 100644 --- a/styles.css +++ b/styles.css @@ -721,9 +721,9 @@ border: 1px solid var(--background-modifier-border); border-radius: 50px; padding: 0.2rem; - opacity: 0; + opacity: 0.25; pointer-events: none; - transition: all 0.2s ease; + transition: opacity 0.2s ease; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12); z-index: 10; width: fit-content; @@ -756,7 +756,6 @@ .message-container:hover .message-actions-external { opacity: 1; pointer-events: auto; - transform: translateY(0); } .message-action-btn { @@ -846,6 +845,8 @@ word-wrap: break-word; overflow-wrap: break-word; hyphens: auto; + user-select: text; + cursor: text; } .message-content p { @@ -5916,9 +5917,8 @@ body.is-mobile .progressive-tool-header { /* Touch devices can't hover, so show actions by default */ body.is-mobile .message-actions-external { - opacity: 0.85; + opacity: 0.75; pointer-events: auto; - transform: translateY(0); } body.is-mobile .message-container:active .message-actions-external { From 1a018977695ecb34b8fcb28f3bf1080a34af4ddc Mon Sep 17 00:00:00 2001 From: Midway65 Date: Sat, 4 Apr 2026 12:46:25 -0700 Subject: [PATCH 11/64] fix: move action pill into message header top-right, reduce transition Pill was sitting inside the bubble with sticky+float positioning, leaving it detached from the header and occasionally blocking layout. Moved it into .message-header for both the standard path (MessageBubble) and the group path (ToolBubbleFactory). The header's justify-content:space-between naturally places the bot icon left and the pill right with no extra CSS. Also reduced pill transition from 0.2s to 0.1s to reduce repaint churn during text selection drag operations. Removed stale z-index and mobile top/right overrides that no longer apply. Co-Authored-By: Claude Sonnet 4.6 --- src/ui/chat/components/MessageBubble.ts | 2 +- .../components/factories/ToolBubbleFactory.ts | 6 ++---- styles.css | 18 ++++-------------- 3 files changed, 7 insertions(+), 19 deletions(-) diff --git a/src/ui/chat/components/MessageBubble.ts b/src/ui/chat/components/MessageBubble.ts index 96b292e8f..21eb421a1 100644 --- a/src/ui/chat/components/MessageBubble.ts +++ b/src/ui/chat/components/MessageBubble.ts @@ -163,7 +163,7 @@ export class MessageBubble extends Component { if (this.message.role === 'user') { actions = header.createDiv('message-actions-external'); } else if (this.message.role === 'assistant') { - actions = bubble.createDiv('message-actions-external'); + actions = header.createDiv('message-actions-external'); } else { actions = messageContainer.createDiv('message-actions-external'); } diff --git a/src/ui/chat/components/factories/ToolBubbleFactory.ts b/src/ui/chat/components/factories/ToolBubbleFactory.ts index 2ff1cc7b6..b0cfcf780 100644 --- a/src/ui/chat/components/factories/ToolBubbleFactory.ts +++ b/src/ui/chat/components/factories/ToolBubbleFactory.ts @@ -99,13 +99,11 @@ export class ToolBubbleFactory { const bubble = messageContainer.createDiv('message-bubble'); - // Actions inside the bubble (for sticky positioning) - bubble.createDiv('message-actions-external'); - - // Header with bot icon + // Header with bot icon; actions pill sits in the header top-right const header = bubble.createDiv('message-header'); const roleIcon = header.createDiv('message-role-icon'); setIcon(roleIcon, 'bot'); + header.createDiv('message-actions-external'); // Message content const content = bubble.createDiv('message-content'); diff --git a/styles.css b/styles.css index f8484c78f..f1c4dae2a 100644 --- a/styles.css +++ b/styles.css @@ -723,9 +723,8 @@ padding: 0.2rem; opacity: 0.25; pointer-events: none; - transition: opacity 0.2s ease; + transition: opacity 0.1s ease; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12); - z-index: 10; width: fit-content; } @@ -736,14 +735,10 @@ left: auto; } -/* Assistant messages: pill in top-right (icon is in top-left), inside bubble with sticky */ +/* Assistant messages: pill sits in the header flex row, pushed to the right */ .message-container.message-assistant .message-actions-external { - position: sticky; - top: 0.5rem; - float: right; - margin-right: 0.75rem; - margin-top: 0.5rem; - z-index: 20; + position: relative; + margin-left: auto; } /* Tool messages: pill in top-right, absolute positioning outside bubble */ @@ -5851,11 +5846,6 @@ body.is-mobile .message-actions-external { border-radius: 8px; } -/* Assistant messages: keep in corner but smaller on mobile */ -body.is-mobile .message-container.message-assistant .message-actions-external { - top: 0.25rem; - right: 0.25rem; -} /* Message bubbles - more padding and margin on mobile */ body.is-mobile .message-bubble { From 938e6c3927e26f8ac2bdae8e4a1a0df5448805c3 Mon Sep 17 00:00:00 2001 From: Midway65 Date: Sat, 4 Apr 2026 13:11:48 -0700 Subject: [PATCH 12/64] fix(action-bar): prevent focus loss on Insert/Append button click Clicking Insert or Append shifted focus from the active note to the chat panel button, causing getActiveViewOfType(MarkdownView) to return null and silently doing nothing. Added mousedown:preventDefault on both buttons so editor focus and cursor position are preserved through the click. Also added user-visible Notice for the no-active-note case. Co-Authored-By: Claude Sonnet 4.6 --- src/ui/chat/components/MessageActionBar.ts | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/ui/chat/components/MessageActionBar.ts b/src/ui/chat/components/MessageActionBar.ts index 76f1668b1..0ba7801ad 100644 --- a/src/ui/chat/components/MessageActionBar.ts +++ b/src/ui/chat/components/MessageActionBar.ts @@ -34,8 +34,15 @@ export class MessageActionBar extends Component { */ renderInto(container: HTMLElement): void { this.copyButton = this.addButton(container, 'copy', 'Copy message', () => this.handleCopy()); - this.addButton(container, 'file-input', 'Insert at cursor', () => this.handleInsert()); - this.addButton(container, 'file-plus-2', 'Append to active note', () => this.handleAppend()); + + // Insert and Append need mousedown:preventDefault so clicking the button + // does not shift focus away from the active note (and lose the cursor). + const insertBtn = this.addButton(container, 'file-input', 'Insert at cursor', () => this.handleInsert()); + this.registerDomEvent(insertBtn, 'mousedown', (e: MouseEvent) => e.preventDefault()); + + const appendBtn = this.addButton(container, 'file-plus-2', 'Append to active note', () => this.handleAppend()); + this.registerDomEvent(appendBtn, 'mousedown', (e: MouseEvent) => e.preventDefault()); + this.addButton(container, 'file-plus', 'Create new file', () => this.handleCreate()); } @@ -87,13 +94,19 @@ export class MessageActionBar extends Component { private handleInsert(): void { const view = this.app.workspace.getActiveViewOfType(MarkdownView); - if (!view) return; + if (!view) { + new Notice('No active note — open a note and place your cursor first.'); + return; + } view.editor.replaceSelection(this.content); } private async handleAppend(): Promise { const view = this.app.workspace.getActiveViewOfType(MarkdownView); - if (!view?.file) return; + if (!view?.file) { + new Notice('No active note — open a note first.'); + return; + } const timestamp = new Date().toLocaleString(); const separator = `\n\n---\n*Appended from Nexus Chat — ${timestamp}*\n\n`; From e948c79dc7d2e83fd537f38913d4b9305d37859f Mon Sep 17 00:00:00 2001 From: Midway65 Date: Sat, 4 Apr 2026 13:20:46 -0700 Subject: [PATCH 13/64] fix(action-bar): find open note when chat panel has workspace focus getActiveViewOfType(MarkdownView) returns null whenever the chat panel is the active workspace view, even if a note is open alongside it. Added getMarkdownView() helper that falls back to getLeavesOfType ('markdown') so Insert and Append work regardless of which panel last received focus. Also added editor.focus() before replaceSelection so the cursor is visible after insertion. Co-Authored-By: Claude Sonnet 4.6 --- src/ui/chat/components/MessageActionBar.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/ui/chat/components/MessageActionBar.ts b/src/ui/chat/components/MessageActionBar.ts index 0ba7801ad..1300207e1 100644 --- a/src/ui/chat/components/MessageActionBar.ts +++ b/src/ui/chat/components/MessageActionBar.ts @@ -92,17 +92,32 @@ export class MessageActionBar extends Component { }, 1500); } + /** + * Returns the active MarkdownView, or falls back to the most recently + * opened markdown leaf if the chat panel currently has workspace focus. + */ + private getMarkdownView(): MarkdownView | null { + const active = this.app.workspace.getActiveViewOfType(MarkdownView); + if (active) return active; + + // Chat panel has focus — find any open note tab + const leaves = this.app.workspace.getLeavesOfType('markdown'); + if (leaves.length === 0) return null; + return leaves[leaves.length - 1].view as MarkdownView; + } + private handleInsert(): void { - const view = this.app.workspace.getActiveViewOfType(MarkdownView); + const view = this.getMarkdownView(); if (!view) { new Notice('No active note — open a note and place your cursor first.'); return; } + view.editor.focus(); view.editor.replaceSelection(this.content); } private async handleAppend(): Promise { - const view = this.app.workspace.getActiveViewOfType(MarkdownView); + const view = this.getMarkdownView(); if (!view?.file) { new Notice('No active note — open a note first.'); return; From c56b1666379163926a1b1fe7c4c6a855fef50cde Mon Sep 17 00:00:00 2001 From: Midway65 Date: Sat, 4 Apr 2026 13:30:42 -0700 Subject: [PATCH 14/64] fix(create-file-modal): correct inbox case, fix toggle not respected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Default folder was '00-inbox' — corrected to '00-Inbox'. Toggle value was read from a class field updated via onChange, but close() runs before the check and could leave state stale. Fix: store the ToggleComponent ref and call getValue() at create time instead. Co-Authored-By: Claude Sonnet 4.6 --- src/ui/chat/components/CreateFileModal.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/ui/chat/components/CreateFileModal.ts b/src/ui/chat/components/CreateFileModal.ts index 842f080e7..3b45f8f62 100644 --- a/src/ui/chat/components/CreateFileModal.ts +++ b/src/ui/chat/components/CreateFileModal.ts @@ -9,12 +9,13 @@ * Used by MessageActionBar when the user clicks "Create new file". */ -import { App, Modal, Notice, Setting, normalizePath } from 'obsidian'; +import { App, Modal, Notice, Setting, ToggleComponent, normalizePath } from 'obsidian'; export class CreateFileModal extends Modal { private filename = ''; - private folderPath = '00-inbox'; + private folderPath = '00-Inbox'; private openAfterSave = true; + private openAfterSaveToggle: ToggleComponent | null = null; private readonly content: string; constructor(app: App, content: string) { @@ -46,12 +47,12 @@ export class CreateFileModal extends Modal { .onChange(value => { this.folderPath = value; }); }); - // Open after save toggle + // Open after save toggle — store ref so handleCreate reads current value directly new Setting(contentEl) .setName('Open after saving') .addToggle(toggle => { - toggle.setValue(this.openAfterSave) - .onChange(value => { this.openAfterSave = value; }); + toggle.setValue(this.openAfterSave); + this.openAfterSaveToggle = toggle; }); // Action buttons @@ -82,7 +83,7 @@ export class CreateFileModal extends Modal { return; } - const folder = this.folderPath.trim() || '00-inbox'; + const folder = this.folderPath.trim() || '00-Inbox'; const filePath = normalizePath(`${folder}/${name}.md`); // Guard against duplicate @@ -97,7 +98,10 @@ export class CreateFileModal extends Modal { new Notice(`Created: ${name}.md`); this.close(); - if (this.openAfterSave) { + const shouldOpen = this.openAfterSaveToggle + ? this.openAfterSaveToggle.getValue() + : this.openAfterSave; + if (shouldOpen) { await this.app.workspace.getLeaf().openFile(file); } } catch (err) { From 99864ff1bcda6183df967c17edd37fe89dbeecfd Mon Sep 17 00:00:00 2001 From: Midway65 Date: Sat, 4 Apr 2026 14:43:30 -0700 Subject: [PATCH 15/64] custom/fork-id: rename plugin id and name to nucleus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prevents Obsidian from auto-updating this fork if/when the upstream ProfSynapse/nexus repo is accepted into the community plugins registry. Obsidian matches plugins by id — "nucleus" will never match "nexus". Co-Authored-By: Claude Sonnet 4.6 --- manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/manifest.json b/manifest.json index 000b31ab5..c648673ed 100644 --- a/manifest.json +++ b/manifest.json @@ -1,6 +1,6 @@ { - "id": "nexus", - "name": "Nexus", + "id": "nucleus", + "name": "Nucleus", "version": "5.6.1", "minAppVersion": "0.15.0", "description": "Agentic AI for your vault. Use Claude, ChatGPT, Gemini, and local models to chat, search, create, and manage your notes with semantic memory, image generation, and MCP server integration.", From 86fe1f0524dd5a7a48d5e9fa192ef1d99c433515 Mon Sep 17 00:00:00 2001 From: Midway65 Date: Sat, 4 Apr 2026 14:48:49 -0700 Subject: [PATCH 16/64] merge: integrate upstream v5.6.2 Upstream changes: - VaultIngestionManager: moved drag-and-drop ingest UI out of ChatView into dedicated VaultIngestionManager (src/core/ingest/VaultIngestionManager.ts) - PdfJsLoader: new lazy-loader for pdfjs-dist to improve startup performance - ChatView/ChatLayoutBuilder: removed inline ingest UI (now in VaultIngestionManager) - DefaultsTab: new ingestion settings fields - PdfPageRenderer/PdfTextExtractor: bug fixes and improvements - types.ts / PluginTypes.ts: new enableIngestion flag support Conflict resolutions: - manifest.json: kept id=nucleus/name=Nucleus, took upstream version=5.6.2 - ChatView.ts: took upstream (removed inline ingest imports and methods); our action buttons and beta-banner-removal changes are in separate sections and were auto-merged cleanly by git - ChatLayoutBuilder.ts: auto-resolved cleanly by git Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 18 +- README.md | 2 +- guide/apps.md | 2 +- manifest.json | 2 +- package.json | 2 +- .../tools/services/PdfJsLoader.ts | 43 +++ .../tools/services/PdfPageRenderer.ts | 6 +- .../tools/services/PdfTextExtractor.ts | 10 +- src/core/PluginLifecycleManager.ts | 11 + src/core/ingest/VaultIngestionManager.ts | 278 ++++++++++++++++++ src/settings/tabs/DefaultsTab.ts | 12 + src/types.ts | 1 + src/types/pdfjs-worker.d.ts | 3 + src/types/plugin/PluginTypes.ts | 1 + src/ui/chat/ChatView.ts | 203 ------------- src/ui/chat/builders/ChatLayoutBuilder.ts | 7 +- src/utils/connectorContent.ts | 2 +- tests/unit/PdfTextExtractor.test.ts | 14 +- 18 files changed, 381 insertions(+), 236 deletions(-) create mode 100644 src/agents/ingestManager/tools/services/PdfJsLoader.ts create mode 100644 src/core/ingest/VaultIngestionManager.ts create mode 100644 src/types/pdfjs-worker.d.ts diff --git a/CLAUDE.md b/CLAUDE.md index 8ddf07637..88bc35aff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,7 +3,7 @@ Last Updated: 2026-03-29 ## Project Overview - **Name**: Nexus (package: claudesidian-mcp) -- **Version**: 5.6.1 +- **Version**: 5.6.2 - **Type**: Obsidian Community Plugin - **Purpose**: MCP integration for Obsidian with AI-powered vault operations - **Architecture**: Agent-Tool pattern with domain-driven design @@ -711,15 +711,17 @@ Key files: `src/ui/chat/components/suggesters/`, `MessageEnhancer.ts`, `SystemPr ## Pinned Context -### pdfjs-dist LoopbackPort bundling (Obsidian/Electron) -To use pdfjs-dist in Obsidian without a separate worker file (which esbuild can't bundle): +### pdfjs-dist in Obsidian/Electron (legacy build + shared loader) +PDF.js 5 expects a configured `workerSrc` in the Electron renderer. Use the legacy build with a shared loader that seeds `globalThis.pdfjsWorker`: ```typescript -import * as pdfjsLib from 'pdfjs-dist'; -import { LoopbackPort } from 'pdfjs-dist'; -pdfjsLib.GlobalWorkerOptions.workerSrc = ''; // disable external worker -const doc = await pdfjsLib.getDocument({ data: uint8Array, worker: new LoopbackPort() }).promise; +// src/agents/ingestManager/tools/services/PdfJsLoader.ts +const [pdfjsLib, pdfjsWorker] = await Promise.all([ + import('pdfjs-dist/legacy/build/pdf.mjs'), + import('pdfjs-dist/legacy/build/pdf.worker.mjs'), +]); +if (!globalThis.pdfjsWorker) globalThis.pdfjsWorker = pdfjsWorker; ``` -No esbuild config changes needed. ~1.5-2MB bundle impact. Used in `PdfTextExtractor.ts` and `PdfPageRenderer.ts`. +Use `loadPdfJs()` from `PdfJsLoader.ts` in both `PdfTextExtractor.ts` and `PdfPageRenderer.ts`. Do NOT use `import('pdfjs-dist')` directly — the main entry fails in Electron without a worker URL. ### IngestManagerAgent — audio transcription provider scope (v1) diff --git a/README.md b/README.md index 4b619ba65..fd4575308 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ Native chat works on desktop and mobile. MCP clients, local desktop providers, a | Search notes and past conversations by meaning | [Semantic search](guide/semantic-search.md) | | Edit selected text directly in notes | [Inline editing](guide/inline-editing.md) | | Open webpages in Obsidian and save them as Markdown, PNG, or PDF *(experimental)* | [Apps](guide/apps.md) | -| Drag PDF or audio files into chat and convert them to Markdown notes *(experimental)* | [Apps](guide/apps.md) | +| Convert PDFs and audio files to Markdown notes — right-click in vault or auto on add *(experimental)* | [Apps](guide/apps.md) | | Merge PDFs, concat markdown, or mix audio tracks into one file *(experimental)* | [Apps](guide/apps.md) | | Create recurring routines and reusable workflows | [Workflow examples](guide/workflow-examples.md) | | Understand the MCP design and available tools | [Two-tool architecture](guide/two-tool-architecture.md) | diff --git a/guide/apps.md b/guide/apps.md index 4b67258ba..49ec58905 100644 --- a/guide/apps.md +++ b/guide/apps.md @@ -17,7 +17,7 @@ Configure apps in **Settings → Nexus → Apps**. Install an app, enter y | App | Tools | What It Does | |-----|-------|--------------| | **ElevenLabs** | textToSpeech, listVoices, soundEffects, generateMusic | AI audio generation — convert text to speech, create sound effects, and generate music. Audio files save directly to your vault. | -| **Nexus Ingester** *(experimental)* | ingest, listCapabilities | Drag a PDF or audio file onto the chat window to convert it into a Markdown note. PDF extraction uses text mode (pdfjs-dist) or vision OCR. Audio transcription supports OpenAI (Whisper, GPT-4o Transcribe), Groq (Whisper), and Google Gemini multimodal audio. The resulting note is saved alongside the original file in your vault. | +| **Nexus Ingester** *(experimental)* | ingest, listCapabilities | Convert PDFs and audio files in your vault to sibling Markdown notes. Two modes: **Manual** — right-click any supported file and choose "Convert to Markdown". **Auto** — enable "Auto-convert new files" in Settings → Defaults → Ingestion and any supported file added to the vault is converted automatically. PDF extraction uses text mode (pdfjs-dist) or vision OCR. Audio transcription supports OpenAI (Whisper, GPT-4o Transcribe), Groq (Whisper), and Google Gemini multimodal audio. | | **Composer** *(experimental)* | compose, listFormats | Combine multiple files into one. Merge PDFs, concatenate Markdown files, or mix and concat audio tracks. Audio output supports WAV, WebM/Opus, and MP3 (via WASM). Supports per-track volume, offset, and fade controls for audio mixing. | | **Web Tools** *(experimental, desktop only)* | openWebpage, capturePagePdf, capturePagePng, captureToMarkdown, extractLinks | Open any webpage in a headless browser and capture it as a PDF, PNG, or clean Markdown (boilerplate stripped). Also extracts all links with their text and type. Requires desktop — not available on mobile. | diff --git a/manifest.json b/manifest.json index c648673ed..bf8b3eacc 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "nucleus", "name": "Nucleus", - "version": "5.6.1", + "version": "5.6.2", "minAppVersion": "0.15.0", "description": "Agentic AI for your vault. Use Claude, ChatGPT, Gemini, and local models to chat, search, create, and manage your notes with semantic memory, image generation, and MCP server integration.", "author": "Synaptic Labs", diff --git a/package.json b/package.json index 51d3ee92e..85c7ef170 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nexus", - "version": "5.6.1", + "version": "5.6.2", "description": "Agentic AI for your vault. Use Claude, ChatGPT, Gemini, and local models to chat, search, create, and manage your notes with semantic memory, image generation, and MCP server integration.", "main": "main.js", "scripts": { diff --git a/src/agents/ingestManager/tools/services/PdfJsLoader.ts b/src/agents/ingestManager/tools/services/PdfJsLoader.ts new file mode 100644 index 000000000..481e5e732 --- /dev/null +++ b/src/agents/ingestManager/tools/services/PdfJsLoader.ts @@ -0,0 +1,43 @@ +/** + * Location: src/agents/ingestManager/tools/services/PdfJsLoader.ts + * Purpose: Load PDF.js in a way that works inside the Obsidian/Electron renderer. + * + * PDF.js 5 treats the renderer as a browser and expects a configured workerSrc + * unless a main-thread worker handler is already registered on globalThis. + * We seed that handler explicitly from the bundled worker module so ingestion + * can run without a separate worker asset URL. + */ + +type PdfJsModule = typeof import('pdfjs-dist/legacy/build/pdf.mjs'); +type PdfJsWorkerModule = typeof import('pdfjs-dist/legacy/build/pdf.worker.mjs'); + +declare global { + interface Window { + pdfjsWorker?: PdfJsWorkerModule; + } + + // eslint-disable-next-line no-var + var pdfjsWorker: PdfJsWorkerModule | undefined; +} + +let pdfJsModulePromise: Promise | null = null; + +export async function loadPdfJs(): Promise { + if (!pdfJsModulePromise) { + pdfJsModulePromise = initializePdfJs(); + } + return pdfJsModulePromise; +} + +async function initializePdfJs(): Promise { + const [pdfjsLib, pdfjsWorker] = await Promise.all([ + import('pdfjs-dist/legacy/build/pdf.mjs'), + import('pdfjs-dist/legacy/build/pdf.worker.mjs'), + ]); + + if (!globalThis.pdfjsWorker) { + globalThis.pdfjsWorker = pdfjsWorker; + } + + return pdfjsLib; +} diff --git a/src/agents/ingestManager/tools/services/PdfPageRenderer.ts b/src/agents/ingestManager/tools/services/PdfPageRenderer.ts index bf034200e..00e5152e6 100644 --- a/src/agents/ingestManager/tools/services/PdfPageRenderer.ts +++ b/src/agents/ingestManager/tools/services/PdfPageRenderer.ts @@ -4,10 +4,11 @@ * Uses pdfjs-dist page rendering via OffscreenCanvas (Electron desktop only). * * Used by: OcrService (vision mode) - * Dependencies: pdfjs-dist + * Dependencies: pdfjs-dist legacy build */ import { PdfPageImage } from '../../types'; +import { loadPdfJs } from './PdfJsLoader'; const RENDER_SCALE = 2.0; // 2x for good OCR quality @@ -20,9 +21,8 @@ export async function renderPdfPages( pdfData: ArrayBuffer, onProgress?: (current: number, total: number) => void ): Promise { - const pdfjsLib = await import('pdfjs-dist'); + const pdfjsLib = await loadPdfJs(); - // In esbuild platform:"node" builds, pdfjs-dist uses LoopbackPort automatically const loadingTask = pdfjsLib.getDocument({ data: new Uint8Array(pdfData), }); diff --git a/src/agents/ingestManager/tools/services/PdfTextExtractor.ts b/src/agents/ingestManager/tools/services/PdfTextExtractor.ts index a8a13fbba..b565e4025 100644 --- a/src/agents/ingestManager/tools/services/PdfTextExtractor.ts +++ b/src/agents/ingestManager/tools/services/PdfTextExtractor.ts @@ -4,21 +4,19 @@ * This is the default (free) PDF mode — no LLM API calls needed. * * Used by: IngestionPipelineService (text mode) - * Dependencies: pdfjs-dist + * Dependencies: pdfjs-dist legacy build */ import { PdfPageContent } from '../../types'; +import { loadPdfJs } from './PdfJsLoader'; /** * Extract text from all pages of a PDF file. - * Uses pdfjs-dist's getTextContent() which runs on the main thread via LoopbackPort. + * Uses pdfjs-dist's legacy getTextContent() build for Node/Electron compatibility. */ export async function extractPdfText(pdfData: ArrayBuffer): Promise { - // Dynamic import to lazy-load pdfjs-dist (only when PDF ingestion is used) - const pdfjsLib = await import('pdfjs-dist'); + const pdfjsLib = await loadPdfJs(); - // In esbuild platform:"node" builds, pdfjs-dist uses LoopbackPort automatically - // when no workerSrc is set. This runs the worker code on the main thread. const loadingTask = pdfjsLib.getDocument({ data: new Uint8Array(pdfData), }); diff --git a/src/core/PluginLifecycleManager.ts b/src/core/PluginLifecycleManager.ts index e140d86e6..62811d4d9 100644 --- a/src/core/PluginLifecycleManager.ts +++ b/src/core/PluginLifecycleManager.ts @@ -20,6 +20,7 @@ import { TaskBoardUIManager } from './ui/TaskBoardUIManager'; import { BackgroundProcessor } from './background/BackgroundProcessor'; import { SettingsTabManager } from './settings/SettingsTabManager'; import { EmbeddingManager } from '../services/embeddings/EmbeddingManager'; +import { VaultIngestionManager } from './ingest/VaultIngestionManager'; import type { ServiceCreationContext } from './services/ServiceDefinitions'; import type { HybridStorageAdapter } from '../database/adapters/HybridStorageAdapter'; import type { ChatTraceService } from '../services/chat/ChatTraceService'; @@ -72,6 +73,7 @@ export class PluginLifecycleManager { private backgroundProcessor: BackgroundProcessor; private settingsTabManager: SettingsTabManager; private inlineEditCommandManager: InlineEditCommandManager; + private vaultIngestionManager: VaultIngestionManager; private embeddingManager: EmbeddingManager | null = null; // Pending timer handles for cleanup on shutdown @@ -143,6 +145,12 @@ export class PluginLifecycleManager { app: config.app, getService: (name, timeoutMs) => this.serviceRegistrar.getService(name, timeoutMs) }); + + this.vaultIngestionManager = new VaultIngestionManager({ + plugin: config.plugin, + app: config.app, + getService: (name, timeoutMs) => this.serviceRegistrar.getService(name, timeoutMs) + }); } /** @@ -231,6 +239,9 @@ export class PluginLifecycleManager { // Register inline edit commands and context menu this.inlineEditCommandManager.registerCommands(); + // Register vault-level ingestion triggers + this.vaultIngestionManager.register(); + // Check for updates this.backgroundProcessor.checkForUpdatesOnStartup(); diff --git a/src/core/ingest/VaultIngestionManager.ts b/src/core/ingest/VaultIngestionManager.ts new file mode 100644 index 000000000..be977614a --- /dev/null +++ b/src/core/ingest/VaultIngestionManager.ts @@ -0,0 +1,278 @@ +import { + App, + Events, + Menu, + Notice, + Plugin, + TAbstractFile, + TFile, + normalizePath +} from 'obsidian'; +import type { AgentManager } from '../../services/AgentManager'; +import type { Settings } from '../../settings'; +import { detectFileType, isSupportedFile } from '../../agents/ingestManager/tools/services/FileTypeDetector'; +import type { IngestToolResult } from '../../agents/ingestManager/types'; + +declare module 'obsidian' { + interface Workspace extends Events { + on( + name: 'file-menu', + callback: (menu: Menu, file: TAbstractFile, source: string) => void + ): import('obsidian').EventRef; + } +} + +interface PluginWithServices extends Plugin { + settings?: Settings; + getService(name: string, timeoutMs?: number): Promise; +} + +export interface VaultIngestionManagerConfig { + plugin: PluginWithServices; + app: App; + getService: (name: string, timeoutMs?: number) => Promise; +} + +type IngestionSource = 'manual' | 'auto'; + +const AUTO_INGEST_DELAY_MS = 1500; + +export class VaultIngestionManager { + private inFlight = new Set(); + private autoWatcherRegistered = false; + + constructor(private config: VaultIngestionManagerConfig) {} + + register(): void { + this.registerFileMenu(); + this.registerAutoIngestionWatcher(); + } + + private registerFileMenu(): void { + this.config.plugin.registerEvent( + this.config.app.workspace.on('file-menu', (menu, file) => { + if (!(file instanceof TFile)) { + return; + } + + if (this.isIngestionDisabled() || !isSupportedFile(file.path)) { + return; + } + + menu.addItem((item) => { + item + .setTitle('Convert to Markdown') + .setIcon('file-text') + .onClick(() => { + void this.convertFile(file, 'manual'); + }); + }); + }) + ); + } + + private registerAutoIngestionWatcher(): void { + this.config.app.workspace.onLayoutReady(() => { + if (this.autoWatcherRegistered) { + return; + } + + this.autoWatcherRegistered = true; + this.config.plugin.registerEvent( + this.config.app.vault.on('create', (file) => { + if (!(file instanceof TFile)) { + return; + } + + if (!this.shouldAutoIngest(file)) { + return; + } + + window.setTimeout(() => { + void this.convertFile(file, 'auto'); + }, AUTO_INGEST_DELAY_MS); + }) + ); + }); + } + + private shouldAutoIngest(file: TFile): boolean { + const settings = this.config.plugin.settings?.settings; + if (!settings || settings.enableIngestion === false || settings.autoIngestion !== true) { + return false; + } + + if (!isSupportedFile(file.path)) { + return false; + } + + if (this.inFlight.has(file.path)) { + return false; + } + + const outputFile = this.getOutputFile(file); + if (outputFile) { + return false; + } + + return true; + } + + private async convertFile(file: TFile, source: IngestionSource): Promise { + if (this.inFlight.has(file.path)) { + if (source === 'manual') { + new Notice(`Already converting ${file.name}.`); + } + return; + } + + const request = this.buildRequest(file); + if (!request.ready) { + if (source === 'manual' || request.noticeOnSkip) { + new Notice(request.message, 7000); + } + return; + } + + this.inFlight.add(file.path); + new Notice( + `${source === 'auto' ? 'Auto-converting' : 'Converting'} ${file.name} to Markdown...`, + 3000 + ); + + try { + const agentManager = await this.config.getService('agentManager'); + if (!agentManager) { + throw new Error('Agent manager not available'); + } + + const ingestAgent = agentManager.getAgent('ingestManager'); + if (!ingestAgent) { + throw new Error('Ingest agent not available'); + } + + const ingestTool = ingestAgent.getTool('ingest'); + if (!ingestTool) { + throw new Error('Ingest tool not available'); + } + + const result = await ingestTool.execute(request.params) as IngestToolResult; + if (!result.success) { + throw new Error(result.error || 'Ingestion failed'); + } + + const outputPath = result.outputPath || this.getOutputPath(file.path); + new Notice(`Converted ${file.name} -> ${outputPath}`, 5000); + + if (result.warnings && result.warnings.length > 0) { + new Notice(result.warnings.join(' '), 7000); + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unexpected ingestion error'; + new Notice(`Failed to convert ${file.name}: ${message}`, 8000); + } finally { + this.inFlight.delete(file.path); + } + } + + private buildRequest(file: TFile): + | { ready: true; params: { filePath: string; mode?: 'text' | 'vision'; ocrProvider?: string; ocrModel?: string; transcriptionProvider?: string; transcriptionModel?: string } } + | { ready: false; message: string; noticeOnSkip: boolean } { + const settings = this.config.plugin.settings?.settings; + const llmSettings = settings?.llmProviders; + + if (!settings || settings.enableIngestion === false) { + return { + ready: false, + message: 'Ingestion is disabled in settings.', + noticeOnSkip: false + }; + } + + const fileType = detectFileType(file.path); + if (!fileType) { + return { + ready: false, + message: `Unsupported file type: ${file.name}`, + noticeOnSkip: false + }; + } + + if (!llmSettings) { + return { + ready: false, + message: 'LLM provider settings are not available.', + noticeOnSkip: true + }; + } + + if (fileType.type === 'pdf') { + const mode = llmSettings.defaultPdfMode || 'text'; + if (mode === 'vision') { + const ocrProvider = llmSettings.defaultOcrModel?.provider; + const ocrModel = llmSettings.defaultOcrModel?.model; + if (!ocrProvider || !ocrModel) { + return { + ready: false, + message: 'Set a default OCR provider and model before converting PDFs in vision mode.', + noticeOnSkip: true + }; + } + + return { + ready: true, + params: { + filePath: file.path, + mode, + ocrProvider, + ocrModel + } + }; + } + + return { + ready: true, + params: { + filePath: file.path, + mode + } + }; + } + + const transcriptionProvider = llmSettings.defaultTranscriptionModel?.provider; + const transcriptionModel = llmSettings.defaultTranscriptionModel?.model; + if (!transcriptionProvider || !transcriptionModel) { + return { + ready: false, + message: 'Set a default transcription provider and model before converting audio files.', + noticeOnSkip: true + }; + } + + return { + ready: true, + params: { + filePath: file.path, + transcriptionProvider, + transcriptionModel + } + }; + } + + private getOutputFile(file: TFile): TFile | null { + return this.config.app.vault.getFileByPath(this.getOutputPath(file.path)); + } + + private getOutputPath(filePath: string): string { + const normalizedPath = normalizePath(filePath); + const dotIndex = normalizedPath.lastIndexOf('.'); + if (dotIndex === -1) { + return `${normalizedPath}.md`; + } + return `${normalizedPath.slice(0, dotIndex)}.md`; + } + + private isIngestionDisabled(): boolean { + return this.config.plugin.settings?.settings?.enableIngestion === false; + } +} diff --git a/src/settings/tabs/DefaultsTab.ts b/src/settings/tabs/DefaultsTab.ts index e155c4a0d..90085ef8d 100644 --- a/src/settings/tabs/DefaultsTab.ts +++ b/src/settings/tabs/DefaultsTab.ts @@ -292,6 +292,18 @@ export class DefaultsTab { }); }); + new Setting(content) + .setName('Auto-convert new files') + .setDesc('When supported PDF or audio files are added to the vault, automatically convert them to sibling Markdown files using the defaults below.') + .addToggle(toggle => { + toggle + .setValue(pluginSettings.autoIngestion === true) + .onChange(async (value) => { + pluginSettings.autoIngestion = value; + await this.services.settings.saveSettings(); + }); + }); + if (!isEnabled) { ingestionSettingsContainer.addClass('nexus-ingest-confirm-hidden'); } diff --git a/src/types.ts b/src/types.ts index b41f38ea1..4522986db 100644 --- a/src/types.ts +++ b/src/types.ts @@ -89,6 +89,7 @@ export const DEFAULT_SETTINGS: MCPSettings = { enabledVault: true, enableEmbeddings: true, // Enable local embeddings by default (desktop only) enableIngestion: true, + autoIngestion: false, configFilePath: undefined, memory: DEFAULT_MEMORY_SETTINGS, customPrompts: DEFAULT_CUSTOM_PROMPTS_SETTINGS, diff --git a/src/types/pdfjs-worker.d.ts b/src/types/pdfjs-worker.d.ts new file mode 100644 index 000000000..27989631c --- /dev/null +++ b/src/types/pdfjs-worker.d.ts @@ -0,0 +1,3 @@ +declare module 'pdfjs-dist/legacy/build/pdf.worker.mjs' { + export const WorkerMessageHandler: unknown; +} diff --git a/src/types/plugin/PluginTypes.ts b/src/types/plugin/PluginTypes.ts index 848f3a950..1fd87e3f8 100644 --- a/src/types/plugin/PluginTypes.ts +++ b/src/types/plugin/PluginTypes.ts @@ -59,6 +59,7 @@ export interface MCPSettings { enabledVault: boolean; enableEmbeddings?: boolean; // Enable/disable local embeddings for semantic search (desktop only) enableIngestion?: boolean; // Enable/disable PDF/audio ingestion UI and ingest-only model settings + autoIngestion?: boolean; // Automatically convert newly added supported binary files to Markdown configFilePath?: string; memory?: MemorySettings; customPrompts?: CustomPromptsSettings; diff --git a/src/ui/chat/ChatView.ts b/src/ui/chat/ChatView.ts index 2e75391ae..54e705f6b 100644 --- a/src/ui/chat/ChatView.ts +++ b/src/ui/chat/ChatView.ts @@ -43,17 +43,6 @@ import { ToolEventCoordinator } from './coordinators/ToolEventCoordinator'; import { ChatLayoutBuilder, ChatLayoutElements } from './builders/ChatLayoutBuilder'; import { ChatEventBinder } from './utils/ChatEventBinder'; -// Ingest UI -import { IngestEventBinder } from '../../agents/ingestManager/ui/IngestEventBinder'; -import { IngestProgressBanner } from '../../agents/ingestManager/ui/IngestProgressBanner'; -import { IngestConfirmModal, IngestConfirmOptions } from '../../agents/ingestManager/ui/IngestConfirmModal'; -import type { IngestProgress, IngestToolResult } from '../../agents/ingestManager/types'; -import { ACCEPTED_AUDIO_EXTENSIONS } from '../../agents/ingestManager/types'; -import { - getIngestCapabilityOptions, - IngestCapabilityOptions -} from '../../agents/ingestManager/tools/services/IngestCapabilityService'; - // Utils import { ReferenceMetadata } from './utils/ReferenceExtractor'; import { CHAT_VIEW_TYPES } from '../../constants/branding'; @@ -67,7 +56,6 @@ import type { AgentManager } from '../../services/AgentManager'; import type { DirectToolExecutor } from '../../services/chat/DirectToolExecutor'; import type { PromptManagerAgent } from '../../agents/promptManager/promptManager'; import type { HybridStorageAdapter } from '../../database/adapters/HybridStorageAdapter'; -import { LLMProviderManager } from '../../services/llm/providers/ProviderManager'; // Branch UI components import { BranchHeader, BranchViewContext } from './components/BranchHeader'; @@ -122,10 +110,6 @@ export class ChatView extends ItemView { // Layout elements private layoutElements!: ChatLayoutElements; - // Ingest UI - private ingestEventBinder: IngestEventBinder | null = null; - private ingestProgressBanner: IngestProgressBanner | null = null; - constructor(leaf: WorkspaceLeaf, private chatService: ChatService) { super(leaf); this.compactionService = new ContextCompactionService(); @@ -492,191 +476,6 @@ export class ChatView extends ItemView { ); this.uiStateController.initializeEventListeners(); - - // Ingest drag-and-drop - this.initializeIngestUI(); - } - - /** - * Initialize ingest drag-and-drop UI and progress banner - */ - private initializeIngestUI(): void { - const plugin = getNexusPlugin(this.app); - if (!plugin || plugin.settings?.settings?.enableIngestion === false) return; - - // Progress banner (always visible container, banners appear inside on ingest) - this.ingestProgressBanner = new IngestProgressBanner( - this.layoutElements.ingestBannerContainer - ); - - // Drag-and-drop event binding - const mainContainer = this.containerEl.querySelector('.chat-main') as HTMLElement; - if (mainContainer) { - this.ingestEventBinder = new IngestEventBinder( - mainContainer, - plugin, - (files) => this.handleIngestFiles(files) - ); - this.ingestEventBinder.bind(); - } - } - - /** - * Handle files dropped onto the chat view for ingestion - */ - private async handleIngestFiles(files: FileList): Promise { - const plugin = getNexusPlugin(this.app); - if (!plugin) return; - if (plugin.settings?.settings?.enableIngestion === false) { - new Notice('Ingestion is disabled in settings.'); - return; - } - - const settings = plugin.settings?.settings; - const llmSettings = settings?.llmProviders; - const ingestCapabilities = await this.getIngestCapabilities(); - - for (let i = 0; i < files.length; i++) { - const file = files[i]; - const ext = file.name.substring(file.name.lastIndexOf('.')).toLowerCase(); - - const isPdf = ext === '.pdf'; - const isAudio = (ACCEPTED_AUDIO_EXTENSIONS as readonly string[]).includes(ext); - if (!isPdf && !isAudio) continue; - - // Resolve vault-relative path from dropped filename - const vaultFile = this.app.vault.getFiles().find(f => f.name === file.name); - if (!vaultFile) { - this.ingestProgressBanner?.update({ - filePath: file.name, - stage: 'error', - error: 'File not found in vault. Try dragging from the Obsidian file explorer.' - }); - continue; - } - const vaultPath = vaultFile.path; - - const fileType = isPdf ? 'pdf' as const : 'audio' as const; - - // Build confirm modal options - const confirmOptions: IngestConfirmOptions = { - filePath: vaultPath, - fileType, - defaultPdfMode: (llmSettings?.defaultPdfMode as 'text' | 'vision') || 'text', - defaultOcrProvider: llmSettings?.defaultOcrModel?.provider, - defaultOcrModel: llmSettings?.defaultOcrModel?.model, - defaultTranscriptionProvider: llmSettings?.defaultTranscriptionModel?.provider, - defaultTranscriptionModel: llmSettings?.defaultTranscriptionModel?.model, - ocrProviders: ingestCapabilities.ocrProviders, - transcriptionProviders: ingestCapabilities.transcriptionProviders - }; - - const modal = new IngestConfirmModal(this.app, confirmOptions); - const result = await modal.prompt(); - - if (!result.confirmed) continue; - - // Show progress banner - this.ingestProgressBanner?.update({ - filePath: vaultPath, - stage: 'queued', - progress: 0 - }); - - // Get the IngestManager agent and run ingestion - try { - const agentManager = await plugin.getService('agentManager'); - if (!agentManager) { - this.ingestProgressBanner?.update({ - filePath: vaultPath, - stage: 'error', - error: 'Agent manager not available' - }); - continue; - } - - const ingestAgent = agentManager.getAgent('ingestManager'); - if (!ingestAgent) { - this.ingestProgressBanner?.update({ - filePath: vaultPath, - stage: 'error', - error: 'Ingest agent not available' - }); - continue; - } - - const ingestTool = ingestAgent.getTool('ingest'); - if (!ingestTool) { - this.ingestProgressBanner?.update({ - filePath: vaultPath, - stage: 'error', - error: 'Ingest tool not available' - }); - continue; - } - - // Update banner to active processing stage - this.ingestProgressBanner?.update({ - filePath: vaultPath, - stage: isPdf ? 'extracting' : 'transcribing', - progress: 10 - }); - - const ingestResult = await ingestTool.execute({ - filePath: vaultPath, - mode: isPdf ? result.pdfMode : undefined, - ocrProvider: result.ocrProvider, - ocrModel: result.ocrModel, - transcriptionProvider: result.transcriptionProvider, - transcriptionModel: result.transcriptionModel, - }) as IngestToolResult; - - if (ingestResult.success) { - this.ingestProgressBanner?.update({ - filePath: vaultPath, - stage: 'complete', - progress: 100 - }); - - if (ingestResult.outputPath) { - new Notice(`Ingested: ${ingestResult.outputPath}`); - } - } else { - this.ingestProgressBanner?.update({ - filePath: vaultPath, - stage: 'error', - error: ingestResult.error || 'Ingestion failed' - }); - } - } catch (err) { - this.ingestProgressBanner?.update({ - filePath: vaultPath, - stage: 'error', - error: err instanceof Error ? err.message : 'Unexpected error during ingestion' - }); - } - } - } - - private async getIngestCapabilities(): Promise { - const plugin = getNexusPlugin(this.app); - if (plugin?.settings?.settings?.enableIngestion === false) { - return { - ocrProviders: [], - transcriptionProviders: [] - }; - } - const llmSettings = plugin?.settings?.settings?.llmProviders; - - if (!llmSettings) { - return { - ocrProviders: [], - transcriptionProviders: [] - }; - } - - const providerManager = new LLMProviderManager(llmSettings, this.app.vault); - return getIngestCapabilityOptions(providerManager); } /** @@ -1547,7 +1346,5 @@ export class ChatView extends ItemView { this.nexusLoadingController?.unload(); this.subagentController?.cleanup(); this.branchHeader?.cleanup(); - this.ingestEventBinder?.destroy(); - this.ingestProgressBanner?.destroy(); } } diff --git a/src/ui/chat/builders/ChatLayoutBuilder.ts b/src/ui/chat/builders/ChatLayoutBuilder.ts index 963f451b8..36c81ac3c 100644 --- a/src/ui/chat/builders/ChatLayoutBuilder.ts +++ b/src/ui/chat/builders/ChatLayoutBuilder.ts @@ -27,7 +27,6 @@ export interface ChatLayoutElements { sidebarContainer: HTMLElement; loadingOverlay: HTMLElement; branchHeaderContainer: HTMLElement; - ingestBannerContainer: HTMLElement; } export class ChatLayoutBuilder { @@ -45,9 +44,6 @@ export class ChatLayoutBuilder { // Header const { chatTitle, hamburgerButton, settingsButton } = this.createHeader(mainContainer); - // Ingest progress banners (above branch header) - const ingestBannerContainer = mainContainer.createDiv('nexus-ingest-banner-container'); - // Branch header container (above messages, separate from message container) // This ensures BranchHeader isn't clobbered when MessageDisplay.setConversation() empties the message container const branchHeaderContainer = mainContainer.createDiv('nexus-branch-header-container'); @@ -76,8 +72,7 @@ export class ChatLayoutBuilder { backdrop, sidebarContainer, loadingOverlay, - branchHeaderContainer, - ingestBannerContainer + branchHeaderContainer }; } diff --git a/src/utils/connectorContent.ts b/src/utils/connectorContent.ts index f29a06435..0d63deabd 100644 --- a/src/utils/connectorContent.ts +++ b/src/utils/connectorContent.ts @@ -5,7 +5,7 @@ * DO NOT EDIT MANUALLY - This file is regenerated during the build process. * To update, modify connector.ts and rebuild. * - * Generated: 2026-03-29T20:06:19.419Z + * Generated: 2026-04-04T21:46:21.922Z */ export const CONNECTOR_JS_CONTENT = `"use strict"; diff --git a/tests/unit/PdfTextExtractor.test.ts b/tests/unit/PdfTextExtractor.test.ts index 1ba5d56d9..dff3e08c0 100644 --- a/tests/unit/PdfTextExtractor.test.ts +++ b/tests/unit/PdfTextExtractor.test.ts @@ -6,15 +6,16 @@ * and whitespace trimming. */ -// Mock pdfjs-dist before importing the module under test -jest.mock('pdfjs-dist', () => ({ - getDocument: jest.fn(), +// Mock the PDF.js loader before importing the module under test +jest.mock('../../src/agents/ingestManager/tools/services/PdfJsLoader', () => ({ + loadPdfJs: jest.fn(), })); import { extractPdfText } from '../../src/agents/ingestManager/tools/services/PdfTextExtractor'; -import * as pdfjsLib from 'pdfjs-dist'; +import { loadPdfJs } from '../../src/agents/ingestManager/tools/services/PdfJsLoader'; -const getDocumentMock = pdfjsLib.getDocument as jest.Mock; +const loadPdfJsMock = loadPdfJs as jest.MockedFunction; +const getDocumentMock = jest.fn(); /** * Helper to build a mock PDF document with specified page text items. @@ -45,6 +46,9 @@ function mockPdfDocument(pages: Array>) describe('PdfTextExtractor', () => { beforeEach(() => { jest.clearAllMocks(); + loadPdfJsMock.mockResolvedValue({ + getDocument: getDocumentMock, + } as unknown as Awaited>); }); // ========================================================================== From 15d90a141b0a0a72b868a967ca23d575245c51b3 Mon Sep 17 00:00:00 2001 From: Midway65 Date: Sat, 4 Apr 2026 14:55:33 -0700 Subject: [PATCH 17/64] revert(fork-id): restore plugin id=nexus name=Nexus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the nucleus rename — it caused the chat plugin to stop functioning. Plugin identity restored to id=nexus/name=Nexus. Version remains 5.6.2 (from the upstream merge). Co-Authored-By: Claude Sonnet 4.6 --- manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/manifest.json b/manifest.json index bf8b3eacc..da63ff4f4 100644 --- a/manifest.json +++ b/manifest.json @@ -1,6 +1,6 @@ { - "id": "nucleus", - "name": "Nucleus", + "id": "nexus", + "name": "Nexus", "version": "5.6.2", "minAppVersion": "0.15.0", "description": "Agentic AI for your vault. Use Claude, ChatGPT, Gemini, and local models to chat, search, create, and manage your notes with semantic memory, image generation, and MCP server integration.", From 919049c8bea3e7e9d30a1a5cf63820685d0ac35f Mon Sep 17 00:00:00 2001 From: Midway65 Date: Sat, 4 Apr 2026 16:13:09 -0700 Subject: [PATCH 18/64] =?UTF-8?q?fix(schema):=20migration=20v12=20?= =?UTF-8?q?=E2=80=94=20rebuild=20note/block=20embedding=20tables=20for=203?= =?UTF-8?q?84-dim=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit note_embeddings and block_embeddings vec0 tables were created with float[768] from an older embedding model. Current model (TaylorAI/bge-micro-v2) produces 384-dim vectors. Drops and recreates both tables, clears associated metadata. Co-Authored-By: Claude Sonnet 4.6 --- src/database/schema/SchemaMigrator.ts | 198 ++++++++++++++------------ 1 file changed, 109 insertions(+), 89 deletions(-) diff --git a/src/database/schema/SchemaMigrator.ts b/src/database/schema/SchemaMigrator.ts index 26e22a193..7db1810a6 100644 --- a/src/database/schema/SchemaMigrator.ts +++ b/src/database/schema/SchemaMigrator.ts @@ -63,39 +63,39 @@ * Note: The raw WASM database only has prepare(), but this interface expects * exec() and run() to be provided by a wrapper/adapter class. */ -export interface MigratableDatabase { - /** Execute SQL and return results */ - exec(sql: string): { values: unknown[][] }[]; - /** Run a statement (INSERT/UPDATE/DELETE) with optional parameters */ - run(sql: string, params?: unknown[]): void; -} +export interface MigratableDatabase { + /** Execute SQL and return results */ + exec(sql: string): { values: unknown[][] }[]; + /** Run a statement (INSERT/UPDATE/DELETE) with optional parameters */ + run(sql: string, params?: unknown[]): void; +} // Alias for backward compatibility type Database = MigratableDatabase; -export const CURRENT_SCHEMA_VERSION = 11; - -export interface Migration { - version: number; - description: string; - /** SQL statements to run. Each is executed separately. */ - sql: string[]; - /** Optional JavaScript migration function for logic that cannot be expressed in SQL alone (e.g., JSON parsing). */ - migrationFn?: (db: MigratableDatabase) => void; -} - -interface LegacyConversationMetadata { - chatSettings?: { - workspaceId?: string; - sessionId?: string; - }; - workspaceId?: string; - sessionId?: string; - workflowId?: string; - runTrigger?: string; - scheduledFor?: number; - runKey?: string; -} +export const CURRENT_SCHEMA_VERSION = 12; + +export interface Migration { + version: number; + description: string; + /** SQL statements to run. Each is executed separately. */ + sql: string[]; + /** Optional JavaScript migration function for logic that cannot be expressed in SQL alone (e.g., JSON parsing). */ + migrationFn?: (db: MigratableDatabase) => void; +} + +interface LegacyConversationMetadata { + chatSettings?: { + workspaceId?: string; + sessionId?: string; + }; + workspaceId?: string; + sessionId?: string; + workflowId?: string; + runTrigger?: string; + scheduledFor?: number; + runKey?: string; +} /** * Migration definitions - add new migrations here when schema changes. @@ -224,7 +224,7 @@ export const MIGRATIONS: Migration[] = [ errorMessage TEXT )`, ], - migrationFn: (db: MigratableDatabase): void => { + migrationFn: (db: MigratableDatabase): void => { // Backfill denormalized workspaceId/sessionId from metadataJson // Cannot use json_extract() — may not be available in WASM SQLite const rows = db.exec('SELECT id, metadataJson FROM conversations WHERE metadataJson IS NOT NULL'); @@ -238,7 +238,7 @@ export const MIGRATIONS: Migration[] = [ let sessionId: string | null = null; try { - const metadata = JSON.parse(metadataJson) as LegacyConversationMetadata; + const metadata = JSON.parse(metadataJson) as LegacyConversationMetadata; // Try chatSettings path first (ConversationManager-created conversations) if (metadata?.chatSettings?.workspaceId) { @@ -280,9 +280,9 @@ export const MIGRATIONS: Migration[] = [ }, // Version 8 -> 9: Add task management tables (projects, tasks, task_dependencies, task_note_links) - { - version: 9, - description: 'Add task management tables (projects, tasks, task_dependencies, task_note_links)', + { + version: 9, + description: 'Add task management tables (projects, tasks, task_dependencies, task_note_links)', sql: [ // Projects table `CREATE TABLE IF NOT EXISTS projects ( @@ -350,61 +350,81 @@ export const MIGRATIONS: Migration[] = [ 'CREATE INDEX IF NOT EXISTS idx_tasks_project_status ON tasks(projectId, status)', 'CREATE INDEX IF NOT EXISTS idx_task_deps_task ON task_dependencies(taskId)', 'CREATE INDEX IF NOT EXISTS idx_task_deps_depends ON task_dependencies(dependsOnTaskId)', - 'CREATE INDEX IF NOT EXISTS idx_task_links_note ON task_note_links(notePath)', - ] - }, - - // Version 9 -> 10: Add workflow run metadata columns to conversations - { - version: 10, - description: 'Add workflow run metadata columns to conversations for scheduled workflow dedupe', - sql: [ - 'ALTER TABLE conversations ADD COLUMN workflowId TEXT', - 'ALTER TABLE conversations ADD COLUMN runTrigger TEXT', - 'ALTER TABLE conversations ADD COLUMN scheduledFor INTEGER', - 'ALTER TABLE conversations ADD COLUMN runKey TEXT', - 'CREATE INDEX IF NOT EXISTS idx_conversations_workflowId ON conversations(workflowId)', - 'CREATE INDEX IF NOT EXISTS idx_conversations_scheduledFor ON conversations(scheduledFor)', - 'CREATE INDEX IF NOT EXISTS idx_conversations_runKey ON conversations(runKey)' - ], - migrationFn: (db: MigratableDatabase): void => { - const rows = db.exec('SELECT id, metadataJson FROM conversations WHERE metadataJson IS NOT NULL'); - if (rows.length === 0) return; - - for (const row of rows[0].values) { - const id = row[0] as string; - const metadataJson = row[1] as string; - - try { - const metadata = JSON.parse(metadataJson) as LegacyConversationMetadata; - const workflowId = metadata?.workflowId; - const runTrigger = metadata?.runTrigger; - const scheduledFor = metadata?.scheduledFor; - const runKey = metadata?.runKey; - - if (workflowId || runTrigger || scheduledFor || runKey) { - db.run( - 'UPDATE conversations SET workflowId = ?, runTrigger = ?, scheduledFor = ?, runKey = ? WHERE id = ?', - [workflowId ?? null, runTrigger ?? null, scheduledFor ?? null, runKey ?? null, id] - ); - } - } catch { - // Ignore unparseable metadata rows. - } - } - } - }, - - // Version 10 -> 11: Add workspace archive flag - { - version: 11, - description: 'Add isArchived column to workspaces table for soft-delete persistence', - sql: [ - 'ALTER TABLE workspaces ADD COLUMN isArchived INTEGER DEFAULT 0', - 'CREATE INDEX IF NOT EXISTS idx_workspaces_archived ON workspaces(isArchived)' - ] - }, -]; + 'CREATE INDEX IF NOT EXISTS idx_task_links_note ON task_note_links(notePath)', + ] + }, + + // Version 9 -> 10: Add workflow run metadata columns to conversations + { + version: 10, + description: 'Add workflow run metadata columns to conversations for scheduled workflow dedupe', + sql: [ + 'ALTER TABLE conversations ADD COLUMN workflowId TEXT', + 'ALTER TABLE conversations ADD COLUMN runTrigger TEXT', + 'ALTER TABLE conversations ADD COLUMN scheduledFor INTEGER', + 'ALTER TABLE conversations ADD COLUMN runKey TEXT', + 'CREATE INDEX IF NOT EXISTS idx_conversations_workflowId ON conversations(workflowId)', + 'CREATE INDEX IF NOT EXISTS idx_conversations_scheduledFor ON conversations(scheduledFor)', + 'CREATE INDEX IF NOT EXISTS idx_conversations_runKey ON conversations(runKey)' + ], + migrationFn: (db: MigratableDatabase): void => { + const rows = db.exec('SELECT id, metadataJson FROM conversations WHERE metadataJson IS NOT NULL'); + if (rows.length === 0) return; + + for (const row of rows[0].values) { + const id = row[0] as string; + const metadataJson = row[1] as string; + + try { + const metadata = JSON.parse(metadataJson) as LegacyConversationMetadata; + const workflowId = metadata?.workflowId; + const runTrigger = metadata?.runTrigger; + const scheduledFor = metadata?.scheduledFor; + const runKey = metadata?.runKey; + + if (workflowId || runTrigger || scheduledFor || runKey) { + db.run( + 'UPDATE conversations SET workflowId = ?, runTrigger = ?, scheduledFor = ?, runKey = ? WHERE id = ?', + [workflowId ?? null, runTrigger ?? null, scheduledFor ?? null, runKey ?? null, id] + ); + } + } catch { + // Ignore unparseable metadata rows. + } + } + } + }, + + // Version 10 -> 11: Add workspace archive flag + { + version: 11, + description: 'Add isArchived column to workspaces table for soft-delete persistence', + sql: [ + 'ALTER TABLE workspaces ADD COLUMN isArchived INTEGER DEFAULT 0', + 'CREATE INDEX IF NOT EXISTS idx_workspaces_archived ON workspaces(isArchived)' + ] + }, + + // Version 11 -> 12: Rebuild note and block embedding tables for 384-dim model + // The note_embeddings and block_embeddings vec0 tables were created with float[768] + // when an older embedding model was in use. The current model (TaylorAI/bge-micro-v2) + // produces 384-dim vectors. vec0 virtual tables cannot be ALTERed — they must be + // dropped and recreated. Dropping a vec0 table also drops its shadow tables + // (_chunks, _info, _rowids, _vector_chunks00), so no manual shadow cleanup is needed. + // Associated metadata rows are also cleared since their rowids are now invalid. + { + version: 12, + description: 'Rebuild note_embeddings and block_embeddings vec0 tables from float[768] to float[384] to match current embedding model output', + sql: [ + 'DROP TABLE IF EXISTS note_embeddings', + 'CREATE VIRTUAL TABLE IF NOT EXISTS note_embeddings USING vec0(embedding float[384])', + 'DROP TABLE IF EXISTS block_embeddings', + 'CREATE VIRTUAL TABLE IF NOT EXISTS block_embeddings USING vec0(embedding float[384])', + 'DELETE FROM embedding_metadata', + 'DELETE FROM block_embedding_metadata', + ] + }, +]; /** * SchemaMigrator handles database schema upgrades From a85ff4fcb579028672fd134f6e642b2e4d61bc39 Mon Sep 17 00:00:00 2001 From: Midway65 Date: Sat, 4 Apr 2026 16:21:08 -0700 Subject: [PATCH 19/64] fix(schema): fix vec0 dimension migration via native db.exec() path vec0 virtual table DROP/CREATE cannot run via prepare().step() in the DatabaseAdapter. Migration v12 is now a version marker only; the actual fix runs in SQLiteCacheManager.fixVec0TableDimensions() using the raw WASM db.exec(), matching the same path used by clearAllData(). Co-Authored-By: Claude Sonnet 4.6 --- src/database/schema/SchemaMigrator.ts | 23 +- src/database/storage/SQLiteCacheManager.ts | 581 +++++++++++---------- 2 files changed, 323 insertions(+), 281 deletions(-) diff --git a/src/database/schema/SchemaMigrator.ts b/src/database/schema/SchemaMigrator.ts index 7db1810a6..cce4bb3fe 100644 --- a/src/database/schema/SchemaMigrator.ts +++ b/src/database/schema/SchemaMigrator.ts @@ -405,24 +405,15 @@ export const MIGRATIONS: Migration[] = [ ] }, - // Version 11 -> 12: Rebuild note and block embedding tables for 384-dim model - // The note_embeddings and block_embeddings vec0 tables were created with float[768] - // when an older embedding model was in use. The current model (TaylorAI/bge-micro-v2) - // produces 384-dim vectors. vec0 virtual tables cannot be ALTERed — they must be - // dropped and recreated. Dropping a vec0 table also drops its shadow tables - // (_chunks, _info, _rowids, _vector_chunks00), so no manual shadow cleanup is needed. - // Associated metadata rows are also cleared since their rowids are now invalid. + // Version 11 -> 12: Fix note/block embedding vec0 table dimensions (768 → 384) + // vec0 virtual tables cannot be DROPped and recreated via prepare().step() DDL — + // they require the native WASM exec() path. This migration is a version marker only; + // the actual DROP/CREATE is handled in SQLiteCacheManager.fixVec0TableDimensions() + // which is called after migrations run and uses the correct raw db.exec() path. { version: 12, - description: 'Rebuild note_embeddings and block_embeddings vec0 tables from float[768] to float[384] to match current embedding model output', - sql: [ - 'DROP TABLE IF EXISTS note_embeddings', - 'CREATE VIRTUAL TABLE IF NOT EXISTS note_embeddings USING vec0(embedding float[384])', - 'DROP TABLE IF EXISTS block_embeddings', - 'CREATE VIRTUAL TABLE IF NOT EXISTS block_embeddings USING vec0(embedding float[384])', - 'DELETE FROM embedding_metadata', - 'DELETE FROM block_embedding_metadata', - ] + description: 'Version marker: note/block vec0 table dimension fix (768→384) handled by SQLiteCacheManager.fixVec0TableDimensions()', + sql: [] }, ]; diff --git a/src/database/storage/SQLiteCacheManager.ts b/src/database/storage/SQLiteCacheManager.ts index 13efc040d..e8330dd70 100644 --- a/src/database/storage/SQLiteCacheManager.ts +++ b/src/database/storage/SQLiteCacheManager.ts @@ -24,22 +24,22 @@ // Import the raw WASM sqlite3 module (has sqlite-vec compiled in) // esbuild alias resolves this to index.mjs which exports sqlite3InitModule -import sqlite3InitModule from '@dao-xyz/sqlite3-vec/wasm'; +import sqlite3InitModule from '@dao-xyz/sqlite3-vec/wasm'; -import { App } from 'obsidian'; -import { PaginatedResult, PaginationParams } from '../../types/pagination/PaginationTypes'; -import { IStorageBackend, RunResult, DatabaseStats } from '../interfaces/IStorageBackend'; -import type { SyncState, ISQLiteCacheManager } from '../sync/SyncCoordinator'; -import { SQLiteSearchService } from './SQLiteSearchService'; -import { QueryParams } from '../repositories/base/BaseRepository'; +import { App } from 'obsidian'; +import { PaginatedResult, PaginationParams } from '../../types/pagination/PaginationTypes'; +import { IStorageBackend, RunResult, DatabaseStats } from '../interfaces/IStorageBackend'; +import type { SyncState, ISQLiteCacheManager } from '../sync/SyncCoordinator'; +import { SQLiteSearchService } from './SQLiteSearchService'; +import { QueryParams } from '../repositories/base/BaseRepository'; // Import schema from TypeScript module (esbuild compatible) import { SCHEMA_SQL } from '../schema/schema'; -import { SchemaMigrator } from '../schema/SchemaMigrator'; - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null; -} +import { SchemaMigrator } from '../schema/SchemaMigrator'; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} export interface SQLiteCacheManagerOptions { app: App; @@ -47,35 +47,35 @@ export interface SQLiteCacheManagerOptions { autoSaveInterval?: number; // ms between auto-saves (default: 30000) } -export interface QueryResult { - items: T[]; - totalCount?: number; -} - -type SQLite3Module = Awaited>; -type SQLiteDatabase = InstanceType; +export interface QueryResult { + items: T[]; + totalCount?: number; +} + +type SQLite3Module = Awaited>; +type SQLiteDatabase = InstanceType; /** * Database adapter that wraps raw WASM SQLite database to provide * exec() and run() methods for MigratableDatabase interface. */ -class DatabaseAdapter { - constructor(private rawDb: SQLiteDatabase) {} - - exec(sql: string): { values: unknown[][] }[] { - const stmt = this.rawDb.prepare(sql); - const results: unknown[][] = []; - while (stmt.step()) { - results.push(stmt.get([]) as unknown[]); - } - stmt.finalize(); - return results.length > 0 ? [{ values: results }] : []; - } - - run(sql: string, params?: QueryParams): void { - const stmt = this.rawDb.prepare(sql); - if (params?.length) { - stmt.bind(params); +class DatabaseAdapter { + constructor(private rawDb: SQLiteDatabase) {} + + exec(sql: string): { values: unknown[][] }[] { + const stmt = this.rawDb.prepare(sql); + const results: unknown[][] = []; + while (stmt.step()) { + results.push(stmt.get([]) as unknown[]); + } + stmt.finalize(); + return results.length > 0 ? [{ values: results }] : []; + } + + run(sql: string, params?: QueryParams): void { + const stmt = this.rawDb.prepare(sql); + if (params?.length) { + stmt.bind(params); } stmt.step(); stmt.finalize(); @@ -93,11 +93,11 @@ class DatabaseAdapter { * - Cursor-based pagination * - Transaction support */ -export class SQLiteCacheManager implements IStorageBackend, ISQLiteCacheManager { +export class SQLiteCacheManager implements IStorageBackend, ISQLiteCacheManager { private app: App; private dbPath: string; // Relative path within vault - private sqlite3: SQLite3Module | null = null; // The sqlite3 WASM module - private db: SQLiteDatabase | null = null; // The oo1.DB instance + private sqlite3: SQLite3Module | null = null; // The sqlite3 WASM module + private db: SQLiteDatabase | null = null; // The oo1.DB instance private isInitialized = false; private searchService: SQLiteSearchService; private hasUnsavedData = false; @@ -108,26 +108,26 @@ export class SQLiteCacheManager implements IStorageBackend, ISQLiteCacheManager private transactionDepth = 0; private transactionLock: Promise = Promise.resolve(); - constructor(options: SQLiteCacheManagerOptions) { + constructor(options: SQLiteCacheManagerOptions) { this.app = options.app; this.dbPath = options.dbPath; this.autoSaveInterval = options.autoSaveInterval ?? 30000; // 30 seconds default - this.searchService = new SQLiteSearchService(this); - } - - private getSqlite3OrThrow(): SQLite3Module { - if (!this.sqlite3) { - throw new Error('SQLite module not initialized'); - } - return this.sqlite3; - } - - private getDbOrThrow(): SQLiteDatabase { - if (!this.db) { - throw new Error('Database not initialized'); - } - return this.db; - } + this.searchService = new SQLiteSearchService(this); + } + + private getSqlite3OrThrow(): SQLite3Module { + if (!this.sqlite3) { + throw new Error('SQLite module not initialized'); + } + return this.sqlite3; + } + + private getDbOrThrow(): SQLiteDatabase { + if (!this.db) { + throw new Error('Database not initialized'); + } + return this.db; + } /** * Resolve the sqlite3.wasm path for the currently-installed plugin folder. @@ -135,8 +135,8 @@ export class SQLiteCacheManager implements IStorageBackend, ISQLiteCacheManager * Nexus supports legacy installs under `.obsidian/plugins/claudesidian-mcp/` * as well as the current `.obsidian/plugins/nexus/` folder. */ - private async resolveSqliteWasmPath(): Promise { - const configDir = this.app.vault.configDir; + private async resolveSqliteWasmPath(): Promise { + const configDir = this.app.vault.configDir; const candidatePluginFolders = ['nexus', 'claudesidian-mcp']; const candidates = candidatePluginFolders.map(folder => `${configDir}/plugins/${folder}/sqlite3.wasm`); @@ -145,9 +145,9 @@ export class SQLiteCacheManager implements IStorageBackend, ISQLiteCacheManager if (await this.app.vault.adapter.exists(candidate)) { return candidate; } - } catch { - // Ignore adapter errors and continue trying other candidates. - } + } catch { + // Ignore adapter errors and continue trying other candidates. + } } throw new Error( @@ -172,24 +172,24 @@ export class SQLiteCacheManager implements IStorageBackend, ISQLiteCacheManager // Read WASM binary using Obsidian's API const wasmBinary = await this.app.vault.adapter.readBinary(wasmPath); - const consoleRef = console; - const originalWarn = consoleRef.warn; - const originalLog = consoleRef.log; + const consoleRef = console; + const originalWarn = consoleRef.warn; + const originalLog = consoleRef.log; const suppressPatterns = [ /OPFS sqlite3_vfs/, /Heap resize call/, /instantiateWasm/ ]; - consoleRef.warn = (...args: unknown[]) => { - const msg = args[0]?.toString() || ''; - if (!suppressPatterns.some(p => p.test(msg))) { - originalWarn.apply(console, args); - } - }; - consoleRef.log = (...args: unknown[]) => { - const msg = args[0]?.toString() || ''; - if (!suppressPatterns.some(p => p.test(msg))) { - originalLog.apply(console, args); + consoleRef.warn = (...args: unknown[]) => { + const msg = args[0]?.toString() || ''; + if (!suppressPatterns.some(p => p.test(msg))) { + originalWarn.apply(console, args); + } + }; + consoleRef.log = (...args: unknown[]) => { + const msg = args[0]?.toString() || ''; + if (!suppressPatterns.some(p => p.test(msg))) { + originalLog.apply(console, args); } }; @@ -209,11 +209,11 @@ export class SQLiteCacheManager implements IStorageBackend, ISQLiteCacheManager }, print: () => undefined, printErr: (msg: string) => console.error('[SQLite]', msg) - } as unknown as Parameters[0]; - this.sqlite3 = await sqlite3InitModule(initOptions); + } as unknown as Parameters[0]; + this.sqlite3 = await sqlite3InitModule(initOptions); } finally { - consoleRef.warn = originalWarn; - consoleRef.log = originalLog; + consoleRef.warn = originalWarn; + consoleRef.log = originalLog; } // Ensure parent directory exists @@ -226,23 +226,26 @@ export class SQLiteCacheManager implements IStorageBackend, ISQLiteCacheManager // Check if database file exists const dbExists = await this.app.vault.adapter.exists(this.dbPath); - if (dbExists) { - // Load existing database from file - await this.loadFromFile(); - } else { - const sqlite3 = this.getSqlite3OrThrow(); - const db = new sqlite3.oo1.DB(':memory:'); - this.db = db; - db.exec(SCHEMA_SQL); - await this.saveToFile(); - } - - // Run schema migrations for existing databases - // Wrap raw database in adapter to provide exec() and run() methods - const dbAdapter = new DatabaseAdapter(this.getDbOrThrow()); - const migrator = new SchemaMigrator(dbAdapter); + if (dbExists) { + // Load existing database from file + await this.loadFromFile(); + } else { + const sqlite3 = this.getSqlite3OrThrow(); + const db = new sqlite3.oo1.DB(':memory:'); + this.db = db; + db.exec(SCHEMA_SQL); + await this.saveToFile(); + } + + // Run schema migrations for existing databases + // Wrap raw database in adapter to provide exec() and run() methods + const dbAdapter = new DatabaseAdapter(this.getDbOrThrow()); + const migrator = new SchemaMigrator(dbAdapter); const migrationResult = await migrator.migrate(); - if (migrationResult.applied > 0) { + // Fix vec0 table dimensions if needed. vec0 virtual tables cannot be + // DROPped/recreated via prepare().step() — must use native db.exec(). + const vec0Fixed = this.fixVec0TableDimensions(dbAdapter); + if (migrationResult.applied > 0 || vec0Fixed) { await this.saveToFile(); // Save after migrations } @@ -274,51 +277,51 @@ export class SQLiteCacheManager implements IStorageBackend, ISQLiteCacheManager const data = await this.app.vault.adapter.readBinary(this.dbPath); const uint8 = new Uint8Array(data); - if (uint8.length === 0) { - // Empty file, create new database - const sqlite3 = this.getSqlite3OrThrow(); - const db = new sqlite3.oo1.DB(':memory:'); - this.db = db; - db.exec(SCHEMA_SQL); - return; - } - - // Allocate memory for the database bytes - const sqlite3 = this.getSqlite3OrThrow(); - const ptr = sqlite3.wasm.allocFromTypedArray(uint8); - - // Create empty in-memory database - this.db = new sqlite3.oo1.DB(':memory:'); - const db = this.getDbOrThrow(); - - // Deserialize the data into the database - const rc = sqlite3.capi.sqlite3_deserialize( - db, - 'main', - ptr, - uint8.byteLength, - uint8.byteLength, - sqlite3.capi.SQLITE_DESERIALIZE_FREEONCLOSE | - sqlite3.capi.SQLITE_DESERIALIZE_RESIZEABLE - ); + if (uint8.length === 0) { + // Empty file, create new database + const sqlite3 = this.getSqlite3OrThrow(); + const db = new sqlite3.oo1.DB(':memory:'); + this.db = db; + db.exec(SCHEMA_SQL); + return; + } + + // Allocate memory for the database bytes + const sqlite3 = this.getSqlite3OrThrow(); + const ptr = sqlite3.wasm.allocFromTypedArray(uint8); + + // Create empty in-memory database + this.db = new sqlite3.oo1.DB(':memory:'); + const db = this.getDbOrThrow(); + + // Deserialize the data into the database + const rc = sqlite3.capi.sqlite3_deserialize( + db, + 'main', + ptr, + uint8.byteLength, + uint8.byteLength, + sqlite3.capi.SQLITE_DESERIALIZE_FREEONCLOSE | + sqlite3.capi.SQLITE_DESERIALIZE_RESIZEABLE + ); if (rc !== 0) { throw new Error(`sqlite3_deserialize failed with code ${rc}`); } - // Verify database integrity - try { - const integrityResult = db.selectValue('PRAGMA integrity_check'); - if (integrityResult !== 'ok') { - const integrityMessage = typeof integrityResult === 'string' - ? integrityResult - : JSON.stringify(integrityResult) ?? 'unknown'; - throw new Error(`Database integrity check failed: ${integrityMessage}`); - } - } catch { - await this.recreateCorruptedDatabase(); - return; - } + // Verify database integrity + try { + const integrityResult = db.selectValue('PRAGMA integrity_check'); + if (integrityResult !== 'ok') { + const integrityMessage = typeof integrityResult === 'string' + ? integrityResult + : JSON.stringify(integrityResult) ?? 'unknown'; + throw new Error(`Database integrity check failed: ${integrityMessage}`); + } + } catch { + await this.recreateCorruptedDatabase(); + return; + } this.hasUnsavedData = false; } catch (error) { @@ -347,31 +350,31 @@ export class SQLiteCacheManager implements IStorageBackend, ISQLiteCacheManager void 0; } - const sqlite3 = this.getSqlite3OrThrow(); - const db = new sqlite3.oo1.DB(':memory:'); - this.db = db; - db.exec(SCHEMA_SQL); - await this.saveToFile(); - } + const sqlite3 = this.getSqlite3OrThrow(); + const db = new sqlite3.oo1.DB(':memory:'); + this.db = db; + db.exec(SCHEMA_SQL); + await this.saveToFile(); + } /** * Save database to file using sqlite3_js_db_export */ - private async saveToFile(): Promise { - try { - const db = this.getDbOrThrow(); - const sqlite3 = this.getSqlite3OrThrow(); - // Temporarily suppress console.log during WASM export to avoid "Heap resize" noise - const consoleRef = console; - const originalLog = consoleRef.log; - consoleRef.log = () => undefined; - - let data: { buffer: ArrayBuffer }; - try { - data = sqlite3.capi.sqlite3_js_db_export(db); - } finally { - consoleRef.log = originalLog; - } + private async saveToFile(): Promise { + try { + const db = this.getDbOrThrow(); + const sqlite3 = this.getSqlite3OrThrow(); + // Temporarily suppress console.log during WASM export to avoid "Heap resize" noise + const consoleRef = console; + const originalLog = consoleRef.log; + consoleRef.log = () => undefined; + + let data: { buffer: ArrayBuffer }; + try { + data = sqlite3.capi.sqlite3_js_db_export(db); + } finally { + consoleRef.log = originalLog; + } await this.app.vault.adapter.writeBinary(this.dbPath, data.buffer); @@ -428,18 +431,18 @@ export class SQLiteCacheManager implements IStorageBackend, ISQLiteCacheManager /** * Query returning multiple rows */ - async query(sql: string, params?: QueryParams): Promise { - try { - const db = this.getDbOrThrow(); - const stmt = db.prepare(sql); - try { - if (params?.length) { - stmt.bind(params); - } - const results: T[] = []; - while (stmt.step()) { - results.push(stmt.get({}) as T); - } + async query(sql: string, params?: QueryParams): Promise { + try { + const db = this.getDbOrThrow(); + const stmt = db.prepare(sql); + try { + if (params?.length) { + stmt.bind(params); + } + const results: T[] = []; + while (stmt.step()) { + results.push(stmt.get({}) as T); + } return results; } finally { stmt.finalize(); @@ -453,16 +456,16 @@ export class SQLiteCacheManager implements IStorageBackend, ISQLiteCacheManager /** * Query returning single row */ - async queryOne(sql: string, params?: QueryParams): Promise { - try { - const db = this.getDbOrThrow(); - const stmt = db.prepare(sql); + async queryOne(sql: string, params?: QueryParams): Promise { + try { + const db = this.getDbOrThrow(); + const stmt = db.prepare(sql); try { - if (params?.length) { - stmt.bind(params); - } - if (stmt.step()) { - return stmt.get({}) as T; + if (params?.length) { + stmt.bind(params); + } + if (stmt.step()) { + return stmt.get({}) as T; } return null; } finally { @@ -478,23 +481,23 @@ export class SQLiteCacheManager implements IStorageBackend, ISQLiteCacheManager * Run a statement (INSERT, UPDATE, DELETE) * Returns changes count and last insert rowid */ - async run(sql: string, params?: QueryParams): Promise { - try { - const db = this.getDbOrThrow(); - const sqlite3 = this.getSqlite3OrThrow(); - const stmt = db.prepare(sql); - try { - if (params?.length) { - stmt.bind(params); + async run(sql: string, params?: QueryParams): Promise { + try { + const db = this.getDbOrThrow(); + const sqlite3 = this.getSqlite3OrThrow(); + const stmt = db.prepare(sql); + try { + if (params?.length) { + stmt.bind(params); } stmt.stepReset(); } finally { stmt.finalize(); } - - // Get changes count and last insert rowid - const changes = db.changes(); - const lastInsertRowid = Number(sqlite3.capi.sqlite3_last_insert_rowid(db)); + + // Get changes count and last insert rowid + const changes = db.changes(); + const lastInsertRowid = Number(sqlite3.capi.sqlite3_last_insert_rowid(db)); this.hasUnsavedData = true; return { changes, lastInsertRowid }; @@ -507,24 +510,24 @@ export class SQLiteCacheManager implements IStorageBackend, ISQLiteCacheManager /** * Begin a transaction */ - async beginTransaction(): Promise { - this.getDbOrThrow().exec('BEGIN TRANSACTION'); - } + async beginTransaction(): Promise { + this.getDbOrThrow().exec('BEGIN TRANSACTION'); + } /** * Commit a transaction */ - async commit(): Promise { - this.getDbOrThrow().exec('COMMIT'); - this.hasUnsavedData = true; - } + async commit(): Promise { + this.getDbOrThrow().exec('COMMIT'); + this.hasUnsavedData = true; + } /** * Rollback a transaction */ - async rollback(): Promise { - this.getDbOrThrow().exec('ROLLBACK'); - } + async rollback(): Promise { + this.getDbOrThrow().exec('ROLLBACK'); + } /** * Execute a function within a transaction @@ -537,9 +540,9 @@ export class SQLiteCacheManager implements IStorageBackend, ISQLiteCacheManager } // Queue this transaction after any pending ones - let resolve: (() => void) | undefined; - const previousLock = this.transactionLock; - this.transactionLock = new Promise((r) => { resolve = r; }); + let resolve: (() => void) | undefined; + const previousLock = this.transactionLock; + this.transactionLock = new Promise((r) => { resolve = r; }); try { // Wait for any pending transaction to complete @@ -560,9 +563,9 @@ export class SQLiteCacheManager implements IStorageBackend, ISQLiteCacheManager } } finally { // Release the lock for the next transaction - resolve?.(); - } - } + resolve?.(); + } + } // ==================== Higher-level query methods ==================== @@ -573,7 +576,7 @@ export class SQLiteCacheManager implements IStorageBackend, ISQLiteCacheManager baseQuery: string, countQuery: string, options: PaginationParams = {}, - params: QueryParams = [] + params: QueryParams = [] ): Promise> { const page = options.page ?? 0; const pageSize = Math.min(options.pageSize ?? 25, 200); @@ -646,20 +649,20 @@ export class SQLiteCacheManager implements IStorageBackend, ISQLiteCacheManager if (!result) return null; - const fileTimestampsRaw: unknown = result.syncedFilesJson ? JSON.parse(result.syncedFilesJson) : {}; - const fileTimestamps: Record = {}; - if (isRecord(fileTimestampsRaw)) { - for (const [key, value] of Object.entries(fileTimestampsRaw)) { - if (typeof value === 'number' && Number.isFinite(value)) { - fileTimestamps[key] = value; - } - } - } - return { - deviceId: result.deviceId, - lastEventTimestamp: result.lastEventTimestamp, - fileTimestamps - }; + const fileTimestampsRaw: unknown = result.syncedFilesJson ? JSON.parse(result.syncedFilesJson) : {}; + const fileTimestamps: Record = {}; + if (isRecord(fileTimestampsRaw)) { + for (const [key, value] of Object.entries(fileTimestampsRaw)) { + if (typeof value === 'number' && Number.isFinite(value)) { + fileTimestamps[key] = value; + } + } + } + return { + deviceId: result.deviceId, + lastEventTimestamp: result.lastEventTimestamp, + fileTimestamps + }; } /** @@ -673,16 +676,64 @@ export class SQLiteCacheManager implements IStorageBackend, ISQLiteCacheManager ); } + // ==================== Schema fixes ==================== + + /** + * Fix vec0 virtual table dimensions for note_embeddings and block_embeddings. + * + * vec0 virtual tables cannot be DROPped and recreated via the prepare().step() + * DDL path used by SchemaMigrator. They require the native WASM db.exec() path. + * This method is called after migrations and uses the raw db directly. + * + * Returns true if any tables were fixed (caller should save to file). + */ + private fixVec0TableDimensions(dbAdapter: DatabaseAdapter): boolean { + const db = this.getDbOrThrow(); + let fixed = false; + + try { + const noteResult = dbAdapter.exec("SELECT sql FROM sqlite_master WHERE name='note_embeddings'"); + const noteSql = noteResult[0]?.values[0]?.[0] as string | undefined; + if (noteSql?.includes('float[768]')) { + db.exec('DROP TABLE IF EXISTS note_embeddings'); + db.exec('CREATE VIRTUAL TABLE IF NOT EXISTS note_embeddings USING vec0(embedding float[384])'); + db.exec('DELETE FROM embedding_metadata'); + fixed = true; + } + } catch (error) { + console.error('[SQLiteCacheManager] Failed to fix note_embeddings dimensions:', error); + } + + try { + const blockResult = dbAdapter.exec("SELECT sql FROM sqlite_master WHERE name='block_embeddings'"); + const blockSql = blockResult[0]?.values[0]?.[0] as string | undefined; + if (blockSql?.includes('float[768]')) { + db.exec('DROP TABLE IF EXISTS block_embeddings'); + db.exec('CREATE VIRTUAL TABLE IF NOT EXISTS block_embeddings USING vec0(embedding float[384])'); + db.exec('DELETE FROM block_embedding_metadata'); + fixed = true; + } + } catch (error) { + console.error('[SQLiteCacheManager] Failed to fix block_embeddings dimensions:', error); + } + + if (fixed) { + console.warn('[SQLiteCacheManager] Fixed vec0 embedding table dimensions (768→384)'); + } + + return fixed; + } + // ==================== Data management ==================== /** * Clear all data (for rebuilding from JSONL) */ - async clearAllData(): Promise { - await this.transaction(async () => { - const db = this.getDbOrThrow(); - db.exec(` - DELETE FROM task_note_links; + async clearAllData(): Promise { + await this.transaction(async () => { + const db = this.getDbOrThrow(); + db.exec(` + DELETE FROM task_note_links; DELETE FROM task_dependencies; DELETE FROM tasks; DELETE FROM projects; @@ -698,43 +749,43 @@ export class SQLiteCacheManager implements IStorageBackend, ISQLiteCacheManager // Drop and recreate vec0 virtual tables (cannot DELETE from vec0) // Conversation embeddings - db.exec(`DROP TABLE IF EXISTS conversation_embeddings`); - db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS conversation_embeddings USING vec0(embedding float[384])`); - db.exec(`DELETE FROM conversation_embedding_metadata`); - db.exec(`DELETE FROM embedding_backfill_state`); - }); - } + db.exec(`DROP TABLE IF EXISTS conversation_embeddings`); + db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS conversation_embeddings USING vec0(embedding float[384])`); + db.exec(`DELETE FROM conversation_embedding_metadata`); + db.exec(`DELETE FROM embedding_backfill_state`); + }); + } /** * Rebuild FTS5 indexes after bulk data changes */ - async rebuildFTSIndexes(): Promise { - await this.transaction(async () => { - const db = this.getDbOrThrow(); - // Rebuild workspace FTS5 - db.exec(` - INSERT INTO workspace_fts(workspace_fts) VALUES ('rebuild'); - `); - - // Rebuild conversation FTS5 - db.exec(` - INSERT INTO conversation_fts(conversation_fts) VALUES ('rebuild'); - `); - - // Rebuild message FTS5 - db.exec(` - INSERT INTO message_fts(message_fts) VALUES ('rebuild'); - `); - }); - } + async rebuildFTSIndexes(): Promise { + await this.transaction(async () => { + const db = this.getDbOrThrow(); + // Rebuild workspace FTS5 + db.exec(` + INSERT INTO workspace_fts(workspace_fts) VALUES ('rebuild'); + `); + + // Rebuild conversation FTS5 + db.exec(` + INSERT INTO conversation_fts(conversation_fts) VALUES ('rebuild'); + `); + + // Rebuild message FTS5 + db.exec(` + INSERT INTO message_fts(message_fts) VALUES ('rebuild'); + `); + }); + } /** * Vacuum the database to reclaim space */ - async vacuum(): Promise { - try { - this.getDbOrThrow().exec('VACUUM'); - this.hasUnsavedData = true; + async vacuum(): Promise { + try { + this.getDbOrThrow().exec('VACUUM'); + this.hasUnsavedData = true; } catch (error) { console.error('[SQLiteCacheManager] Vacuum failed:', error); throw error; @@ -747,30 +798,30 @@ export class SQLiteCacheManager implements IStorageBackend, ISQLiteCacheManager /** * Search workspaces using FTS4 */ - async searchWorkspaces(query: string, limit = 50): Promise { - return this.searchService.searchWorkspaces(query, limit); - } + async searchWorkspaces(query: string, limit = 50): Promise { + return this.searchService.searchWorkspaces(query, limit); + } /** * Search conversations using FTS4 */ - async searchConversations(query: string, limit = 50): Promise { - return this.searchService.searchConversations(query, limit); - } + async searchConversations(query: string, limit = 50): Promise { + return this.searchService.searchConversations(query, limit); + } /** * Search messages using FTS4 */ - async searchMessages(query: string, limit = 50): Promise { - return this.searchService.searchMessages(query, limit); - } + async searchMessages(query: string, limit = 50): Promise { + return this.searchService.searchMessages(query, limit); + } /** * Search messages within a specific conversation using FTS4 */ - async searchMessagesInConversation(conversationId: string, query: string, limit = 50): Promise { - return this.searchService.searchMessagesInConversation(conversationId, query, limit); - } + async searchMessagesInConversation(conversationId: string, query: string, limit = 50): Promise { + return this.searchService.searchMessagesInConversation(conversationId, query, limit); + } // ==================== Statistics ==================== From c77c79990960acc861fad4071b865aa0b81cb875 Mon Sep 17 00:00:00 2001 From: Midway65 Date: Sat, 4 Apr 2026 16:43:47 -0700 Subject: [PATCH 20/64] fix(schema): add stub migrations v13-v16 and drop orphaned embedding_config (v17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CURRENT_SCHEMA_VERSION bumped 12 → 17 - Stubs v13-v16: acknowledge prior local-fixes fork era (Nomic pipeline, semantic panel, block embeddings) so version comparison stays accurate - Migration v17: DROP TABLE IF EXISTS embedding_config — orphaned table from old Nomic era; our fork never reads or writes it Co-Authored-By: Claude Sonnet 4.6 --- src/database/schema/SchemaMigrator.ts | 51 ++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/src/database/schema/SchemaMigrator.ts b/src/database/schema/SchemaMigrator.ts index cce4bb3fe..33033983c 100644 --- a/src/database/schema/SchemaMigrator.ts +++ b/src/database/schema/SchemaMigrator.ts @@ -73,7 +73,7 @@ export interface MigratableDatabase { // Alias for backward compatibility type Database = MigratableDatabase; -export const CURRENT_SCHEMA_VERSION = 12; +export const CURRENT_SCHEMA_VERSION = 17; export interface Migration { version: number; @@ -415,6 +415,55 @@ export const MIGRATIONS: Migration[] = [ description: 'Version marker: note/block vec0 table dimension fix (768→384) handled by SQLiteCacheManager.fixVec0TableDimensions()', sql: [] }, + + // ======================================================================== + // Versions 13–16: Stub acknowledgement markers for the prior fork era + // + // The live cache.db was at schema version 16 when this fork was initialized. + // That v16 state came from a previous local-fixes branch of nexus that had: + // - A Nomic embedding pipeline (nomic-embed-text-v1.5, 768-dim) + // - A semantic panel UI feature with block_embeddings and semantic_feedback tables + // - An embedding_config key/value table + // That branch ran migrations up to v16, then was abandoned in favour of this fork. + // + // These stubs record that history so CURRENT_SCHEMA_VERSION matches the live DB + // and the migrate() version comparison stays accurate for future migrations. + // They do nothing — all actual cleanup is in migration v17 below. + // ======================================================================== + { + version: 13, + description: 'Stub: acknowledge legacy Nomic embedding pipeline era (prior local-fixes fork, v13)', + sql: [] + }, + { + version: 14, + description: 'Stub: acknowledge legacy batch GPU inference / semantic panel features (prior local-fixes fork, v14)', + sql: [] + }, + { + version: 15, + description: 'Stub: acknowledge legacy mtime-based embedding optimization (prior local-fixes fork, v15)', + sql: [] + }, + { + version: 16, + description: 'Stub: acknowledge legacy upstream-merge state (prior local-fixes fork, v16)', + sql: [] + }, + + // Version 16 -> 17: Drop orphaned embedding_config table from prior Nomic era. + // The embedding_config table (key TEXT PRIMARY KEY, value TEXT NOT NULL) was created + // by the old local-fixes fork's Nomic embedding pipeline. It holds stale records like + // activeModel=Xenova/nomic-embed-text-v1.5 and activeDimension=768. Our fork uses + // Xenova/all-MiniLM-L6-v2 via iframe/CDN and never reads or writes embedding_config. + // Dropping it removes the confusion and shrinks the DB. + { + version: 17, + description: 'Drop orphaned embedding_config table left by prior Nomic embedding era', + sql: [ + 'DROP TABLE IF EXISTS embedding_config', + ] + }, ]; /** From 00e905468e4fff60fc033a0f9289c66fa3539b74 Mon Sep 17 00:00:00 2001 From: Midway65 Date: Sat, 4 Apr 2026 16:48:53 -0700 Subject: [PATCH 21/64] =?UTF-8?q?fix(schema):=20add=20migration=20v18=20?= =?UTF-8?q?=E2=80=94=20recreate=20embedding=5Fmetadata=20without=20dimensi?= =?UTF-8?q?on=20column?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old local-fixes fork added a `dimension INTEGER NOT NULL` column to embedding_metadata between its v13-v16 migrations. That fork's strip commit would have removed it (at its v12/v13), but the live DB was already at v16 so the strip never ran. Our NoteEmbeddingService inserts without the dimension column, causing NOT NULL constraint failures on every note embedding attempt. Fix: drop and recreate embedding_metadata with the clean schema (no dimension column). Cache data is expendable — notes will be re-indexed. Co-Authored-By: Claude Sonnet 4.6 --- src/database/schema/SchemaMigrator.ts | 29 ++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/src/database/schema/SchemaMigrator.ts b/src/database/schema/SchemaMigrator.ts index 33033983c..ec3cac88d 100644 --- a/src/database/schema/SchemaMigrator.ts +++ b/src/database/schema/SchemaMigrator.ts @@ -73,7 +73,7 @@ export interface MigratableDatabase { // Alias for backward compatibility type Database = MigratableDatabase; -export const CURRENT_SCHEMA_VERSION = 17; +export const CURRENT_SCHEMA_VERSION = 18; export interface Migration { version: number; @@ -464,6 +464,33 @@ export const MIGRATIONS: Migration[] = [ 'DROP TABLE IF EXISTS embedding_config', ] }, + + // Version 17 -> 18: Recreate embedding_metadata without the dimension column. + // An intermediate migration in the old local-fixes fork (between its v13-v16) added a + // `dimension INTEGER NOT NULL` column to embedding_metadata. That fork's strip commit + // (which dropped the column) ran at migration v12/v13 in its final HEAD numbering, but + // the live DB was already at v16 so that strip never executed. The column is not in our + // fresh-install schema or in NoteEmbeddingService's INSERT statement, causing a + // NOT NULL constraint violation on every note indexing attempt. Fix: drop and recreate + // the table with the correct schema. Data loss is safe — this table is a re-indexing + // cache; the system will re-embed notes on the next indexing pass. + { + version: 18, + description: 'Recreate embedding_metadata without legacy dimension column from old local-fixes fork', + sql: [ + 'DROP TABLE IF EXISTS embedding_metadata', + `CREATE TABLE IF NOT EXISTS embedding_metadata ( + rowid INTEGER PRIMARY KEY, + notePath TEXT NOT NULL UNIQUE, + model TEXT NOT NULL, + contentHash TEXT NOT NULL, + created INTEGER NOT NULL, + updated INTEGER NOT NULL + )`, + 'CREATE INDEX IF NOT EXISTS idx_embedding_meta_path ON embedding_metadata(notePath)', + 'CREATE INDEX IF NOT EXISTS idx_embedding_meta_hash ON embedding_metadata(contentHash)', + ] + }, ]; /** From ac0b83dc827665108b2e8f4c272395c5e93c8d6e Mon Sep 17 00:00:00 2001 From: Midway65 Date: Sat, 4 Apr 2026 17:53:15 -0700 Subject: [PATCH 22/64] =?UTF-8?q?fix(schema):=20migration=20v19=20?= =?UTF-8?q?=E2=80=94=20drop=20orphaned=20semantic=5Ffeedback=20and=20block?= =?UTF-8?q?=5Fembedding=5Fmetadata=20tables?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/database/schema/SchemaMigrator.ts | 18 +++++++++++++++++- src/database/storage/SQLiteCacheManager.ts | 1 - 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/database/schema/SchemaMigrator.ts b/src/database/schema/SchemaMigrator.ts index d993a05a4..a30a12f81 100644 --- a/src/database/schema/SchemaMigrator.ts +++ b/src/database/schema/SchemaMigrator.ts @@ -73,7 +73,7 @@ export interface MigratableDatabase { // Alias for backward compatibility type Database = MigratableDatabase; -export const CURRENT_SCHEMA_VERSION = 18; +export const CURRENT_SCHEMA_VERSION = 19; export interface Migration { version: number; @@ -491,6 +491,22 @@ export const MIGRATIONS: Migration[] = [ 'CREATE INDEX IF NOT EXISTS idx_embedding_meta_hash ON embedding_metadata(contentHash)', ] }, + + // Version 18 -> 19: Drop remaining orphaned tables from the prior local-fixes fork. + // The abandoned C:\Users\middl\Documents\GitHub\nexus branch (local-fixes) ran migrations + // through v16, leaving behind two tables our fork never uses: + // - semantic_feedback: from the semantic panel / in-chat feedback UI (Plan 04/05) + // - block_embedding_metadata: metadata index for block-level embeddings (Nomic era) + // Neither table has any code references in this fork. Both are safe to drop. + // IF EXISTS ensures this is a no-op on fresh installs that never had these tables. + { + version: 19, + description: 'Drop orphaned semantic_feedback and block_embedding_metadata tables from prior local-fixes fork', + sql: [ + 'DROP TABLE IF EXISTS semantic_feedback', + 'DROP TABLE IF EXISTS block_embedding_metadata', + ] + }, ]; /** diff --git a/src/database/storage/SQLiteCacheManager.ts b/src/database/storage/SQLiteCacheManager.ts index cc24a3328..1c38c9c48 100644 --- a/src/database/storage/SQLiteCacheManager.ts +++ b/src/database/storage/SQLiteCacheManager.ts @@ -781,7 +781,6 @@ export class SQLiteCacheManager implements IStorageBackend, ISQLiteCacheManager if (blockSql?.includes('float[768]')) { db.exec('DROP TABLE IF EXISTS block_embeddings'); db.exec('CREATE VIRTUAL TABLE IF NOT EXISTS block_embeddings USING vec0(embedding float[384])'); - db.exec('DELETE FROM block_embedding_metadata'); fixed = true; } } catch (error) { From 4e87963692a9ec5aeb3bc4669a5a24377b10993f Mon Sep 17 00:00:00 2001 From: Midway65 Date: Sat, 4 Apr 2026 18:17:54 -0700 Subject: [PATCH 23/64] fix(css): merge duplicate mobile rule, smooth copy-success transition - Merged two body.is-mobile .message-actions-external blocks into one; updated stale comment (pill is in header, not below message) - Added transform 0.1s ease to .message-action-btn transition so the copy-success scale(1.1) animates instead of snapping Co-Authored-By: Claude Sonnet 4.6 --- styles.css | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/styles.css b/styles.css index f1c4dae2a..a4dc2e07f 100644 --- a/styles.css +++ b/styles.css @@ -759,7 +759,7 @@ padding: 0.35rem; border-radius: 50%; cursor: pointer; - transition: background 0.15s ease, color 0.15s ease; + transition: background 0.15s ease, color 0.15s ease, transform 0.1s ease; display: flex; align-items: center; justify-content: center; @@ -5839,11 +5839,13 @@ body.is-mobile .message-action-btn { --icon-size: 16px; } -/* Action button container - position BELOW the message on mobile */ +/* Action button container - pill sizing on mobile; always visible since touch can't hover */ body.is-mobile .message-actions-external { gap: 2px; padding: 2px; border-radius: 8px; + opacity: 0.75; + pointer-events: auto; } @@ -5901,16 +5903,6 @@ body.is-mobile .progressive-tool-header { padding: 0.75rem 1rem; } -/* ------------------------------ */ -/* ALWAYS-VISIBLE MESSAGE ACTIONS */ -/* ------------------------------ */ -/* Touch devices can't hover, so show actions by default */ - -body.is-mobile .message-actions-external { - opacity: 0.75; - pointer-events: auto; -} - body.is-mobile .message-container:active .message-actions-external { opacity: 1; pointer-events: auto; From 17a5eb8a374310d799a43cc292fa865eeea7699d Mon Sep 17 00:00:00 2001 From: Midway65 Date: Sat, 4 Apr 2026 18:26:30 -0700 Subject: [PATCH 24/64] chore: remove dead ContentProcessor utility class No file in the codebase imports or uses ContentProcessor. Confirmed via full-repo grep across all .ts and .js files. Build and lint pass with zero errors after removal. Co-Authored-By: Claude Sonnet 4.6 --- src/ui/chat/utils/ContentProcessor.ts | 159 -------------------------- 1 file changed, 159 deletions(-) delete mode 100644 src/ui/chat/utils/ContentProcessor.ts diff --git a/src/ui/chat/utils/ContentProcessor.ts b/src/ui/chat/utils/ContentProcessor.ts deleted file mode 100644 index 400696ff0..000000000 --- a/src/ui/chat/utils/ContentProcessor.ts +++ /dev/null @@ -1,159 +0,0 @@ -/** - * ContentProcessor - Handles content formatting, escaping, and processing utilities - */ - -export class ContentProcessor { - /** - * Escape HTML for safe display - */ - static escapeHtml(text: string): string { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; - } - - /** - * Unescape HTML entities - */ - static unescapeHtml(html: string): string { - const doc = new DOMParser().parseFromString(html, 'text/html'); - return doc.body.textContent || ''; - } - - /** - * Process markdown content for display (basic implementation) - */ - static processMarkdown(content: string): string { - // Simple markdown processing - can be enhanced later - let processed = content; - - // Code blocks - processed = processed.replace(/```(\w*)\n([\s\S]*?)```/g, '
$2
'); - - // Inline code - processed = processed.replace(/`([^`]+)`/g, '$1'); - - // Bold - processed = processed.replace(/\*\*([^*]+)\*\*/g, '$1'); - - // Italic - processed = processed.replace(/\*([^*]+)\*/g, '$1'); - - // Headers - processed = processed.replace(/^### (.*$)/gim, '

$1

'); - processed = processed.replace(/^## (.*$)/gim, '

$1

'); - processed = processed.replace(/^# (.*$)/gim, '

$1

'); - - // Lists - processed = processed.replace(/^[\s]*\* (.+)$/gm, '
  • $1
  • '); - processed = processed.replace(/^[\s]*- (.+)$/gm, '
  • $1
  • '); - - // Wrap consecutive list items in ul tags - processed = processed.replace(/(
  • .*<\/li>)/g, '
      $1
    '); - - // Line breaks - processed = processed.replace(/\n/g, '
    '); - - return processed; - } - - /** - * Sanitize content to prevent XSS - */ - static sanitizeContent(content: string): string { - // Remove potentially dangerous tags and attributes - const dangerous = /)<[^<]*)*<\/script>/gi; - let sanitized = content.replace(dangerous, ''); - - // Remove javascript: and data: URLs - sanitized = sanitized.replace(/javascript:/gi, ''); - sanitized = sanitized.replace(/data:/gi, ''); - - // Remove on* event handlers - sanitized = sanitized.replace(/on\w+\s*=/gi, ''); - - return sanitized; - } - - /** - * Truncate text to specified length with ellipsis - */ - static truncateText(text: string, maxLength: number, ellipsis = '...'): string { - if (text.length <= maxLength) { - return text; - } - - return text.substring(0, maxLength - ellipsis.length) + ellipsis; - } - - /** - * Extract plain text from HTML content - */ - static extractPlainText(html: string): string { - const doc = new DOMParser().parseFromString(html, 'text/html'); - return doc.body.textContent || ''; - } - - /** - * Format conversation preview text - */ - static formatConversationPreview(lastMessage: string, maxLength = 100): string { - // Remove markdown formatting for preview - const preview = lastMessage - .replace(/```[\s\S]*?```/g, '[code block]') // Replace code blocks - .replace(/`([^`]+)`/g, '$1') // Remove inline code backticks - .replace(/\*\*([^*]+)\*\*/g, '$1') // Remove bold - .replace(/\*([^*]+)\*/g, '$1') // Remove italic - .replace(/^#+\s*/gm, '') // Remove headers - .replace(/^\s*[-*]\s*/gm, '') // Remove list markers - .replace(/\n+/g, ' ') // Replace newlines with spaces - .trim(); - - return this.truncateText(preview, maxLength); - } - - /** - * Validate and clean message content - */ - static cleanMessageContent(content: string): string { - // Trim whitespace - let cleaned = content.trim(); - - // Remove excessive whitespace - cleaned = cleaned.replace(/\s+/g, ' '); - - // Remove null bytes - cleaned = cleaned.replace(/\0/g, ''); - - return cleaned; - } - - /** - * Check if content is safe for display - */ - static isContentSafe(content: string): boolean { - // Check for dangerous patterns - const dangerousPatterns = [ - /