Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 13 additions & 0 deletions src/features/chat/controllers/input-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down
9 changes: 9 additions & 0 deletions src/features/chat/ui/file-context/file-context-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,13 +153,15 @@ export class FileContextManager {
/** 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;
this.clearComposerReferences();
this.state.resetForLoadedConversation(hasMessages);
this.refreshCurrentNoteChip();
}
Expand Down Expand Up @@ -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.
Expand Down
15 changes: 8 additions & 7 deletions src/features/chat/ui/image-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ const IMAGE_EXTENSIONS: Record<string, ImageMediaType> = {
'.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;
}
Expand Down Expand Up @@ -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<boolean> {
Expand All @@ -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;
Expand Down
39 changes: 29 additions & 10 deletions src/features/chat/ui/vault-drop.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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();
Expand All @@ -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();
Expand All @@ -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;
Expand All @@ -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<string>();
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 {
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,8 @@
"steerUnavailable": "今は割り込みできません。メッセージはキューに保持されます。"
},
"drop": {
"context": "ノートやフォルダをここにドロップしてコンテキストに追加"
"context": "ノート、フォルダ、画像をここにドロップしてコンテキストに追加",
"ignored": "サポートされていない {count} 件のファイルを無視しました。ノート、フォルダ、画像のみドロップできます"
}
},
"settings": {
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,8 @@
"steerUnavailable": "지금은 끼어들 수 없습니다. 메시지는 대기열에 유지됩니다."
},
"drop": {
"context": "노트나 폴더를 여기에 끌어다 놓아 컨텍스트로 추가"
"context": "노트, 폴더, 이미지를 여기에 끌어다 놓아 컨텍스트로 추가",
"ignored": "지원되지 않는 파일 {count}개를 무시했습니다. 노트, 폴더, 이미지만 끌어다 놓을 수 있습니다"
}
},
"settings": {
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,8 @@
"steerUnavailable": "Сейчас нельзя вклиниться. Сообщение останется в очереди."
},
"drop": {
"context": "Перетащите заметки или папки сюда, чтобы добавить их как контекст"
"context": "Перетащите заметки, папки или изображения сюда, чтобы добавить их как контекст",
"ignored": "Пропущено {count} неподдерживаемых файлов; перетаскивать можно только заметки, папки и изображения"
}
},
"settings": {
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,8 @@
"steerUnavailable": "当前无法引导,消息仍保留在队列中。"
},
"drop": {
"context": "拖拽笔记或文件夹到此处,添加为上下文"
"context": "拖拽笔记、文件夹或图片到此处,添加为上下文",
"ignored": "已忽略 {count} 个不支持的文件,仅支持拖入笔记、文件夹和图片"
}
},
"settings": {
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/zh-TW.json
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,8 @@
"steerUnavailable": "目前無法引導,訊息仍保留在佇列中。"
},
"drop": {
"context": "拖曳筆記或資料夾到此處,新增為上下文"
"context": "拖曳筆記、資料夾或圖片到此處,新增為上下文",
"ignored": "已忽略 {count} 個不支援的檔案,僅支援拖入筆記、資料夾與圖片"
}
},
"settings": {
Expand Down
1 change: 1 addition & 0 deletions src/i18n/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ export type TranslationKey =

// Vault drag & drop into composer
| 'chat.drop.context'
| 'chat.drop.ignored'

// Settings - Section Headings
| 'settings.title'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading
Loading