From f0be5b30d366477fb53baaccac6d145b82528d99 Mon Sep 17 00:00:00 2001 From: liuxuezhuo Date: Tue, 25 Aug 2026 09:25:04 +0800 Subject: [PATCH 1/3] fix(chat): steer button refresh, mixed-drag notice, reference cleanup - Re-render the send queue when isStreaming flips in either direction so the Steer action tracks the live turn: rows rendered during the idle gap after process() kept losing the button once the next queued turn started streaming, and plan-approval exits left stale buttons behind - Mixed vault drags (notes plus images/other files) are claimed wholesale, so show a notice counting the ignored non-note items instead of dropping them silently; collectDragged now reports both references and the ignored count - Clear tracked composer references at conversation boundaries (new/loaded) so the registry no longer accumulates phantom entries - Document the dormant user_message_start/assistant_message_start contract: steering splices locally and never echoes through it; a future producer must register expected echoes first Co-authored-by: QoderAI (Qwen 3.8 Max) --- CHANGELOG.md | 6 ++++ .../chat/controllers/input-controller.ts | 13 +++++++++ .../ui/file-context/file-context-manager.ts | 9 ++++++ src/features/chat/ui/vault-drop.ts | 25 +++++++++++------ src/i18n/locales/de.json | 3 +- src/i18n/locales/en.json | 3 +- src/i18n/locales/es.json | 3 +- src/i18n/locales/fr.json | 3 +- src/i18n/locales/ja.json | 3 +- src/i18n/locales/ko.json | 3 +- src/i18n/locales/pt.json | 3 +- src/i18n/locales/ru.json | 3 +- src/i18n/locales/zh-CN.json | 3 +- src/i18n/locales/zh-TW.json | 3 +- src/i18n/types.ts | 1 + .../queued-message-controller.test.ts | 17 +++++++++++ .../file-context/file-context-manager.test.ts | 21 ++++++++++++++ .../unit/features/chat/ui/vault-drop.test.ts | 28 ++++++++++++++++++- 18 files changed, 131 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a20f58..f5d38a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,12 @@ 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. +- Mixed drags into the composer (notes together with images or other + files) now show a notice counting the ignored non-note items instead of + dropping them silently. ## [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/vault-drop.ts b/src/features/chat/ui/vault-drop.ts index d84bf03..cddf253 100644 --- a/src/features/chat/ui/vault-drop.ts +++ b/src/features/chat/ui/vault-drop.ts @@ -1,5 +1,5 @@ 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'; @@ -55,20 +55,20 @@ export class VaultDropController { } private readonly handleDragEnter = (event: DragEvent): void => { - if (this.getDraggedReferences().length === 0) return; + if (this.collectDragged().references.length === 0) return; event.preventDefault(); event.stopImmediatePropagation(); this.dropOverlayEl.addClass('visible'); }; private readonly handleDragOver = (event: DragEvent): void => { - if (this.getDraggedReferences().length === 0) return; + if (this.collectDragged().references.length === 0) return; event.preventDefault(); event.stopImmediatePropagation(); }; private readonly handleDragLeave = (event: DragEvent): void => { - if (this.getDraggedReferences().length === 0) return; + if (this.collectDragged().references.length === 0) return; event.stopImmediatePropagation(); const rect = this.inputWrapperEl.getBoundingClientRect(); @@ -83,7 +83,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,6 +101,10 @@ 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(); }; @@ -123,9 +127,10 @@ 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 !== '' @@ -133,11 +138,15 @@ export class VaultDropController { : item instanceof TFile && item.extension.toLowerCase() === 'md' ? { 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..70496d9 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 oder Ordner hier ablegen, um sie als Kontext hinzuzufügen", + "ignored": "{count} Dateien ignoriert, die keine Notizen sind; es können nur Notizen und Ordner abgelegt werden" } }, "settings": { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 2c9e91a..2cd4164 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 or folders here to add as context", + "ignored": "Ignored {count} non-note file(s); only notes and folders can be dropped" } }, "settings": { diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 8c6124a..fb6d2da 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 o carpetas aquí para añadirlas como contexto", + "ignored": "Se ignoraron {count} archivos que no son notas; solo se pueden soltar notas y carpetas" } }, "settings": { diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 5eed4b6..5287f13 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 ou des dossiers ici pour les ajouter comme contexte", + "ignored": "{count} fichiers non-notes ignorés ; seules les notes et dossiers peuvent être déposés" } }, "settings": { diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 49f063a..fa291d2 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..39fcca4 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..a06f5c6 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 ou pastas aqui para adicioná-las como contexto", + "ignored": "{count} arquivos que não são notas foram ignorados; apenas notas e pastas podem ser soltas" } }, "settings": { diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 4781028..3619dc6 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..9eb2b8a 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..033a57a 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/vault-drop.test.ts b/tests/unit/features/chat/ui/vault-drop.test.ts index 9c994b4..467bb51 100644 --- a/tests/unit/features/chat/ui/vault-drop.test.ts +++ b/tests/unit/features/chat/ui/vault-drop.test.ts @@ -2,7 +2,7 @@ * @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'; @@ -69,6 +69,7 @@ describe('VaultDropController', () => { let inputEl: any; beforeEach(() => { + (Notice as unknown as jest.Mock).mockClear(); wrapper = createMockEl(); wrapper.getBoundingClientRect = () => ({ top: 0, @@ -132,6 +133,31 @@ describe('VaultDropController', () => { expect(inputEl.value).toBe('@a.md '); }); + it('notifies about ignored non-note items in 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 '); + 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') }); From 6b637ab25aa77f089110eaacc807dfab10e53349 Mon Sep 17 00:00:00 2001 From: liuxuezhuo Date: Tue, 25 Aug 2026 13:03:24 +0800 Subject: [PATCH 2/3] fix(chat): route vault image drags into the attachment pipeline Vault file-explorer drags carry their payload in app.dragManager, not dataTransfer.files, so ImageContextManager (which only reads OS-level file drops) never saw them: dragging a vault image into the composer gave no overlay feedback and the default drop action pasted the raw obsidian:// URI into the input. - VaultDropController now classifies dragged TFiles into notes/folders, images, and unsupported; images are claimed (overlay + preventDefault) and routed through a new onDropImages option - tab.ts wires onDropImages to ImageContextManager.attachImageBuffer, which reads the vault bytes and mirrors the paste pipeline (preview chip, size limit, image bubble on send) - Mixed drags combine mentions and attachments; only truly unsupported types count toward the ignored notice, and the drop overlay copy now mentions images in all locales Co-authored-by: QoderAI (Qwen 3.8 Max) --- CHANGELOG.md | 7 +- src/features/chat/tabs/tab.ts | 12 +++- src/features/chat/ui/image-context.ts | 55 ++++++++------ src/features/chat/ui/vault-drop.ts | 44 +++++++++--- src/i18n/locales/de.json | 4 +- src/i18n/locales/en.json | 4 +- src/i18n/locales/es.json | 4 +- src/i18n/locales/fr.json | 4 +- src/i18n/locales/ja.json | 4 +- src/i18n/locales/ko.json | 4 +- src/i18n/locales/pt.json | 4 +- src/i18n/locales/ru.json | 4 +- src/i18n/locales/zh-CN.json | 4 +- src/i18n/locales/zh-TW.json | 4 +- .../features/chat/ui/image-context.test.ts | 71 ++++++++++++++----- .../unit/features/chat/ui/vault-drop.test.ts | 49 +++++++++++-- 16 files changed, 201 insertions(+), 77 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f5d38a5..25fbf16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,9 +51,10 @@ version with its date and start a fresh empty `[Unreleased]` above it. - 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. -- Mixed drags into the composer (notes together with images or other - files) now show a notice counting the ignored non-note items instead of - dropping them silently. +- Vault images dragged into the composer now attach like pasted images, + with a preview chip and an image bubble on send; mixed drags combine + mentions and attachments, and only truly unsupported file types are + reported as ignored. ## [1.0.5] - 2026-08-18 diff --git a/src/features/chat/tabs/tab.ts b/src/features/chat/tabs/tab.ts index a67b878..8e7c2a3 100644 --- a/src/features/chat/tabs/tab.ts +++ b/src/features/chat/tabs/tab.ts @@ -32,7 +32,7 @@ import { BangBashModeManager as BangBashModeManagerClass } from '../ui/bang-bash import { ComposerBridge } from '../ui/composer/composer-bridge'; import { ComposerActionButton } from '../ui/composer-action-button'; import { FileContextManager } from '../ui/file-context/file-context-manager'; -import { ImageContextManager } from '../ui/image-context'; +import { ImageContextManager, imageMediaTypeForFilename } from '../ui/image-context'; import { createInputToolbar } from '../ui/input-toolbar'; import { InstructionModeManager as InstructionModeManagerClass } from '../ui/instruction-mode-manager'; import { NavigationSidebar } from '../ui/navigation-sidebar'; @@ -295,6 +295,16 @@ function initializeContextManagers(tab: TabData, plugin: QoderianPlugin): void { onInsertReference: (reference) => { tab.ui.fileContextManager?.registerComposerReference(reference); }, + onDropImages: (files) => { + void (async (): Promise => { + for (const file of files) { + const mediaType = imageMediaTypeForFilename(file.name); + if (!mediaType) continue; + const buffer = await app.vault.readBinary(file); + await tab.ui.imageContextManager?.attachImageBuffer(file.name, mediaType, buffer, 'drop'); + } + })(); + }, }); // Image context manager - drag/drop uses inputContainerEl, preview in contextRowEl diff --git a/src/features/chat/ui/image-context.ts b/src/features/chat/ui/image-context.ts index 47a83c8..934e3d8 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,40 +190,38 @@ 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 { + /** + * Attaches an image from raw bytes (vault drags). Mirrors the paste + * pipeline so dropped vault images preview and send like pasted ones. + */ + async attachImageBuffer( + name: string, + mediaType: ImageMediaType, + buffer: ArrayBuffer, + source: 'paste' | 'drop' = 'drop', + ): Promise { if (!this.enabled) { new Notice('Image attachments are not supported by this Qoder runtime.'); return false; } - if (file.size > MAX_IMAGE_SIZE) { + if (buffer.byteLength > MAX_IMAGE_SIZE) { this.notifyImageError(`Image exceeds ${this.formatSize(MAX_IMAGE_SIZE)} limit.`); return false; } - const mediaType = this.getMediaType(file.name) || (file.type as ImageMediaType); - if (!mediaType) { - this.notifyImageError('Unsupported image type.'); - return false; - } - try { - const base64 = await this.fileToBase64(file); + const base64 = Buffer.from(buffer).toString('base64'); const attachment: ImageAttachment = { id: this.generateId(), - name: file.name || `image-${Date.now()}.${mediaType.split('/')[1]}`, + name: name || `image-${Date.now()}.${mediaType.split('/')[1]}`, mediaType, data: base64, - size: file.size, + size: buffer.byteLength, source, }; @@ -231,10 +235,19 @@ export class ImageContextManager { } } - private async fileToBase64(file: File): Promise { - const arrayBuffer = await file.arrayBuffer(); - const buffer = Buffer.from(arrayBuffer); - return buffer.toString('base64'); + private async addImageFromFile(file: File, source: 'paste' | 'drop'): Promise { + const mediaType = imageMediaTypeForFilename(file.name) || (file.type as ImageMediaType); + if (!mediaType) { + this.notifyImageError('Unsupported image type.'); + return false; + } + + try { + return await this.attachImageBuffer(file.name, mediaType, await file.arrayBuffer(), source); + } catch (error) { + this.notifyImageError('Failed to attach image.', error); + return false; + } } // ============================================ diff --git a/src/features/chat/ui/vault-drop.ts b/src/features/chat/ui/vault-drop.ts index cddf253..ac95f50 100644 --- a/src/features/chat/ui/vault-drop.ts +++ b/src/features/chat/ui/vault-drop.ts @@ -4,6 +4,8 @@ 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; @@ -13,6 +15,8 @@ export interface VaultDropReference { export interface VaultDropOptions { /** Called for every inserted reference so consumers can chipify it. */ onInsertReference?: (reference: MentionInsertReference) => void; + /** Called with vault image files so consumers can attach them. */ + onDropImages?: (files: TFile[]) => void; } interface DragManagerHost { @@ -20,8 +24,9 @@ interface DragManagerHost { } /** - * Accepts Obsidian file-explorer drags on the composer and inserts them as - * `@path` / `@path/ ` mention tokens at the caret position. + * Accepts Obsidian file-explorer drags on the composer: notes and folders + * are inserted as `@path` / `@path/ ` mention tokens at the caret, vault + * images are routed to the image attachment pipeline. * * Must be attached before ImageContextManager so vault drags can be claimed * via stopImmediatePropagation before the image drop handlers run. The drop @@ -31,6 +36,7 @@ interface DragManagerHost { export class VaultDropController { private readonly dropOverlayEl: HTMLElement; private readonly onInsertReference?: (reference: MentionInsertReference) => void; + private readonly onDropImages?: (files: TFile[]) => void; constructor( private readonly app: App, @@ -39,6 +45,7 @@ export class VaultDropController { options: VaultDropOptions = {}, ) { this.onInsertReference = options.onInsertReference; + this.onDropImages = options.onDropImages; this.dropOverlayEl = this.createDropOverlay(); this.inputWrapperEl.addEventListener('dragenter', this.handleDragEnter); this.inputWrapperEl.addEventListener('dragover', this.handleDragOver); @@ -55,20 +62,20 @@ export class VaultDropController { } private readonly handleDragEnter = (event: DragEvent): void => { - if (this.collectDragged().references.length === 0) return; + if (!this.hasClaimableDrag()) return; event.preventDefault(); event.stopImmediatePropagation(); this.dropOverlayEl.addClass('visible'); }; private readonly handleDragOver = (event: DragEvent): void => { - if (this.collectDragged().references.length === 0) return; + if (!this.hasClaimableDrag()) return; event.preventDefault(); event.stopImmediatePropagation(); }; private readonly handleDragLeave = (event: DragEvent): void => { - if (this.collectDragged().references.length === 0) return; + if (!this.hasClaimableDrag()) return; event.stopImmediatePropagation(); const rect = this.inputWrapperEl.getBoundingClientRect(); @@ -83,8 +90,8 @@ export class VaultDropController { }; private readonly handleDrop = (event: DragEvent): void => { - const { references, ignoredCount } = this.collectDragged(); - if (references.length === 0) return; + const { references, imageFiles, ignoredCount } = this.collectDragged(); + if (references.length === 0 && imageFiles.length === 0) return; event.preventDefault(); event.stopImmediatePropagation(); this.dropOverlayEl.removeClass('visible'); @@ -101,6 +108,9 @@ export class VaultDropController { } this.inputEl.dispatchEvent(new Event('input', { bubbles: true })); } + if (imageFiles.length > 0) { + this.onDropImages?.(imageFiles); + } // Mixed drags are claimed wholesale, so surface the items we dropped. if (ignoredCount > 0) { new Notice(t('chat.drop.ignored', { count: ignoredCount })); @@ -108,6 +118,11 @@ export class VaultDropController { this.inputEl.focus(); }; + private hasClaimableDrag(): boolean { + const { references, imageFiles } = this.collectDragged(); + return references.length > 0 || imageFiles.length > 0; + }; + private getDraggedItems(): unknown[] { const host = this.app as unknown as DragManagerHost; const dragManager = host.dragManager; @@ -127,11 +142,22 @@ export class VaultDropController { return typeof value === 'object' && value !== null; } - private collectDragged(): { references: VaultDropReference[]; ignoredCount: number } { + private collectDragged(): { + references: VaultDropReference[]; + imageFiles: TFile[]; + ignoredCount: number; + } { const references: VaultDropReference[] = []; + const imageFiles: TFile[] = []; const seenPaths = new Set(); let ignoredCount = 0; for (const item of this.getDraggedItems()) { + if (item instanceof TFile && imageMediaTypeForFilename(item.name) !== null) { + if (seenPaths.has(item.path)) continue; + seenPaths.add(item.path); + imageFiles.push(item); + continue; + } const reference = item instanceof TFolder && item.path !== '/' && item.path !== '' ? { path: item.path, kind: 'folder' as const } @@ -146,7 +172,7 @@ export class VaultDropController { seenPaths.add(reference.path); references.push(reference); } - return { references, ignoredCount }; + return { references, imageFiles, ignoredCount }; } private mentionToken(reference: VaultDropReference): string { diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 70496d9..5658cdf 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -113,8 +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", - "ignored": "{count} Dateien ignoriert, die keine Notizen sind; es können nur Notizen und Ordner abgelegt werden" + "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 2cd4164..43c5563 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -113,8 +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", - "ignored": "Ignored {count} non-note file(s); only notes and folders can be dropped" + "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 fb6d2da..dec3548 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -113,8 +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", - "ignored": "Se ignoraron {count} archivos que no son notas; solo se pueden soltar notas y carpetas" + "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 5287f13..92caffe 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -113,8 +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", - "ignored": "{count} fichiers non-notes ignorés ; seules les notes et dossiers peuvent être déposés" + "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 fa291d2..e42ce3d 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -113,8 +113,8 @@ "steerUnavailable": "今は割り込みできません。メッセージはキューに保持されます。" }, "drop": { - "context": "ノートやフォルダをここにドロップしてコンテキストに追加", - "ignored": "ノート以外の {count} 件のファイルを無視しました。ノートとフォルダのみドロップできます" + "context": "ノート、フォルダ、画像をここにドロップしてコンテキストに追加", + "ignored": "サポートされていない {count} 件のファイルを無視しました。ノート、フォルダ、画像のみドロップできます" } }, "settings": { diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 39fcca4..042c837 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -113,8 +113,8 @@ "steerUnavailable": "지금은 끼어들 수 없습니다. 메시지는 대기열에 유지됩니다." }, "drop": { - "context": "노트나 폴더를 여기에 끌어다 놓아 컨텍스트로 추가", - "ignored": "노트 이외 파일 {count}개를 무시했습니다. 노트와 폴더만 끌어다 놓을 수 있습니다" + "context": "노트, 폴더, 이미지를 여기에 끌어다 놓아 컨텍스트로 추가", + "ignored": "지원되지 않는 파일 {count}개를 무시했습니다. 노트, 폴더, 이미지만 끌어다 놓을 수 있습니다" } }, "settings": { diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index a06f5c6..c059b52 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -113,8 +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", - "ignored": "{count} arquivos que não são notas foram ignorados; apenas notas e pastas podem ser soltas" + "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 3619dc6..6bdcfb3 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -113,8 +113,8 @@ "steerUnavailable": "Сейчас нельзя вклиниться. Сообщение останется в очереди." }, "drop": { - "context": "Перетащите заметки или папки сюда, чтобы добавить их как контекст", - "ignored": "Пропущено {count} файлов, не являющихся заметками; перетаскивать можно только заметки и папки" + "context": "Перетащите заметки, папки или изображения сюда, чтобы добавить их как контекст", + "ignored": "Пропущено {count} неподдерживаемых файлов; перетаскивать можно только заметки, папки и изображения" } }, "settings": { diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 9eb2b8a..8f33010 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -113,8 +113,8 @@ "steerUnavailable": "当前无法引导,消息仍保留在队列中。" }, "drop": { - "context": "拖拽笔记或文件夹到此处,添加为上下文", - "ignored": "已忽略 {count} 个非笔记文件,仅支持拖入笔记和文件夹" + "context": "拖拽笔记、文件夹或图片到此处,添加为上下文", + "ignored": "已忽略 {count} 个不支持的文件,仅支持拖入笔记、文件夹和图片" } }, "settings": { diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 033a57a..be39e7c 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -113,8 +113,8 @@ "steerUnavailable": "目前無法引導,訊息仍保留在佇列中。" }, "drop": { - "context": "拖曳筆記或資料夾到此處,新增為上下文", - "ignored": "已忽略 {count} 個非筆記檔案,僅支援拖入筆記與資料夾" + "context": "拖曳筆記、資料夾或圖片到此處,新增為上下文", + "ignored": "已忽略 {count} 個不支援的檔案,僅支援拖入筆記、資料夾與圖片" } }, "settings": { diff --git a/tests/unit/features/chat/ui/image-context.test.ts b/tests/unit/features/chat/ui/image-context.test.ts index 40adb8b..6829f79 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(), @@ -118,6 +118,36 @@ describe('ImageContextManager', () => { }); }); + describe('attachImageBuffer', () => { + it('attaches an image from raw bytes like the paste pipeline', async () => { + const buffer = new Uint8Array([1, 2, 3]).buffer; + + const ok = await manager.attachImageBuffer('dropped.png', 'image/png', buffer, 'drop'); + + expect(ok).toBe(true); + const images = manager.getAttachedImages(); + expect(images).toHaveLength(1); + expect(images[0]).toMatchObject({ + name: 'dropped.png', + mediaType: 'image/png', + source: 'drop', + size: 3, + data: Buffer.from([1, 2, 3]).toString('base64'), + }); + expect(callbacks.onImagesChanged).toHaveBeenCalled(); + }); + + it('rejects buffers above the size limit', async () => { + const buffer = new ArrayBuffer(5 * 1024 * 1024 + 1); + + const ok = await manager.attachImageBuffer('big.png', 'image/png', buffer); + + expect(ok).toBe(false); + expect(manager.hasImages()).toBe(false); + expect(Notice).toHaveBeenCalled(); + }); + }); + describe('setImages', () => { it('should replace existing images', () => { manager.setImages([createImageAttachment({ id: 'old' })]); @@ -246,41 +276,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'); }); }); @@ -350,7 +380,7 @@ describe('ImageContextManager - Private Helpers', () => { name: 'huge.png', type: 'image/png', size: 6 * 1024 * 1024, // 6MB > 5MB limit - arrayBuffer: jest.fn(), + arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(6 * 1024 * 1024)), } as unknown as File; const result = await manager['addImageFromFile'](file, 'paste'); @@ -372,7 +402,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', @@ -743,19 +773,22 @@ describe('ImageContextManager - Private Helpers', () => { }); describe('fileToBase64', () => { - it('should convert file to base64 string', async () => { + it('should convert file bytes to base64 through the attach pipeline', async () => { const textEncoder = new TextEncoder(); const bytes = textEncoder.encode('hello'); const mockBuffer = bytes.buffer; const file = { + name: 'hello.png', + type: 'image/png', + size: 5, arrayBuffer: jest.fn().mockResolvedValue(mockBuffer), } as unknown as File; - const result = await manager['fileToBase64'](file); - expect(typeof result).toBe('string'); - expect(result.length).toBeGreaterThan(0); - // Verify it's valid base64 - const decoded = Buffer.from(result, 'base64').toString(); + const result = await manager['addImageFromFile'](file, 'paste'); + expect(result).toBe(true); + + const images = manager.getAttachedImages(); + const decoded = Buffer.from(images[0].data, 'base64').toString(); expect(decoded).toBe('hello'); }); }); diff --git a/tests/unit/features/chat/ui/vault-drop.test.ts b/tests/unit/features/chat/ui/vault-drop.test.ts index 467bb51..3507f7f 100644 --- a/tests/unit/features/chat/ui/vault-drop.test.ts +++ b/tests/unit/features/chat/ui/vault-drop.test.ts @@ -9,6 +9,7 @@ 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; } @@ -116,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'), @@ -133,10 +134,38 @@ describe('VaultDropController', () => { expect(inputEl.value).toBe('@a.md '); }); - it('notifies about ignored non-note items in mixed drags', () => { + it('routes vault image drags to the image pipeline', () => { + const png = makeFile('pics/logo.png'); + const app = createApp({ type: 'files', files: [png] }); + const onDropImages = jest.fn(); + new VaultDropController(app, wrapper, inputEl, { onDropImages }); + + const event = createDropEvent(); + wrapper.dispatchEvent('drop', event); + + expect(inputEl.value).toBe(''); + expect(event.preventDefault).toHaveBeenCalled(); + expect(onDropImages).toHaveBeenCalledWith([png]); + expect(Notice).not.toHaveBeenCalled(); + }); + + it('combines mentions and image attachments for note+image drags', () => { + const png = makeFile('logo.png'); + const app = createApp({ type: 'files', files: [makeFile('a.md'), png] }); + const onDropImages = jest.fn(); + new VaultDropController(app, wrapper, inputEl, { onDropImages }); + + wrapper.dispatchEvent('drop', createDropEvent()); + + expect(inputEl.value).toBe('@a.md '); + expect(onDropImages).toHaveBeenCalledWith([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.png')], + files: [makeFile('a.md'), makeFile('logo.pdf')], }); new VaultDropController(app, wrapper, inputEl); @@ -260,6 +289,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', () => { From 3bdeb85e726a6f10815afab2490414a05dc88af1 Mon Sep 17 00:00:00 2001 From: liuxuezhuo Date: Tue, 25 Aug 2026 15:58:16 +0800 Subject: [PATCH 3/3] fix(chat): drop vault images as @path mentions like regular files Per product direction, vault image drags should not attach with a preview chip; they behave like any other droppable file: the image is inserted as an @path mention token (chipified in the composer and sent bubbles) instead of being routed to the image attachment pipeline. - collectDragged accepts image-extension TFiles as 'file' references - Remove the onDropImages option and ImageContextManager.attachImageBuffer (attachment preview stays exclusive to paste / OS-level image drops) Co-authored-by: QoderAI (Qwen 3.8 Max) --- CHANGELOG.md | 8 ++-- src/features/chat/tabs/tab.ts | 12 +---- src/features/chat/ui/image-context.ts | 42 ++++++----------- src/features/chat/ui/vault-drop.ts | 40 +++++----------- .../features/chat/ui/image-context.test.ts | 47 +++---------------- .../unit/features/chat/ui/vault-drop.test.ts | 31 ++++++------ 6 files changed, 56 insertions(+), 124 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25fbf16..9ae03ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,10 +51,10 @@ version with its date and start a fresh empty `[Unreleased]` above it. - 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 now attach like pasted images, - with a preview chip and an image bubble on send; mixed drags combine - mentions and attachments, and only truly unsupported file types are - reported as ignored. +- 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/tabs/tab.ts b/src/features/chat/tabs/tab.ts index 8e7c2a3..a67b878 100644 --- a/src/features/chat/tabs/tab.ts +++ b/src/features/chat/tabs/tab.ts @@ -32,7 +32,7 @@ import { BangBashModeManager as BangBashModeManagerClass } from '../ui/bang-bash import { ComposerBridge } from '../ui/composer/composer-bridge'; import { ComposerActionButton } from '../ui/composer-action-button'; import { FileContextManager } from '../ui/file-context/file-context-manager'; -import { ImageContextManager, imageMediaTypeForFilename } from '../ui/image-context'; +import { ImageContextManager } from '../ui/image-context'; import { createInputToolbar } from '../ui/input-toolbar'; import { InstructionModeManager as InstructionModeManagerClass } from '../ui/instruction-mode-manager'; import { NavigationSidebar } from '../ui/navigation-sidebar'; @@ -295,16 +295,6 @@ function initializeContextManagers(tab: TabData, plugin: QoderianPlugin): void { onInsertReference: (reference) => { tab.ui.fileContextManager?.registerComposerReference(reference); }, - onDropImages: (files) => { - void (async (): Promise => { - for (const file of files) { - const mediaType = imageMediaTypeForFilename(file.name); - if (!mediaType) continue; - const buffer = await app.vault.readBinary(file); - await tab.ui.imageContextManager?.attachImageBuffer(file.name, mediaType, buffer, 'drop'); - } - })(); - }, }); // Image context manager - drag/drop uses inputContainerEl, preview in contextRowEl diff --git a/src/features/chat/ui/image-context.ts b/src/features/chat/ui/image-context.ts index 934e3d8..c002efc 100644 --- a/src/features/chat/ui/image-context.ts +++ b/src/features/chat/ui/image-context.ts @@ -193,35 +193,32 @@ export class ImageContextManager { return file.type.startsWith('image/') && imageMediaTypeForFilename(file.name) !== null; } - /** - * Attaches an image from raw bytes (vault drags). Mirrors the paste - * pipeline so dropped vault images preview and send like pasted ones. - */ - async attachImageBuffer( - name: string, - mediaType: ImageMediaType, - buffer: ArrayBuffer, - source: 'paste' | 'drop' = 'drop', - ): Promise { + private async addImageFromFile(file: File, source: 'paste' | 'drop'): Promise { if (!this.enabled) { new Notice('Image attachments are not supported by this Qoder runtime.'); return false; } - if (buffer.byteLength > MAX_IMAGE_SIZE) { + if (file.size > MAX_IMAGE_SIZE) { this.notifyImageError(`Image exceeds ${this.formatSize(MAX_IMAGE_SIZE)} limit.`); return false; } + const mediaType = imageMediaTypeForFilename(file.name) || (file.type as ImageMediaType); + if (!mediaType) { + this.notifyImageError('Unsupported image type.'); + return false; + } + try { - const base64 = Buffer.from(buffer).toString('base64'); + const base64 = await this.fileToBase64(file); const attachment: ImageAttachment = { id: this.generateId(), - name: name || `image-${Date.now()}.${mediaType.split('/')[1]}`, + name: file.name || `image-${Date.now()}.${mediaType.split('/')[1]}`, mediaType, data: base64, - size: buffer.byteLength, + size: file.size, source, }; @@ -235,19 +232,10 @@ export class ImageContextManager { } } - private async addImageFromFile(file: File, source: 'paste' | 'drop'): Promise { - const mediaType = imageMediaTypeForFilename(file.name) || (file.type as ImageMediaType); - if (!mediaType) { - this.notifyImageError('Unsupported image type.'); - return false; - } - - try { - return await this.attachImageBuffer(file.name, mediaType, await file.arrayBuffer(), source); - } catch (error) { - this.notifyImageError('Failed to attach image.', error); - return false; - } + private async fileToBase64(file: File): Promise { + const arrayBuffer = await file.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + return buffer.toString('base64'); } // ============================================ diff --git a/src/features/chat/ui/vault-drop.ts b/src/features/chat/ui/vault-drop.ts index ac95f50..f24d63c 100644 --- a/src/features/chat/ui/vault-drop.ts +++ b/src/features/chat/ui/vault-drop.ts @@ -15,8 +15,6 @@ export interface VaultDropReference { export interface VaultDropOptions { /** Called for every inserted reference so consumers can chipify it. */ onInsertReference?: (reference: MentionInsertReference) => void; - /** Called with vault image files so consumers can attach them. */ - onDropImages?: (files: TFile[]) => void; } interface DragManagerHost { @@ -24,9 +22,9 @@ interface DragManagerHost { } /** - * Accepts Obsidian file-explorer drags on the composer: notes and folders - * are inserted as `@path` / `@path/ ` mention tokens at the caret, vault - * images are routed to the image attachment pipeline. + * Accepts Obsidian file-explorer drags on the composer and inserts them as + * `@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 @@ -36,7 +34,6 @@ interface DragManagerHost { export class VaultDropController { private readonly dropOverlayEl: HTMLElement; private readonly onInsertReference?: (reference: MentionInsertReference) => void; - private readonly onDropImages?: (files: TFile[]) => void; constructor( private readonly app: App, @@ -45,7 +42,6 @@ export class VaultDropController { options: VaultDropOptions = {}, ) { this.onInsertReference = options.onInsertReference; - this.onDropImages = options.onDropImages; this.dropOverlayEl = this.createDropOverlay(); this.inputWrapperEl.addEventListener('dragenter', this.handleDragEnter); this.inputWrapperEl.addEventListener('dragover', this.handleDragOver); @@ -90,8 +86,8 @@ export class VaultDropController { }; private readonly handleDrop = (event: DragEvent): void => { - const { references, imageFiles, ignoredCount } = this.collectDragged(); - if (references.length === 0 && imageFiles.length === 0) return; + const { references, ignoredCount } = this.collectDragged(); + if (references.length === 0) return; event.preventDefault(); event.stopImmediatePropagation(); this.dropOverlayEl.removeClass('visible'); @@ -108,9 +104,6 @@ export class VaultDropController { } this.inputEl.dispatchEvent(new Event('input', { bubbles: true })); } - if (imageFiles.length > 0) { - this.onDropImages?.(imageFiles); - } // Mixed drags are claimed wholesale, so surface the items we dropped. if (ignoredCount > 0) { new Notice(t('chat.drop.ignored', { count: ignoredCount })); @@ -119,8 +112,8 @@ export class VaultDropController { }; private hasClaimableDrag(): boolean { - const { references, imageFiles } = this.collectDragged(); - return references.length > 0 || imageFiles.length > 0; + const { references } = this.collectDragged(); + return references.length > 0; }; private getDraggedItems(): unknown[] { @@ -142,26 +135,17 @@ export class VaultDropController { return typeof value === 'object' && value !== null; } - private collectDragged(): { - references: VaultDropReference[]; - imageFiles: TFile[]; - ignoredCount: number; - } { + private collectDragged(): { references: VaultDropReference[]; ignoredCount: number } { const references: VaultDropReference[] = []; - const imageFiles: TFile[] = []; const seenPaths = new Set(); let ignoredCount = 0; for (const item of this.getDraggedItems()) { - if (item instanceof TFile && imageMediaTypeForFilename(item.name) !== null) { - if (seenPaths.has(item.path)) continue; - seenPaths.add(item.path); - imageFiles.push(item); - continue; - } 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) { @@ -172,7 +156,7 @@ export class VaultDropController { seenPaths.add(reference.path); references.push(reference); } - return { references, imageFiles, ignoredCount }; + return { references, ignoredCount }; } private mentionToken(reference: VaultDropReference): string { diff --git a/tests/unit/features/chat/ui/image-context.test.ts b/tests/unit/features/chat/ui/image-context.test.ts index 6829f79..5bfeb5d 100644 --- a/tests/unit/features/chat/ui/image-context.test.ts +++ b/tests/unit/features/chat/ui/image-context.test.ts @@ -118,36 +118,6 @@ describe('ImageContextManager', () => { }); }); - describe('attachImageBuffer', () => { - it('attaches an image from raw bytes like the paste pipeline', async () => { - const buffer = new Uint8Array([1, 2, 3]).buffer; - - const ok = await manager.attachImageBuffer('dropped.png', 'image/png', buffer, 'drop'); - - expect(ok).toBe(true); - const images = manager.getAttachedImages(); - expect(images).toHaveLength(1); - expect(images[0]).toMatchObject({ - name: 'dropped.png', - mediaType: 'image/png', - source: 'drop', - size: 3, - data: Buffer.from([1, 2, 3]).toString('base64'), - }); - expect(callbacks.onImagesChanged).toHaveBeenCalled(); - }); - - it('rejects buffers above the size limit', async () => { - const buffer = new ArrayBuffer(5 * 1024 * 1024 + 1); - - const ok = await manager.attachImageBuffer('big.png', 'image/png', buffer); - - expect(ok).toBe(false); - expect(manager.hasImages()).toBe(false); - expect(Notice).toHaveBeenCalled(); - }); - }); - describe('setImages', () => { it('should replace existing images', () => { manager.setImages([createImageAttachment({ id: 'old' })]); @@ -380,7 +350,7 @@ describe('ImageContextManager - Private Helpers', () => { name: 'huge.png', type: 'image/png', size: 6 * 1024 * 1024, // 6MB > 5MB limit - arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(6 * 1024 * 1024)), + arrayBuffer: jest.fn(), } as unknown as File; const result = await manager['addImageFromFile'](file, 'paste'); @@ -773,22 +743,19 @@ describe('ImageContextManager - Private Helpers', () => { }); describe('fileToBase64', () => { - it('should convert file bytes to base64 through the attach pipeline', async () => { + it('should convert file to base64 string', async () => { const textEncoder = new TextEncoder(); const bytes = textEncoder.encode('hello'); const mockBuffer = bytes.buffer; const file = { - name: 'hello.png', - type: 'image/png', - size: 5, arrayBuffer: jest.fn().mockResolvedValue(mockBuffer), } as unknown as File; - const result = await manager['addImageFromFile'](file, 'paste'); - expect(result).toBe(true); - - const images = manager.getAttachedImages(); - const decoded = Buffer.from(images[0].data, 'base64').toString(); + const result = await manager['fileToBase64'](file); + expect(typeof result).toBe('string'); + expect(result.length).toBeGreaterThan(0); + // Verify it's valid base64 + const decoded = Buffer.from(result, 'base64').toString(); expect(decoded).toBe('hello'); }); }); diff --git a/tests/unit/features/chat/ui/vault-drop.test.ts b/tests/unit/features/chat/ui/vault-drop.test.ts index 3507f7f..a5dc195 100644 --- a/tests/unit/features/chat/ui/vault-drop.test.ts +++ b/tests/unit/features/chat/ui/vault-drop.test.ts @@ -134,31 +134,34 @@ describe('VaultDropController', () => { expect(inputEl.value).toBe('@a.md '); }); - it('routes vault image drags to the image pipeline', () => { - const png = makeFile('pics/logo.png'); - const app = createApp({ type: 'files', files: [png] }); - const onDropImages = jest.fn(); - new VaultDropController(app, wrapper, inputEl, { onDropImages }); + 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(''); + expect(inputEl.value).toBe('@pics/logo.png '); expect(event.preventDefault).toHaveBeenCalled(); - expect(onDropImages).toHaveBeenCalledWith([png]); + expect(onInsertReference).toHaveBeenCalledWith({ + token: '@pics/logo.png', + path: 'pics/logo.png', + kind: 'file', + }); expect(Notice).not.toHaveBeenCalled(); }); - it('combines mentions and image attachments for note+image drags', () => { - const png = makeFile('logo.png'); - const app = createApp({ type: 'files', files: [makeFile('a.md'), png] }); - const onDropImages = jest.fn(); - new VaultDropController(app, wrapper, inputEl, { onDropImages }); + 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 '); - expect(onDropImages).toHaveBeenCalledWith([png]); + expect(inputEl.value).toBe('@a.md @logo.png '); expect(Notice).not.toHaveBeenCalled(); });