Skip to content

Commit c951fde

Browse files
luoxuanzaoQoder-AI
andauthored
feat: pause send queue on interrupt (Codex-style) and edit-overwrites-composer (#32)
* feat: pause send queue when a turn is interrupted (Codex-style) Stopping a streaming turn now suspends the queue auto-drain instead of sending the head entry: the panel header switches to a paused notice with a Resume action, queued entries are preserved, and resuming (or clearing the queue) restores the normal one-per-turn drain. Paused/resume strings localized in all ten locales. Co-authored-by: QoderAI (Qwen 3.8 Max) <qoder_ai@qoder.com> * fix: editing a queued message replaces composer content Withdrawing a queued entry into the composer now overwrites the input (and attached images) instead of appending, matching the edit affordance. Co-authored-by: QoderAI (Qwen 3.8 Max) <qoder_ai@qoder.com> --------- Co-authored-by: QoderAI (Qwen 3.8 Max) <qoder_ai@qoder.com>
1 parent 3cee7e2 commit c951fde

18 files changed

Lines changed: 184 additions & 47 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,12 @@ version with its date and start a fresh empty `[Unreleased]` above it.
2121

2222
### Changed
2323

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

2831
## [1.0.5] - 2026-08-18
2932

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -818,6 +818,8 @@ export class InputController {
818818
const { state, streamController } = this.deps;
819819
if (!state.isStreaming) return;
820820
state.cancelRequested = true;
821+
// Codex-style: interrupting a turn pauses the queue instead of draining it.
822+
this.queuedMessages.pause();
821823
this.getAgentService()?.cancel();
822824
streamController.hideThinkingIndicator();
823825
}

src/features/chat/controllers/queued-message-controller.ts

Lines changed: 71 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { setIcon } from 'obsidian';
22

33
import type { ChatTurnRequest } from '../../../core/runtime/types';
44
import { t } from '../../../i18n/i18n';
5-
import { appendMarkdownSnippet } from '../../../shared/markdown/markdown';
65
import type { ChatState } from '../state/chat-state';
76
import type { QueuedMessage } from '../state/types';
87
import type { ImageContextManager } from '../ui/image-context';
@@ -42,32 +41,19 @@ export class QueuedMessageController {
4241

4342
const messages = state.queuedMessages;
4443
if (messages.length === 0) {
44+
state.queuePaused = false;
4545
containerEl.removeClass('qoderian-visible-flex');
4646
containerEl.addClass('qoderian-hidden');
4747
return;
4848
}
4949

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-
},
58-
});
59-
setIcon(toggleEl, this.collapsed ? 'chevron-right' : 'chevron-down');
60-
toggleEl.addEventListener('click', (event) => {
61-
event.stopPropagation();
62-
this.collapsed = !this.collapsed;
63-
this.updateIndicator();
64-
});
65-
headerEl.createSpan({
66-
cls: 'qoderian-queue-header-title',
67-
text: t('chat.queue.title', { count: messages.length }),
68-
});
50+
if (state.queuePaused) {
51+
this.renderPausedHeader(containerEl);
52+
} else {
53+
this.renderCollapsibleHeader(containerEl, messages.length);
54+
}
6955

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

94-
/** Withdraw one item back into the composer. */
80+
/** Withdraw one item back into the composer, replacing its content. */
9581
withdrawToComposer(id: string): void {
9682
const { state } = this.deps;
9783
const target = state.queuedMessages.find(message => message.id === id);
9884
if (!target) return;
9985
state.queuedMessages = state.queuedMessages.filter(message => message.id !== id);
100-
this.restoreMessageToInput(target, true);
86+
this.restoreMessageToInput(target);
87+
this.updateIndicator();
88+
}
89+
90+
/** Suspend auto-drain after the user interrupts a turn (Codex-style pause). */
91+
pause(): void {
92+
const { state } = this.deps;
93+
if (state.queuedMessages.length === 0) return;
94+
state.queuePaused = true;
10195
this.updateIndicator();
10296
}
10397

98+
/** Resume auto-drain and immediately send the head entry. */
99+
resume(): void {
100+
const { state } = this.deps;
101+
state.queuePaused = false;
102+
this.updateIndicator();
103+
this.process();
104+
}
105+
104106
/** Drain the head of the queue at turn end. */
105107
process(): void {
106108
const { state } = this.deps;
109+
if (state.queuePaused) return;
107110
const next = state.queuedMessages[0];
108111
if (!next) return;
109112
state.queuedMessages = state.queuedMessages.slice(1);
110113
this.updateIndicator();
111114
window.setTimeout(() => this.deps.sendQueuedTurn(this.toQueuedChatTurn(next)), 0);
112115
}
113116

117+
private renderCollapsibleHeader(containerEl: HTMLElement, count: number): void {
118+
const headerEl = containerEl.createDiv({ cls: 'qoderian-queue-header' });
119+
const toggleEl = headerEl.createEl('button', {
120+
cls: 'qoderian-queue-header-toggle',
121+
attr: {
122+
'aria-label': this.collapsed ? t('chat.queue.expand') : t('chat.queue.collapse'),
123+
title: this.collapsed ? t('chat.queue.expand') : t('chat.queue.collapse'),
124+
type: 'button',
125+
},
126+
});
127+
setIcon(toggleEl, this.collapsed ? 'chevron-right' : 'chevron-down');
128+
toggleEl.addEventListener('click', (event) => {
129+
event.stopPropagation();
130+
this.collapsed = !this.collapsed;
131+
this.updateIndicator();
132+
});
133+
headerEl.createSpan({
134+
cls: 'qoderian-queue-header-title',
135+
text: t('chat.queue.title', { count }),
136+
});
137+
}
138+
139+
private renderPausedHeader(containerEl: HTMLElement): void {
140+
const headerEl = containerEl.createDiv({ cls: 'qoderian-queue-header qoderian-queue-header-paused' });
141+
const pauseIconEl = headerEl.createSpan({ cls: 'qoderian-queue-paused-icon' });
142+
setIcon(pauseIconEl, 'pause');
143+
headerEl.createSpan({
144+
cls: 'qoderian-queue-header-title',
145+
text: t('chat.queue.paused'),
146+
});
147+
const resumeEl = headerEl.createEl('button', {
148+
cls: 'qoderian-queue-resume',
149+
attr: { 'aria-label': t('chat.queue.resume'), title: t('chat.queue.resume'), type: 'button' },
150+
});
151+
setIcon(resumeEl, 'play');
152+
resumeEl.createSpan({ cls: 'qoderian-queue-resume-label', text: t('chat.queue.resume') });
153+
resumeEl.addEventListener('click', (event) => {
154+
event.stopPropagation();
155+
this.resume();
156+
});
157+
}
158+
114159
private renderRow(listEl: HTMLElement, message: QueuedMessage): void {
115160
const rowEl = listEl.createDiv({ cls: 'qoderian-queue-row' });
116161
rowEl.dataset.queueId = message.id;
@@ -146,19 +191,12 @@ export class QueuedMessageController {
146191
});
147192
}
148193

149-
private restoreMessageToInput(message: QueuedMessage, mergeWithComposer: boolean): void {
194+
private restoreMessageToInput(message: QueuedMessage): void {
150195
const inputEl = this.deps.getInputEl();
151-
const currentContent = mergeWithComposer ? inputEl.value.trim() : '';
152-
inputEl.value = currentContent
153-
? appendMarkdownSnippet(message.content, currentContent)
154-
: message.content;
196+
inputEl.value = message.content;
155197

156198
const imageContextManager = this.deps.getImageContextManager();
157-
const currentImages = mergeWithComposer
158-
? (imageContextManager?.getAttachedImages() ?? [])
159-
: [];
160-
const restoredImages = [...(message.images ?? []), ...currentImages];
161-
if (restoredImages.length > 0) imageContextManager?.setImages(restoredImages);
199+
imageContextManager?.setImages([...(message.images ?? [])]);
162200
this.deps.resetInputHeight();
163201
inputEl.focus();
164202
}

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ function createInitialState(): ChatStateData {
2020
hasPendingConversationSave: false,
2121
currentConversationId: null,
2222
queuedMessages: [],
23+
queuePaused: false,
2324
currentContentEl: null,
2425
currentTextEl: null,
2526
currentTextContent: '',
@@ -171,6 +172,14 @@ export class ChatState {
171172
this.state.queuedMessages = value;
172173
}
173174

175+
get queuePaused(): boolean {
176+
return this.state.queuePaused;
177+
}
178+
179+
set queuePaused(value: boolean) {
180+
this.state.queuePaused = value;
181+
}
182+
174183
// ============================================
175184
// Streaming DOM State
176185
// ============================================
@@ -393,6 +402,7 @@ export class ChatState {
393402
this.resetStreamingState();
394403
this.clearMaps();
395404
this.state.queuedMessages = [];
405+
this.state.queuePaused = false;
396406
this.usage = null;
397407
this.autoScrollEnabled = true;
398408
}

src/features/chat/state/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ export interface ChatStateData {
6565

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

6971
// Active streaming DOM state
7072
currentContentEl: HTMLElement | null;

src/i18n/locales/de.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,9 @@
106106
"drag": "Ziehen",
107107
"dragTooltip": "Ziehen, um die Reihenfolge zu ändern",
108108
"collapse": "Warteschlange einklappen",
109-
"expand": "Warteschlange ausklappen"
109+
"expand": "Warteschlange ausklappen",
110+
"paused": "Warteschlange pausiert, weil du die aktuelle Antwort unterbrochen hast",
111+
"resume": "Fortsetzen"
110112
}
111113
},
112114
"settings": {

src/i18n/locales/en.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,9 @@
106106
"drag": "Drag",
107107
"dragTooltip": "Drag to reorder",
108108
"collapse": "Collapse queue",
109-
"expand": "Expand queue"
109+
"expand": "Expand queue",
110+
"paused": "Queue paused because you interrupted the current response",
111+
"resume": "Resume"
110112
}
111113
},
112114
"settings": {

src/i18n/locales/es.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,9 @@
106106
"drag": "Arrastrar",
107107
"dragTooltip": "Arrastra para reordenar",
108108
"collapse": "Contraer cola",
109-
"expand": "Expandir cola"
109+
"expand": "Expandir cola",
110+
"paused": "Cola en pausa porque interrumpiste la respuesta actual",
111+
"resume": "Continuar"
110112
}
111113
},
112114
"settings": {

src/i18n/locales/fr.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,9 @@
106106
"drag": "Glisser",
107107
"dragTooltip": "Glisser pour réordonner",
108108
"collapse": "Replier la file",
109-
"expand": "Déplier la file"
109+
"expand": "Déplier la file",
110+
"paused": "File en pause car vous avez interrompu la réponse en cours",
111+
"resume": "Reprendre"
110112
}
111113
},
112114
"settings": {

src/i18n/locales/ja.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,9 @@
106106
"drag": "ドラッグ",
107107
"dragTooltip": "ドラッグで順序を変更",
108108
"collapse": "キューを折りたたむ",
109-
"expand": "キューを展開"
109+
"expand": "キューを展開",
110+
"paused": "現在の応答を中断したため、キューを一時停止しました",
111+
"resume": "再開"
110112
}
111113
},
112114
"settings": {

0 commit comments

Comments
 (0)