Skip to content

Commit c4c6fd2

Browse files
luoxuanzaoQoder-AI
andauthored
fix(chat): steer refresh, vault image drops as mentions, reference cleanup (#37)
- Re-render the send queue when isStreaming flips so the Steer action tracks the live turn (missing after auto-drain, stale after plan-approval exits) - Vault image drags now insert @path mentions like regular files (chipified, deduped, overlay feedback); only unsupported types raise the ignored notice; attachment previews stay exclusive to paste / OS-level drops - Clear composer references at conversation boundaries - Document the dormant user_message_start / assistant_message_start boundary-chunk contract Co-authored-by: QoderAI (Qwen 3.8 Max) <qoder_ai@qoder.com>
1 parent 5c7ee22 commit c4c6fd2

20 files changed

Lines changed: 210 additions & 42 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,13 @@ version with its date and start a fresh empty `[Unreleased]` above it.
4848
- Dropping a vault note or folder into the composer no longer pastes the
4949
raw `obsidian://open` URI next to the mention, and the inserted mention
5050
chipifies like dropdown insertions.
51+
- The queue's Steer action now tracks the streaming state: it no longer
52+
goes missing on remaining rows after queued messages auto-drain, and no
53+
longer lingers after a turn ends through plan approval paths.
54+
- Vault images dragged into the composer are now inserted as `@path`
55+
mentions just like notes (chipified, no attachment preview); mixed
56+
drags combine note, folder, and image mentions, and only truly
57+
unsupported file types are reported as ignored.
5158

5259
## [1.0.5] - 2026-08-18
5360

‎src/features/chat/controllers/input-controller.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,13 @@ export class InputController {
7979
private readonly inputCommands: InputCommandController;
8080
private readonly queuedMessages: QueuedMessageController;
8181
private activeStreamingAssistantMessage: ChatMessage | null = null;
82+
// Contract: `user_message_start` / `assistant_message_start` boundary
83+
// chunks currently have no producer in the runtime — they are a reserved
84+
// mechanism, so the handlers below are dormant. Steering relies on
85+
// immediate UI-side splicing (spliceRuntimeUserMessage) and does NOT echo
86+
// through this path. If a producer is ever wired up, queued-message
87+
// steering must first register its expected echo here, otherwise the echo
88+
// would splice a second, duplicate bubble for the same message.
8289
private pendingRuntimeUserMessages: Array<{
8390
displayContent: string;
8491
persistedContent?: string;
@@ -233,6 +240,9 @@ export class InputController {
233240
state.isStreaming = true;
234241
state.cancelRequested = false;
235242
state.ignoreUsageUpdates = false; // Allow usage updates for new query
243+
// Re-render the send queue so steer buttons reflect the now-active turn
244+
// (rows rendered during the brief idle gap after process() lost them).
245+
this.updateQueueIndicator();
236246
this.deps.getSubagentManager().resetSpawnedCount();
237247
state.autoScrollEnabled = plugin.settings.enableAutoScroll ?? true; // Reset auto-scroll based on setting
238248
const streamGeneration = state.bumpStreamGeneration();
@@ -408,6 +418,9 @@ export class InputController {
408418
streamController.hideThinkingIndicator();
409419
state.isStreaming = false;
410420
state.cancelRequested = false;
421+
// Re-render the send queue so steer buttons drop now that the turn
422+
// ended (guards the paused/revised paths that skip process()).
423+
this.updateQueueIndicator();
411424
// Capture response duration before resetting state (skip for interrupted responses and compaction)
412425
const hasCompactBoundary = finalAssistantMsg.contentBlocks?.some(b => b.type === 'context_compacted');
413426
const hasError = hasErrorContentBlock(finalAssistantMsg);

‎src/features/chat/ui/file-context/file-context-manager.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,13 +153,15 @@ export class FileContextManager {
153153
/** Resets state for a new conversation. */
154154
resetForNewConversation() {
155155
this.currentNotePath = null;
156+
this.clearComposerReferences();
156157
this.state.resetForNewConversation();
157158
this.refreshCurrentNoteChip();
158159
}
159160

160161
/** Resets state for loading an existing conversation. */
161162
resetForLoadedConversation(hasMessages: boolean) {
162163
this.currentNotePath = null;
164+
this.clearComposerReferences();
163165
this.state.resetForLoadedConversation(hasMessages);
164166
this.refreshCurrentNoteChip();
165167
}
@@ -367,6 +369,13 @@ export class FileContextManager {
367369
this.notifyReferencesChanged();
368370
}
369371

372+
/** Drops all tracked composer references at a conversation boundary. */
373+
private clearComposerReferences(): void {
374+
if (this.composerReferences.size === 0) return;
375+
this.composerReferences.clear();
376+
this.notifyReferencesChanged();
377+
}
378+
370379
/**
371380
* Rewrites references under a renamed path: tokens in the input text are
372381
* replaced so chips keep pointing at the new location.

‎src/features/chat/ui/image-context.ts‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ const IMAGE_EXTENSIONS: Record<string, ImageMediaType> = {
1313
'.webp': 'image/webp',
1414
};
1515

16+
/** Maps supported image filenames to their media types. */
17+
export function imageMediaTypeForFilename(filename: string): ImageMediaType | null {
18+
const ext = path.extname(filename).toLowerCase();
19+
return IMAGE_EXTENSIONS[ext] || null;
20+
}
21+
1622
export interface ImageContextCallbacks {
1723
onImagesChanged: () => void;
1824
}
@@ -184,12 +190,7 @@ export class ImageContextManager {
184190
}
185191

186192
private isImageFile(file: File): boolean {
187-
return file.type.startsWith('image/') && this.getMediaType(file.name) !== null;
188-
}
189-
190-
private getMediaType(filename: string): ImageMediaType | null {
191-
const ext = path.extname(filename).toLowerCase();
192-
return IMAGE_EXTENSIONS[ext] || null;
193+
return file.type.startsWith('image/') && imageMediaTypeForFilename(file.name) !== null;
193194
}
194195

195196
private async addImageFromFile(file: File, source: 'paste' | 'drop'): Promise<boolean> {
@@ -203,7 +204,7 @@ export class ImageContextManager {
203204
return false;
204205
}
205206

206-
const mediaType = this.getMediaType(file.name) || (file.type as ImageMediaType);
207+
const mediaType = imageMediaTypeForFilename(file.name) || (file.type as ImageMediaType);
207208
if (!mediaType) {
208209
this.notifyImageError('Unsupported image type.');
209210
return false;

‎src/features/chat/ui/vault-drop.ts‎

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import type { App } from 'obsidian';
2-
import { TFile, TFolder } from 'obsidian';
2+
import { Notice, TFile, TFolder } from 'obsidian';
33

44
import { t } from '@/i18n/i18n';
55
import type { MentionInsertReference } from '@/shared/mention/types';
66

7+
import { imageMediaTypeForFilename } from './image-context';
8+
79
/** A vault file or folder reference extracted from an Obsidian drag payload. */
810
export interface VaultDropReference {
911
path: string;
@@ -21,7 +23,8 @@ interface DragManagerHost {
2123

2224
/**
2325
* Accepts Obsidian file-explorer drags on the composer and inserts them as
24-
* `@path` / `@path/ ` mention tokens at the caret position.
26+
* `@path` / `@path/ ` mention tokens at the caret position. Notes, folders,
27+
* and images are accepted; anything else is ignored.
2528
*
2629
* Must be attached before ImageContextManager so vault drags can be claimed
2730
* via stopImmediatePropagation before the image drop handlers run. The drop
@@ -55,20 +58,20 @@ export class VaultDropController {
5558
}
5659

5760
private readonly handleDragEnter = (event: DragEvent): void => {
58-
if (this.getDraggedReferences().length === 0) return;
61+
if (!this.hasClaimableDrag()) return;
5962
event.preventDefault();
6063
event.stopImmediatePropagation();
6164
this.dropOverlayEl.addClass('visible');
6265
};
6366

6467
private readonly handleDragOver = (event: DragEvent): void => {
65-
if (this.getDraggedReferences().length === 0) return;
68+
if (!this.hasClaimableDrag()) return;
6669
event.preventDefault();
6770
event.stopImmediatePropagation();
6871
};
6972

7073
private readonly handleDragLeave = (event: DragEvent): void => {
71-
if (this.getDraggedReferences().length === 0) return;
74+
if (!this.hasClaimableDrag()) return;
7275
event.stopImmediatePropagation();
7376

7477
const rect = this.inputWrapperEl.getBoundingClientRect();
@@ -83,7 +86,7 @@ export class VaultDropController {
8386
};
8487

8588
private readonly handleDrop = (event: DragEvent): void => {
86-
const references = this.getDraggedReferences();
89+
const { references, ignoredCount } = this.collectDragged();
8790
if (references.length === 0) return;
8891
event.preventDefault();
8992
event.stopImmediatePropagation();
@@ -101,9 +104,18 @@ export class VaultDropController {
101104
}
102105
this.inputEl.dispatchEvent(new Event('input', { bubbles: true }));
103106
}
107+
// Mixed drags are claimed wholesale, so surface the items we dropped.
108+
if (ignoredCount > 0) {
109+
new Notice(t('chat.drop.ignored', { count: ignoredCount }));
110+
}
104111
this.inputEl.focus();
105112
};
106113

114+
private hasClaimableDrag(): boolean {
115+
const { references } = this.collectDragged();
116+
return references.length > 0;
117+
};
118+
107119
private getDraggedItems(): unknown[] {
108120
const host = this.app as unknown as DragManagerHost;
109121
const dragManager = host.dragManager;
@@ -123,21 +135,28 @@ export class VaultDropController {
123135
return typeof value === 'object' && value !== null;
124136
}
125137

126-
private getDraggedReferences(): VaultDropReference[] {
138+
private collectDragged(): { references: VaultDropReference[]; ignoredCount: number } {
127139
const references: VaultDropReference[] = [];
128140
const seenPaths = new Set<string>();
141+
let ignoredCount = 0;
129142
for (const item of this.getDraggedItems()) {
130143
const reference =
131144
item instanceof TFolder && item.path !== '/' && item.path !== ''
132145
? { path: item.path, kind: 'folder' as const }
133-
: item instanceof TFile && item.extension.toLowerCase() === 'md'
146+
: item instanceof TFile &&
147+
(item.extension.toLowerCase() === 'md' ||
148+
imageMediaTypeForFilename(item.name) !== null)
134149
? { path: item.path, kind: 'file' as const }
135150
: null;
136-
if (!reference || seenPaths.has(reference.path)) continue;
151+
if (!reference) {
152+
ignoredCount += 1;
153+
continue;
154+
}
155+
if (seenPaths.has(reference.path)) continue;
137156
seenPaths.add(reference.path);
138157
references.push(reference);
139158
}
140-
return references;
159+
return { references, ignoredCount };
141160
}
142161

143162
private mentionToken(reference: VaultDropReference): string {

‎src/i18n/locales/de.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,8 @@
113113
"steerUnavailable": "Steuern ist gerade nicht möglich. Die Nachricht bleibt in der Warteschlange."
114114
},
115115
"drop": {
116-
"context": "Notizen oder Ordner hier ablegen, um sie als Kontext hinzuzufügen"
116+
"context": "Notizen, Ordner oder Bilder hier ablegen, um sie als Kontext hinzuzufügen",
117+
"ignored": "{count} nicht unterstützte Dateien ignoriert; es können nur Notizen, Ordner und Bilder abgelegt werden"
117118
}
118119
},
119120
"settings": {

‎src/i18n/locales/en.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,8 @@
113113
"steerUnavailable": "Can't steer right now. The message stays in the queue."
114114
},
115115
"drop": {
116-
"context": "Drop notes or folders here to add as context"
116+
"context": "Drop notes, folders, or images here to add as context",
117+
"ignored": "Ignored {count} unsupported file(s); only notes, folders, and images can be dropped"
117118
}
118119
},
119120
"settings": {

‎src/i18n/locales/es.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,8 @@
113113
"steerUnavailable": "Ahora no se puede redirigir. El mensaje permanece en la cola."
114114
},
115115
"drop": {
116-
"context": "Arrastra notas o carpetas aquí para añadirlas como contexto"
116+
"context": "Arrastra notas, carpetas o imágenes aquí para añadirlas como contexto",
117+
"ignored": "Se ignoraron {count} archivos no compatibles; solo se pueden soltar notas, carpetas e imágenes"
117118
}
118119
},
119120
"settings": {

‎src/i18n/locales/fr.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,8 @@
113113
"steerUnavailable": "Impossible d'orienter pour le moment. Le message reste dans la file d'attente."
114114
},
115115
"drop": {
116-
"context": "Déposez des notes ou des dossiers ici pour les ajouter comme contexte"
116+
"context": "Déposez des notes, des dossiers ou des images ici pour les ajouter comme contexte",
117+
"ignored": "{count} fichiers non pris en charge ignorés ; seules les notes, dossiers et images peuvent être déposés"
117118
}
118119
},
119120
"settings": {

‎src/i18n/locales/ja.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,8 @@
113113
"steerUnavailable": "今は割り込みできません。メッセージはキューに保持されます。"
114114
},
115115
"drop": {
116-
"context": "ノートやフォルダをここにドロップしてコンテキストに追加"
116+
"context": "ノート、フォルダ、画像をここにドロップしてコンテキストに追加",
117+
"ignored": "サポートされていない {count} 件のファイルを無視しました。ノート、フォルダ、画像のみドロップできます"
117118
}
118119
},
119120
"settings": {

0 commit comments

Comments
 (0)