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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,20 @@ version with its date and start a fresh empty `[Unreleased]` above it.

## [Unreleased]

### Added

- Multi-message send queue in the chat composer: while a response is
streaming, further sends stack into a collapsible panel above the
composer (aligned with the Qoder IDE send queue). Each entry shows a
drag handle for reordering plus edit (withdraw into the composer) and
delete actions, and entries drain one per turn end in FIFO order.

### Changed

- Stopping a streaming turn no longer withdraws queued messages into the
composer: the queue is preserved as-is and the head entry is still sent
after the interrupted turn settles (stop now means "skip this turn").

## [1.0.5] - 2026-08-18

### Added
Expand Down
6 changes: 0 additions & 6 deletions src/features/chat/controllers/input-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -528,10 +528,6 @@ export class InputController {
this.queuedMessages.clear();
}

withdrawQueuedMessageToComposer(): void {
this.queuedMessages.withdrawToComposer();
}

private async buildTurnSubmission(options: {
content: string;
images?: ChatMessage['images'];
Expand Down Expand Up @@ -822,8 +818,6 @@ export class InputController {
const { state, streamController } = this.deps;
if (!state.isStreaming) return;
state.cancelRequested = true;
// Restore queued message to input instead of discarding
this.queuedMessages.restorePendingToComposer();
this.getAgentService()?.cancel();
streamController.hideThinkingIndicator();
}
Expand Down
198 changes: 138 additions & 60 deletions src/features/chat/controllers/queued-message-controller.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { setIcon } from 'obsidian';

import type { ChatTurnRequest } from '../../../core/runtime/types';
import { t } from '../../../i18n/i18n';
import { appendMarkdownSnippet } from '../../../shared/markdown/markdown';
import type { ChatState } from '../state/chat-state';
import type { QueuedMessage } from '../state/types';
import type { ImageContextManager } from '../ui/image-context';
import {
cloneChatTurnRequest,
mergeQueuedChatTurns,
type QueuedChatTurn,
} from './queued-turn';

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

/** Owns the single queued turn, its composer projection, and indicator UI. */
let nextQueuedMessageId = 1;

/** Owns the FIFO queue of pending turns, its composer projection, and list UI. */
export class QueuedMessageController {
private collapsed = false;
private draggingId: string | null = null;

constructor(private readonly deps: QueuedMessageControllerDeps) {}

enqueue(displayContent: string, turnRequest: ChatTurnRequest): void {
const incoming = this.createQueuedMessage(displayContent, turnRequest);
this.deps.state.queuedMessage = this.mergeQueuedMessages(
this.deps.state.queuedMessage,
incoming,
);
this.deps.state.queuedMessages = [...this.deps.state.queuedMessages, incoming];
this.updateIndicator();
}

updateIndicator(): void {
const { state } = this.deps;
const indicatorEl = state.queueIndicatorEl;
if (!indicatorEl) return;
indicatorEl.empty();

const message = state.queuedMessage;
if (!message) {
indicatorEl.removeClass('qoderian-visible-flex');
indicatorEl.addClass('qoderian-hidden');
const containerEl = state.queueIndicatorEl;
if (!containerEl) return;
containerEl.empty();

const messages = state.queuedMessages;
if (messages.length === 0) {
containerEl.removeClass('qoderian-visible-flex');
containerEl.addClass('qoderian-hidden');
return;
}

indicatorEl.createSpan({
cls: 'qoderian-queue-indicator-text',
text: `⌙ Queued: ${this.getQueuedMessageDisplay(message)}`,
const headerEl = containerEl.createDiv({ cls: 'qoderian-queue-header' });
const toggleEl = headerEl.createEl('button', {
cls: 'qoderian-queue-header-toggle',
attr: {
'aria-label': this.collapsed ? t('chat.queue.expand') : t('chat.queue.collapse'),
title: this.collapsed ? t('chat.queue.expand') : t('chat.queue.collapse'),
type: 'button',
},
});
const actionsEl = indicatorEl.createDiv({ cls: 'qoderian-queue-indicator-actions' });
const editButton = this.createIconButton(actionsEl, 'pencil', 'Edit queued message');
editButton.addEventListener('click', (event) => {
setIcon(toggleEl, this.collapsed ? 'chevron-right' : 'chevron-down');
toggleEl.addEventListener('click', (event) => {
event.stopPropagation();
this.withdrawToComposer();
this.collapsed = !this.collapsed;
this.updateIndicator();
});
const discardButton = this.createIconButton(actionsEl, 'trash-2', 'Discard queued message');
discardButton.addEventListener('click', (event) => {
event.stopPropagation();
this.clear();
headerEl.createSpan({
cls: 'qoderian-queue-header-title',
text: t('chat.queue.title', { count: messages.length }),
});
indicatorEl.addClass('qoderian-visible-flex');
indicatorEl.removeClass('qoderian-hidden');

if (!this.collapsed) {
const listEl = containerEl.createDiv({ cls: 'qoderian-queue-list' });
for (const message of messages) {
this.renderRow(listEl, message);
}
}

containerEl.addClass('qoderian-visible-flex');
containerEl.removeClass('qoderian-hidden');
}

clear(): void {
this.deps.state.queuedMessage = null;
this.deps.state.queuedMessages = [];
this.updateIndicator();
}

withdrawToComposer(): void {
const { state } = this.deps;
if (!state.queuedMessage) return;
const queuedMessage = this.cloneQueuedMessage(state.queuedMessage);
state.queuedMessage = null;
this.restoreMessageToInput(queuedMessage, true);
/** Remove one item by id. */
discard(id: string): void {
const messages = this.deps.state.queuedMessages;
if (!messages.some(message => message.id === id)) return;
this.deps.state.queuedMessages = messages.filter(message => message.id !== id);
this.updateIndicator();
}

restorePendingToComposer(): void {
/** Withdraw one item back into the composer. */
withdrawToComposer(id: string): void {
const { state } = this.deps;
this.restoreMessageToInput(state.queuedMessage, true);
state.queuedMessage = null;
const target = state.queuedMessages.find(message => message.id === id);
if (!target) return;
state.queuedMessages = state.queuedMessages.filter(message => message.id !== id);
this.restoreMessageToInput(target, true);
this.updateIndicator();
}

/** Drain the head of the queue at turn end. */
process(): void {
const { state } = this.deps;
if (!state.queuedMessage) return;
const queuedMessage = this.cloneQueuedMessage(state.queuedMessage);
state.queuedMessage = null;
const next = state.queuedMessages[0];
if (!next) return;
state.queuedMessages = state.queuedMessages.slice(1);
this.updateIndicator();
window.setTimeout(() => this.deps.sendQueuedTurn(this.toQueuedChatTurn(queuedMessage)), 0);
window.setTimeout(() => this.deps.sendQueuedTurn(this.toQueuedChatTurn(next)), 0);
}

private restoreMessageToInput(message: QueuedMessage | null, mergeWithComposer: boolean): void {
if (!message) return;
private renderRow(listEl: HTMLElement, message: QueuedMessage): void {
const rowEl = listEl.createDiv({ cls: 'qoderian-queue-row' });
rowEl.dataset.queueId = message.id;

const handleEl = rowEl.createSpan({
cls: 'qoderian-queue-row-handle',
attr: {
'aria-label': t('chat.queue.drag'),
title: t('chat.queue.dragTooltip'),
draggable: 'true',
},
});
setIcon(handleEl, 'grip-vertical');
this.attachDragHandlers(handleEl, rowEl);

rowEl.createSpan({
cls: 'qoderian-queue-row-text',
text: this.getQueuedMessageDisplay(message),
attr: { title: message.content.trim() },
});

const actionsEl = rowEl.createDiv({ cls: 'qoderian-queue-row-actions' });
const editEl = this.createIconButton(actionsEl, 'pencil', t('chat.queue.edit'));
editEl.addEventListener('click', (event) => {
event.stopPropagation();
this.withdrawToComposer(message.id);
});

const deleteEl = this.createIconButton(actionsEl, 'trash-2', t('chat.queue.delete'));
deleteEl.addEventListener('click', (event) => {
event.stopPropagation();
this.discard(message.id);
});
}

private restoreMessageToInput(message: QueuedMessage, mergeWithComposer: boolean): void {
const inputEl = this.deps.getInputEl();
const currentContent = mergeWithComposer ? inputEl.value.trim() : '';
inputEl.value = currentContent
Expand All @@ -119,6 +170,49 @@ export class QueuedMessageController {
return preview;
}

private attachDragHandlers(handleEl: HTMLElement, rowEl: HTMLElement): void {
handleEl.addEventListener('dragstart', (event) => {
this.draggingId = rowEl.dataset.queueId ?? null;
rowEl.addClass('qoderian-queue-row-dragging');
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', this.draggingId ?? '');
}
});
handleEl.addEventListener('dragend', () => {
rowEl.removeClass('qoderian-queue-row-dragging');
this.draggingId = null;
this.commitDomOrder();
});
rowEl.addEventListener('dragover', (event) => {
if (!this.draggingId || rowEl.dataset.queueId === this.draggingId) return;
event.preventDefault();
const listEl = rowEl.parentElement;
const draggingEl = listEl?.querySelector(`[data-queue-id="${this.draggingId}"]`);
if (!listEl || !draggingEl) return;
const rect = rowEl.getBoundingClientRect();
const before = event.clientY < rect.top + rect.height / 2;
listEl.insertBefore(draggingEl, before ? rowEl : rowEl.nextSibling);
});
}

/** Persist the DOM order after a drag ends. */
private commitDomOrder(): void {
const { state } = this.deps;
const containerEl = state.queueIndicatorEl;
if (!containerEl) return;
const ids = [...containerEl.querySelectorAll<HTMLElement>('[data-queue-id]')]
.map(el => el.dataset.queueId ?? '');
const byId = new Map(state.queuedMessages.map(message => [message.id, message]));
const reordered = ids
.map(id => byId.get(id))
.filter((message): message is QueuedMessage => Boolean(message));
for (const message of state.queuedMessages) {
if (!ids.includes(message.id)) reordered.push(message);
}
state.queuedMessages = reordered;
}

private createIconButton(parentEl: HTMLElement, icon: string, label: string): HTMLElement {
const button = parentEl.createEl('button', {
cls: 'qoderian-queue-indicator-icon-action',
Expand All @@ -128,17 +222,10 @@ export class QueuedMessageController {
return button;
}

private cloneQueuedMessage(message: QueuedMessage): QueuedMessage {
return {
...message,
images: message.images ? [...message.images] : undefined,
turnRequest: message.turnRequest ? cloneChatTurnRequest(message.turnRequest) : undefined,
};
}

private createQueuedMessage(displayContent: string, turnRequest: ChatTurnRequest): QueuedMessage {
const request = cloneChatTurnRequest(turnRequest);
return {
id: `queued-${nextQueuedMessageId++}`,
content: displayContent,
images: request.images,
editorContext: request.editorSelection ?? null,
Expand Down Expand Up @@ -166,13 +253,4 @@ export class QueuedMessageController {
},
};
}

private mergeQueuedMessages(existing: QueuedMessage | null, incoming: QueuedMessage): QueuedMessage {
if (!existing) return this.cloneQueuedMessage(incoming);
const mergedTurn = mergeQueuedChatTurns(
this.toQueuedChatTurn(existing),
this.toQueuedChatTurn(incoming),
);
return this.createQueuedMessage(mergedTurn.displayContent, mergedTurn.request);
}
}
12 changes: 6 additions & 6 deletions src/features/chat/state/chat-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ function createInitialState(): ChatStateData {
isSwitchingConversation: false,
hasPendingConversationSave: false,
currentConversationId: null,
queuedMessage: null,
queuedMessages: [],
currentContentEl: null,
currentTextEl: null,
currentTextContent: '',
Expand Down Expand Up @@ -163,12 +163,12 @@ export class ChatState {
// Queued Message
// ============================================

get queuedMessage(): QueuedMessage | null {
return this.state.queuedMessage;
get queuedMessages(): QueuedMessage[] {
return this.state.queuedMessages;
}

set queuedMessage(value: QueuedMessage | null) {
this.state.queuedMessage = value;
set queuedMessages(value: QueuedMessage[]) {
this.state.queuedMessages = value;
}

// ============================================
Expand Down Expand Up @@ -392,7 +392,7 @@ export class ChatState {
this.clearMessages();
this.resetStreamingState();
this.clearMaps();
this.state.queuedMessage = null;
this.state.queuedMessages = [];
this.usage = null;
this.autoScrollEnabled = true;
}
Expand Down
6 changes: 4 additions & 2 deletions src/features/chat/state/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import type { WriteEditState } from '../rendering/write-edit-renderer';

/** Queued message waiting to be sent after current streaming completes. */
export interface QueuedMessage {
/** Stable id used for list rendering and per-item actions. */
id: string;
content: string;
images?: ImageAttachment[];
editorContext: EditorSelectionContext | null;
Expand Down Expand Up @@ -61,8 +63,8 @@ export interface ChatStateData {
// Conversation identity
currentConversationId: string | null;

// Queued message
queuedMessage: QueuedMessage | null;
// Queued messages (FIFO; drained one per completed turn)
queuedMessages: QueuedMessage[];

// Active streaming DOM state
currentContentEl: HTMLElement | null;
Expand Down
9 changes: 9 additions & 0 deletions src/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,15 @@
"conversation": {
"emptyPreview": "Neue Unterhaltung",
"deleteFailed": "Unterhaltung konnte nicht gelöscht werden: {error}"
},
"queue": {
"title": "Warten auf Senden {count}",
"edit": "Bearbeiten",
"delete": "Löschen",
"drag": "Ziehen",
"dragTooltip": "Ziehen, um die Reihenfolge zu ändern",
"collapse": "Warteschlange einklappen",
"expand": "Warteschlange ausklappen"
}
},
"settings": {
Expand Down
Loading
Loading