diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a20f58..9ae03ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,13 @@ version with its date and start a fresh empty `[Unreleased]` above it. - Dropping a vault note or folder into the composer no longer pastes the raw `obsidian://open` URI next to the mention, and the inserted mention chipifies like dropdown insertions. +- The queue's Steer action now tracks the streaming state: it no longer + goes missing on remaining rows after queued messages auto-drain, and no + longer lingers after a turn ends through plan approval paths. +- Vault images dragged into the composer are now inserted as `@path` + mentions just like notes (chipified, no attachment preview); mixed + drags combine note, folder, and image mentions, and only truly + unsupported file types are reported as ignored. ## [1.0.5] - 2026-08-18 diff --git a/src/features/chat/controllers/input-controller.ts b/src/features/chat/controllers/input-controller.ts index 9e4f2db..945c598 100644 --- a/src/features/chat/controllers/input-controller.ts +++ b/src/features/chat/controllers/input-controller.ts @@ -79,6 +79,13 @@ export class InputController { private readonly inputCommands: InputCommandController; private readonly queuedMessages: QueuedMessageController; private activeStreamingAssistantMessage: ChatMessage | null = null; + // Contract: `user_message_start` / `assistant_message_start` boundary + // chunks currently have no producer in the runtime — they are a reserved + // mechanism, so the handlers below are dormant. Steering relies on + // immediate UI-side splicing (spliceRuntimeUserMessage) and does NOT echo + // through this path. If a producer is ever wired up, queued-message + // steering must first register its expected echo here, otherwise the echo + // would splice a second, duplicate bubble for the same message. private pendingRuntimeUserMessages: Array<{ displayContent: string; persistedContent?: string; @@ -233,6 +240,9 @@ export class InputController { state.isStreaming = true; state.cancelRequested = false; state.ignoreUsageUpdates = false; // Allow usage updates for new query + // Re-render the send queue so steer buttons reflect the now-active turn + // (rows rendered during the brief idle gap after process() lost them). + this.updateQueueIndicator(); this.deps.getSubagentManager().resetSpawnedCount(); state.autoScrollEnabled = plugin.settings.enableAutoScroll ?? true; // Reset auto-scroll based on setting const streamGeneration = state.bumpStreamGeneration(); @@ -408,6 +418,9 @@ export class InputController { streamController.hideThinkingIndicator(); state.isStreaming = false; state.cancelRequested = false; + // Re-render the send queue so steer buttons drop now that the turn + // ended (guards the paused/revised paths that skip process()). + this.updateQueueIndicator(); // Capture response duration before resetting state (skip for interrupted responses and compaction) const hasCompactBoundary = finalAssistantMsg.contentBlocks?.some(b => b.type === 'context_compacted'); const hasError = hasErrorContentBlock(finalAssistantMsg); 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 470ce17..e7e196e 100644 --- a/src/features/chat/ui/file-context/file-context-manager.ts +++ b/src/features/chat/ui/file-context/file-context-manager.ts @@ -153,6 +153,7 @@ export class FileContextManager { /** Resets state for a new conversation. */ resetForNewConversation() { this.currentNotePath = null; + this.clearComposerReferences(); this.state.resetForNewConversation(); this.refreshCurrentNoteChip(); } @@ -160,6 +161,7 @@ export class FileContextManager { /** Resets state for loading an existing conversation. */ resetForLoadedConversation(hasMessages: boolean) { this.currentNotePath = null; + this.clearComposerReferences(); this.state.resetForLoadedConversation(hasMessages); this.refreshCurrentNoteChip(); } @@ -367,6 +369,13 @@ export class FileContextManager { this.notifyReferencesChanged(); } + /** Drops all tracked composer references at a conversation boundary. */ + private clearComposerReferences(): void { + if (this.composerReferences.size === 0) return; + this.composerReferences.clear(); + this.notifyReferencesChanged(); + } + /** * Rewrites references under a renamed path: tokens in the input text are * replaced so chips keep pointing at the new location. diff --git a/src/features/chat/ui/image-context.ts b/src/features/chat/ui/image-context.ts index 47a83c8..c002efc 100644 --- a/src/features/chat/ui/image-context.ts +++ b/src/features/chat/ui/image-context.ts @@ -13,6 +13,12 @@ const IMAGE_EXTENSIONS: Record = { '.webp': 'image/webp', }; +/** Maps supported image filenames to their media types. */ +export function imageMediaTypeForFilename(filename: string): ImageMediaType | null { + const ext = path.extname(filename).toLowerCase(); + return IMAGE_EXTENSIONS[ext] || null; +} + export interface ImageContextCallbacks { onImagesChanged: () => void; } @@ -184,12 +190,7 @@ export class ImageContextManager { } private isImageFile(file: File): boolean { - return file.type.startsWith('image/') && this.getMediaType(file.name) !== null; - } - - private getMediaType(filename: string): ImageMediaType | null { - const ext = path.extname(filename).toLowerCase(); - return IMAGE_EXTENSIONS[ext] || null; + return file.type.startsWith('image/') && imageMediaTypeForFilename(file.name) !== null; } private async addImageFromFile(file: File, source: 'paste' | 'drop'): Promise { @@ -203,7 +204,7 @@ export class ImageContextManager { return false; } - const mediaType = this.getMediaType(file.name) || (file.type as ImageMediaType); + const mediaType = imageMediaTypeForFilename(file.name) || (file.type as ImageMediaType); if (!mediaType) { this.notifyImageError('Unsupported image type.'); return false; diff --git a/src/features/chat/ui/vault-drop.ts b/src/features/chat/ui/vault-drop.ts index d84bf03..f24d63c 100644 --- a/src/features/chat/ui/vault-drop.ts +++ b/src/features/chat/ui/vault-drop.ts @@ -1,9 +1,11 @@ import type { App } from 'obsidian'; -import { TFile, TFolder } from 'obsidian'; +import { Notice, TFile, TFolder } from 'obsidian'; import { t } from '@/i18n/i18n'; import type { MentionInsertReference } from '@/shared/mention/types'; +import { imageMediaTypeForFilename } from './image-context'; + /** A vault file or folder reference extracted from an Obsidian drag payload. */ export interface VaultDropReference { path: string; @@ -21,7 +23,8 @@ interface DragManagerHost { /** * Accepts Obsidian file-explorer drags on the composer and inserts them as - * `@path` / `@path/ ` mention tokens at the caret position. + * `@path` / `@path/ ` mention tokens at the caret position. Notes, folders, + * and images are accepted; anything else is ignored. * * Must be attached before ImageContextManager so vault drags can be claimed * via stopImmediatePropagation before the image drop handlers run. The drop @@ -55,20 +58,20 @@ export class VaultDropController { } private readonly handleDragEnter = (event: DragEvent): void => { - if (this.getDraggedReferences().length === 0) return; + if (!this.hasClaimableDrag()) return; event.preventDefault(); event.stopImmediatePropagation(); this.dropOverlayEl.addClass('visible'); }; private readonly handleDragOver = (event: DragEvent): void => { - if (this.getDraggedReferences().length === 0) return; + if (!this.hasClaimableDrag()) return; event.preventDefault(); event.stopImmediatePropagation(); }; private readonly handleDragLeave = (event: DragEvent): void => { - if (this.getDraggedReferences().length === 0) return; + if (!this.hasClaimableDrag()) return; event.stopImmediatePropagation(); const rect = this.inputWrapperEl.getBoundingClientRect(); @@ -83,7 +86,7 @@ export class VaultDropController { }; private readonly handleDrop = (event: DragEvent): void => { - const references = this.getDraggedReferences(); + const { references, ignoredCount } = this.collectDragged(); if (references.length === 0) return; event.preventDefault(); event.stopImmediatePropagation(); @@ -101,9 +104,18 @@ export class VaultDropController { } this.inputEl.dispatchEvent(new Event('input', { bubbles: true })); } + // Mixed drags are claimed wholesale, so surface the items we dropped. + if (ignoredCount > 0) { + new Notice(t('chat.drop.ignored', { count: ignoredCount })); + } this.inputEl.focus(); }; + private hasClaimableDrag(): boolean { + const { references } = this.collectDragged(); + return references.length > 0; + }; + private getDraggedItems(): unknown[] { const host = this.app as unknown as DragManagerHost; const dragManager = host.dragManager; @@ -123,21 +135,28 @@ export class VaultDropController { return typeof value === 'object' && value !== null; } - private getDraggedReferences(): VaultDropReference[] { + private collectDragged(): { references: VaultDropReference[]; ignoredCount: number } { const references: VaultDropReference[] = []; const seenPaths = new Set(); + let ignoredCount = 0; for (const item of this.getDraggedItems()) { const reference = item instanceof TFolder && item.path !== '/' && item.path !== '' ? { path: item.path, kind: 'folder' as const } - : item instanceof TFile && item.extension.toLowerCase() === 'md' + : item instanceof TFile && + (item.extension.toLowerCase() === 'md' || + imageMediaTypeForFilename(item.name) !== null) ? { path: item.path, kind: 'file' as const } : null; - if (!reference || seenPaths.has(reference.path)) continue; + if (!reference) { + ignoredCount += 1; + continue; + } + if (seenPaths.has(reference.path)) continue; seenPaths.add(reference.path); references.push(reference); } - return references; + return { references, ignoredCount }; } private mentionToken(reference: VaultDropReference): string { diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 8110264..5658cdf 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -113,7 +113,8 @@ "steerUnavailable": "Steuern ist gerade nicht möglich. Die Nachricht bleibt in der Warteschlange." }, "drop": { - "context": "Notizen oder Ordner hier ablegen, um sie als Kontext hinzuzufügen" + "context": "Notizen, Ordner oder Bilder hier ablegen, um sie als Kontext hinzuzufügen", + "ignored": "{count} nicht unterstützte Dateien ignoriert; es können nur Notizen, Ordner und Bilder abgelegt werden" } }, "settings": { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 2c9e91a..43c5563 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -113,7 +113,8 @@ "steerUnavailable": "Can't steer right now. The message stays in the queue." }, "drop": { - "context": "Drop notes or folders here to add as context" + "context": "Drop notes, folders, or images here to add as context", + "ignored": "Ignored {count} unsupported file(s); only notes, folders, and images can be dropped" } }, "settings": { diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 8c6124a..dec3548 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -113,7 +113,8 @@ "steerUnavailable": "Ahora no se puede redirigir. El mensaje permanece en la cola." }, "drop": { - "context": "Arrastra notas o carpetas aquí para añadirlas como contexto" + "context": "Arrastra notas, carpetas o imágenes aquí para añadirlas como contexto", + "ignored": "Se ignoraron {count} archivos no compatibles; solo se pueden soltar notas, carpetas e imágenes" } }, "settings": { diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 5eed4b6..92caffe 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -113,7 +113,8 @@ "steerUnavailable": "Impossible d'orienter pour le moment. Le message reste dans la file d'attente." }, "drop": { - "context": "Déposez des notes ou des dossiers ici pour les ajouter comme contexte" + "context": "Déposez des notes, des dossiers ou des images ici pour les ajouter comme contexte", + "ignored": "{count} fichiers non pris en charge ignorés ; seules les notes, dossiers et images peuvent être déposés" } }, "settings": { diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 49f063a..e42ce3d 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -113,7 +113,8 @@ "steerUnavailable": "今は割り込みできません。メッセージはキューに保持されます。" }, "drop": { - "context": "ノートやフォルダをここにドロップしてコンテキストに追加" + "context": "ノート、フォルダ、画像をここにドロップしてコンテキストに追加", + "ignored": "サポートされていない {count} 件のファイルを無視しました。ノート、フォルダ、画像のみドロップできます" } }, "settings": { diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 701cd57..042c837 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -113,7 +113,8 @@ "steerUnavailable": "지금은 끼어들 수 없습니다. 메시지는 대기열에 유지됩니다." }, "drop": { - "context": "노트나 폴더를 여기에 끌어다 놓아 컨텍스트로 추가" + "context": "노트, 폴더, 이미지를 여기에 끌어다 놓아 컨텍스트로 추가", + "ignored": "지원되지 않는 파일 {count}개를 무시했습니다. 노트, 폴더, 이미지만 끌어다 놓을 수 있습니다" } }, "settings": { diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 0004a5a..c059b52 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -113,7 +113,8 @@ "steerUnavailable": "Não é possível redirecionar agora. A mensagem permanece na fila." }, "drop": { - "context": "Solte notas ou pastas aqui para adicioná-las como contexto" + "context": "Solte notas, pastas ou imagens aqui para adicioná-las como contexto", + "ignored": "{count} arquivos não suportados foram ignorados; apenas notas, pastas e imagens podem ser soltas" } }, "settings": { diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 4781028..6bdcfb3 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -113,7 +113,8 @@ "steerUnavailable": "Сейчас нельзя вклиниться. Сообщение останется в очереди." }, "drop": { - "context": "Перетащите заметки или папки сюда, чтобы добавить их как контекст" + "context": "Перетащите заметки, папки или изображения сюда, чтобы добавить их как контекст", + "ignored": "Пропущено {count} неподдерживаемых файлов; перетаскивать можно только заметки, папки и изображения" } }, "settings": { diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index e454311..8f33010 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -113,7 +113,8 @@ "steerUnavailable": "当前无法引导,消息仍保留在队列中。" }, "drop": { - "context": "拖拽笔记或文件夹到此处,添加为上下文" + "context": "拖拽笔记、文件夹或图片到此处,添加为上下文", + "ignored": "已忽略 {count} 个不支持的文件,仅支持拖入笔记、文件夹和图片" } }, "settings": { diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 52b37a2..be39e7c 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -113,7 +113,8 @@ "steerUnavailable": "目前無法引導,訊息仍保留在佇列中。" }, "drop": { - "context": "拖曳筆記或資料夾到此處,新增為上下文" + "context": "拖曳筆記、資料夾或圖片到此處,新增為上下文", + "ignored": "已忽略 {count} 個不支援的檔案,僅支援拖入筆記、資料夾與圖片" } }, "settings": { diff --git a/src/i18n/types.ts b/src/i18n/types.ts index 9c1b2e9..b0f2a98 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -132,6 +132,7 @@ export type TranslationKey = // Vault drag & drop into composer | 'chat.drop.context' + | 'chat.drop.ignored' // Settings - Section Headings | 'settings.title' diff --git a/tests/unit/features/chat/controllers/queued-message-controller.test.ts b/tests/unit/features/chat/controllers/queued-message-controller.test.ts index 282e9aa..8e33de1 100644 --- a/tests/unit/features/chat/controllers/queued-message-controller.test.ts +++ b/tests/unit/features/chat/controllers/queued-message-controller.test.ts @@ -222,6 +222,23 @@ describe('QueuedMessageController', () => { expect(plain.state.queueIndicatorEl!.querySelector('.qoderian-queue-row-steer')).toBeNull(); }); + it('picks up steer availability changes on re-render', () => { + let steerable = false; + const { controller, state } = createController({ canSteerQueuedTurn: () => steerable }); + controller.enqueue('first', turnRequest('first')); + expect(state.queueIndicatorEl!.querySelector('.qoderian-queue-row-steer')).toBeNull(); + + // Streaming started: a plain re-render must surface the action. + steerable = true; + controller.updateIndicator(); + expect(state.queueIndicatorEl!.querySelector('.qoderian-queue-row-steer')).not.toBeNull(); + + // Streaming ended: the next re-render must drop it again. + steerable = false; + controller.updateIndicator(); + expect(state.queueIndicatorEl!.querySelector('.qoderian-queue-row-steer')).toBeNull(); + }); + it('steerToTurn removes the item when the runtime accepts it', () => { const steerQueuedTurn = jest.fn().mockReturnValue(true); const { controller, state, sendQueuedTurn } = createController({ 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 baa2fa2..fb8f4cc 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 @@ -501,6 +501,27 @@ describe('FileContextManager', () => { expect(manager.isSessionStarted()).toBe(false); manager.destroy(); }); + + it('clears tracked composer references at conversation boundaries', () => { + const app = createMockApp(); + const onReferencesChanged = jest.fn(); + const manager = new FileContextManager( + app, containerEl as any, inputEl, { ...createMockCallbacks(), onReferencesChanged } + ); + + manager.registerComposerReference({ token: '@a.md', path: 'a.md', kind: 'file' }); + expect(onReferencesChanged).toHaveBeenLastCalledWith( + [expect.objectContaining({ token: '@a.md' })], + ); + + manager.resetForNewConversation(); + expect(onReferencesChanged).toHaveBeenLastCalledWith([]); + + manager.registerComposerReference({ token: '@b.md', path: 'b.md', kind: 'file' }); + manager.resetForLoadedConversation(true); + expect(onReferencesChanged).toHaveBeenLastCalledWith([]); + manager.destroy(); + }); }); describe('handleFileOpen', () => { diff --git a/tests/unit/features/chat/ui/image-context.test.ts b/tests/unit/features/chat/ui/image-context.test.ts index 40adb8b..5bfeb5d 100644 --- a/tests/unit/features/chat/ui/image-context.test.ts +++ b/tests/unit/features/chat/ui/image-context.test.ts @@ -2,7 +2,7 @@ import { createMockEl } from '@test/helpers/mock-element'; import { Notice } from 'obsidian'; import type { ImageAttachment } from '@/core/types'; -import { ImageContextManager } from '@/features/chat/ui/image-context'; +import { ImageContextManager, imageMediaTypeForFilename } from '@/features/chat/ui/image-context'; jest.mock('obsidian', () => ({ Notice: jest.fn(), @@ -246,41 +246,41 @@ describe('ImageContextManager - Private Helpers', () => { }); }); - describe('getMediaType', () => { + describe('imageMediaTypeForFilename', () => { it('should return correct media type for .jpg', () => { - expect(manager['getMediaType']('photo.jpg')).toBe('image/jpeg'); + expect(imageMediaTypeForFilename('photo.jpg')).toBe('image/jpeg'); }); it('should return correct media type for .jpeg', () => { - expect(manager['getMediaType']('photo.jpeg')).toBe('image/jpeg'); + expect(imageMediaTypeForFilename('photo.jpeg')).toBe('image/jpeg'); }); it('should return correct media type for .png', () => { - expect(manager['getMediaType']('image.png')).toBe('image/png'); + expect(imageMediaTypeForFilename('image.png')).toBe('image/png'); }); it('should return correct media type for .gif', () => { - expect(manager['getMediaType']('animation.gif')).toBe('image/gif'); + expect(imageMediaTypeForFilename('animation.gif')).toBe('image/gif'); }); it('should return correct media type for .webp', () => { - expect(manager['getMediaType']('photo.webp')).toBe('image/webp'); + expect(imageMediaTypeForFilename('photo.webp')).toBe('image/webp'); }); it('should return null for unsupported extension', () => { - expect(manager['getMediaType']('document.pdf')).toBeNull(); + expect(imageMediaTypeForFilename('document.pdf')).toBeNull(); }); it('should return null for no extension', () => { - expect(manager['getMediaType']('noextension')).toBeNull(); + expect(imageMediaTypeForFilename('noextension')).toBeNull(); }); it('should handle uppercase extensions', () => { - expect(manager['getMediaType']('PHOTO.JPG')).toBe('image/jpeg'); + expect(imageMediaTypeForFilename('PHOTO.JPG')).toBe('image/jpeg'); }); it('should handle mixed case extensions', () => { - expect(manager['getMediaType']('image.Png')).toBe('image/png'); + expect(imageMediaTypeForFilename('image.Png')).toBe('image/png'); }); }); @@ -372,7 +372,7 @@ describe('ImageContextManager - Private Helpers', () => { }); it('should add valid image file and invoke callback', async () => { - const mockBuffer = new ArrayBuffer(4); + const mockBuffer = new ArrayBuffer(1024); const file = { name: 'test.png', type: 'image/png', diff --git a/tests/unit/features/chat/ui/vault-drop.test.ts b/tests/unit/features/chat/ui/vault-drop.test.ts index 9c994b4..a5dc195 100644 --- a/tests/unit/features/chat/ui/vault-drop.test.ts +++ b/tests/unit/features/chat/ui/vault-drop.test.ts @@ -2,13 +2,14 @@ * @jest-environment jsdom */ import { createMockEl } from '@test/helpers/mock-element'; -import { TFile, TFolder } from 'obsidian'; +import { Notice, TFile, TFolder } from 'obsidian'; import { VaultDropController } from '@/features/chat/ui/vault-drop'; function makeFile(path: string): any { const file = new (TFile as unknown as new () => Record)(); file.path = path; + file.name = path.split('/').pop() ?? path; file.extension = path.split('.').pop() ?? ''; return file; } @@ -69,6 +70,7 @@ describe('VaultDropController', () => { let inputEl: any; beforeEach(() => { + (Notice as unknown as jest.Mock).mockClear(); wrapper = createMockEl(); wrapper.getBoundingClientRect = () => ({ top: 0, @@ -115,11 +117,11 @@ describe('VaultDropController', () => { expect(inputEl.value).toBe('@a.md @dir/ @b.md '); }); - it('skips non-markdown files, root folder, and duplicates', () => { + it('skips unsupported files, root folder, and duplicates', () => { const app = createApp({ type: 'files', files: [ - makeFile('image.png'), + makeFile('report.pdf'), makeFolder('/'), makeFile('a.md'), makeFile('a.md'), @@ -132,6 +134,62 @@ describe('VaultDropController', () => { expect(inputEl.value).toBe('@a.md '); }); + it('inserts an image mention like a regular file', () => { + const app = createApp({ type: 'files', files: [makeFile('pics/logo.png')] }); + const onInsertReference = jest.fn(); + new VaultDropController(app, wrapper, inputEl, { onInsertReference }); + + const event = createDropEvent(); + wrapper.dispatchEvent('drop', event); + + expect(inputEl.value).toBe('@pics/logo.png '); + expect(event.preventDefault).toHaveBeenCalled(); + expect(onInsertReference).toHaveBeenCalledWith({ + token: '@pics/logo.png', + path: 'pics/logo.png', + kind: 'file', + }); + expect(Notice).not.toHaveBeenCalled(); + }); + + it('combines note and image mentions for mixed drags', () => { + const app = createApp({ + type: 'files', + files: [makeFile('a.md'), makeFile('logo.png')], + }); + new VaultDropController(app, wrapper, inputEl); + + wrapper.dispatchEvent('drop', createDropEvent()); + + expect(inputEl.value).toBe('@a.md @logo.png '); + expect(Notice).not.toHaveBeenCalled(); + }); + + it('notifies about ignored unsupported items in mixed drags', () => { + const app = createApp({ + type: 'files', + files: [makeFile('a.md'), makeFile('logo.pdf')], + }); + new VaultDropController(app, wrapper, inputEl); + + wrapper.dispatchEvent('drop', createDropEvent()); + + expect(inputEl.value).toBe('@a.md '); + expect(Notice).toHaveBeenCalledWith(expect.stringContaining('1')); + }); + + it('does not notify when every dragged item is a note or folder', () => { + const app = createApp({ + type: 'files', + files: [makeFile('a.md'), makeFolder('dir')], + }); + new VaultDropController(app, wrapper, inputEl); + + wrapper.dispatchEvent('drop', createDropEvent()); + + expect(Notice).not.toHaveBeenCalled(); + }); + it('does not insert a mention that already exists in the input', () => { inputEl.value = 'see @notes/idea.md for details'; const app = createApp({ file: makeFile('notes/idea.md') }); @@ -234,6 +292,18 @@ describe('VaultDropController', () => { expect(overlay.className).not.toContain('visible'); expect(event.stopImmediatePropagation).not.toHaveBeenCalled(); }); + + it('shows the overlay for image-only vault drags', () => { + const app = createApp({ type: 'files', files: [makeFile('logo.png')] }); + new VaultDropController(app, wrapper, inputEl); + + const event = createDragEvent('dragenter'); + wrapper.dispatchEvent('dragenter', event); + + const overlay = findOverlay(wrapper); + expect(overlay.className).toContain('visible'); + expect(event.stopImmediatePropagation).toHaveBeenCalled(); + }); }); describe('destroy', () => {