Skip to content

Commit 71340ea

Browse files
luoxuanzaoQoder-AI
andauthored
feat: add composer send/stop button aligned with the Qoder IDE (#23)
* feat: add composer send/stop button aligned with the Qoder IDE - Add ComposerActionButton (send when idle, stop while streaming) at the right end of the input toolbar, grouped next to the permission toggle - IDE-style green rounded square with soft dark arrow; disabled when the composer is empty and not streaming - Wire send through the input controller (Enter path in instruction/bang modes), stop through cancelStreaming; availability tracks input text, attached images and streaming state changes - Localize composer.send/composer.stop across all 10 locales - Add component tests and a CHANGELOG entry Co-authored-by: QoderAI (Qwen 3.8 Max) <qoder_ai@qoder.com> * style: replace !important with raised specificity for composer button PR review CSS lint flagged the !important overrides. Obsidian's native button defaults are beaten with a (0,3,1) selector chain (.qoderian-input-wrapper .qoderian-input-toolbar button.qoderian-composer-action-btn) instead, which also zeroes the native box-shadow. Verified on device: brand-green fill and gray-black arrow unchanged, no native shadow. 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 354d4c2 commit 71340ea

17 files changed

Lines changed: 298 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ version with its date and start a fresh empty `[Unreleased]` above it.
1818
view (plan credits with edition badge, personal/add-on resource
1919
pack, organization resource package, renewal date) and links to the
2020
edition's account usage page.
21+
- Send/stop action button in the chat composer: a round button at the
22+
end of the input toolbar sends the message (same path as Enter),
23+
turns into a stop control while a response is streaming (same path
24+
as Esc), and stays disabled while the composer is empty.
2125
- Qoder CLI edition switch in settings (Setup section): choose the
2226
international build (`qodercli`, config under `~/.qoder`) or the
2327
China build (`qoderclicn`, config under `~/.qoder-cn`). Auto-

src/features/chat/tabs/tab.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { BangBashService } from '../services/bang-bash-service';
2828
import { SubagentManager } from '../services/subagent-manager';
2929
import { ChatState } from '../state/chat-state';
3030
import { BangBashModeManager as BangBashModeManagerClass } from '../ui/bang-bash-mode-manager';
31+
import { ComposerActionButton } from '../ui/composer-action-button';
3132
import { FileContextManager } from '../ui/file-context/file-context-manager';
3233
import { ImageContextManager } from '../ui/image-context';
3334
import { createInputToolbar } from '../ui/input-toolbar';
@@ -152,6 +153,7 @@ export function createTab(options: TabCreateOptions): TabData {
152153
externalContextSelector: null,
153154
mcpServerSelector: null,
154155
permissionToggle: null,
156+
composerActionButton: null,
155157
slashCommandDropdown: null,
156158
instructionModeManager: null,
157159
bangBashModeManager: null,
@@ -283,6 +285,7 @@ function initializeContextManagers(tab: TabData, plugin: QoderianPlugin): void {
283285
tab.controllers.canvasSelectionController?.updateContextRowVisibility();
284286
autoResizeTextarea(dom.inputEl);
285287
tab.renderer?.scrollToBottomIfNeeded();
288+
updateComposerSendAvailability(tab);
286289
},
287290
},
288291
dom.contextRowEl
@@ -365,6 +368,14 @@ function isBangBashEnabled(plugin: QoderianPlugin): boolean {
365368
return getQoderSettings(plugin.settings).enableBangBash;
366369
}
367370

371+
/** Refreshes the send button's enabled state based on composer content. */
372+
function updateComposerSendAvailability(tab: TabData): void {
373+
const hasContent =
374+
tab.dom.inputEl.value.trim().length > 0 ||
375+
(tab.ui.imageContextManager?.hasImages() ?? false);
376+
tab.ui.composerActionButton?.updateSendAvailability(hasContent);
377+
}
378+
368379
/**
369380
* Creates and wires the input toolbar for a tab.
370381
*/
@@ -482,6 +493,33 @@ function initializeInputToolbar(
482493
tab.ui.mcpServerSelector = toolbarComponents.mcpServerSelector;
483494
tab.ui.permissionToggle = toolbarComponents.permissionToggle;
484495

496+
// Send/stop action button pinned to the end of the toolbar row.
497+
// Clicking it is equivalent to pressing Enter (or Esc while streaming).
498+
tab.ui.composerActionButton = new ComposerActionButton(inputToolbar, {
499+
onSend: () => {
500+
// Instruction/bang modes own the Enter key; route through the same
501+
// keydown path so their submit logic applies.
502+
if (
503+
tab.ui.instructionModeManager?.isActive() ||
504+
tab.ui.bangBashModeManager?.isActive()
505+
) {
506+
dom.inputEl.dispatchEvent(
507+
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
508+
);
509+
return;
510+
}
511+
void tab.controllers.inputController?.sendMessage();
512+
},
513+
onStop: () => {
514+
tab.controllers.inputController?.cancelStreaming();
515+
},
516+
});
517+
518+
const updateSendAvailability = () => updateComposerSendAvailability(tab);
519+
dom.inputEl.addEventListener('input', updateSendAvailability);
520+
dom.eventCleanups.push(() => dom.inputEl.removeEventListener('input', updateSendAvailability));
521+
updateSendAvailability();
522+
485523
tab.ui.mcpServerSelector.setMcpManager(getQoderMcpManager(plugin));
486524

487525
// Sync @-mentions to UI selector
@@ -567,8 +605,20 @@ export function initializeTabUI(
567605
initializeInputModes(tab, plugin);
568606
initializeInputToolbar(tab, plugin, options.getQoderCatalogConfig);
569607

608+
// Chain onto the previously registered streaming callback (tab bar
609+
// indicator) so the action button tracks streaming state too.
610+
const previousStreamingCallback = state.callbacks.onStreamingStateChanged;
570611
state.callbacks = {
571612
...state.callbacks,
613+
onStreamingStateChanged: (isStreaming) => {
614+
previousStreamingCallback?.(isStreaming);
615+
tab.ui.composerActionButton?.setStreaming(isStreaming);
616+
if (!isStreaming) {
617+
// The composer may have been cleared programmatically (no input
618+
// event); re-evaluate send availability when streaming ends.
619+
updateComposerSendAvailability(tab);
620+
}
621+
},
572622
onUsageChanged: (usage) => {
573623
tab.ui.contextUsageMeter?.update(usage);
574624
},

src/features/chat/tabs/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type { MessageRenderer } from '../rendering/message-renderer';
1515
import type { SubagentManager } from '../services/subagent-manager';
1616
import type { ChatState } from '../state/chat-state';
1717
import type { BangBashModeManager } from '../ui/bang-bash-mode-manager';
18+
import type { ComposerActionButton } from '../ui/composer-action-button';
1819
import type { FileContextManager } from '../ui/file-context/file-context-manager';
1920
import type { ImageContextManager } from '../ui/image-context';
2021
import type {
@@ -121,6 +122,7 @@ export interface TabUIComponents {
121122
externalContextSelector: ExternalContextSelector | null;
122123
mcpServerSelector: McpServerSelector | null;
123124
permissionToggle: PermissionToggle | null;
125+
composerActionButton: ComposerActionButton | null;
124126
slashCommandDropdown: SlashCommandDropdown | null;
125127
instructionModeManager: InstructionModeManager | null;
126128
bangBashModeManager: BangBashModeManager | null;
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { setIcon } from 'obsidian';
2+
3+
import { t } from '../../../i18n/i18n';
4+
5+
export interface ComposerActionButtonCallbacks {
6+
/** Sends the current composer content (same path as the Enter key). */
7+
onSend: () => void;
8+
/** Interrupts the active stream (same path as Escape). */
9+
onStop: () => void;
10+
}
11+
12+
/**
13+
* Send/stop action button at the end of the input toolbar.
14+
* Shows a send arrow while idle and switches to a stop square while streaming.
15+
*/
16+
export class ComposerActionButton {
17+
private buttonEl: HTMLButtonElement;
18+
private iconEl: HTMLElement;
19+
private callbacks: ComposerActionButtonCallbacks;
20+
private streaming = false;
21+
private sendEnabled = false;
22+
23+
constructor(parentEl: HTMLElement, callbacks: ComposerActionButtonCallbacks) {
24+
this.callbacks = callbacks;
25+
this.buttonEl = parentEl.createEl('button', {
26+
cls: 'qoderian-composer-action-btn',
27+
attr: { type: 'button' },
28+
});
29+
this.iconEl = this.buttonEl.createSpan({ cls: 'qoderian-composer-action-btn-icon' });
30+
31+
this.buttonEl.addEventListener('click', (e) => {
32+
e.stopPropagation();
33+
if (this.streaming) {
34+
this.callbacks.onStop();
35+
} else if (this.sendEnabled) {
36+
this.callbacks.onSend();
37+
}
38+
});
39+
40+
this.setStreaming(false);
41+
this.updateSendAvailability(false);
42+
}
43+
44+
/** Switches between send (idle) and stop (streaming) modes. */
45+
setStreaming(streaming: boolean): void {
46+
this.streaming = streaming;
47+
this.buttonEl.empty();
48+
this.iconEl = this.buttonEl.createSpan({ cls: 'qoderian-composer-action-btn-icon' });
49+
if (streaming) {
50+
setIcon(this.iconEl, 'square');
51+
this.buttonEl.addClass('is-stop');
52+
this.buttonEl.toggleClass('is-disabled', false);
53+
this.buttonEl.disabled = false;
54+
this.buttonEl.setAttribute('aria-label', t('composer.stop'));
55+
this.buttonEl.setAttribute('title', t('composer.stop'));
56+
} else {
57+
setIcon(this.iconEl, 'arrow-up');
58+
this.buttonEl.removeClass('is-stop');
59+
this.buttonEl.setAttribute('aria-label', t('composer.send'));
60+
this.buttonEl.setAttribute('title', t('composer.send'));
61+
this.applySendEnabled();
62+
}
63+
}
64+
65+
/** Updates whether the composer has sendable content (text or images). */
66+
updateSendAvailability(hasContent: boolean): void {
67+
this.sendEnabled = hasContent;
68+
if (!this.streaming) {
69+
this.applySendEnabled();
70+
}
71+
}
72+
73+
private applySendEnabled(): void {
74+
this.buttonEl.toggleClass('is-disabled', !this.sendEnabled);
75+
this.buttonEl.disabled = !this.sendEnabled;
76+
}
77+
}

src/i18n/locales/de.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@
2121
"refresh": "Aktualisieren",
2222
"rewind": "Zurückspulen"
2323
},
24+
"composer": {
25+
"send": "Nachricht senden",
26+
"stop": "Generierung stoppen"
27+
},
2428
"commands": {
2529
"openView": "Chat-Ansicht öffnen",
2630
"inlineEdit": "Inline-Bearbeitung",

src/i18n/locales/en.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@
2121
"refresh": "Refresh",
2222
"rewind": "Rewind"
2323
},
24+
"composer": {
25+
"send": "Send message",
26+
"stop": "Stop generation"
27+
},
2428
"commands": {
2529
"openView": "Open chat view",
2630
"inlineEdit": "Inline edit",

src/i18n/locales/es.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@
2121
"refresh": "Actualizar",
2222
"rewind": "Rebobinar"
2323
},
24+
"composer": {
25+
"send": "Enviar mensaje",
26+
"stop": "Detener generación"
27+
},
2428
"commands": {
2529
"openView": "Abrir vista de chat",
2630
"inlineEdit": "Edición en línea",

src/i18n/locales/fr.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@
2121
"refresh": "Actualiser",
2222
"rewind": "Rembobiner"
2323
},
24+
"composer": {
25+
"send": "Envoyer le message",
26+
"stop": "Arrêter la génération"
27+
},
2428
"commands": {
2529
"openView": "Ouvrir la vue de discussion",
2630
"inlineEdit": "Édition en ligne",

src/i18n/locales/ja.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@
2121
"refresh": "更新",
2222
"rewind": "巻き戻し"
2323
},
24+
"composer": {
25+
"send": "メッセージを送信",
26+
"stop": "生成を停止"
27+
},
2428
"commands": {
2529
"openView": "チャットビューを開く",
2630
"inlineEdit": "インライン編集",

src/i18n/locales/ko.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@
2121
"refresh": "새로고침",
2222
"rewind": "되감기"
2323
},
24+
"composer": {
25+
"send": "메시지 보내기",
26+
"stop": "생성 중지"
27+
},
2428
"commands": {
2529
"openView": "채팅 뷰 열기",
2630
"inlineEdit": "인라인 편집",

0 commit comments

Comments
 (0)