diff --git a/CHANGELOG.md b/CHANGELOG.md index 91a9a94..8884fb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,19 @@ version with its date and start a fresh empty `[Unreleased]` above it. ## [Unreleased] +### Changed + +- The agent now learns which note you are viewing from the message text: the + turn you send after switching notes carries the new path as a + `` tag (older messages may still carry ``, which + keeps working), and switching conversations reports it again, so the + composer no longer needs a chip for it. + +### Removed + +- The current-note chip above the composer is gone. Use the "Excluded tags" + setting to keep a note out of context. + ## [1.0.15] - 2026-09-21 ### Added diff --git a/src/features/chat/chat-view.ts b/src/features/chat/chat-view.ts index 45b83d3..2bf0e5b 100644 --- a/src/features/chat/chat-view.ts +++ b/src/features/chat/chat-view.ts @@ -700,15 +700,6 @@ export class QoderianView extends ItemView { this.plugin.app.vault.on('modify', () => markCacheDirty(false)) ); - // File open event - this.registerEvent( - this.plugin.app.workspace.on('file-open', (file) => { - if (file) { - this.tabManager?.getActiveTab()?.ui.fileContextManager?.handleFileOpen(file); - } - }) - ); - // Click outside to close mention dropdown this.registerDomEvent(activeDocument, 'click', (e) => { const activeTab = this.tabManager?.getActiveTab(); diff --git a/src/features/chat/controllers/conversation-controller.ts b/src/features/chat/controllers/conversation-controller.ts index c48f196..89e61ef 100644 --- a/src/features/chat/controllers/conversation-controller.ts +++ b/src/features/chat/controllers/conversation-controller.ts @@ -165,7 +165,6 @@ export class ConversationController { const fileCtx = this.deps.getFileContextManager(); fileCtx?.resetForNewConversation(); - fileCtx?.autoAttachActiveFile(); this.deps.getImageContextManager()?.clearImages(); this.deps.getMcpServerSelector()?.clearEnabled(); @@ -211,7 +210,6 @@ export class ConversationController { const fileCtx = this.deps.getFileContextManager(); fileCtx?.resetForNewConversation(); - fileCtx?.autoAttachActiveFile(); // Initialize external contexts with persistent paths from settings this.deps.getExternalContextSelector()?.clearExternalContexts( @@ -232,7 +230,7 @@ export class ConversationController { } await this.deps.ensureServiceForConversation?.(conversation); - this.restoreConversation(conversation, { autoAttachFile: true }); + this.restoreConversation(conversation); this.updateWelcomeVisibility(); this.callbacks.onConversationLoaded?.(); @@ -448,10 +446,7 @@ export class ConversationController { * Shared logic for restoring a conversation into the current tab. * Used by both loadActive() and switchTo() to avoid duplication. */ - private restoreConversation( - conversation: Conversation, - options?: { autoAttachFile?: boolean } - ): void { + private restoreConversation(conversation: Conversation): void { const { plugin, state, renderer } = this.deps; state.currentConversationId = conversation.id; @@ -471,13 +466,7 @@ export class ConversationController { this.getAgentService()?.syncConversationState(conversation, externalContextPaths); const fileCtx = this.deps.getFileContextManager(); - fileCtx?.resetForLoadedConversation(hasMessages); - - if (conversation.currentNote) { - fileCtx?.setCurrentNote(conversation.currentNote); - } else if (!hasMessages && options?.autoAttachFile) { - fileCtx?.autoAttachActiveFile(); - } + fileCtx?.resetForLoadedConversation(); this.restoreExternalContextPaths(conversation.externalContextPaths, !hasMessages); @@ -985,10 +974,7 @@ export class ConversationController { const welcomeEl = this.deps.getWelcomeEl(); if (!welcomeEl) return; - // Initialize file context to auto-attach the currently focused note - const fileCtx = this.deps.getFileContextManager(); - fileCtx?.resetForNewConversation(); - fileCtx?.autoAttachActiveFile(); + this.deps.getFileContextManager()?.resetForNewConversation(); // Only add greeting if not already present if (!welcomeEl.querySelector('.qoderian-welcome-greeting')) { diff --git a/src/features/chat/controllers/input-controller.ts b/src/features/chat/controllers/input-controller.ts index b5c2659..52d7f15 100644 --- a/src/features/chat/controllers/input-controller.ts +++ b/src/features/chat/controllers/input-controller.ts @@ -197,7 +197,6 @@ export class InputController { const inputEl = this.deps.getInputEl(); const imageContextManager = this.deps.getImageContextManager(); - const fileContextManager = this.deps.getFileContextManager(); const contentOverride = options?.content; const shouldUseInput = contentOverride === undefined; @@ -266,8 +265,6 @@ export class InputController { welcomeEl.addClass('qoderian-hidden'); } - fileContextManager?.startSession(); - // Slash commands are passed directly to SDK for handling // SDK handles expansion, $ARGUMENTS, @file references, and frontmatter options const images = imageOverride ?? imageContextManager?.getAttachedImages() ?? []; @@ -293,8 +290,6 @@ export class InputController { }); const { displayContent, turnRequest } = turnSubmission; - fileContextManager?.markCurrentNoteSent(); - const userMsg: ChatMessage = { id: this.deps.generateId(), role: 'user', @@ -656,7 +651,6 @@ export class InputController { const externalContextSelector = this.deps.getExternalContextSelector(); const currentNotePath = fileContextManager?.getCurrentNotePath() || null; - const shouldSendCurrentNote = fileContextManager?.shouldSendCurrentNote(currentNotePath) ?? false; const editorContext = options.editorContextOverride !== undefined ? options.editorContextOverride @@ -680,7 +674,7 @@ export class InputController { turnRequest: { text: transformedText, images: options.images, - currentNotePath: shouldSendCurrentNote && currentNotePath ? currentNotePath : undefined, + currentNotePath: currentNotePath ?? undefined, editorSelection: editorContext, browserSelection: browserContext, canvasSelection: canvasContext, diff --git a/src/features/chat/tabs/tab.ts b/src/features/chat/tabs/tab.ts index 8b61de8..d814c30 100644 --- a/src/features/chat/tabs/tab.ts +++ b/src/features/chat/tabs/tab.ts @@ -269,20 +269,13 @@ function initializeContextManagers(tab: TabData, plugin: QoderianPlugin): void { }, }); - // File context manager - chips in contextRowEl, dropdown in inputContainerEl + // File context manager - dropdown in inputContainerEl tab.ui.fileContextManager = new FileContextManager( app, dom.contextRowEl, dom.inputEl, { getExcludedTags: () => plugin.settings.excludedTags, - onChipsChanged: () => { - tab.controllers.selectionController?.updateContextRowVisibility(); - tab.controllers.browserSelectionController?.updateContextRowVisibility(); - tab.controllers.canvasSelectionController?.updateContextRowVisibility(); - autoResizeTextarea(dom.inputEl); - tab.renderer?.scrollToBottomIfNeeded(); - }, onReferencesChanged: (references) => { tab.ui.composerBridge?.setReferences(references); }, diff --git a/src/features/chat/ui/file-context/file-chips-view.ts b/src/features/chat/ui/file-context/file-chips-view.ts deleted file mode 100644 index 4835f2e..0000000 --- a/src/features/chat/ui/file-context/file-chips-view.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { setIcon } from 'obsidian'; - -export interface FileChipsViewCallbacks { - onRemoveAttachment: (path: string) => void; - onOpenFile: (path: string) => void; -} - -export class FileChipsView { - private containerEl: HTMLElement; - private callbacks: FileChipsViewCallbacks; - private fileIndicatorEl: HTMLElement; - - constructor(containerEl: HTMLElement, callbacks: FileChipsViewCallbacks) { - this.containerEl = containerEl; - this.callbacks = callbacks; - - const firstChild = this.containerEl.firstChild; - this.fileIndicatorEl = this.containerEl.createDiv({ cls: 'qoderian-file-indicator' }); - if (firstChild) { - this.containerEl.insertBefore(this.fileIndicatorEl, firstChild); - } - } - - destroy(): void { - this.fileIndicatorEl.remove(); - } - - renderCurrentNote(filePath: string | null): void { - this.fileIndicatorEl.empty(); - - if (!filePath) { - this.fileIndicatorEl.removeClass('qoderian-visible-flex'); - this.fileIndicatorEl.addClass('qoderian-hidden'); - return; - } - - this.fileIndicatorEl.addClass('qoderian-visible-flex'); - this.fileIndicatorEl.removeClass('qoderian-hidden'); - this.renderFileChip(filePath, () => { - this.callbacks.onRemoveAttachment(filePath); - }); - } - - private renderFileChip(filePath: string, onRemove: () => void): void { - const chipEl = this.fileIndicatorEl.createDiv({ cls: 'qoderian-file-chip' }); - - const iconEl = chipEl.createSpan({ cls: 'qoderian-file-chip-icon' }); - setIcon(iconEl, 'file-text'); - - const normalizedPath = filePath.replace(/\\/g, '/'); - const filename = normalizedPath.split('/').pop() || filePath; - const nameEl = chipEl.createSpan({ cls: 'qoderian-file-chip-name' }); - nameEl.setText(filename); - nameEl.setAttribute('title', filePath); - - const removeEl = chipEl.createSpan({ cls: 'qoderian-file-chip-remove' }); - removeEl.setText('\u00D7'); - removeEl.setAttribute('aria-label', 'Remove'); - - chipEl.addEventListener('click', (e) => { - if (!(e.target as HTMLElement).closest('.qoderian-file-chip-remove')) { - this.callbacks.onOpenFile(filePath); - } - }); - - removeEl.addEventListener('click', () => { - onRemove(); - }); - } -} diff --git a/src/features/chat/ui/file-context/file-context-manager.ts b/src/features/chat/ui/file-context/file-context-manager.ts index e7e196e..1bbf43c 100644 --- a/src/features/chat/ui/file-context/file-context-manager.ts +++ b/src/features/chat/ui/file-context/file-context-manager.ts @@ -1,5 +1,5 @@ import type { App, EventRef } from 'obsidian'; -import { Notice, setIcon, TFile } from 'obsidian'; +import { setIcon, TFile } from 'obsidian'; import { createExternalContextLookupGetter, @@ -20,13 +20,11 @@ import type { ExtensionMentionItem, MentionExtensionProvider } from '../../../.. import type { MentionInsertReference } from '../../../../shared/mention/types'; import { VaultMentionIndex } from '../../../../shared/mention/vault-mention-index'; import type { ComposerReference } from '../composer/composer-reference'; -import { FileChipsView } from './file-chips-view'; import { FileContextState } from './file-context-state'; import { isTagExcluded } from './tag-exclusion'; export interface FileContextCallbacks { getExcludedTags: () => string[]; - onChipsChanged?: () => void; /** Notified whenever the composer reference set changes (insert/rename/delete). */ onReferencesChanged?: (references: readonly ComposerReference[]) => void; getExternalContexts?: () => string[]; @@ -37,21 +35,16 @@ export interface FileContextCallbacks { export class FileContextManager { private app: App; private callbacks: FileContextCallbacks; - private chipsContainerEl: HTMLElement; private dropdownContainerEl: HTMLElement; private inputEl: HTMLTextAreaElement; private state: FileContextState; private mentionIndex: VaultMentionIndex; - private chipsView: FileChipsView; private mentionDropdown: MentionDropdownController; private mcpManager: McpServerManager | null = null; private agentService: AgentMentionIndex | null = null; private deleteEventRef: EventRef | null = null; private renameEventRef: EventRef | null = null; - // Current note (shown as chip) - private currentNotePath: string | null = null; - // Reference tokens inserted via the mention dropdown, keyed by token text private readonly composerReferences = new Map(); @@ -66,7 +59,6 @@ export class FileContextManager { dropdownContainerEl?: HTMLElement ) { this.app = app; - this.chipsContainerEl = chipsContainerEl; this.dropdownContainerEl = dropdownContainerEl ?? chipsContainerEl; this.inputEl = inputEl; this.callbacks = callbacks; @@ -75,30 +67,6 @@ export class FileContextManager { this.mentionIndex = new VaultMentionIndex(this.app); this.mentionIndex.initializeInBackground(); - this.chipsView = new FileChipsView(this.chipsContainerEl, { - onRemoveAttachment: (filePath) => { - if (filePath === this.currentNotePath) { - this.currentNotePath = null; - this.state.detachFile(filePath); - this.refreshCurrentNoteChip(); - } - }, - onOpenFile: (filePath) => { - void (async (): Promise => { - const file = this.app.vault.getAbstractFileByPath(filePath); - if (!(file instanceof TFile)) { - new Notice(`Could not open file: ${filePath}`); - return; - } - try { - await this.app.workspace.getLeaf().openFile(file); - } catch (error) { - new Notice(`Failed to open file: ${error instanceof Error ? error.message : String(error)}`); - } - })(); - }, - }); - this.mentionDropdown = new MentionDropdownController( this.dropdownContainerEl, this.inputEl, @@ -122,87 +90,31 @@ export class FileContextManager { }); } - /** Returns the current note path (shown as chip). */ + /** + * Resolves the note the user is currently viewing (vault-relative), or null + * when none is open. Read live so the answer is never stale, e.g. when the + * user switched notes while another tab was active. + */ getCurrentNotePath(): string | null { - return this.currentNotePath; + const activeFile = this.app.workspace.getActiveFile(); + if (!activeFile || this.hasExcludedTag(activeFile)) return null; + return this.normalizePathForVault(activeFile.path); } getAttachedFiles(): Set { return this.state.getAttachedFiles(); } - /** Checks whether current note should be sent for this session. */ - shouldSendCurrentNote(notePath?: string | null): boolean { - const resolvedPath = notePath ?? this.currentNotePath; - return !!resolvedPath && !this.state.hasSentCurrentNote(); - } - - /** Marks current note as sent (call after sending a message). */ - markCurrentNoteSent() { - this.state.markCurrentNoteSent(); - } - - isSessionStarted(): boolean { - return this.state.isSessionStarted(); - } - - startSession() { - this.state.startSession(); - } - /** Resets state for a new conversation. */ resetForNewConversation() { - this.currentNotePath = null; this.clearComposerReferences(); this.state.resetForNewConversation(); - this.refreshCurrentNoteChip(); } /** Resets state for loading an existing conversation. */ - resetForLoadedConversation(hasMessages: boolean) { - this.currentNotePath = null; + resetForLoadedConversation() { this.clearComposerReferences(); - this.state.resetForLoadedConversation(hasMessages); - this.refreshCurrentNoteChip(); - } - - /** Sets current note (for restoring persisted state). */ - setCurrentNote(notePath: string | null) { - this.currentNotePath = notePath; - if (notePath) { - this.state.attachFile(notePath); - } - this.refreshCurrentNoteChip(); - } - - /** Auto-attaches the currently focused file (for new sessions). */ - autoAttachActiveFile() { - const activeFile = this.app.workspace.getActiveFile(); - if (activeFile && !this.hasExcludedTag(activeFile)) { - const normalizedPath = this.normalizePathForVault(activeFile.path); - if (normalizedPath) { - this.currentNotePath = normalizedPath; - this.state.attachFile(normalizedPath); - this.refreshCurrentNoteChip(); - } - } - } - - /** Handles file open event. */ - handleFileOpen(file: TFile) { - const normalizedPath = this.normalizePathForVault(file.path); - if (!normalizedPath) return; - - if (!this.state.isSessionStarted()) { - this.state.clearAttachments(); - if (!this.hasExcludedTag(file)) { - this.currentNotePath = normalizedPath; - this.state.attachFile(normalizedPath); - } else { - this.currentNotePath = null; - } - this.refreshCurrentNoteChip(); - } + this.state.resetForLoadedConversation(); } markFileCacheDirty() { @@ -285,7 +197,6 @@ export class FileContextManager { if (this.deleteEventRef) this.app.vault.offref(this.deleteEventRef); if (this.renameEventRef) this.app.vault.offref(this.renameEventRef); this.mentionDropdown.destroy(); - this.chipsView.destroy(); } /** Normalizes a file path to be vault-relative with forward slashes. */ @@ -294,69 +205,34 @@ export class FileContextManager { return normalizePathForVaultUtil(rawPath, vaultPath); } - private refreshCurrentNoteChip(): void { - this.chipsView.renderCurrentNote(this.currentNotePath); - this.callbacks.onChipsChanged?.(); - } - private handleFileRenamed(oldPath: string, newPath: string) { const normalizedOld = this.normalizePathForVault(oldPath); const normalizedNew = this.normalizePathForVault(newPath); if (!normalizedOld) return; - let needsUpdate = false; - - // Update current note path if renamed - if (this.currentNotePath === normalizedOld) { - this.currentNotePath = normalizedNew; - needsUpdate = true; - } - // Update attached files if (this.state.getAttachedFiles().has(normalizedOld)) { this.state.detachFile(normalizedOld); if (normalizedNew) { this.state.attachFile(normalizedNew); } - needsUpdate = true; } // Update composer reference tokens so chips survive renames - if (this.renameComposerReferences(normalizedOld, normalizedNew)) { - needsUpdate = true; - } - - if (needsUpdate) { - this.refreshCurrentNoteChip(); - } + this.renameComposerReferences(normalizedOld, normalizedNew); } private handleFileDeleted(deletedPath: string): void { const normalized = this.normalizePathForVault(deletedPath); if (!normalized) return; - let needsUpdate = false; - - // Clear current note if deleted - if (this.currentNotePath === normalized) { - this.currentNotePath = null; - needsUpdate = true; - } - // Remove from attached files if (this.state.getAttachedFiles().has(normalized)) { this.state.detachFile(normalized); - needsUpdate = true; } // Drop composer references whose file no longer exists - if (this.removeComposerReferencesForPath(normalized)) { - needsUpdate = true; - } - - if (needsUpdate) { - this.refreshCurrentNoteChip(); - } + this.removeComposerReferencesForPath(normalized); } // ======================================== diff --git a/src/features/chat/ui/file-context/file-context-state.ts b/src/features/chat/ui/file-context/file-context-state.ts index 1936ed0..bf7b892 100644 --- a/src/features/chat/ui/file-context/file-context-state.ts +++ b/src/features/chat/ui/file-context/file-context-state.ts @@ -1,40 +1,18 @@ export class FileContextState { private attachedFiles: Set = new Set(); - private sessionStarted = false; private mentionedMcpServers: Set = new Set(); - private currentNoteSent = false; getAttachedFiles(): Set { return new Set(this.attachedFiles); } - hasSentCurrentNote(): boolean { - return this.currentNoteSent; - } - - markCurrentNoteSent(): void { - this.currentNoteSent = true; - } - - isSessionStarted(): boolean { - return this.sessionStarted; - } - - startSession(): void { - this.sessionStarted = true; - } - resetForNewConversation(): void { - this.sessionStarted = false; - this.currentNoteSent = false; this.attachedFiles.clear(); this.clearMcpMentions(); } - resetForLoadedConversation(hasMessages: boolean): void { - this.currentNoteSent = hasMessages; + resetForLoadedConversation(): void { this.attachedFiles.clear(); - this.sessionStarted = hasMessages; this.clearMcpMentions(); } diff --git a/src/qoder/prompt/context/prompt-context.ts b/src/qoder/prompt/context/prompt-context.ts index 4877c7d..7e57ed8 100644 --- a/src/qoder/prompt/context/prompt-context.ts +++ b/src/qoder/prompt/context/prompt-context.ts @@ -6,8 +6,8 @@ import { escapeXmlClosingTag } from './xml-context'; -const LINKED_NOTE_TAG = 'linked_note'; -const NOTE_CONTEXT_TAG_PATTERN = '(linked_note|current_note)'; +const CURRENT_NOTE_TAG = 'current_note'; +const NOTE_CONTEXT_TAG_PATTERN = '(current_note|linked_note)'; const EXTERNAL_CONTEXT_TAG = 'external_context'; // Matches note context at the START of prompt (legacy placement) @@ -18,15 +18,15 @@ const NOTE_CONTEXT_SUFFIX_REGEX = new RegExp(`\\n\\n<${NOTE_CONTEXT_TAG_PATTERN} /** * Pattern to match XML context tags appended to prompts. * These tags are always preceded by \n\n separator. - * Matches: linked_note/current_note, editor_selection (with attributes), editor_cursor (with attributes), + * Matches: current_note/linked_note, editor_selection (with attributes), editor_cursor (with attributes), * context_files, canvas_selection, browser_selection, external_context */ -export const XML_CONTEXT_PATTERN = /\n\n<(?:linked_note|current_note|editor_selection|editor_cursor|context_files|canvas_selection|browser_selection|external_context)[\s>]/; +export const XML_CONTEXT_PATTERN = /\n\n<(?:current_note|linked_note|editor_selection|editor_cursor|context_files|canvas_selection|browser_selection|external_context)[\s>]/; const BRACKET_CONTEXT_PATTERN = /\n\[(?:Current note|Editor selection from|Browser selection from|Canvas selection from)\b/; export function formatCurrentNote(notePath: string): string { - const safePath = escapeXmlClosingTag(notePath, LINKED_NOTE_TAG); - return `<${LINKED_NOTE_TAG}>\n${safePath}\n`; + const safePath = escapeXmlClosingTag(notePath, CURRENT_NOTE_TAG); + return `<${CURRENT_NOTE_TAG}>\n${safePath}\n`; } export function appendCurrentNote(prompt: string, notePath: string): string { @@ -35,7 +35,7 @@ export function appendCurrentNote(prompt: string, notePath: string): string { /** * Strips note context from a prompt. - * Handles legacy tags and canonical tags. + * Handles legacy tags and canonical tags. */ export function stripCurrentNoteContext(prompt: string): string { const strippedPrefix = prompt.replace(NOTE_CONTEXT_PREFIX_REGEX, ''); @@ -104,7 +104,7 @@ export function extractUserQuery(prompt: string): string { // No XML context - return the whole prompt stripped of any remaining tags return prompt - .replace(/<(linked_note|current_note)>[\s\S]*?<\/\1>\s*/g, '') + .replace(/<(current_note|linked_note)>[\s\S]*?<\/\1>\s*/g, '') .replace(/\s*/g, '') .replace(/\s*/g, '') .replace(/[\s\S]*?<\/context_files>\s*/g, '') diff --git a/src/qoder/prompt/main-agent.ts b/src/qoder/prompt/main-agent.ts index c209dd1..8c8d243 100644 --- a/src/qoder/prompt/main-agent.ts +++ b/src/qoder/prompt/main-agent.ts @@ -62,9 +62,9 @@ User messages have the query first, followed by optional XML context tags: \`\`\` User's question or request here - + path/to/note.md - + selected text content @@ -76,7 +76,7 @@ selected content from an Obsidian browser view \`\`\` - The user's query/instruction always comes first in the message. -- \`\`: The note this session is linked to. Read this to understand session context. Legacy messages may use \`\` for the same context. +- \`\`: The note the user is currently viewing. It arrives when the note changes, so the newest one is what the user has open. Legacy messages may use \`\` for the same context. - \`\`: Text currently selected in the editor, with file path and line numbers. - \`\`: Text selected in an Obsidian browser/web view (for example Surfing), including optional source/title/url metadata. - \`@filename.md\`: Files mentioned with @ in the query. Read these files when referenced. diff --git a/src/qoder/runtime/qoder-chat-runtime.ts b/src/qoder/runtime/qoder-chat-runtime.ts index 14072fe..7e32ed5 100644 --- a/src/qoder/runtime/qoder-chat-runtime.ts +++ b/src/qoder/runtime/qoder-chat-runtime.ts @@ -136,6 +136,7 @@ export class QoderChatRuntime implements ChatRuntime { private vaultPath: string | null = null; private currentExternalContextPaths: string[] = []; private announcedExternalContextPaths: string[] = []; + private announcedCurrentNotePath: string | null = null; private currentMcpServers: Record = {}; private readyStateListeners = new Set<(ready: boolean) => void>(); @@ -217,9 +218,14 @@ export class QoderChatRuntime implements ChatRuntime { } prepareTurn(request: ChatTurnRequest): PreparedChatTurn { - const notice = this.consumeExternalContextsNotice(request.externalContextPaths); + const contextsNotice = this.consumeExternalContextsNotice(request.externalContextPaths); + const noteNotice = this.consumeCurrentNoteNotice(request.currentNotePath); return encodeQoderTurn( - notice === undefined ? request : { ...request, externalContextsNotice: notice }, + { + ...request, + ...(contextsNotice === undefined ? {} : { externalContextsNotice: contextsNotice }), + currentNotePath: noteNotice, + }, this.mcpManager, ); } @@ -238,6 +244,21 @@ export class QoderChatRuntime implements ChatRuntime { return unchanged ? undefined : current; } + /** + * The composer no longer shows which note the user is viewing, so the model + * learns it from the turn text: announce the path once per change. Closing + * the note is not announced, but it resets the tracker so reopening the + * same note reports it again. + */ + private consumeCurrentNoteNotice(notePath: string | undefined): string | undefined { + const current = notePath ?? null; + if (current === this.announcedCurrentNotePath) { + return undefined; + } + this.announcedCurrentNotePath = current; + return current ?? undefined; + } + consumeTurnMetadata(): ChatTurnMetadata { return this.turnTracker.consumeMetadata(); } @@ -1296,6 +1317,8 @@ export class QoderChatRuntime implements ChatRuntime { this.crashRecoveryAttempted = false; // A restored session never saw the current directories; announce them. this.announcedExternalContextPaths = []; + // Likewise, report the note the user is viewing to the restored session. + this.announcedCurrentNotePath = null; } this.sessionManager.setSessionId(id, this.getScopedSettings().model); diff --git a/tests/integration/qoder/runtime/qoder-chat-runtime.test.ts b/tests/integration/qoder/runtime/qoder-chat-runtime.test.ts index f0fe835..9e46ec8 100644 --- a/tests/integration/qoder/runtime/qoder-chat-runtime.test.ts +++ b/tests/integration/qoder/runtime/qoder-chat-runtime.test.ts @@ -1164,7 +1164,7 @@ describe('QoderChatRuntime', () => { // Now test the standalone function directly const context = buildContextFromHistory(messages); - expect(context).toContain(''); + expect(context).toContain(''); expect(context).toContain('notes/file.md'); }); @@ -1226,7 +1226,7 @@ describe('QoderChatRuntime', () => { expect(prompts[0]).toBe('Follow up'); expect(prompts[1]).toContain('User: First question'); expect(prompts[1]).toContain('Assistant: Answer'); - expect(prompts[1]).toContain(''); + expect(prompts[1]).toContain(''); expect(prompts[1]).toContain('note.md'); expect(chunks.some((c) => c.type === 'text' && c.content === 'Recovered')).toBe(true); expect(service.getSessionId()).toBeNull(); diff --git a/tests/unit/features/chat/ui/file-context/file-context-manager.test.ts b/tests/unit/features/chat/ui/file-context/file-context-manager.test.ts index 5ff56c5..0f96c45 100644 --- a/tests/unit/features/chat/ui/file-context/file-context-manager.test.ts +++ b/tests/unit/features/chat/ui/file-context/file-context-manager.test.ts @@ -11,7 +11,6 @@ jest.mock('obsidian', () => { return { ...actual, setIcon: jest.fn(), - Notice: jest.fn(), }; }); @@ -109,6 +108,20 @@ function createMockCallbacks(options: { }; } +/** Picks a file from the @ dropdown, which attaches it to the manager state. */ +function attachViaMention( + manager: FileContextManager, + inputEl: HTMLTextAreaElement, + query: string, +): void { + inputEl.value = `@${query}`; + inputEl.selectionStart = inputEl.value.length; + inputEl.selectionEnd = inputEl.value.length; + manager.handleInputChange(); + jest.advanceTimersByTime(200); + manager.handleMentionKeydown({ key: 'Enter', preventDefault: jest.fn() } as any); +} + describe('FileContextManager', () => { let containerEl: MockElement; let inputEl: HTMLTextAreaElement; @@ -131,33 +144,11 @@ describe('FileContextManager', () => { jest.useRealTimers(); }); - it('tracks current note send state per session', () => { - const app = createMockApp(); - const manager = new FileContextManager( - app, - containerEl as any, - inputEl, - createMockCallbacks() - ); - - manager.setCurrentNote('notes/alpha.md'); - expect(manager.shouldSendCurrentNote()).toBe(true); - manager.markCurrentNoteSent(); - expect(manager.shouldSendCurrentNote()).toBe(false); - - manager.resetForLoadedConversation(true); - manager.setCurrentNote('notes/alpha.md'); - expect(manager.shouldSendCurrentNote()).toBe(false); - - manager.resetForLoadedConversation(false); - manager.setCurrentNote('notes/beta.md'); - expect(manager.shouldSendCurrentNote()).toBe(true); - - manager.destroy(); - }); - - it('should NOT resend current note when loading conversation with existing messages', () => { - const app = createMockApp(); + it('resolves the current note from the active file', () => { + const app = createMockApp({ + files: ['notes/alpha.md'], + activeFilePath: 'notes/alpha.md', + }); const manager = new FileContextManager( app, containerEl as any, @@ -165,35 +156,19 @@ describe('FileContextManager', () => { createMockCallbacks() ); - // When loading a conversation that already has messages, the current note - // should be marked as already sent to avoid re-sending context - manager.resetForLoadedConversation(true); - manager.setCurrentNote('notes/restored.md'); - expect(manager.shouldSendCurrentNote()).toBe(false); + expect(manager.getCurrentNotePath()).toBe('notes/alpha.md'); - manager.destroy(); - }); - - it('should send current note when loading empty conversation', () => { - const app = createMockApp(); - const manager = new FileContextManager( - app, - containerEl as any, - inputEl, - createMockCallbacks() - ); - - // When loading a conversation with no messages, the current note - // should be sent with the first message - manager.resetForLoadedConversation(false); - manager.setCurrentNote('notes/new.md'); - expect(manager.shouldSendCurrentNote()).toBe(true); + app.workspace.getActiveFile = jest.fn(() => null); + expect(manager.getCurrentNotePath()).toBeNull(); manager.destroy(); }); - it('renders current note chip and removes on click', () => { - const app = createMockApp(); + it('resolves the current note from the most recent file even when the sidebar is focused', () => { + const app = createMockApp({ + files: ['notes/alpha.md', 'notes/beta.md'], + activeFilePath: 'notes/alpha.md', + }); const manager = new FileContextManager( app, containerEl as any, @@ -201,24 +176,16 @@ describe('FileContextManager', () => { createMockCallbacks() ); - manager.setCurrentNote('notes/chip.md'); - - const indicator = findByClass(containerEl, 'qoderian-file-indicator'); - expect(indicator).toBeDefined(); - expect(indicator?.style.display).toBe('flex'); + expect(manager.getCurrentNotePath()).toBe('notes/alpha.md'); - const removeEl = findByClass(containerEl, 'qoderian-file-chip-remove'); - expect(removeEl).toBeDefined(); - - removeEl!.click(); - - expect(manager.getCurrentNotePath()).toBeNull(); - expect(indicator?.style.display).toBe('none'); + // Switching notes is picked up without any file-open bookkeeping. + app.workspace.getActiveFile = jest.fn(() => createMockTFile('notes/beta.md')); + expect(manager.getCurrentNotePath()).toBe('notes/beta.md'); manager.destroy(); }); - it('auto-attaches active file unless excluded by tag', () => { + it('does not resolve a current note with an excluded tag', () => { const fileCacheByPath = new Map([ ['notes/private.md', { frontmatter: { tags: ['private'] } }], ]); @@ -235,11 +202,9 @@ describe('FileContextManager', () => { createMockCallbacks({ excludedTags: ['private'] }) ); - manager.autoAttachActiveFile(); expect(manager.getCurrentNotePath()).toBeNull(); app.workspace.getActiveFile = jest.fn(() => createMockTFile('notes/public.md')); - manager.autoAttachActiveFile(); expect(manager.getCurrentNotePath()).toBe('notes/public.md'); manager.destroy(); @@ -484,40 +449,7 @@ describe('FileContextManager', () => { manager.destroy(); }); - describe('session lifecycle', () => { - it('should report session not started initially', () => { - const app = createMockApp(); - const manager = new FileContextManager( - app, containerEl as any, inputEl, createMockCallbacks() - ); - expect(manager.isSessionStarted()).toBe(false); - manager.destroy(); - }); - - it('should report session started after startSession', () => { - const app = createMockApp(); - const manager = new FileContextManager( - app, containerEl as any, inputEl, createMockCallbacks() - ); - manager.startSession(); - expect(manager.isSessionStarted()).toBe(true); - manager.destroy(); - }); - - it('should reset state for new conversation', () => { - const app = createMockApp(); - const manager = new FileContextManager( - app, containerEl as any, inputEl, createMockCallbacks() - ); - manager.setCurrentNote('notes/test.md'); - manager.startSession(); - - manager.resetForNewConversation(); - expect(manager.getCurrentNotePath()).toBeNull(); - expect(manager.isSessionStarted()).toBe(false); - manager.destroy(); - }); - + describe('conversation boundaries', () => { it('clears tracked composer references at conversation boundaries', () => { const app = createMockApp(); const onReferencesChanged = jest.fn(); @@ -534,88 +466,35 @@ describe('FileContextManager', () => { expect(onReferencesChanged).toHaveBeenLastCalledWith([]); manager.registerComposerReference({ token: '@b.md', path: 'b.md', kind: 'file' }); - manager.resetForLoadedConversation(true); + manager.resetForLoadedConversation(); expect(onReferencesChanged).toHaveBeenLastCalledWith([]); manager.destroy(); }); }); - describe('handleFileOpen', () => { - it('should update current note when session not started', () => { - const app = createMockApp({ files: ['notes/new.md'] }); - const manager = new FileContextManager( - app, containerEl as any, inputEl, createMockCallbacks() - ); - - const file = createMockTFile('notes/new.md'); - manager.handleFileOpen(file); - expect(manager.getCurrentNotePath()).toBe('notes/new.md'); - manager.destroy(); - }); - - it('should clear attachments when opening a new file before session starts', () => { - const app = createMockApp({ files: ['notes/a.md', 'notes/b.md'] }); - const manager = new FileContextManager( - app, containerEl as any, inputEl, createMockCallbacks() - ); - - manager.setCurrentNote('notes/a.md'); - const fileB = createMockTFile('notes/b.md'); - manager.handleFileOpen(fileB); - expect(manager.getCurrentNotePath()).toBe('notes/b.md'); - manager.destroy(); - }); - - it('should not update current note when session is started', () => { - const app = createMockApp({ files: ['notes/a.md'] }); - const manager = new FileContextManager( - app, containerEl as any, inputEl, createMockCallbacks() - ); - - manager.setCurrentNote('notes/a.md'); - manager.startSession(); - - const fileB = createMockTFile('notes/b.md'); - manager.handleFileOpen(fileB); - // Should NOT update because session is started - expect(manager.getCurrentNotePath()).toBe('notes/a.md'); - manager.destroy(); - }); - - it('should not attach file with excluded tag', () => { - const fileCacheByPath = new Map([ - ['notes/secret.md', { frontmatter: { tags: ['private'] } }], - ]); - const app = createMockApp({ files: ['notes/secret.md'], fileCacheByPath }); - const manager = new FileContextManager( - app, containerEl as any, inputEl, - createMockCallbacks({ excludedTags: ['private'] }) - ); - - const file = createMockTFile('notes/secret.md'); - manager.handleFileOpen(file); - expect(manager.getCurrentNotePath()).toBeNull(); - manager.destroy(); - }); - }); - describe('file rename handling', () => { - it('should update current note path when file is renamed', () => { + it('rewrites composer reference tokens when the file is renamed', () => { const app = createMockApp({ files: ['notes/old.md', 'notes/new.md'] }); + const onReferencesChanged = jest.fn(); const manager = new FileContextManager( - app, containerEl as any, inputEl, createMockCallbacks() + app, containerEl as any, inputEl, { ...createMockCallbacks(), onReferencesChanged } ); - manager.setCurrentNote('notes/old.md'); - expect(manager.getCurrentNotePath()).toBe('notes/old.md'); + manager.registerComposerReference({ + token: '@notes/old.md', path: 'notes/old.md', kind: 'file', + }); + inputEl.value = 'See @notes/old.md for details'; - // Simulate vault rename event const renameHandler = (app.vault.on as jest.Mock).mock.calls .find((c: any[]) => c[0] === 'rename')?.[1]; expect(renameHandler).toBeDefined(); renameHandler(createMockTFile('notes/new.md'), 'notes/old.md'); - expect(manager.getCurrentNotePath()).toBe('notes/new.md'); + + expect(inputEl.value).toBe('See @notes/new.md for details'); + expect(onReferencesChanged).toHaveBeenLastCalledWith( + [expect.objectContaining({ token: '@notes/new.md', path: 'notes/new.md' })], + ); manager.destroy(); }); @@ -625,7 +504,8 @@ describe('FileContextManager', () => { app, containerEl as any, inputEl, createMockCallbacks() ); - manager.setCurrentNote('notes/old.md'); + attachViaMention(manager, inputEl, 'old'); + expect(manager.getAttachedFiles().has('notes/old.md')).toBe(true); const renameHandler = (app.vault.on as jest.Mock).mock.calls .find((c: any[]) => c[0] === 'rename')?.[1]; @@ -635,40 +515,24 @@ describe('FileContextManager', () => { expect(manager.getAttachedFiles().has('notes/old.md')).toBe(false); manager.destroy(); }); - - it('should not update if renamed file is not attached', () => { - const app = createMockApp({ files: ['notes/a.md', 'notes/unrelated.md'] }); - const manager = new FileContextManager( - app, containerEl as any, inputEl, createMockCallbacks() - ); - - manager.setCurrentNote('notes/a.md'); - - const renameHandler = (app.vault.on as jest.Mock).mock.calls - .find((c: any[]) => c[0] === 'rename')?.[1]; - - renameHandler(createMockTFile('notes/renamed.md'), 'notes/unrelated.md'); - // Current note should remain unchanged - expect(manager.getCurrentNotePath()).toBe('notes/a.md'); - manager.destroy(); - }); }); describe('file delete handling', () => { - it('should clear current note when file is deleted', () => { - const app = createMockApp({ files: ['notes/doomed.md'] }); + it('drops composer references when the file is deleted', () => { + const app = createMockApp({ files: ['notes/a.md'] }); const manager = new FileContextManager( app, containerEl as any, inputEl, createMockCallbacks() ); - manager.setCurrentNote('notes/doomed.md'); + manager.registerComposerReference({ token: '@notes/a.md', path: 'notes/a.md', kind: 'file' }); + inputEl.value = 'See @notes/a.md'; const deleteHandler = (app.vault.on as jest.Mock).mock.calls .find((c: any[]) => c[0] === 'delete')?.[1]; expect(deleteHandler).toBeDefined(); - deleteHandler(createMockTFile('notes/doomed.md')); - expect(manager.getCurrentNotePath()).toBeNull(); + deleteHandler(createMockTFile('notes/a.md')); + expect(inputEl.value).toBe('See '); manager.destroy(); }); @@ -678,7 +542,7 @@ describe('FileContextManager', () => { app, containerEl as any, inputEl, createMockCallbacks() ); - manager.setCurrentNote('notes/a.md'); + attachViaMention(manager, inputEl, 'a'); expect(manager.getAttachedFiles().has('notes/a.md')).toBe(true); const deleteHandler = (app.vault.on as jest.Mock).mock.calls @@ -688,22 +552,6 @@ describe('FileContextManager', () => { expect(manager.getAttachedFiles().has('notes/a.md')).toBe(false); manager.destroy(); }); - - it('should not update if deleted file is not attached', () => { - const app = createMockApp({ files: ['notes/a.md', 'notes/other.md'] }); - const manager = new FileContextManager( - app, containerEl as any, inputEl, createMockCallbacks() - ); - - manager.setCurrentNote('notes/a.md'); - - const deleteHandler = (app.vault.on as jest.Mock).mock.calls - .find((c: any[]) => c[0] === 'delete')?.[1]; - - deleteHandler(createMockTFile('notes/other.md')); - expect(manager.getCurrentNotePath()).toBe('notes/a.md'); - manager.destroy(); - }); }); describe('hasExcludedTag edge cases', () => { @@ -724,7 +572,6 @@ describe('FileContextManager', () => { createMockCallbacks({ excludedTags: ['system'] }) ); - manager.autoAttachActiveFile(); expect(manager.getCurrentNotePath()).toBeNull(); manager.destroy(); }); @@ -744,7 +591,6 @@ describe('FileContextManager', () => { createMockCallbacks({ excludedTags: ['private'] }) ); - manager.autoAttachActiveFile(); expect(manager.getCurrentNotePath()).toBeNull(); manager.destroy(); }); @@ -764,7 +610,6 @@ describe('FileContextManager', () => { createMockCallbacks({ excludedTags: ['draft'] }) ); - manager.autoAttachActiveFile(); expect(manager.getCurrentNotePath()).toBeNull(); manager.destroy(); }); @@ -783,7 +628,6 @@ describe('FileContextManager', () => { createMockCallbacks({ excludedTags: ['#System'] }) ); - manager.autoAttachActiveFile(); expect(manager.getCurrentNotePath()).toBeNull(); manager.destroy(); }); @@ -802,7 +646,6 @@ describe('FileContextManager', () => { createMockCallbacks({ excludedTags: ['private'] }) ); - manager.autoAttachActiveFile(); expect(manager.getCurrentNotePath()).toBeNull(); manager.destroy(); }); @@ -821,7 +664,6 @@ describe('FileContextManager', () => { createMockCallbacks({ excludedTags: ['private'] }) ); - manager.autoAttachActiveFile(); expect(manager.getCurrentNotePath()).toBe('notes/privateer.md'); manager.destroy(); }); @@ -1000,22 +842,4 @@ describe('FileContextManager', () => { expect(app.vault.offref).toHaveBeenCalledTimes(2); }); }); - - describe('onOpenFile callback', () => { - it('should show Notice when file not found in vault', async () => { - const { Notice: NoticeMock } = jest.requireMock('obsidian'); - const app = createMockApp(); - const manager = new FileContextManager( - app, containerEl as any, inputEl, createMockCallbacks() - ); - - const chipsView = (manager as any).chipsView; - const openCallback = chipsView.callbacks.onOpenFile; - expect(openCallback).toBeDefined(); - - await openCallback('notes/missing.md'); - expect(NoticeMock).toHaveBeenCalledWith(expect.stringContaining('Could not open file')); - manager.destroy(); - }); - }); }); diff --git a/tests/unit/features/chat/ui/file-context/file-context-state.test.ts b/tests/unit/features/chat/ui/file-context/file-context-state.test.ts index c748c5f..a79c6a7 100644 --- a/tests/unit/features/chat/ui/file-context/file-context-state.test.ts +++ b/tests/unit/features/chat/ui/file-context/file-context-state.test.ts @@ -12,66 +12,33 @@ describe('FileContextState', () => { expect(state.getAttachedFiles().size).toBe(0); }); - it('should start with session not started', () => { - expect(state.isSessionStarted()).toBe(false); - }); - - it('should start with current note not sent', () => { - expect(state.hasSentCurrentNote()).toBe(false); - }); - it('should start with no MCP mentions', () => { expect(state.getMentionedMcpServers().size).toBe(0); }); }); - describe('session lifecycle', () => { - it('should mark session as started', () => { - state.startSession(); - expect(state.isSessionStarted()).toBe(true); - }); - - it('should mark current note as sent', () => { - state.markCurrentNoteSent(); - expect(state.hasSentCurrentNote()).toBe(true); - }); - }); - describe('resetForNewConversation', () => { it('should reset all state', () => { - state.startSession(); - state.markCurrentNoteSent(); state.attachFile('file1.md'); state.addMentionedMcpServer('server1'); state.resetForNewConversation(); - expect(state.isSessionStarted()).toBe(false); - expect(state.hasSentCurrentNote()).toBe(false); expect(state.getAttachedFiles().size).toBe(0); expect(state.getMentionedMcpServers().size).toBe(0); }); }); describe('resetForLoadedConversation', () => { - it('should set state based on whether conversation has messages', () => { + it('should reset all state', () => { state.attachFile('file1.md'); state.addMentionedMcpServer('server1'); - state.resetForLoadedConversation(true); + state.resetForLoadedConversation(); - expect(state.isSessionStarted()).toBe(true); - expect(state.hasSentCurrentNote()).toBe(true); expect(state.getAttachedFiles().size).toBe(0); expect(state.getMentionedMcpServers().size).toBe(0); }); - - it('should not mark as started when no messages', () => { - state.resetForLoadedConversation(false); - - expect(state.isSessionStarted()).toBe(false); - expect(state.hasSentCurrentNote()).toBe(false); - }); }); describe('file attachments', () => { diff --git a/tests/unit/qoder/prompt/context/prompt-context.test.ts b/tests/unit/qoder/prompt/context/prompt-context.test.ts index 2f8f3d5..ce39833 100644 --- a/tests/unit/qoder/prompt/context/prompt-context.test.ts +++ b/tests/unit/qoder/prompt/context/prompt-context.test.ts @@ -12,19 +12,19 @@ import { describe('formatCurrentNote', () => { it('formats note path in XML tags', () => { expect(formatCurrentNote('notes/test.md')).toBe( - '\nnotes/test.md\n' + '\nnotes/test.md\n' ); }); it('handles paths with special characters', () => { expect(formatCurrentNote('notes/my file (1).md')).toBe( - '\nnotes/my file (1).md\n' + '\nnotes/my file (1).md\n' ); }); it('escapes an embedded closing tag in the note path', () => { - expect(formatCurrentNote('beforeafter')).toContain( - 'before</linked_note>after', + expect(formatCurrentNote('beforeafter')).toContain( + 'before</current_note>after', ); }); }); @@ -33,7 +33,7 @@ describe('appendCurrentNote', () => { it('appends current note to prompt with double newline separator', () => { const result = appendCurrentNote('Hello', 'notes/test.md'); expect(result).toBe( - 'Hello\n\n\nnotes/test.md\n' + 'Hello\n\n\nnotes/test.md\n' ); }); @@ -45,37 +45,37 @@ describe('appendCurrentNote', () => { describe('stripCurrentNoteContext', () => { describe('prefix format', () => { - it('strips linked_note from start of prompt', () => { - const prompt = '\nnotes/test.md\n\n\nUser query here'; + it('strips current_note from start of prompt', () => { + const prompt = '\nnotes/test.md\n\n\nUser query here'; expect(stripCurrentNoteContext(prompt)).toBe('User query here'); }); it('handles multiline note content in prefix', () => { - const prompt = '\npath/to/note.md\nwith extra info\n\n\nQuery'; + const prompt = '\npath/to/note.md\nwith extra info\n\n\nQuery'; expect(stripCurrentNoteContext(prompt)).toBe('Query'); }); }); describe('suffix format', () => { - it('strips linked_note from end of prompt', () => { - const prompt = 'User query here\n\n\nnotes/test.md\n'; + it('strips current_note from end of prompt', () => { + const prompt = 'User query here\n\n\nnotes/test.md\n'; expect(stripCurrentNoteContext(prompt)).toBe('User query here'); }); it('handles multiline note content in suffix', () => { - const prompt = 'Query\n\n\npath/to/note.md\n'; + const prompt = 'Query\n\n\npath/to/note.md\n'; expect(stripCurrentNoteContext(prompt)).toBe('Query'); }); }); - describe('legacy current_note compatibility', () => { - it('strips current_note from start of prompt', () => { - const prompt = '\nnotes/test.md\n\n\nUser query here'; + describe('legacy linked_note compatibility', () => { + it('strips linked_note from start of prompt', () => { + const prompt = '\nnotes/test.md\n\n\nUser query here'; expect(stripCurrentNoteContext(prompt)).toBe('User query here'); }); - it('strips current_note from end of prompt', () => { - const prompt = 'User query here\n\n\nnotes/test.md\n'; + it('strips linked_note from end of prompt', () => { + const prompt = 'User query here\n\n\nnotes/test.md\n'; expect(stripCurrentNoteContext(prompt)).toBe('User query here'); }); }); @@ -87,19 +87,19 @@ describe('stripCurrentNoteContext', () => { it('prefers prefix format when both could match', () => { // This tests the function order: it tries prefix first - const prefixPrompt = '\ntest.md\n\n\nQuery'; + const prefixPrompt = '\ntest.md\n\n\nQuery'; expect(stripCurrentNoteContext(prefixPrompt)).toBe('Query'); }); }); describe('XML_CONTEXT_PATTERN', () => { - it('matches linked_note tag', () => { - const text = 'Query\n\n\ntest.md\n'; + it('matches current_note tag', () => { + const text = 'Query\n\n\ntest.md\n'; expect(XML_CONTEXT_PATTERN.test(text)).toBe(true); }); - it('matches legacy current_note tag', () => { - const text = 'Query\n\n\ntest.md\n'; + it('matches legacy linked_note tag', () => { + const text = 'Query\n\n\ntest.md\n'; expect(XML_CONTEXT_PATTERN.test(text)).toBe(true); }); @@ -129,7 +129,7 @@ describe('XML_CONTEXT_PATTERN', () => { }); it('does not match without double newline separator', () => { - const text = 'Query\n\ntest.md\n'; + const text = 'Query\n\ntest.md\n'; expect(XML_CONTEXT_PATTERN.test(text)).toBe(false); }); @@ -142,7 +142,7 @@ describe('XML_CONTEXT_PATTERN', () => { describe('extractContentBeforeXmlContext', () => { describe('legacy format with tags', () => { it('extracts content from query tags', () => { - const prompt = '\ntest.md\n\n\n\nUser question\n'; + const prompt = '\ntest.md\n\n\n\nUser question\n'; expect(extractContentBeforeXmlContext(prompt)).toBe('User question'); }); @@ -158,13 +158,13 @@ describe('extractContentBeforeXmlContext', () => { }); describe('current format with user content first', () => { - it('extracts content before linked_note tag', () => { - const prompt = 'User query\n\n\ntest.md\n'; + it('extracts content before current_note tag', () => { + const prompt = 'User query\n\n\ntest.md\n'; expect(extractContentBeforeXmlContext(prompt)).toBe('User query'); }); - it('extracts content before legacy current_note tag', () => { - const prompt = 'User query\n\n\ntest.md\n'; + it('extracts content before legacy linked_note tag', () => { + const prompt = 'User query\n\n\ntest.md\n'; expect(extractContentBeforeXmlContext(prompt)).toBe('User query'); }); @@ -184,7 +184,7 @@ describe('extractContentBeforeXmlContext', () => { }); it('handles multiple context tags - extracts before first one', () => { - const prompt = 'Query\n\n\ntest.md\n\n\n\ny\n'; + const prompt = 'Query\n\n\ntest.md\n\n\n\ny\n'; expect(extractContentBeforeXmlContext(prompt)).toBe('Query'); }); @@ -194,7 +194,7 @@ describe('extractContentBeforeXmlContext', () => { }); it('trims whitespace from extracted content', () => { - const prompt = ' spaced query \n\n\ntest.md\n'; + const prompt = ' spaced query \n\n\ntest.md\n'; expect(extractContentBeforeXmlContext(prompt)).toBe('spaced query'); }); }); @@ -217,7 +217,7 @@ describe('extractContentBeforeXmlContext', () => { describe('extractUserDisplayContent', () => { it('extracts display content before XML context tags', () => { - expect(extractUserDisplayContent('Summarize this\n\n\nnotes/today.md\n')) + expect(extractUserDisplayContent('Summarize this\n\n\nnotes/today.md\n')) .toBe('Summarize this'); }); @@ -234,25 +234,25 @@ describe('extractUserDisplayContent', () => { describe('extractUserQuery', () => { describe('with XML context (delegates to extractContentBeforeXmlContext)', () => { it('extracts content from legacy query tags', () => { - const prompt = '\ntest.md\n\n\n\nUser question\n'; + const prompt = '\ntest.md\n\n\n\nUser question\n'; expect(extractUserQuery(prompt)).toBe('User question'); }); it('extracts content before XML context tags', () => { - const prompt = 'User query\n\n\ntest.md\n'; + const prompt = 'User query\n\n\ntest.md\n'; expect(extractUserQuery(prompt)).toBe('User query'); }); }); describe('fallback tag stripping', () => { - it('strips linked_note tags without structured format', () => { - const prompt = 'Query test.md continues'; + it('strips current_note tags without structured format', () => { + const prompt = 'Query test.md continues'; expect(extractUserQuery(prompt)).toBe('Query continues'); }); - it('strips legacy current_note tags without structured format', () => { + it('strips legacy linked_note tags without structured format', () => { // Tag and trailing whitespace are replaced, leaving single space - const prompt = 'Query test.md continues'; + const prompt = 'Query test.md continues'; expect(extractUserQuery(prompt)).toBe('Query continues'); }); @@ -282,7 +282,7 @@ describe('extractUserQuery', () => { }); it('strips multiple tag types', () => { - const prompt = 'a.mdQueryb.md'; + const prompt = 'a.mdQueryb.md'; expect(extractUserQuery(prompt)).toBe('Query'); }); }); diff --git a/tests/unit/qoder/prompt/qoder-turn-encoder.test.ts b/tests/unit/qoder/prompt/qoder-turn-encoder.test.ts index bf1915c..5ecea80 100644 --- a/tests/unit/qoder/prompt/qoder-turn-encoder.test.ts +++ b/tests/unit/qoder/prompt/qoder-turn-encoder.test.ts @@ -63,7 +63,7 @@ describe('encodeQoderTurn', () => { }; const result = encodeQoderTurn(request, mcpManager); - expect(result.persistedContent).toContain(''); + expect(result.persistedContent).toContain(''); expect(result.persistedContent).toContain('notes/test.md'); }); diff --git a/tests/unit/qoder/runtime/qoder-chat-runtime.test.ts b/tests/unit/qoder/runtime/qoder-chat-runtime.test.ts index 2dcfb61..3878654 100644 --- a/tests/unit/qoder/runtime/qoder-chat-runtime.test.ts +++ b/tests/unit/qoder/runtime/qoder-chat-runtime.test.ts @@ -123,7 +123,7 @@ describe('QoderChatRuntime', () => { text: 'explain this', currentNotePath: 'notes/test.md', }); - expect(result.persistedContent).toContain(''); + expect(result.persistedContent).toContain(''); expect(result.persistedContent).toContain('notes/test.md'); }); @@ -170,6 +170,40 @@ describe('QoderChatRuntime', () => { const afterSwitch = service.prepareTurn({ text: 'resumed', externalContextPaths: ['/tmp/a'] }); expect(afterSwitch.persistedContent).toContain(''); }); + + it('should announce the current note only when it changes', () => { + const first = service.prepareTurn({ text: 'hi', currentNotePath: 'notes/a.md' }); + expect(first.persistedContent).toContain(''); + expect(first.persistedContent).toContain('notes/a.md'); + + const unchanged = service.prepareTurn({ text: 'again', currentNotePath: 'notes/a.md' }); + expect(unchanged.persistedContent).not.toContain(''); + + const switched = service.prepareTurn({ text: 'more', currentNotePath: 'notes/b.md' }); + expect(switched.persistedContent).toContain(''); + expect(switched.persistedContent).toContain('notes/b.md'); + }); + + it('should stay silent when the note is closed and report it again when reopened', () => { + service.prepareTurn({ text: 'hi', currentNotePath: 'notes/a.md' }); + + const closed = service.prepareTurn({ text: 'closed' }); + expect(closed.persistedContent).not.toContain(''); + + const reopened = service.prepareTurn({ text: 'reopened', currentNotePath: 'notes/a.md' }); + expect(reopened.persistedContent).toContain(''); + expect(reopened.persistedContent).toContain('notes/a.md'); + }); + + it('should announce the current note again after a session switch', () => { + service.prepareTurn({ text: 'hi', currentNotePath: 'notes/a.md' }); + service.prepareTurn({ text: 'again', currentNotePath: 'notes/a.md' }); + + service.setSessionId('session-with-note'); + + const afterSwitch = service.prepareTurn({ text: 'resumed', currentNotePath: 'notes/a.md' }); + expect(afterSwitch.persistedContent).toContain(''); + }); }); describe('query with PreparedChatTurn', () => { diff --git a/tests/unit/qoder/runtime/session-context.test.ts b/tests/unit/qoder/runtime/session-context.test.ts index 05af9ff..7db9130 100644 --- a/tests/unit/qoder/runtime/session-context.test.ts +++ b/tests/unit/qoder/runtime/session-context.test.ts @@ -728,8 +728,8 @@ describe('session utilities', () => { describe('new format (user content before XML context)', () => { it('avoids duplication when actualPrompt matches last user message', () => { - const prompt = 'Explain this\n\n\ntest.md\n'; - const actualPrompt = 'Explain this\n\n\ntest.md\n'; + const prompt = 'Explain this\n\n\ntest.md\n'; + const actualPrompt = 'Explain this\n\n\ntest.md\n'; const messages: ChatMessage[] = [ { id: 'msg-1', @@ -747,8 +747,8 @@ describe('session utilities', () => { }); it('appends prompt when actualPrompt differs from last user message', () => { - const oldPrompt = 'First question\n\n\nold.md\n'; - const newPrompt = 'Second question\n\n\nnew.md\n'; + const oldPrompt = 'First question\n\n\nold.md\n'; + const newPrompt = 'Second question\n\n\nnew.md\n'; const messages: ChatMessage[] = [ { id: 'msg-1', @@ -785,7 +785,7 @@ describe('session utilities', () => { }); it('extracts user query from content with multiple XML context tags', () => { - const prompt = 'Update code\n\n\ntest.md\n\n\n\nselected\n'; + const prompt = 'Update code\n\n\ntest.md\n\n\n\nselected\n'; const messages: ChatMessage[] = [ { id: 'msg-1', @@ -803,7 +803,7 @@ describe('session utilities', () => { }); it('falls back to extractUserQuery when displayContent is not available', () => { - const prompt = 'Help me\n\n\nfile.md\n'; + const prompt = 'Help me\n\n\nfile.md\n'; const messages: ChatMessage[] = [ { id: 'msg-1',