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

### 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").
- Stopping a streaming turn now pauses the send queue (Codex-style):
queued messages are preserved but no longer auto-sent, the panel header
switches to a "queue paused" notice with a Resume action, and resuming
(or clearing the queue) restores the normal one-per-turn drain.
- Editing a queued message now replaces the composer content instead of
appending to it.

## [1.0.5] - 2026-08-18

Expand Down
2 changes: 2 additions & 0 deletions src/features/chat/controllers/input-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -818,6 +818,8 @@ export class InputController {
const { state, streamController } = this.deps;
if (!state.isStreaming) return;
state.cancelRequested = true;
// Codex-style: interrupting a turn pauses the queue instead of draining it.
this.queuedMessages.pause();
this.getAgentService()?.cancel();
streamController.hideThinkingIndicator();
}
Expand Down
104 changes: 71 additions & 33 deletions src/features/chat/controllers/queued-message-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ 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';
Expand Down Expand Up @@ -42,32 +41,19 @@ export class QueuedMessageController {

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

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',
},
});
setIcon(toggleEl, this.collapsed ? 'chevron-right' : 'chevron-down');
toggleEl.addEventListener('click', (event) => {
event.stopPropagation();
this.collapsed = !this.collapsed;
this.updateIndicator();
});
headerEl.createSpan({
cls: 'qoderian-queue-header-title',
text: t('chat.queue.title', { count: messages.length }),
});
if (state.queuePaused) {
this.renderPausedHeader(containerEl);
} else {
this.renderCollapsibleHeader(containerEl, messages.length);
}

if (!this.collapsed) {
if (!this.collapsed || state.queuePaused) {
const listEl = containerEl.createDiv({ cls: 'qoderian-queue-list' });
for (const message of messages) {
this.renderRow(listEl, message);
Expand All @@ -91,26 +77,85 @@ export class QueuedMessageController {
this.updateIndicator();
}

/** Withdraw one item back into the composer. */
/** Withdraw one item back into the composer, replacing its content. */
withdrawToComposer(id: string): void {
const { state } = this.deps;
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.restoreMessageToInput(target);
this.updateIndicator();
}

/** Suspend auto-drain after the user interrupts a turn (Codex-style pause). */
pause(): void {
const { state } = this.deps;
if (state.queuedMessages.length === 0) return;
state.queuePaused = true;
this.updateIndicator();
}

/** Resume auto-drain and immediately send the head entry. */
resume(): void {
const { state } = this.deps;
state.queuePaused = false;
this.updateIndicator();
this.process();
}

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

private renderCollapsibleHeader(containerEl: HTMLElement, count: number): void {
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',
},
});
setIcon(toggleEl, this.collapsed ? 'chevron-right' : 'chevron-down');
toggleEl.addEventListener('click', (event) => {
event.stopPropagation();
this.collapsed = !this.collapsed;
this.updateIndicator();
});
headerEl.createSpan({
cls: 'qoderian-queue-header-title',
text: t('chat.queue.title', { count }),
});
}

private renderPausedHeader(containerEl: HTMLElement): void {
const headerEl = containerEl.createDiv({ cls: 'qoderian-queue-header qoderian-queue-header-paused' });
const pauseIconEl = headerEl.createSpan({ cls: 'qoderian-queue-paused-icon' });
setIcon(pauseIconEl, 'pause');
headerEl.createSpan({
cls: 'qoderian-queue-header-title',
text: t('chat.queue.paused'),
});
const resumeEl = headerEl.createEl('button', {
cls: 'qoderian-queue-resume',
attr: { 'aria-label': t('chat.queue.resume'), title: t('chat.queue.resume'), type: 'button' },
});
setIcon(resumeEl, 'play');
resumeEl.createSpan({ cls: 'qoderian-queue-resume-label', text: t('chat.queue.resume') });
resumeEl.addEventListener('click', (event) => {
event.stopPropagation();
this.resume();
});
}

private renderRow(listEl: HTMLElement, message: QueuedMessage): void {
const rowEl = listEl.createDiv({ cls: 'qoderian-queue-row' });
rowEl.dataset.queueId = message.id;
Expand Down Expand Up @@ -146,19 +191,12 @@ export class QueuedMessageController {
});
}

private restoreMessageToInput(message: QueuedMessage, mergeWithComposer: boolean): void {
private restoreMessageToInput(message: QueuedMessage): void {
const inputEl = this.deps.getInputEl();
const currentContent = mergeWithComposer ? inputEl.value.trim() : '';
inputEl.value = currentContent
? appendMarkdownSnippet(message.content, currentContent)
: message.content;
inputEl.value = message.content;

const imageContextManager = this.deps.getImageContextManager();
const currentImages = mergeWithComposer
? (imageContextManager?.getAttachedImages() ?? [])
: [];
const restoredImages = [...(message.images ?? []), ...currentImages];
if (restoredImages.length > 0) imageContextManager?.setImages(restoredImages);
imageContextManager?.setImages([...(message.images ?? [])]);
this.deps.resetInputHeight();
inputEl.focus();
}
Expand Down
10 changes: 10 additions & 0 deletions src/features/chat/state/chat-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ function createInitialState(): ChatStateData {
hasPendingConversationSave: false,
currentConversationId: null,
queuedMessages: [],
queuePaused: false,
currentContentEl: null,
currentTextEl: null,
currentTextContent: '',
Expand Down Expand Up @@ -171,6 +172,14 @@ export class ChatState {
this.state.queuedMessages = value;
}

get queuePaused(): boolean {
return this.state.queuePaused;
}

set queuePaused(value: boolean) {
this.state.queuePaused = value;
}

// ============================================
// Streaming DOM State
// ============================================
Expand Down Expand Up @@ -393,6 +402,7 @@ export class ChatState {
this.resetStreamingState();
this.clearMaps();
this.state.queuedMessages = [];
this.state.queuePaused = false;
this.usage = null;
this.autoScrollEnabled = true;
}
Expand Down
2 changes: 2 additions & 0 deletions src/features/chat/state/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ export interface ChatStateData {

// Queued messages (FIFO; drained one per completed turn)
queuedMessages: QueuedMessage[];
/** Queue auto-drain suspended after the user interrupted a turn. */
queuePaused: boolean;

// Active streaming DOM state
currentContentEl: HTMLElement | null;
Expand Down
4 changes: 3 additions & 1 deletion src/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,9 @@
"drag": "Ziehen",
"dragTooltip": "Ziehen, um die Reihenfolge zu ändern",
"collapse": "Warteschlange einklappen",
"expand": "Warteschlange ausklappen"
"expand": "Warteschlange ausklappen",
"paused": "Warteschlange pausiert, weil du die aktuelle Antwort unterbrochen hast",
"resume": "Fortsetzen"
}
},
"settings": {
Expand Down
4 changes: 3 additions & 1 deletion src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,9 @@
"drag": "Drag",
"dragTooltip": "Drag to reorder",
"collapse": "Collapse queue",
"expand": "Expand queue"
"expand": "Expand queue",
"paused": "Queue paused because you interrupted the current response",
"resume": "Resume"
}
},
"settings": {
Expand Down
4 changes: 3 additions & 1 deletion src/i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,9 @@
"drag": "Arrastrar",
"dragTooltip": "Arrastra para reordenar",
"collapse": "Contraer cola",
"expand": "Expandir cola"
"expand": "Expandir cola",
"paused": "Cola en pausa porque interrumpiste la respuesta actual",
"resume": "Continuar"
}
},
"settings": {
Expand Down
4 changes: 3 additions & 1 deletion src/i18n/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,9 @@
"drag": "Glisser",
"dragTooltip": "Glisser pour réordonner",
"collapse": "Replier la file",
"expand": "Déplier la file"
"expand": "Déplier la file",
"paused": "File en pause car vous avez interrompu la réponse en cours",
"resume": "Reprendre"
}
},
"settings": {
Expand Down
4 changes: 3 additions & 1 deletion src/i18n/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,9 @@
"drag": "ドラッグ",
"dragTooltip": "ドラッグで順序を変更",
"collapse": "キューを折りたたむ",
"expand": "キューを展開"
"expand": "キューを展開",
"paused": "現在の応答を中断したため、キューを一時停止しました",
"resume": "再開"
}
},
"settings": {
Expand Down
4 changes: 3 additions & 1 deletion src/i18n/locales/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,9 @@
"drag": "드래그",
"dragTooltip": "드래그하여 순서 변경",
"collapse": "큐 접기",
"expand": "큐 펼치기"
"expand": "큐 펼치기",
"paused": "현재 응답을 중단했기 때문에 대기열이 일시 중지되었습니다",
"resume": "계속"
}
},
"settings": {
Expand Down
4 changes: 3 additions & 1 deletion src/i18n/locales/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,9 @@
"drag": "Arrastar",
"dragTooltip": "Arraste para reordenar",
"collapse": "Recolher fila",
"expand": "Expandir fila"
"expand": "Expandir fila",
"paused": "Fila pausada porque você interrompeu a resposta atual",
"resume": "Continuar"
}
},
"settings": {
Expand Down
4 changes: 3 additions & 1 deletion src/i18n/locales/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,9 @@
"drag": "Перетащить",
"dragTooltip": "Перетащите, чтобы изменить порядок",
"collapse": "Свернуть очередь",
"expand": "Развернуть очередь"
"expand": "Развернуть очередь",
"paused": "Очередь на паузе: вы прервали текущий ответ",
"resume": "Продолжить"
}
},
"settings": {
Expand Down
4 changes: 3 additions & 1 deletion src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,9 @@
"drag": "拖拽",
"dragTooltip": "拖拽调整顺序",
"collapse": "折叠队列",
"expand": "展开队列"
"expand": "展开队列",
"paused": "由于你中断了当前响应,队列已暂停",
"resume": "继续"
}
},
"settings": {
Expand Down
4 changes: 3 additions & 1 deletion src/i18n/locales/zh-TW.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,9 @@
"drag": "拖曳",
"dragTooltip": "拖曳調整順序",
"collapse": "折疊佇列",
"expand": "展開佇列"
"expand": "展開佇列",
"paused": "由於你中斷了當前回應,佇列已暫停",
"resume": "繼續"
}
},
"settings": {
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@ export type TranslationKey =
| 'chat.queue.dragTooltip'
| 'chat.queue.collapse'
| 'chat.queue.expand'
| 'chat.queue.paused'
| 'chat.queue.resume'

// Settings - Section Headings
| 'settings.title'
Expand Down
37 changes: 37 additions & 0 deletions src/style/components/input.css
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,43 @@
color: var(--text-normal);
}

.qoderian-queue-paused-icon {
display: inline-flex;
align-items: center;
flex: 0 0 auto;
color: var(--text-muted);
}

.qoderian-queue-paused-icon svg {
width: 14px;
height: 14px;
}

.qoderian-input-queue-row button.qoderian-queue-resume {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: auto;
padding: 2px 8px;
border: 0;
border-radius: 6px;
background: transparent;
box-shadow: none;
color: var(--text-normal);
font: inherit;
font-weight: 600;
cursor: pointer;
}

.qoderian-input-queue-row button.qoderian-queue-resume:hover {
background: var(--background-modifier-hover);
}

.qoderian-queue-resume svg {
width: 12px;
height: 12px;
}

.qoderian-queue-list {
display: flex;
flex-direction: column;
Expand Down
Loading
Loading