Skip to content

Commit 3cee7e2

Browse files
luoxuanzaoQoder-AI
andauthored
feat: multi-message send queue aligned with Qoder IDE (#31)
Replace the single queued-message slot with a FIFO queue panel above the composer: collapsible header with pending count, per-entry drag handle for reordering, edit (withdraw into composer) and delete actions. Entries drain one per turn end in send order. Stopping a streaming turn now preserves the queue and still sends the head entry after the interrupted turn settles (stop = skip this turn), instead of withdrawing queued content into the composer. Queue strings are localized in all ten locales. Co-authored-by: QoderAI (Qwen 3.8 Max) <qoder_ai@qoder.com>
1 parent b1cc3f2 commit 3cee7e2

19 files changed

Lines changed: 544 additions & 111 deletions

File tree

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,20 @@ version with its date and start a fresh empty `[Unreleased]` above it.
1111

1212
## [Unreleased]
1313

14+
### Added
15+
16+
- Multi-message send queue in the chat composer: while a response is
17+
streaming, further sends stack into a collapsible panel above the
18+
composer (aligned with the Qoder IDE send queue). Each entry shows a
19+
drag handle for reordering plus edit (withdraw into the composer) and
20+
delete actions, and entries drain one per turn end in FIFO order.
21+
22+
### Changed
23+
24+
- Stopping a streaming turn no longer withdraws queued messages into the
25+
composer: the queue is preserved as-is and the head entry is still sent
26+
after the interrupted turn settles (stop now means "skip this turn").
27+
1428
## [1.0.5] - 2026-08-18
1529

1630
### Added

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

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -528,10 +528,6 @@ export class InputController {
528528
this.queuedMessages.clear();
529529
}
530530

531-
withdrawQueuedMessageToComposer(): void {
532-
this.queuedMessages.withdrawToComposer();
533-
}
534-
535531
private async buildTurnSubmission(options: {
536532
content: string;
537533
images?: ChatMessage['images'];
@@ -822,8 +818,6 @@ export class InputController {
822818
const { state, streamController } = this.deps;
823819
if (!state.isStreaming) return;
824820
state.cancelRequested = true;
825-
// Restore queued message to input instead of discarding
826-
this.queuedMessages.restorePendingToComposer();
827821
this.getAgentService()?.cancel();
828822
streamController.hideThinkingIndicator();
829823
}
Lines changed: 138 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import { setIcon } from 'obsidian';
22

33
import type { ChatTurnRequest } from '../../../core/runtime/types';
4+
import { t } from '../../../i18n/i18n';
45
import { appendMarkdownSnippet } from '../../../shared/markdown/markdown';
56
import type { ChatState } from '../state/chat-state';
67
import type { QueuedMessage } from '../state/types';
78
import type { ImageContextManager } from '../ui/image-context';
89
import {
910
cloneChatTurnRequest,
10-
mergeQueuedChatTurns,
1111
type QueuedChatTurn,
1212
} from './queued-turn';
1313

@@ -19,83 +19,134 @@ export interface QueuedMessageControllerDeps {
1919
sendQueuedTurn: (message: QueuedChatTurn) => void;
2020
}
2121

22-
/** Owns the single queued turn, its composer projection, and indicator UI. */
22+
let nextQueuedMessageId = 1;
23+
24+
/** Owns the FIFO queue of pending turns, its composer projection, and list UI. */
2325
export class QueuedMessageController {
26+
private collapsed = false;
27+
private draggingId: string | null = null;
28+
2429
constructor(private readonly deps: QueuedMessageControllerDeps) {}
2530

2631
enqueue(displayContent: string, turnRequest: ChatTurnRequest): void {
2732
const incoming = this.createQueuedMessage(displayContent, turnRequest);
28-
this.deps.state.queuedMessage = this.mergeQueuedMessages(
29-
this.deps.state.queuedMessage,
30-
incoming,
31-
);
33+
this.deps.state.queuedMessages = [...this.deps.state.queuedMessages, incoming];
3234
this.updateIndicator();
3335
}
3436

3537
updateIndicator(): void {
3638
const { state } = this.deps;
37-
const indicatorEl = state.queueIndicatorEl;
38-
if (!indicatorEl) return;
39-
indicatorEl.empty();
40-
41-
const message = state.queuedMessage;
42-
if (!message) {
43-
indicatorEl.removeClass('qoderian-visible-flex');
44-
indicatorEl.addClass('qoderian-hidden');
39+
const containerEl = state.queueIndicatorEl;
40+
if (!containerEl) return;
41+
containerEl.empty();
42+
43+
const messages = state.queuedMessages;
44+
if (messages.length === 0) {
45+
containerEl.removeClass('qoderian-visible-flex');
46+
containerEl.addClass('qoderian-hidden');
4547
return;
4648
}
4749

48-
indicatorEl.createSpan({
49-
cls: 'qoderian-queue-indicator-text',
50-
text: `⌙ Queued: ${this.getQueuedMessageDisplay(message)}`,
50+
const headerEl = containerEl.createDiv({ cls: 'qoderian-queue-header' });
51+
const toggleEl = headerEl.createEl('button', {
52+
cls: 'qoderian-queue-header-toggle',
53+
attr: {
54+
'aria-label': this.collapsed ? t('chat.queue.expand') : t('chat.queue.collapse'),
55+
title: this.collapsed ? t('chat.queue.expand') : t('chat.queue.collapse'),
56+
type: 'button',
57+
},
5158
});
52-
const actionsEl = indicatorEl.createDiv({ cls: 'qoderian-queue-indicator-actions' });
53-
const editButton = this.createIconButton(actionsEl, 'pencil', 'Edit queued message');
54-
editButton.addEventListener('click', (event) => {
59+
setIcon(toggleEl, this.collapsed ? 'chevron-right' : 'chevron-down');
60+
toggleEl.addEventListener('click', (event) => {
5561
event.stopPropagation();
56-
this.withdrawToComposer();
62+
this.collapsed = !this.collapsed;
63+
this.updateIndicator();
5764
});
58-
const discardButton = this.createIconButton(actionsEl, 'trash-2', 'Discard queued message');
59-
discardButton.addEventListener('click', (event) => {
60-
event.stopPropagation();
61-
this.clear();
65+
headerEl.createSpan({
66+
cls: 'qoderian-queue-header-title',
67+
text: t('chat.queue.title', { count: messages.length }),
6268
});
63-
indicatorEl.addClass('qoderian-visible-flex');
64-
indicatorEl.removeClass('qoderian-hidden');
69+
70+
if (!this.collapsed) {
71+
const listEl = containerEl.createDiv({ cls: 'qoderian-queue-list' });
72+
for (const message of messages) {
73+
this.renderRow(listEl, message);
74+
}
75+
}
76+
77+
containerEl.addClass('qoderian-visible-flex');
78+
containerEl.removeClass('qoderian-hidden');
6579
}
6680

6781
clear(): void {
68-
this.deps.state.queuedMessage = null;
82+
this.deps.state.queuedMessages = [];
6983
this.updateIndicator();
7084
}
7185

72-
withdrawToComposer(): void {
73-
const { state } = this.deps;
74-
if (!state.queuedMessage) return;
75-
const queuedMessage = this.cloneQueuedMessage(state.queuedMessage);
76-
state.queuedMessage = null;
77-
this.restoreMessageToInput(queuedMessage, true);
86+
/** Remove one item by id. */
87+
discard(id: string): void {
88+
const messages = this.deps.state.queuedMessages;
89+
if (!messages.some(message => message.id === id)) return;
90+
this.deps.state.queuedMessages = messages.filter(message => message.id !== id);
7891
this.updateIndicator();
7992
}
8093

81-
restorePendingToComposer(): void {
94+
/** Withdraw one item back into the composer. */
95+
withdrawToComposer(id: string): void {
8296
const { state } = this.deps;
83-
this.restoreMessageToInput(state.queuedMessage, true);
84-
state.queuedMessage = null;
97+
const target = state.queuedMessages.find(message => message.id === id);
98+
if (!target) return;
99+
state.queuedMessages = state.queuedMessages.filter(message => message.id !== id);
100+
this.restoreMessageToInput(target, true);
85101
this.updateIndicator();
86102
}
87103

104+
/** Drain the head of the queue at turn end. */
88105
process(): void {
89106
const { state } = this.deps;
90-
if (!state.queuedMessage) return;
91-
const queuedMessage = this.cloneQueuedMessage(state.queuedMessage);
92-
state.queuedMessage = null;
107+
const next = state.queuedMessages[0];
108+
if (!next) return;
109+
state.queuedMessages = state.queuedMessages.slice(1);
93110
this.updateIndicator();
94-
window.setTimeout(() => this.deps.sendQueuedTurn(this.toQueuedChatTurn(queuedMessage)), 0);
111+
window.setTimeout(() => this.deps.sendQueuedTurn(this.toQueuedChatTurn(next)), 0);
95112
}
96113

97-
private restoreMessageToInput(message: QueuedMessage | null, mergeWithComposer: boolean): void {
98-
if (!message) return;
114+
private renderRow(listEl: HTMLElement, message: QueuedMessage): void {
115+
const rowEl = listEl.createDiv({ cls: 'qoderian-queue-row' });
116+
rowEl.dataset.queueId = message.id;
117+
118+
const handleEl = rowEl.createSpan({
119+
cls: 'qoderian-queue-row-handle',
120+
attr: {
121+
'aria-label': t('chat.queue.drag'),
122+
title: t('chat.queue.dragTooltip'),
123+
draggable: 'true',
124+
},
125+
});
126+
setIcon(handleEl, 'grip-vertical');
127+
this.attachDragHandlers(handleEl, rowEl);
128+
129+
rowEl.createSpan({
130+
cls: 'qoderian-queue-row-text',
131+
text: this.getQueuedMessageDisplay(message),
132+
attr: { title: message.content.trim() },
133+
});
134+
135+
const actionsEl = rowEl.createDiv({ cls: 'qoderian-queue-row-actions' });
136+
const editEl = this.createIconButton(actionsEl, 'pencil', t('chat.queue.edit'));
137+
editEl.addEventListener('click', (event) => {
138+
event.stopPropagation();
139+
this.withdrawToComposer(message.id);
140+
});
141+
142+
const deleteEl = this.createIconButton(actionsEl, 'trash-2', t('chat.queue.delete'));
143+
deleteEl.addEventListener('click', (event) => {
144+
event.stopPropagation();
145+
this.discard(message.id);
146+
});
147+
}
148+
149+
private restoreMessageToInput(message: QueuedMessage, mergeWithComposer: boolean): void {
99150
const inputEl = this.deps.getInputEl();
100151
const currentContent = mergeWithComposer ? inputEl.value.trim() : '';
101152
inputEl.value = currentContent
@@ -119,6 +170,49 @@ export class QueuedMessageController {
119170
return preview;
120171
}
121172

173+
private attachDragHandlers(handleEl: HTMLElement, rowEl: HTMLElement): void {
174+
handleEl.addEventListener('dragstart', (event) => {
175+
this.draggingId = rowEl.dataset.queueId ?? null;
176+
rowEl.addClass('qoderian-queue-row-dragging');
177+
if (event.dataTransfer) {
178+
event.dataTransfer.effectAllowed = 'move';
179+
event.dataTransfer.setData('text/plain', this.draggingId ?? '');
180+
}
181+
});
182+
handleEl.addEventListener('dragend', () => {
183+
rowEl.removeClass('qoderian-queue-row-dragging');
184+
this.draggingId = null;
185+
this.commitDomOrder();
186+
});
187+
rowEl.addEventListener('dragover', (event) => {
188+
if (!this.draggingId || rowEl.dataset.queueId === this.draggingId) return;
189+
event.preventDefault();
190+
const listEl = rowEl.parentElement;
191+
const draggingEl = listEl?.querySelector(`[data-queue-id="${this.draggingId}"]`);
192+
if (!listEl || !draggingEl) return;
193+
const rect = rowEl.getBoundingClientRect();
194+
const before = event.clientY < rect.top + rect.height / 2;
195+
listEl.insertBefore(draggingEl, before ? rowEl : rowEl.nextSibling);
196+
});
197+
}
198+
199+
/** Persist the DOM order after a drag ends. */
200+
private commitDomOrder(): void {
201+
const { state } = this.deps;
202+
const containerEl = state.queueIndicatorEl;
203+
if (!containerEl) return;
204+
const ids = [...containerEl.querySelectorAll<HTMLElement>('[data-queue-id]')]
205+
.map(el => el.dataset.queueId ?? '');
206+
const byId = new Map(state.queuedMessages.map(message => [message.id, message]));
207+
const reordered = ids
208+
.map(id => byId.get(id))
209+
.filter((message): message is QueuedMessage => Boolean(message));
210+
for (const message of state.queuedMessages) {
211+
if (!ids.includes(message.id)) reordered.push(message);
212+
}
213+
state.queuedMessages = reordered;
214+
}
215+
122216
private createIconButton(parentEl: HTMLElement, icon: string, label: string): HTMLElement {
123217
const button = parentEl.createEl('button', {
124218
cls: 'qoderian-queue-indicator-icon-action',
@@ -128,17 +222,10 @@ export class QueuedMessageController {
128222
return button;
129223
}
130224

131-
private cloneQueuedMessage(message: QueuedMessage): QueuedMessage {
132-
return {
133-
...message,
134-
images: message.images ? [...message.images] : undefined,
135-
turnRequest: message.turnRequest ? cloneChatTurnRequest(message.turnRequest) : undefined,
136-
};
137-
}
138-
139225
private createQueuedMessage(displayContent: string, turnRequest: ChatTurnRequest): QueuedMessage {
140226
const request = cloneChatTurnRequest(turnRequest);
141227
return {
228+
id: `queued-${nextQueuedMessageId++}`,
142229
content: displayContent,
143230
images: request.images,
144231
editorContext: request.editorSelection ?? null,
@@ -166,13 +253,4 @@ export class QueuedMessageController {
166253
},
167254
};
168255
}
169-
170-
private mergeQueuedMessages(existing: QueuedMessage | null, incoming: QueuedMessage): QueuedMessage {
171-
if (!existing) return this.cloneQueuedMessage(incoming);
172-
const mergedTurn = mergeQueuedChatTurns(
173-
this.toQueuedChatTurn(existing),
174-
this.toQueuedChatTurn(incoming),
175-
);
176-
return this.createQueuedMessage(mergedTurn.displayContent, mergedTurn.request);
177-
}
178256
}

src/features/chat/state/chat-state.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ function createInitialState(): ChatStateData {
1919
isSwitchingConversation: false,
2020
hasPendingConversationSave: false,
2121
currentConversationId: null,
22-
queuedMessage: null,
22+
queuedMessages: [],
2323
currentContentEl: null,
2424
currentTextEl: null,
2525
currentTextContent: '',
@@ -163,12 +163,12 @@ export class ChatState {
163163
// Queued Message
164164
// ============================================
165165

166-
get queuedMessage(): QueuedMessage | null {
167-
return this.state.queuedMessage;
166+
get queuedMessages(): QueuedMessage[] {
167+
return this.state.queuedMessages;
168168
}
169169

170-
set queuedMessage(value: QueuedMessage | null) {
171-
this.state.queuedMessage = value;
170+
set queuedMessages(value: QueuedMessage[]) {
171+
this.state.queuedMessages = value;
172172
}
173173

174174
// ============================================
@@ -392,7 +392,7 @@ export class ChatState {
392392
this.clearMessages();
393393
this.resetStreamingState();
394394
this.clearMaps();
395-
this.state.queuedMessage = null;
395+
this.state.queuedMessages = [];
396396
this.usage = null;
397397
this.autoScrollEnabled = true;
398398
}

src/features/chat/state/types.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import type { WriteEditState } from '../rendering/write-edit-renderer';
1515

1616
/** Queued message waiting to be sent after current streaming completes. */
1717
export interface QueuedMessage {
18+
/** Stable id used for list rendering and per-item actions. */
19+
id: string;
1820
content: string;
1921
images?: ImageAttachment[];
2022
editorContext: EditorSelectionContext | null;
@@ -61,8 +63,8 @@ export interface ChatStateData {
6163
// Conversation identity
6264
currentConversationId: string | null;
6365

64-
// Queued message
65-
queuedMessage: QueuedMessage | null;
66+
// Queued messages (FIFO; drained one per completed turn)
67+
queuedMessages: QueuedMessage[];
6668

6769
// Active streaming DOM state
6870
currentContentEl: HTMLElement | null;

src/i18n/locales/de.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,15 @@
9898
"conversation": {
9999
"emptyPreview": "Neue Unterhaltung",
100100
"deleteFailed": "Unterhaltung konnte nicht gelöscht werden: {error}"
101+
},
102+
"queue": {
103+
"title": "Warten auf Senden {count}",
104+
"edit": "Bearbeiten",
105+
"delete": "Löschen",
106+
"drag": "Ziehen",
107+
"dragTooltip": "Ziehen, um die Reihenfolge zu ändern",
108+
"collapse": "Warteschlange einklappen",
109+
"expand": "Warteschlange ausklappen"
101110
}
102111
},
103112
"settings": {

0 commit comments

Comments
 (0)