diff --git a/CHANGELOG.md b/CHANGELOG.md index a6ec887..fb3d0cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/features/chat/controllers/input-controller.ts b/src/features/chat/controllers/input-controller.ts index 71bf909..633a621 100644 --- a/src/features/chat/controllers/input-controller.ts +++ b/src/features/chat/controllers/input-controller.ts @@ -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(); } diff --git a/src/features/chat/controllers/queued-message-controller.ts b/src/features/chat/controllers/queued-message-controller.ts index 9cb3edf..d75e644 100644 --- a/src/features/chat/controllers/queued-message-controller.ts +++ b/src/features/chat/controllers/queued-message-controller.ts @@ -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'; @@ -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); @@ -91,19 +77,36 @@ 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); @@ -111,6 +114,48 @@ export class QueuedMessageController { 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; @@ -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(); } diff --git a/src/features/chat/state/chat-state.ts b/src/features/chat/state/chat-state.ts index 999a0b8..2c7e8cd 100644 --- a/src/features/chat/state/chat-state.ts +++ b/src/features/chat/state/chat-state.ts @@ -20,6 +20,7 @@ function createInitialState(): ChatStateData { hasPendingConversationSave: false, currentConversationId: null, queuedMessages: [], + queuePaused: false, currentContentEl: null, currentTextEl: null, currentTextContent: '', @@ -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 // ============================================ @@ -393,6 +402,7 @@ export class ChatState { this.resetStreamingState(); this.clearMaps(); this.state.queuedMessages = []; + this.state.queuePaused = false; this.usage = null; this.autoScrollEnabled = true; } diff --git a/src/features/chat/state/types.ts b/src/features/chat/state/types.ts index e5bea4c..df40fa0 100644 --- a/src/features/chat/state/types.ts +++ b/src/features/chat/state/types.ts @@ -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; diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 0169e33..3007f15 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -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": { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 1135e5f..7b24730 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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": { diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index b7bfc11..e2d81d0 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -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": { diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 18c3b7b..ace8794 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -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": { diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index e8c99c1..2c8bde6 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -106,7 +106,9 @@ "drag": "ドラッグ", "dragTooltip": "ドラッグで順序を変更", "collapse": "キューを折りたたむ", - "expand": "キューを展開" + "expand": "キューを展開", + "paused": "現在の応答を中断したため、キューを一時停止しました", + "resume": "再開" } }, "settings": { diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 9ac608d..bdb9593 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -106,7 +106,9 @@ "drag": "드래그", "dragTooltip": "드래그하여 순서 변경", "collapse": "큐 접기", - "expand": "큐 펼치기" + "expand": "큐 펼치기", + "paused": "현재 응답을 중단했기 때문에 대기열이 일시 중지되었습니다", + "resume": "계속" } }, "settings": { diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 43f26cd..4c0ef2e 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -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": { diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index ce029e2..52aac3d 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -106,7 +106,9 @@ "drag": "Перетащить", "dragTooltip": "Перетащите, чтобы изменить порядок", "collapse": "Свернуть очередь", - "expand": "Развернуть очередь" + "expand": "Развернуть очередь", + "paused": "Очередь на паузе: вы прервали текущий ответ", + "resume": "Продолжить" } }, "settings": { diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 0b3862a..5167bd5 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -106,7 +106,9 @@ "drag": "拖拽", "dragTooltip": "拖拽调整顺序", "collapse": "折叠队列", - "expand": "展开队列" + "expand": "展开队列", + "paused": "由于你中断了当前响应,队列已暂停", + "resume": "继续" } }, "settings": { diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index b01acd3..74f57f2 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -106,7 +106,9 @@ "drag": "拖曳", "dragTooltip": "拖曳調整順序", "collapse": "折疊佇列", - "expand": "展開佇列" + "expand": "展開佇列", + "paused": "由於你中斷了當前回應,佇列已暫停", + "resume": "繼續" } }, "settings": { diff --git a/src/i18n/types.ts b/src/i18n/types.ts index ac580fe..60c7dff 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -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' diff --git a/src/style/components/input.css b/src/style/components/input.css index 203b3a5..44e240f 100644 --- a/src/style/components/input.css +++ b/src/style/components/input.css @@ -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; diff --git a/tests/unit/features/chat/controllers/queued-message-controller.test.ts b/tests/unit/features/chat/controllers/queued-message-controller.test.ts index 64131eb..b5ae514 100644 --- a/tests/unit/features/chat/controllers/queued-message-controller.test.ts +++ b/tests/unit/features/chat/controllers/queued-message-controller.test.ts @@ -135,8 +135,9 @@ describe('QueuedMessageController', () => { expect(state.queuedMessages.map(message => message.content)).toEqual(['second']); }); - it('withdrawToComposer moves one item back into the input', () => { + it('withdrawToComposer replaces the input content', () => { const { controller, state, inputEl } = createController(); + inputEl.value = 'draft in progress'; controller.enqueue('first', turnRequest('first')); controller.enqueue('second', turnRequest('second')); @@ -146,6 +147,28 @@ describe('QueuedMessageController', () => { expect(state.queuedMessages.map(message => message.content)).toEqual(['first']); }); + it('pause blocks process until resume', () => { + const { controller, state, sendQueuedTurn } = createController(); + controller.enqueue('first', turnRequest('first')); + controller.enqueue('second', turnRequest('second')); + + controller.pause(); + controller.process(); + jest.runAllTimers(); + + expect(sendQueuedTurn).not.toHaveBeenCalled(); + expect(state.queuedMessages).toHaveLength(2); + expect(state.queueIndicatorEl!.querySelector('.qoderian-queue-resume')).not.toBeNull(); + + const resumeEl = state.queueIndicatorEl!.querySelector('.qoderian-queue-resume') as HTMLElement; + resumeEl.dispatchEvent(new Event('click')); + jest.runAllTimers(); + + expect(sendQueuedTurn).toHaveBeenCalledTimes(1); + expect(state.queuedMessages.map(message => message.content)).toEqual(['second']); + expect(state.queueIndicatorEl!.querySelector('.qoderian-queue-resume')).toBeNull(); + }); + it('clear empties the queue and hides the panel', () => { const { controller, state } = createController(); controller.enqueue('first', turnRequest('first'));