From 3903ca8decbf378a56f42c951b0fc2b0bc36995a Mon Sep 17 00:00:00 2001 From: liuxuezhuo Date: Tue, 18 Aug 2026 16:04:37 +0800 Subject: [PATCH] feat: multi-message send queue aligned with Qoder IDE 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) --- CHANGELOG.md | 14 ++ .../chat/controllers/input-controller.ts | 6 - .../controllers/queued-message-controller.ts | 198 ++++++++++++------ src/features/chat/state/chat-state.ts | 12 +- src/features/chat/state/types.ts | 6 +- src/i18n/locales/de.json | 9 + src/i18n/locales/en.json | 9 + src/i18n/locales/es.json | 9 + src/i18n/locales/fr.json | 9 + src/i18n/locales/ja.json | 9 + src/i18n/locales/ko.json | 9 + src/i18n/locales/pt.json | 9 + src/i18n/locales/ru.json | 9 + src/i18n/locales/zh-CN.json | 9 + src/i18n/locales/zh-TW.json | 9 + src/i18n/types.ts | 9 + src/style/components/input.css | 119 ++++++++--- .../queued-message-controller.test.ts | 187 +++++++++++++++++ .../features/chat/state/chat-state.test.ts | 14 +- 19 files changed, 544 insertions(+), 111 deletions(-) create mode 100644 tests/unit/features/chat/controllers/queued-message-controller.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e55f13..a6ec887 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/features/chat/controllers/input-controller.ts b/src/features/chat/controllers/input-controller.ts index 453989a..71bf909 100644 --- a/src/features/chat/controllers/input-controller.ts +++ b/src/features/chat/controllers/input-controller.ts @@ -528,10 +528,6 @@ export class InputController { this.queuedMessages.clear(); } - withdrawQueuedMessageToComposer(): void { - this.queuedMessages.withdrawToComposer(); - } - private async buildTurnSubmission(options: { content: string; images?: ChatMessage['images']; @@ -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(); } diff --git a/src/features/chat/controllers/queued-message-controller.ts b/src/features/chat/controllers/queued-message-controller.ts index 097511c..9cb3edf 100644 --- a/src/features/chat/controllers/queued-message-controller.ts +++ b/src/features/chat/controllers/queued-message-controller.ts @@ -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'; @@ -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 @@ -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('[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', @@ -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, @@ -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); - } } diff --git a/src/features/chat/state/chat-state.ts b/src/features/chat/state/chat-state.ts index 292c933..999a0b8 100644 --- a/src/features/chat/state/chat-state.ts +++ b/src/features/chat/state/chat-state.ts @@ -19,7 +19,7 @@ function createInitialState(): ChatStateData { isSwitchingConversation: false, hasPendingConversationSave: false, currentConversationId: null, - queuedMessage: null, + queuedMessages: [], currentContentEl: null, currentTextEl: null, currentTextContent: '', @@ -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; } // ============================================ @@ -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; } diff --git a/src/features/chat/state/types.ts b/src/features/chat/state/types.ts index b5c796f..e5bea4c 100644 --- a/src/features/chat/state/types.ts +++ b/src/features/chat/state/types.ts @@ -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; @@ -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; diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index dda8c80..0169e33 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -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": { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index a753846..1135e5f 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -98,6 +98,15 @@ "conversation": { "emptyPreview": "New conversation", "deleteFailed": "Failed to delete conversation: {error}" + }, + "queue": { + "title": "Waiting to send {count}", + "edit": "Edit", + "delete": "Delete", + "drag": "Drag", + "dragTooltip": "Drag to reorder", + "collapse": "Collapse queue", + "expand": "Expand queue" } }, "settings": { diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 4a91e5f..b7bfc11 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -98,6 +98,15 @@ "conversation": { "emptyPreview": "Nueva conversación", "deleteFailed": "Error al eliminar la conversación: {error}" + }, + "queue": { + "title": "Esperando para enviar {count}", + "edit": "Editar", + "delete": "Eliminar", + "drag": "Arrastrar", + "dragTooltip": "Arrastra para reordenar", + "collapse": "Contraer cola", + "expand": "Expandir cola" } }, "settings": { diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 7b48beb..18c3b7b 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -98,6 +98,15 @@ "conversation": { "emptyPreview": "Nouvelle conversation", "deleteFailed": "Échec de la suppression de la conversation : {error}" + }, + "queue": { + "title": "En attente d'envoi {count}", + "edit": "Modifier", + "delete": "Supprimer", + "drag": "Glisser", + "dragTooltip": "Glisser pour réordonner", + "collapse": "Replier la file", + "expand": "Déplier la file" } }, "settings": { diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 0b21cc7..e8c99c1 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -98,6 +98,15 @@ "conversation": { "emptyPreview": "新しい会話", "deleteFailed": "会話の削除に失敗しました:{error}" + }, + "queue": { + "title": "送信待ち {count}", + "edit": "編集", + "delete": "削除", + "drag": "ドラッグ", + "dragTooltip": "ドラッグで順序を変更", + "collapse": "キューを折りたたむ", + "expand": "キューを展開" } }, "settings": { diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 046862d..9ac608d 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -98,6 +98,15 @@ "conversation": { "emptyPreview": "새 대화", "deleteFailed": "대화 삭제 실패: {error}" + }, + "queue": { + "title": "전송 대기 {count}", + "edit": "편집", + "delete": "삭제", + "drag": "드래그", + "dragTooltip": "드래그하여 순서 변경", + "collapse": "큐 접기", + "expand": "큐 펼치기" } }, "settings": { diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index a5e1589..43f26cd 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -98,6 +98,15 @@ "conversation": { "emptyPreview": "Nova conversa", "deleteFailed": "Falha ao excluir a conversa: {error}" + }, + "queue": { + "title": "Aguardando envio {count}", + "edit": "Editar", + "delete": "Excluir", + "drag": "Arrastar", + "dragTooltip": "Arraste para reordenar", + "collapse": "Recolher fila", + "expand": "Expandir fila" } }, "settings": { diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 2ccff00..ce029e2 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -98,6 +98,15 @@ "conversation": { "emptyPreview": "Новый диалог", "deleteFailed": "Не удалось удалить диалог: {error}" + }, + "queue": { + "title": "Ждут отправки {count}", + "edit": "Редактировать", + "delete": "Удалить", + "drag": "Перетащить", + "dragTooltip": "Перетащите, чтобы изменить порядок", + "collapse": "Свернуть очередь", + "expand": "Развернуть очередь" } }, "settings": { diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 57b45a8..0b3862a 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -98,6 +98,15 @@ "conversation": { "emptyPreview": "新对话", "deleteFailed": "删除会话失败:{error}" + }, + "queue": { + "title": "等待发送 {count}", + "edit": "编辑", + "delete": "删除", + "drag": "拖拽", + "dragTooltip": "拖拽调整顺序", + "collapse": "折叠队列", + "expand": "展开队列" } }, "settings": { diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 73517db..b01acd3 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -98,6 +98,15 @@ "conversation": { "emptyPreview": "新對話", "deleteFailed": "刪除對話失敗:{error}" + }, + "queue": { + "title": "等待發送 {count}", + "edit": "編輯", + "delete": "刪除", + "drag": "拖曳", + "dragTooltip": "拖曳調整順序", + "collapse": "折疊佇列", + "expand": "展開佇列" } }, "settings": { diff --git a/src/i18n/types.ts b/src/i18n/types.ts index 1487f76..ac580fe 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -117,6 +117,15 @@ export type TranslationKey = | 'chat.fork.commandNoMessages' | 'chat.fork.commandNoAssistantUuid' + // Send queue (multi-message queue above the composer) + | 'chat.queue.title' + | 'chat.queue.edit' + | 'chat.queue.delete' + | 'chat.queue.drag' + | 'chat.queue.dragTooltip' + | 'chat.queue.collapse' + | 'chat.queue.expand' + // Settings - Section Headings | 'settings.title' | 'settings.display' diff --git a/src/style/components/input.css b/src/style/components/input.css index 0627146..203b3a5 100644 --- a/src/style/components/input.css +++ b/src/style/components/input.css @@ -274,68 +274,127 @@ background: var(--background-modifier-hover); } -/* Composer queue status row for queued follow-up previews. */ +/* Composer send-queue panel: collapsible list of pending turns. */ .qoderian-input-queue-row { display: none; - font-size: 12px; + flex-direction: column; + margin: 0 2px 8px 2px; + padding: 4px 6px 6px; + border: 1px solid var(--background-modifier-border); + border-radius: 10px; + background: var(--background-secondary); + font-size: 13px; color: var(--text-muted); font-style: normal; - align-items: center; - gap: 8px; - padding: 0 2px 8px 2px; -} - -.qoderian-queue-indicator-text { - min-width: 0; - flex: 1 1 auto; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; } -.qoderian-queue-indicator-actions { - flex: 0 0 auto; +.qoderian-queue-header { display: flex; align-items: center; - gap: 4px; + gap: 6px; + padding: 4px 6px; } -.qoderian-queue-indicator-action { +/* Scoped button selectors outrank Obsidian's default button chrome. */ +.qoderian-input-queue-row button.qoderian-queue-header-toggle { + display: inline-flex; + align-items: center; + justify-content: center; flex: 0 0 auto; - padding: 1px 8px; + width: 20px; + height: 20px; + padding: 0; border: 0; + border-radius: 6px; background: transparent; - color: var(--interactive-accent); - font: inherit; + box-shadow: none; + color: var(--text-muted); cursor: pointer; } -.qoderian-queue-indicator-action:hover { - text-decoration: underline; +.qoderian-input-queue-row button.qoderian-queue-header-toggle:hover { + background: var(--background-modifier-hover); + color: var(--text-normal); +} + +.qoderian-queue-header-toggle svg { + width: 14px; + height: 14px; +} + +.qoderian-queue-header-title { + font-weight: 600; + color: var(--text-normal); +} + +.qoderian-queue-list { + display: flex; + flex-direction: column; + gap: 2px; } -.qoderian-queue-indicator-action[disabled] { +.qoderian-queue-row { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + border-radius: 6px; +} + +.qoderian-queue-row:hover { + background: var(--background-modifier-hover); +} + +.qoderian-queue-row-dragging { + opacity: 0.5; +} + +.qoderian-queue-row-handle { + display: inline-flex; + align-items: center; + flex: 0 0 auto; color: var(--text-faint); - cursor: default; - text-decoration: none; + cursor: grab; +} + +.qoderian-queue-row-handle svg { + width: 14px; + height: 14px; } -.qoderian-queue-indicator-icon-action { +.qoderian-queue-row-text { + min-width: 0; + flex: 1 1 auto; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--text-normal); +} + +.qoderian-queue-row-actions { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 2px; +} + +.qoderian-input-queue-row button.qoderian-queue-indicator-icon-action { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; - width: 22px; - height: 22px; + width: 24px; + height: 24px; padding: 0; border: 0; - border-radius: 4px; + border-radius: 6px; background: transparent; + box-shadow: none; color: var(--text-muted); cursor: pointer; } -.qoderian-queue-indicator-icon-action:hover { +.qoderian-input-queue-row button.qoderian-queue-indicator-icon-action:hover { background: var(--background-modifier-hover); color: var(--text-normal); } diff --git a/tests/unit/features/chat/controllers/queued-message-controller.test.ts b/tests/unit/features/chat/controllers/queued-message-controller.test.ts new file mode 100644 index 0000000..64131eb --- /dev/null +++ b/tests/unit/features/chat/controllers/queued-message-controller.test.ts @@ -0,0 +1,187 @@ +/** + * @jest-environment jsdom + */ +import { QueuedMessageController } from '@/features/chat/controllers/queued-message-controller'; +import type { QueuedChatTurn } from '@/features/chat/controllers/queued-turn'; +import { ChatState } from '@/features/chat/state/chat-state'; + +// setup-window polyfills createEl/createDiv/createSpan as globals but the +// controller calls them as element methods; port the missing pieces here. +const proto = HTMLElement.prototype as unknown as { + empty?: () => void; + addClass?: (cls: string) => void; + removeClass?: (cls: string) => void; + createEl?: (tag: string, info?: DomInfo | string) => HTMLElement; + createDiv?: (info?: DomInfo | string) => HTMLElement; + createSpan?: (info?: DomInfo | string) => HTMLElement; +}; + +interface DomInfo { + cls?: string; + text?: string; + attr?: Record; + title?: string; +} + +function applyInfo(el: HTMLElement, info: DomInfo | string | undefined): void { + const normalized = typeof info === 'string' ? { cls: info } : info ?? {}; + if (normalized.cls) el.classList.add(...normalized.cls.split(/\s+/).filter(Boolean)); + if (normalized.text) el.textContent = normalized.text; + if (normalized.attr) { + for (const [name, value] of Object.entries(normalized.attr)) el.setAttribute(name, String(value)); + } + if (normalized.title !== undefined) el.title = normalized.title; +} + +if (!proto.empty) { + proto.empty = function (this: HTMLElement) { + while (this.firstChild) this.removeChild(this.firstChild); + }; +} +if (!proto.addClass) { + proto.addClass = function (this: HTMLElement, cls: string) { + this.classList.add(cls); + }; +} +if (!proto.removeClass) { + proto.removeClass = function (this: HTMLElement, cls: string) { + this.classList.remove(cls); + }; +} +if (!proto.createEl) { + proto.createEl = function (this: HTMLElement, tag: string, info?: DomInfo | string) { + const el = this.ownerDocument.createElement(tag); + applyInfo(el, info); + this.appendChild(el); + return el; + }; +} +if (!proto.createDiv) { + proto.createDiv = function (this: HTMLElement, info?: DomInfo | string) { + return proto.createEl!.call(this, 'div', info); + }; +} +if (!proto.createSpan) { + proto.createSpan = function (this: HTMLElement, info?: DomInfo | string) { + return proto.createEl!.call(this, 'span', info); + }; +} + +function createController(overrides?: Partial<{ + sendQueuedTurn: (turn: QueuedChatTurn) => void; +}>) { + const state = new ChatState(); + state.queueIndicatorEl = document.createElement('div'); + const inputEl = document.createElement('textarea'); + const sendQueuedTurn = overrides?.sendQueuedTurn ?? jest.fn(); + const controller = new QueuedMessageController({ + state, + getInputEl: () => inputEl, + getImageContextManager: () => null, + resetInputHeight: jest.fn(), + sendQueuedTurn, + }); + return { controller, state, inputEl, sendQueuedTurn }; +} + +function turnRequest(text: string) { + return { text }; +} + +describe('QueuedMessageController', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('enqueues multiple messages in FIFO order and renders the count', () => { + const { controller, state } = createController(); + + controller.enqueue('first', turnRequest('first')); + controller.enqueue('second', turnRequest('second')); + + expect(state.queuedMessages).toHaveLength(2); + expect(state.queuedMessages.map(message => message.content)).toEqual(['first', 'second']); + + const header = state.queueIndicatorEl!.querySelector('.qoderian-queue-header-title'); + expect(header?.textContent).toContain('2'); + const rows = state.queueIndicatorEl!.querySelectorAll('.qoderian-queue-row'); + expect(rows).toHaveLength(2); + }); + + it('process drains the head item as a turn', () => { + const { controller, state, sendQueuedTurn } = createController(); + controller.enqueue('first', turnRequest('first')); + controller.enqueue('second', turnRequest('second')); + + controller.process(); + jest.runAllTimers(); + + expect(sendQueuedTurn).toHaveBeenCalledTimes(1); + expect(sendQueuedTurn).toHaveBeenCalledWith(expect.objectContaining({ displayContent: 'first' })); + expect(state.queuedMessages.map(message => message.content)).toEqual(['second']); + }); + + it('discard removes one item by id', () => { + const { controller, state } = createController(); + controller.enqueue('first', turnRequest('first')); + controller.enqueue('second', turnRequest('second')); + + controller.discard(state.queuedMessages[0].id); + + expect(state.queuedMessages.map(message => message.content)).toEqual(['second']); + }); + + it('withdrawToComposer moves one item back into the input', () => { + const { controller, state, inputEl } = createController(); + controller.enqueue('first', turnRequest('first')); + controller.enqueue('second', turnRequest('second')); + + controller.withdrawToComposer(state.queuedMessages[1].id); + + expect(inputEl.value).toBe('second'); + expect(state.queuedMessages.map(message => message.content)).toEqual(['first']); + }); + + it('clear empties the queue and hides the panel', () => { + const { controller, state } = createController(); + controller.enqueue('first', turnRequest('first')); + controller.clear(); + + expect(state.queuedMessages).toHaveLength(0); + expect(state.queueIndicatorEl!.classList.contains('qoderian-hidden')).toBe(true); + }); + + it('drag reorders the queue on dragend', () => { + const { controller, state } = createController(); + controller.enqueue('first', turnRequest('first')); + controller.enqueue('second', turnRequest('second')); + + const rows = () => [...state.queueIndicatorEl!.querySelectorAll('.qoderian-queue-row')] as HTMLElement[]; + const [row1, row2] = rows(); + const handle1 = row1.querySelector('.qoderian-queue-row-handle') as HTMLElement; + + handle1.dispatchEvent(new Event('dragstart')); + // jsdom rects are zero; clientY 1 lands below the midpoint -> insert after row2 + row2.dispatchEvent(new MouseEvent('dragover', { clientY: 1 })); + handle1.dispatchEvent(new Event('dragend')); + + expect(state.queuedMessages.map(message => message.content)).toEqual(['second', 'first']); + }); + + it('collapses and expands the list from the header toggle', () => { + const { controller, state } = createController(); + controller.enqueue('first', turnRequest('first')); + + const toggle = state.queueIndicatorEl!.querySelector('.qoderian-queue-header-toggle') as HTMLElement; + toggle.dispatchEvent(new Event('click')); + expect(state.queueIndicatorEl!.querySelectorAll('.qoderian-queue-row')).toHaveLength(0); + + const toggleAgain = state.queueIndicatorEl!.querySelector('.qoderian-queue-header-toggle') as HTMLElement; + toggleAgain.dispatchEvent(new Event('click')); + expect(state.queueIndicatorEl!.querySelectorAll('.qoderian-queue-row')).toHaveLength(1); + }); +}); diff --git a/tests/unit/features/chat/state/chat-state.test.ts b/tests/unit/features/chat/state/chat-state.test.ts index 358db7a..e40d955 100644 --- a/tests/unit/features/chat/state/chat-state.test.ts +++ b/tests/unit/features/chat/state/chat-state.test.ts @@ -47,7 +47,7 @@ describe('ChatState', () => { expect(state.isCreatingConversation).toBe(false); expect(state.isSwitchingConversation).toBe(false); expect(state.currentConversationId).toBeNull(); - expect(state.queuedMessage).toBeNull(); + expect(state.queuedMessages).toEqual([]); expect(state.currentContentEl).toBeNull(); expect(state.currentTextEl).toBeNull(); expect(state.currentTextContent).toBe(''); @@ -172,13 +172,13 @@ describe('ChatState', () => { }); describe('queued message', () => { - it('stores and retrieves queued message', () => { + it('stores and retrieves queued messages', () => { const chatState = new ChatState(); - const queued = { content: 'queued', editorContext: null, canvasContext: null }; + const queued = { id: 'q1', content: 'queued', editorContext: null, canvasContext: null }; - chatState.queuedMessage = queued; + chatState.queuedMessages = [queued]; - expect(chatState.queuedMessage).toBe(queued); + expect(chatState.queuedMessages).toEqual([queued]); }); }); @@ -417,7 +417,7 @@ describe('ChatState', () => { chatState.cancelRequested = true; chatState.currentContentEl = {} as HTMLElement; chatState.toolCallElements.set('a', {} as HTMLElement); - chatState.queuedMessage = { content: 'queued', editorContext: null, canvasContext: null }; + chatState.queuedMessages = [{ id: 'q1', content: 'queued', editorContext: null, canvasContext: null }]; chatState.usage = { inputTokens: 100, outputTokens: 50 } as any; // autoScrollEnabled defaults to true, set to false first so reset triggers change chatState.autoScrollEnabled = false; @@ -434,7 +434,7 @@ describe('ChatState', () => { expect(chatState.toolCallElements.size).toBe(0); expect(chatState.writeEditStates.size).toBe(0); expect(chatState.pendingTools.size).toBe(0); - expect(chatState.queuedMessage).toBeNull(); + expect(chatState.queuedMessages).toEqual([]); expect(chatState.usage).toBeNull(); expect(chatState.autoScrollEnabled).toBe(true);