Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-
Expand Down
50 changes: 50 additions & 0 deletions src/features/chat/tabs/tab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -152,6 +153,7 @@ export function createTab(options: TabCreateOptions): TabData {
externalContextSelector: null,
mcpServerSelector: null,
permissionToggle: null,
composerActionButton: null,
slashCommandDropdown: null,
instructionModeManager: null,
bangBashModeManager: null,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
},
Expand Down
2 changes: 2 additions & 0 deletions src/features/chat/tabs/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down
77 changes: 77 additions & 0 deletions src/features/chat/ui/composer-action-button.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
4 changes: 4 additions & 0 deletions src/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@
"refresh": "Aktualisieren",
"rewind": "Zurückspulen"
},
"composer": {
"send": "Nachricht senden",
"stop": "Generierung stoppen"
},
"commands": {
"openView": "Chat-Ansicht öffnen",
"inlineEdit": "Inline-Bearbeitung",
Expand Down
4 changes: 4 additions & 0 deletions src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@
"refresh": "Refresh",
"rewind": "Rewind"
},
"composer": {
"send": "Send message",
"stop": "Stop generation"
},
"commands": {
"openView": "Open chat view",
"inlineEdit": "Inline edit",
Expand Down
4 changes: 4 additions & 0 deletions src/i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions src/i18n/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions src/i18n/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@
"refresh": "更新",
"rewind": "巻き戻し"
},
"composer": {
"send": "メッセージを送信",
"stop": "生成を停止"
},
"commands": {
"openView": "チャットビューを開く",
"inlineEdit": "インライン編集",
Expand Down
4 changes: 4 additions & 0 deletions src/i18n/locales/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@
"refresh": "새로고침",
"rewind": "되감기"
},
"composer": {
"send": "메시지 보내기",
"stop": "생성 중지"
},
"commands": {
"openView": "채팅 뷰 열기",
"inlineEdit": "인라인 편집",
Expand Down
4 changes: 4 additions & 0 deletions src/i18n/locales/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions src/i18n/locales/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@
"refresh": "Обновить",
"rewind": "Откатить"
},
"composer": {
"send": "Отправить сообщение",
"stop": "Остановить генерацию"
},
"commands": {
"openView": "Открыть представление чата",
"inlineEdit": "Встроенное редактирование",
Expand Down
4 changes: 4 additions & 0 deletions src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@
"refresh": "刷新",
"rewind": "回退"
},
"composer": {
"send": "发送消息",
"stop": "停止生成"
},
"commands": {
"openView": "打开聊天视图",
"inlineEdit": "内联编辑",
Expand Down
4 changes: 4 additions & 0 deletions src/i18n/locales/zh-TW.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@
"refresh": "重新整理",
"rewind": "回退"
},
"composer": {
"send": "傳送訊息",
"stop": "停止生成"
},
"commands": {
"openView": "開啟聊天視圖",
"inlineEdit": "行內編輯",
Expand Down
4 changes: 4 additions & 0 deletions src/i18n/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
53 changes: 53 additions & 0 deletions src/style/components/input.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading