diff --git a/CHANGELOG.md b/CHANGELOG.md index 8715ae1..952c3e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ version with its date and start a fresh empty `[Unreleased]` above it. view (plan credits with edition badge, personal/add-on resource pack, organization resource package, renewal date) and links to the edition's account usage page. +- Send/stop action button in the chat composer: a round button at the + end of the input toolbar sends the message (same path as Enter), + turns into a stop control while a response is streaming (same path + as Esc), and stays disabled while the composer is empty. - Qoder CLI edition switch in settings (Setup section): choose the international build (`qodercli`, config under `~/.qoder`) or the China build (`qoderclicn`, config under `~/.qoder-cn`). Auto- diff --git a/src/features/chat/tabs/tab.ts b/src/features/chat/tabs/tab.ts index 8c7e507..bd5808f 100644 --- a/src/features/chat/tabs/tab.ts +++ b/src/features/chat/tabs/tab.ts @@ -28,6 +28,7 @@ import { BangBashService } from '../services/bang-bash-service'; import { SubagentManager } from '../services/subagent-manager'; import { ChatState } from '../state/chat-state'; import { BangBashModeManager as BangBashModeManagerClass } from '../ui/bang-bash-mode-manager'; +import { ComposerActionButton } from '../ui/composer-action-button'; import { FileContextManager } from '../ui/file-context/file-context-manager'; import { ImageContextManager } from '../ui/image-context'; import { createInputToolbar } from '../ui/input-toolbar'; @@ -152,6 +153,7 @@ export function createTab(options: TabCreateOptions): TabData { externalContextSelector: null, mcpServerSelector: null, permissionToggle: null, + composerActionButton: null, slashCommandDropdown: null, instructionModeManager: null, bangBashModeManager: null, @@ -283,6 +285,7 @@ function initializeContextManagers(tab: TabData, plugin: QoderianPlugin): void { tab.controllers.canvasSelectionController?.updateContextRowVisibility(); autoResizeTextarea(dom.inputEl); tab.renderer?.scrollToBottomIfNeeded(); + updateComposerSendAvailability(tab); }, }, dom.contextRowEl @@ -365,6 +368,14 @@ function isBangBashEnabled(plugin: QoderianPlugin): boolean { return getQoderSettings(plugin.settings).enableBangBash; } +/** Refreshes the send button's enabled state based on composer content. */ +function updateComposerSendAvailability(tab: TabData): void { + const hasContent = + tab.dom.inputEl.value.trim().length > 0 || + (tab.ui.imageContextManager?.hasImages() ?? false); + tab.ui.composerActionButton?.updateSendAvailability(hasContent); +} + /** * Creates and wires the input toolbar for a tab. */ @@ -482,6 +493,33 @@ function initializeInputToolbar( tab.ui.mcpServerSelector = toolbarComponents.mcpServerSelector; tab.ui.permissionToggle = toolbarComponents.permissionToggle; + // Send/stop action button pinned to the end of the toolbar row. + // Clicking it is equivalent to pressing Enter (or Esc while streaming). + tab.ui.composerActionButton = new ComposerActionButton(inputToolbar, { + onSend: () => { + // Instruction/bang modes own the Enter key; route through the same + // keydown path so their submit logic applies. + if ( + tab.ui.instructionModeManager?.isActive() || + tab.ui.bangBashModeManager?.isActive() + ) { + dom.inputEl.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }), + ); + return; + } + void tab.controllers.inputController?.sendMessage(); + }, + onStop: () => { + tab.controllers.inputController?.cancelStreaming(); + }, + }); + + const updateSendAvailability = () => updateComposerSendAvailability(tab); + dom.inputEl.addEventListener('input', updateSendAvailability); + dom.eventCleanups.push(() => dom.inputEl.removeEventListener('input', updateSendAvailability)); + updateSendAvailability(); + tab.ui.mcpServerSelector.setMcpManager(getQoderMcpManager(plugin)); // Sync @-mentions to UI selector @@ -567,8 +605,20 @@ export function initializeTabUI( initializeInputModes(tab, plugin); initializeInputToolbar(tab, plugin, options.getQoderCatalogConfig); + // Chain onto the previously registered streaming callback (tab bar + // indicator) so the action button tracks streaming state too. + const previousStreamingCallback = state.callbacks.onStreamingStateChanged; state.callbacks = { ...state.callbacks, + onStreamingStateChanged: (isStreaming) => { + previousStreamingCallback?.(isStreaming); + tab.ui.composerActionButton?.setStreaming(isStreaming); + if (!isStreaming) { + // The composer may have been cleared programmatically (no input + // event); re-evaluate send availability when streaming ends. + updateComposerSendAvailability(tab); + } + }, onUsageChanged: (usage) => { tab.ui.contextUsageMeter?.update(usage); }, diff --git a/src/features/chat/tabs/types.ts b/src/features/chat/tabs/types.ts index e1bbcc5..59d1d97 100644 --- a/src/features/chat/tabs/types.ts +++ b/src/features/chat/tabs/types.ts @@ -15,6 +15,7 @@ import type { MessageRenderer } from '../rendering/message-renderer'; import type { SubagentManager } from '../services/subagent-manager'; import type { ChatState } from '../state/chat-state'; import type { BangBashModeManager } from '../ui/bang-bash-mode-manager'; +import type { ComposerActionButton } from '../ui/composer-action-button'; import type { FileContextManager } from '../ui/file-context/file-context-manager'; import type { ImageContextManager } from '../ui/image-context'; import type { @@ -121,6 +122,7 @@ export interface TabUIComponents { externalContextSelector: ExternalContextSelector | null; mcpServerSelector: McpServerSelector | null; permissionToggle: PermissionToggle | null; + composerActionButton: ComposerActionButton | null; slashCommandDropdown: SlashCommandDropdown | null; instructionModeManager: InstructionModeManager | null; bangBashModeManager: BangBashModeManager | null; diff --git a/src/features/chat/ui/composer-action-button.ts b/src/features/chat/ui/composer-action-button.ts new file mode 100644 index 0000000..42d08cf --- /dev/null +++ b/src/features/chat/ui/composer-action-button.ts @@ -0,0 +1,77 @@ +import { setIcon } from 'obsidian'; + +import { t } from '../../../i18n/i18n'; + +export interface ComposerActionButtonCallbacks { + /** Sends the current composer content (same path as the Enter key). */ + onSend: () => void; + /** Interrupts the active stream (same path as Escape). */ + onStop: () => void; +} + +/** + * Send/stop action button at the end of the input toolbar. + * Shows a send arrow while idle and switches to a stop square while streaming. + */ +export class ComposerActionButton { + private buttonEl: HTMLButtonElement; + private iconEl: HTMLElement; + private callbacks: ComposerActionButtonCallbacks; + private streaming = false; + private sendEnabled = false; + + constructor(parentEl: HTMLElement, callbacks: ComposerActionButtonCallbacks) { + this.callbacks = callbacks; + this.buttonEl = parentEl.createEl('button', { + cls: 'qoderian-composer-action-btn', + attr: { type: 'button' }, + }); + this.iconEl = this.buttonEl.createSpan({ cls: 'qoderian-composer-action-btn-icon' }); + + this.buttonEl.addEventListener('click', (e) => { + e.stopPropagation(); + if (this.streaming) { + this.callbacks.onStop(); + } else if (this.sendEnabled) { + this.callbacks.onSend(); + } + }); + + this.setStreaming(false); + this.updateSendAvailability(false); + } + + /** Switches between send (idle) and stop (streaming) modes. */ + setStreaming(streaming: boolean): void { + this.streaming = streaming; + this.buttonEl.empty(); + this.iconEl = this.buttonEl.createSpan({ cls: 'qoderian-composer-action-btn-icon' }); + if (streaming) { + setIcon(this.iconEl, 'square'); + this.buttonEl.addClass('is-stop'); + this.buttonEl.toggleClass('is-disabled', false); + this.buttonEl.disabled = false; + this.buttonEl.setAttribute('aria-label', t('composer.stop')); + this.buttonEl.setAttribute('title', t('composer.stop')); + } else { + setIcon(this.iconEl, 'arrow-up'); + this.buttonEl.removeClass('is-stop'); + this.buttonEl.setAttribute('aria-label', t('composer.send')); + this.buttonEl.setAttribute('title', t('composer.send')); + this.applySendEnabled(); + } + } + + /** Updates whether the composer has sendable content (text or images). */ + updateSendAvailability(hasContent: boolean): void { + this.sendEnabled = hasContent; + if (!this.streaming) { + this.applySendEnabled(); + } + } + + private applySendEnabled(): void { + this.buttonEl.toggleClass('is-disabled', !this.sendEnabled); + this.buttonEl.disabled = !this.sendEnabled; + } +} diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index ec8289d..807469e 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -21,6 +21,10 @@ "refresh": "Aktualisieren", "rewind": "Zurückspulen" }, + "composer": { + "send": "Nachricht senden", + "stop": "Generierung stoppen" + }, "commands": { "openView": "Chat-Ansicht öffnen", "inlineEdit": "Inline-Bearbeitung", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index f59186b..09fe464 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -21,6 +21,10 @@ "refresh": "Refresh", "rewind": "Rewind" }, + "composer": { + "send": "Send message", + "stop": "Stop generation" + }, "commands": { "openView": "Open chat view", "inlineEdit": "Inline edit", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 2de1e2b..0c3051b 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -21,6 +21,10 @@ "refresh": "Actualizar", "rewind": "Rebobinar" }, + "composer": { + "send": "Enviar mensaje", + "stop": "Detener generación" + }, "commands": { "openView": "Abrir vista de chat", "inlineEdit": "Edición en línea", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 98c8346..f91c9f6 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -21,6 +21,10 @@ "refresh": "Actualiser", "rewind": "Rembobiner" }, + "composer": { + "send": "Envoyer le message", + "stop": "Arrêter la génération" + }, "commands": { "openView": "Ouvrir la vue de discussion", "inlineEdit": "Édition en ligne", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 3c2e077..7dc3572 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -21,6 +21,10 @@ "refresh": "更新", "rewind": "巻き戻し" }, + "composer": { + "send": "メッセージを送信", + "stop": "生成を停止" + }, "commands": { "openView": "チャットビューを開く", "inlineEdit": "インライン編集", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 924c839..7033a0f 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -21,6 +21,10 @@ "refresh": "새로고침", "rewind": "되감기" }, + "composer": { + "send": "메시지 보내기", + "stop": "생성 중지" + }, "commands": { "openView": "채팅 뷰 열기", "inlineEdit": "인라인 편집", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 4b21da9..a7d637d 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -21,6 +21,10 @@ "refresh": "Atualizar", "rewind": "Retroceder" }, + "composer": { + "send": "Enviar mensagem", + "stop": "Parar geração" + }, "commands": { "openView": "Abrir visualização de chat", "inlineEdit": "Edição inline", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index a79b7b0..f240c8c 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -21,6 +21,10 @@ "refresh": "Обновить", "rewind": "Откатить" }, + "composer": { + "send": "Отправить сообщение", + "stop": "Остановить генерацию" + }, "commands": { "openView": "Открыть представление чата", "inlineEdit": "Встроенное редактирование", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 61c6f87..5309a52 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -21,6 +21,10 @@ "refresh": "刷新", "rewind": "回退" }, + "composer": { + "send": "发送消息", + "stop": "停止生成" + }, "commands": { "openView": "打开聊天视图", "inlineEdit": "内联编辑", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 8090038..22cb72e 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -21,6 +21,10 @@ "refresh": "重新整理", "rewind": "回退" }, + "composer": { + "send": "傳送訊息", + "stop": "停止生成" + }, "commands": { "openView": "開啟聊天視圖", "inlineEdit": "行內編輯", diff --git a/src/i18n/types.ts b/src/i18n/types.ts index 196aa7f..777a5a8 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -33,6 +33,10 @@ export type TranslationKey = | 'common.refresh' | 'common.rewind' + // Composer - send/stop action button + | 'composer.send' + | 'composer.stop' + // Chat - Rewind | 'chat.rewind.confirmMessage' | 'chat.rewind.confirmMessageConversationOnly' diff --git a/src/style/components/input.css b/src/style/components/input.css index 588084c..0627146 100644 --- a/src/style/components/input.css +++ b/src/style/components/input.css @@ -345,6 +345,59 @@ height: 14px; } +/* Send/stop action button - IDE-style green rounded square with dark icon */ +.qoderian-composer-action-btn { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 28px; + height: 28px; + padding: 0; + border: none; + border-radius: 8px; + cursor: pointer; + transition: filter 0.15s ease, opacity 0.15s ease; +} + +/* Obsidian's native button defaults (interactive-normal gray) out-specify a + single class; raise specificity instead of !important to override them. */ +.qoderian-input-wrapper .qoderian-input-toolbar button.qoderian-composer-action-btn { + border: none; + box-shadow: none; + background: var(--qoderian-brand); + color: rgba(0, 0, 0, 0.7); +} + +/* When the permission toggle is hidden the button inherits the row's + right alignment instead of sitting next to the left group. */ +.qoderian-permission-toggle.qoderian-hidden ~ .qoderian-composer-action-btn { + margin-inline-start: auto; +} + +.qoderian-composer-action-btn:hover:not(:disabled) { + filter: brightness(1.08); +} + +.qoderian-composer-action-btn svg { + width: 16px; + height: 16px; +} + +/* Stop mode: filled square icon, slightly emphasized */ +.qoderian-composer-action-btn.is-stop svg { + width: 14px; + height: 14px; + fill: currentColor; +} + +/* Idle with empty composer: dimmed, not clickable */ +.qoderian-composer-action-btn.is-disabled, +.qoderian-composer-action-btn:disabled { + opacity: 0.4; + cursor: default; +} + /* Light blue border when instruction mode is active */ .qoderian-input-wrapper.qoderian-input-instruction-mode { --qoderian-input-wrapper-border-color: #60a5fa; diff --git a/tests/unit/features/chat/ui/composer-action-button.test.ts b/tests/unit/features/chat/ui/composer-action-button.test.ts new file mode 100644 index 0000000..0e55dfe --- /dev/null +++ b/tests/unit/features/chat/ui/composer-action-button.test.ts @@ -0,0 +1,68 @@ +import { createMockEl } from '@test/helpers/mock-element'; + +import { ComposerActionButton } from '@/features/chat/ui/composer-action-button'; + +describe('ComposerActionButton', () => { + function createButton() { + const parentEl = createMockEl(); + const onSend = jest.fn(); + const onStop = jest.fn(); + const button = new ComposerActionButton(parentEl, { onSend, onStop }); + const buttonEl = parentEl.querySelector('.qoderian-composer-action-btn'); + if (!buttonEl) throw new Error('action button missing'); + return { button, buttonEl, onSend, onStop }; + } + + it('starts disabled with the send label', () => { + const { buttonEl } = createButton(); + + expect(buttonEl.disabled).toBe(true); + expect(buttonEl.hasClass('is-disabled')).toBe(true); + expect(buttonEl.getAttribute('title')).toBe('Send message'); + }); + + it('enables once the composer has content and sends on click', () => { + const { button, buttonEl, onSend } = createButton(); + + button.updateSendAvailability(true); + expect(buttonEl.disabled).toBe(false); + + buttonEl.click(); + expect(onSend).toHaveBeenCalledTimes(1); + }); + + it('ignores clicks while disabled', () => { + const { buttonEl, onSend } = createButton(); + + buttonEl.click(); + expect(onSend).not.toHaveBeenCalled(); + }); + + it('switches to stop mode while streaming and routes clicks to stop', () => { + const { button, buttonEl, onSend, onStop } = createButton(); + button.updateSendAvailability(true); + + button.setStreaming(true); + expect(buttonEl.disabled).toBe(false); + expect(buttonEl.hasClass('is-stop')).toBe(true); + expect(buttonEl.getAttribute('title')).toBe('Stop generation'); + + buttonEl.click(); + expect(onStop).toHaveBeenCalledTimes(1); + expect(onSend).not.toHaveBeenCalled(); + }); + + it('re-applies send availability when streaming ends', () => { + const { button, buttonEl } = createButton(); + + button.setStreaming(true); + button.setStreaming(false); + expect(buttonEl.disabled).toBe(true); + expect(buttonEl.hasClass('is-stop')).toBe(false); + + button.updateSendAvailability(true); + button.setStreaming(true); + button.setStreaming(false); + expect(buttonEl.disabled).toBe(false); + }); +});