From 5ebdce836034ec0c0d93a6bd4e9feb109c786697 Mon Sep 17 00:00:00 2001 From: wangweijia0204 Date: Sat, 5 Sep 2026 13:08:35 +0800 Subject: [PATCH 1/3] feat(chat): clarify tab actions and resize composer --- CHANGELOG.md | 19 +++ src/features/chat/chat-view.ts | 9 +- src/features/chat/tabs/tab-bar.ts | 41 ++++- src/features/chat/tabs/tab-dom.ts | 2 + src/features/chat/tabs/tab-lifecycle.ts | 2 + src/features/chat/tabs/tab.ts | 8 + src/features/chat/tabs/types.ts | 3 + src/features/chat/ui/composer-resize.ts | 154 ++++++++++++++++++ src/i18n/locales/de.json | 6 +- src/i18n/locales/en.json | 6 +- src/i18n/locales/es.json | 6 +- src/i18n/locales/fr.json | 6 +- src/i18n/locales/ja.json | 6 +- src/i18n/locales/ko.json | 6 +- src/i18n/locales/pt.json | 6 +- src/i18n/locales/ru.json | 6 +- src/i18n/locales/zh-CN.json | 6 +- src/i18n/locales/zh-TW.json | 6 +- src/i18n/types.ts | 2 + src/style/components/composer.css | 21 +++ src/style/components/input.css | 50 ++++++ src/style/components/tabs.css | 57 +++++++ tests/unit/features/chat/tabs/tab-bar.test.ts | 91 +++++++++++ .../features/chat/ui/composer-resize.test.ts | 94 +++++++++++ 24 files changed, 587 insertions(+), 26 deletions(-) create mode 100644 src/features/chat/ui/composer-resize.ts create mode 100644 tests/unit/features/chat/tabs/tab-bar.test.ts create mode 100644 tests/unit/features/chat/ui/composer-resize.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b5bdc66..5ad19d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,25 @@ version with its date and start a fresh empty `[Unreleased]` above it. ## [Unreleased] +### Added + +- Tabs now reveal a dedicated close button on hover (with keyboard access), + making tab removal discoverable without relying on a context-menu gesture. +- The message composer can be resized by dragging its top edge or using the + Up/Down arrow keys on the resize handle; double-click or Home restores + automatic content-based sizing. + +### Changed + +- The ambiguous square-edit action now uses a restart icon and explicitly says + that it starts a new conversation in the current tab, distinguishing it from + the adjacent action that opens a separate tab. + +### Fixed + +- Long composer input no longer shifts its wrapping width when the vertical + scrollbar appears, preventing the input height from flickering at its cap. + ## [1.0.7] - 2026-09-02 ### Added diff --git a/src/features/chat/chat-view.ts b/src/features/chat/chat-view.ts index c1a4309..ffa2b04 100644 --- a/src/features/chat/chat-view.ts +++ b/src/features/chat/chat-view.ts @@ -253,8 +253,8 @@ export class QoderianView extends ItemView { }); const newBtn = navActionsEl.createDiv({ cls: 'qoderian-input-nav-btn' }); - setIcon(newBtn, 'square-pen'); - setButtonTooltip(newBtn, t('nav.newConversation')); + setIcon(newBtn, 'refresh-cw'); + setButtonTooltip(newBtn, t('commands.newSession')); this.newConversationButtonEl = newBtn; newBtn.addEventListener('click', () => { void (async () => { @@ -355,10 +355,13 @@ export class QoderianView extends ItemView { refreshLocalizedChrome(): void { if (this.newTabButtonEl) setButtonTooltip(this.newTabButtonEl, t('commands.newTab')); if (this.newConversationButtonEl) { - setButtonTooltip(this.newConversationButtonEl, t('nav.newConversation')); + setButtonTooltip(this.newConversationButtonEl, t('commands.newSession')); } if (this.historyButtonEl) setButtonTooltip(this.historyButtonEl, t('nav.chatHistory')); this.creditsUsageButton?.refreshLocale(); + for (const tab of this.tabManager?.getAllTabs() ?? []) { + tab.ui.composerResize?.refreshLocale(); + } this.updateTabBar(); } diff --git a/src/features/chat/tabs/tab-bar.ts b/src/features/chat/tabs/tab-bar.ts index 40a2c55..2be3511 100644 --- a/src/features/chat/tabs/tab-bar.ts +++ b/src/features/chat/tabs/tab-bar.ts @@ -1,3 +1,6 @@ +import { setIcon } from 'obsidian'; + +import { t } from '../../../i18n/i18n'; import { scheduleAnimationFrame } from '../../../shared/dom/animation-frame'; import { setButtonTooltip } from '../../../shared/dom/tooltip'; import type { TabBarItem, TabId } from './types'; @@ -77,8 +80,12 @@ export class TabBar { cls: [ 'qoderian-tab-badge', stateClass, + item.canClose ? 'qoderian-tab-badge-closable' : '', isTitleExpanded ? 'qoderian-tab-badge-expanded' : '', ].filter(Boolean).join(' '), + }); + const labelEl = badgeEl.createSpan({ + cls: 'qoderian-tab-badge-label', text: this.getBadgeLabel(item), }); @@ -86,6 +93,30 @@ export class TabBar { setButtonTooltip(badgeEl, item.title); badgeEl.setAttribute('data-title-expanded', isTitleExpanded ? 'true' : 'false'); + if (item.canClose) { + const closeEl = badgeEl.createSpan({ cls: 'qoderian-tab-badge-close' }); + closeEl.setAttribute('role', 'button'); + closeEl.setAttribute('tabindex', '0'); + setIcon(closeEl, 'x'); + setButtonTooltip(closeEl, t('commands.closeTab')); + + const closeTab = (event: Event): void => { + event.preventDefault(); + event.stopPropagation(); + this.callbacks.onTabClose(item.id); + }; + closeEl.addEventListener('click', closeTab); + closeEl.addEventListener('dblclick', (event) => { + event.preventDefault(); + event.stopPropagation(); + }); + closeEl.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ') { + closeTab(event); + } + }); + } + // Click handler to switch tab badgeEl.addEventListener('click', () => { this.captureScrollPosition(); @@ -95,7 +126,7 @@ export class TabBar { badgeEl.addEventListener('dblclick', (e) => { e.preventDefault(); e.stopPropagation(); - this.toggleBadgeTitle(item, badgeEl); + this.toggleBadgeTitle(item, badgeEl, labelEl); }); // Right-click to close (if allowed) @@ -147,7 +178,11 @@ export class TabBar { } } - private toggleBadgeTitle(item: TabBarItem, badgeEl: HTMLElement): void { + private toggleBadgeTitle( + item: TabBarItem, + badgeEl: HTMLElement, + labelEl: HTMLElement, + ): void { if (this.expandedTitleTabIds.has(item.id)) { this.expandedTitleTabIds.delete(item.id); } else { @@ -155,7 +190,7 @@ export class TabBar { } const isTitleExpanded = this.expandedTitleTabIds.has(item.id); - badgeEl.textContent = this.getBadgeLabel(item); + labelEl.textContent = this.getBadgeLabel(item); badgeEl.toggleClass('qoderian-tab-badge-expanded', isTitleExpanded); badgeEl.setAttribute('data-title-expanded', isTitleExpanded ? 'true' : 'false'); } diff --git a/src/features/chat/tabs/tab-dom.ts b/src/features/chat/tabs/tab-dom.ts index bb410bb..6d70b73 100644 --- a/src/features/chat/tabs/tab-dom.ts +++ b/src/features/chat/tabs/tab-dom.ts @@ -11,6 +11,7 @@ export function buildTabDOM(contentEl: HTMLElement): TabDOMElements { const queueIndicatorEl = inputContainerEl.createDiv({ cls: 'qoderian-input-queue-row' }); const navRowEl = inputContainerEl.createDiv({ cls: 'qoderian-input-nav-row' }); const inputWrapper = inputContainerEl.createDiv({ cls: 'qoderian-input-wrapper' }); + const composerResizeHandleEl = inputWrapper.createDiv({ cls: 'qoderian-composer-resize-handle' }); const contextRowEl = inputWrapper.createDiv({ cls: 'qoderian-context-row' }); const inputEl = inputWrapper.createEl('textarea', { cls: 'qoderian-input', @@ -30,6 +31,7 @@ export function buildTabDOM(contentEl: HTMLElement): TabDOMElements { inputContainerEl, queueIndicatorEl, inputWrapper, + composerResizeHandleEl, inputEl, navRowEl, contextRowEl, diff --git a/src/features/chat/tabs/tab-lifecycle.ts b/src/features/chat/tabs/tab-lifecycle.ts index 1ee249a..ea36f74 100644 --- a/src/features/chat/tabs/tab-lifecycle.ts +++ b/src/features/chat/tabs/tab-lifecycle.ts @@ -40,6 +40,8 @@ export async function destroyTab(tab: TabData): Promise { tab.ui.fileContextManager?.destroy(); tab.ui.vaultDropController?.destroy(); tab.ui.vaultDropController = null; + tab.ui.composerResize?.destroy(); + tab.ui.composerResize = null; tab.ui.composerBridge?.destroy(); tab.ui.composerBridge = null; tab.ui.modelSelector?.destroy(); diff --git a/src/features/chat/tabs/tab.ts b/src/features/chat/tabs/tab.ts index 28c82cd..fb78bc9 100644 --- a/src/features/chat/tabs/tab.ts +++ b/src/features/chat/tabs/tab.ts @@ -32,6 +32,7 @@ import { ChatState } from '../state/chat-state'; import { BangBashModeManager as BangBashModeManagerClass } from '../ui/bang-bash-mode-manager'; import { ComposerBridge } from '../ui/composer/composer-bridge'; import { ComposerActionButton } from '../ui/composer-action-button'; +import { attachComposerResize } from '../ui/composer-resize'; import { FileContextManager } from '../ui/file-context/file-context-manager'; import { ImageContextManager } from '../ui/image-context'; import { createInputToolbar } from '../ui/input-toolbar'; @@ -151,6 +152,7 @@ export function createTab(options: TabCreateOptions): TabData { }, ui: { composerBridge: null, + composerResize: null, fileContextManager: null, imageContextManager: null, vaultDropController: null, @@ -583,6 +585,12 @@ export function initializeTabUI( ): void { const { dom, state } = tab; + tab.ui.composerResize = attachComposerResize( + dom.inputWrapper, + dom.composerResizeHandleEl, + () => tab.renderer?.scrollToBottomIfNeeded(), + ); + // Initialize context managers (file/image) initializeContextManagers(tab, plugin); diff --git a/src/features/chat/tabs/types.ts b/src/features/chat/tabs/types.ts index b79fec8..0e3b784 100644 --- a/src/features/chat/tabs/types.ts +++ b/src/features/chat/tabs/types.ts @@ -17,6 +17,7 @@ import type { ChatState } from '../state/chat-state'; import type { BangBashModeManager } from '../ui/bang-bash-mode-manager'; import type { ComposerBridge } from '../ui/composer/composer-bridge'; import type { ComposerActionButton } from '../ui/composer-action-button'; +import type { ComposerResizeController } from '../ui/composer-resize'; import type { FileContextManager } from '../ui/file-context/file-context-manager'; import type { ImageContextManager } from '../ui/image-context'; import type { @@ -118,6 +119,7 @@ export interface TabServices { export interface TabUIComponents { /** Bridges the legacy textarea with the CodeMirror live composer. */ composerBridge: ComposerBridge | null; + composerResize: ComposerResizeController | null; fileContextManager: FileContextManager | null; imageContextManager: ImageContextManager | null; vaultDropController: VaultDropController | null; @@ -150,6 +152,7 @@ export interface TabDOMElements { inputContainerEl: HTMLElement; queueIndicatorEl: HTMLElement; inputWrapper: HTMLElement; + composerResizeHandleEl: HTMLElement; inputEl: HTMLTextAreaElement; /** Nav row for tab badges and header icons (above input wrapper). */ diff --git a/src/features/chat/ui/composer-resize.ts b/src/features/chat/ui/composer-resize.ts new file mode 100644 index 0000000..0eda291 --- /dev/null +++ b/src/features/chat/ui/composer-resize.ts @@ -0,0 +1,154 @@ +import { t } from '../../../i18n/i18n'; +import { setButtonTooltip } from '../../../shared/dom/tooltip'; + +export const COMPOSER_MIN_HEIGHT = 140; +export const COMPOSER_MAX_HEIGHT_PERCENT = 0.75; +export const COMPOSER_KEYBOARD_RESIZE_STEP = 24; + +export interface ComposerResizeController { + destroy(): void; + refreshLocale(): void; + reset(): void; +} + +export function calculateComposerMaxHeight(viewHeight: number): number { + return Math.max(COMPOSER_MIN_HEIGHT, Math.floor(viewHeight * COMPOSER_MAX_HEIGHT_PERCENT)); +} + +export function clampComposerHeight(height: number, viewHeight: number): number { + return Math.min( + calculateComposerMaxHeight(viewHeight), + Math.max(COMPOSER_MIN_HEIGHT, Math.round(height)), + ); +} + +/** + * Makes the top edge of the composer draggable. The footer is anchored at the + * bottom of the chat view, so dragging upward increases the input area. + * Double-clicking the handle (or pressing Home) returns to content-driven size. + */ +export function attachComposerResize( + wrapperEl: HTMLElement, + handleEl: HTMLElement, + onResize?: () => void, +): ComposerResizeController { + const doc = handleEl.ownerDocument; + const viewWindow = doc.defaultView; + const containerEl = wrapperEl.closest('.qoderian-container'); + let dragging = false; + let startClientY = 0; + let startHeight = COMPOSER_MIN_HEIGHT; + + const getViewHeight = (): number => { + const containerHeight = containerEl?.clientHeight; + return containerHeight || doc.defaultView?.innerHeight || COMPOSER_MIN_HEIGHT; + }; + + const currentHeight = (): number => { + const measured = wrapperEl.getBoundingClientRect().height || wrapperEl.clientHeight; + return measured || COMPOSER_MIN_HEIGHT; + }; + + const updateAriaBounds = (height: number): void => { + handleEl.setAttribute('aria-valuemin', String(COMPOSER_MIN_HEIGHT)); + handleEl.setAttribute('aria-valuemax', String(calculateComposerMaxHeight(getViewHeight()))); + handleEl.setAttribute('aria-valuenow', String(Math.round(height))); + }; + + const applyHeight = (height: number): void => { + const nextHeight = clampComposerHeight(height, getViewHeight()); + wrapperEl.classList.add('qoderian-input-wrapper--resized'); + wrapperEl.style.setProperty('--qoderian-input-wrapper-height', `${nextHeight}px`); + updateAriaBounds(nextHeight); + onResize?.(); + }; + + const stopDragging = (): void => { + if (!dragging) return; + dragging = false; + doc.removeEventListener('pointermove', handlePointerMove); + doc.removeEventListener('pointerup', stopDragging); + doc.removeEventListener('pointercancel', stopDragging); + doc.body?.classList.remove('qoderian-composer-resizing'); + }; + + const handlePointerMove = (event: PointerEvent): void => { + if (!dragging) return; + applyHeight(startHeight + startClientY - event.clientY); + }; + + const handlePointerDown = (event: PointerEvent): void => { + if (event.button !== 0) return; + event.preventDefault(); + stopDragging(); + dragging = true; + startClientY = event.clientY; + startHeight = currentHeight(); + doc.addEventListener('pointermove', handlePointerMove); + doc.addEventListener('pointerup', stopDragging); + doc.addEventListener('pointercancel', stopDragging); + doc.body?.classList.add('qoderian-composer-resizing'); + }; + + const reset = (): void => { + stopDragging(); + wrapperEl.classList.remove('qoderian-input-wrapper--resized'); + wrapperEl.style.removeProperty('--qoderian-input-wrapper-height'); + updateAriaBounds(currentHeight()); + onResize?.(); + }; + + const handleKeydown = (event: KeyboardEvent): void => { + if (event.key === 'Home') { + event.preventDefault(); + reset(); + return; + } + + if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return; + event.preventDefault(); + const delta = event.key === 'ArrowUp' + ? COMPOSER_KEYBOARD_RESIZE_STEP + : -COMPOSER_KEYBOARD_RESIZE_STEP; + applyHeight(currentHeight() + delta); + }; + + const refreshLocale = (): void => { + setButtonTooltip(handleEl, t('composer.resize')); + }; + + handleEl.setAttribute('role', 'separator'); + handleEl.setAttribute('aria-orientation', 'horizontal'); + handleEl.setAttribute('tabindex', '0'); + updateAriaBounds(currentHeight()); + refreshLocale(); + handleEl.addEventListener('pointerdown', handlePointerDown); + handleEl.addEventListener('dblclick', reset); + handleEl.addEventListener('keydown', handleKeydown); + viewWindow?.addEventListener('blur', stopDragging); + + let resizeObserver: ResizeObserver | null = null; + if (typeof ResizeObserver !== 'undefined' && containerEl) { + resizeObserver = new ResizeObserver(() => { + if (wrapperEl.classList.contains('qoderian-input-wrapper--resized')) { + applyHeight(currentHeight()); + } else { + updateAriaBounds(currentHeight()); + } + }); + resizeObserver.observe(containerEl); + } + + return { + destroy: () => { + stopDragging(); + resizeObserver?.disconnect(); + handleEl.removeEventListener('pointerdown', handlePointerDown); + handleEl.removeEventListener('dblclick', reset); + handleEl.removeEventListener('keydown', handleKeydown); + viewWindow?.removeEventListener('blur', stopDragging); + }, + refreshLocale, + reset, + }; +} diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 54ab8f8..119ee2b 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -23,7 +23,8 @@ }, "composer": { "send": "Nachricht senden", - "stop": "Generierung stoppen" + "stop": "Generierung stoppen", + "resize": "Ziehen, um die Eingabehöhe anzupassen; doppelklicken zum Zurücksetzen" }, "restore": { "failed": "Einige Tabs oder Unterhaltungen konnten nicht wiederhergestellt werden ({count} Problem(e)). Details in der Entwicklerkonsole." @@ -35,7 +36,8 @@ "inserted": "Eingefügt", "editApplied": "Bearbeitung angewendet", "newTab": "Neuer Tab", - "newSession": "Neue Sitzung (im aktuellen Tab)", + "newSession": "Neue Unterhaltung im aktuellen Tab starten", + "closeTab": "Tab schließen", "closeCurrentTab": "Aktuellen Tab schließen" }, "nav": { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 7380d97..fda3926 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -23,7 +23,8 @@ }, "composer": { "send": "Send message", - "stop": "Stop generation" + "stop": "Stop generation", + "resize": "Drag to resize the message input; double-click to reset" }, "restore": { "failed": "Some of your previous tabs or conversations could not be restored ({count} issue(s)). Details are in the developer console." @@ -35,7 +36,8 @@ "inserted": "Inserted", "editApplied": "Edit applied", "newTab": "New tab", - "newSession": "New session (in current tab)", + "newSession": "Start new conversation in current tab", + "closeTab": "Close tab", "closeCurrentTab": "Close current tab" }, "nav": { diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 36d21b4..6d76594 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -23,7 +23,8 @@ }, "composer": { "send": "Enviar mensaje", - "stop": "Detener generación" + "stop": "Detener generación", + "resize": "Arrastra para ajustar la altura del campo; haz doble clic para restablecer" }, "restore": { "failed": "No se pudieron restaurar algunas pestañas o conversaciones ({count} problema(s)). Detalles en la consola de desarrollador." @@ -35,7 +36,8 @@ "inserted": "Insertado", "editApplied": "Edición aplicada", "newTab": "Nueva pestaña", - "newSession": "Nueva sesión (en la pestaña actual)", + "newSession": "Iniciar una conversación nueva en la pestaña actual", + "closeTab": "Cerrar pestaña", "closeCurrentTab": "Cerrar pestaña actual" }, "nav": { diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 51c72c1..950ded0 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -23,7 +23,8 @@ }, "composer": { "send": "Envoyer le message", - "stop": "Arrêter la génération" + "stop": "Arrêter la génération", + "resize": "Faites glisser pour redimensionner la saisie ; double-cliquez pour réinitialiser" }, "restore": { "failed": "Certains onglets ou conversations n'ont pas pu être restaurés ({count} problème(s)). Détails dans la console développeur." @@ -35,7 +36,8 @@ "inserted": "Inséré", "editApplied": "Modification appliquée", "newTab": "Nouvel onglet", - "newSession": "Nouvelle session (dans l'onglet actuel)", + "newSession": "Démarrer une nouvelle conversation dans l'onglet actuel", + "closeTab": "Fermer l'onglet", "closeCurrentTab": "Fermer l'onglet actuel" }, "nav": { diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 8b1db00..a98c55b 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -23,7 +23,8 @@ }, "composer": { "send": "メッセージを送信", - "stop": "生成を停止" + "stop": "生成を停止", + "resize": "ドラッグして入力欄の高さを調整。ダブルクリックで自動サイズに戻す" }, "restore": { "failed": "一部のタブまたは会話を復元できませんでした({count} 件の問題)。詳細は開発者コンソールを確認してください。" @@ -35,7 +36,8 @@ "inserted": "挿入しました", "editApplied": "編集を適用しました", "newTab": "新しいタブ", - "newSession": "新しいセッション(現在のタブ)", + "newSession": "現在のタブで新しい会話を開始", + "closeTab": "タブを閉じる", "closeCurrentTab": "現在のタブを閉じる" }, "nav": { diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index c8a12da..d4a4a4d 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -23,7 +23,8 @@ }, "composer": { "send": "메시지 보내기", - "stop": "생성 중지" + "stop": "생성 중지", + "resize": "드래그하여 입력 높이 조절; 두 번 클릭하여 자동 높이로 재설정" }, "restore": { "failed": "이전 탭 또는 대화를 완전히 복원하지 못했습니다(문제 {count}건). 자세한 내용은 개발자 콘솔을 확인하세요." @@ -35,7 +36,8 @@ "inserted": "삽입됨", "editApplied": "편집 적용됨", "newTab": "새 탭", - "newSession": "새 세션 (현재 탭)", + "newSession": "현재 탭에서 새 대화 시작", + "closeTab": "탭 닫기", "closeCurrentTab": "현재 탭 닫기" }, "nav": { diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 5d50996..a7d0274 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -23,7 +23,8 @@ }, "composer": { "send": "Enviar mensagem", - "stop": "Parar geração" + "stop": "Parar geração", + "resize": "Arraste para ajustar a altura da entrada; clique duas vezes para redefinir" }, "restore": { "failed": "Algumas abas ou conversas não puderam ser restauradas ({count} problema(s)). Detalhes no console do desenvolvedor." @@ -35,7 +36,8 @@ "inserted": "Inserido", "editApplied": "Edição aplicada", "newTab": "Nova aba", - "newSession": "Nova sessão (na aba atual)", + "newSession": "Iniciar nova conversa na aba atual", + "closeTab": "Fechar aba", "closeCurrentTab": "Fechar aba atual" }, "nav": { diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index e636969..47f8e7d 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -23,7 +23,8 @@ }, "composer": { "send": "Отправить сообщение", - "stop": "Остановить генерацию" + "stop": "Остановить генерацию", + "resize": "Перетащите, чтобы изменить высоту поля; двойной щелчок — сброс" }, "restore": { "failed": "Не удалось восстановить часть вкладок или бесед (проблем: {count}). Подробности — в консоли разработчика." @@ -35,7 +36,8 @@ "inserted": "Вставлено", "editApplied": "Правка применена", "newTab": "Новая вкладка", - "newSession": "Новая сессия (в текущей вкладке)", + "newSession": "Начать новый разговор в текущей вкладке", + "closeTab": "Закрыть вкладку", "closeCurrentTab": "Закрыть текущую вкладку" }, "nav": { diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 079e432..5b49141 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -23,7 +23,8 @@ }, "composer": { "send": "发送消息", - "stop": "停止生成" + "stop": "停止生成", + "resize": "拖动调整输入框高度;双击恢复自动高度" }, "restore": { "failed": "部分标签或会话未能恢复({count} 个问题)。详情已输出到开发者控制台。" @@ -35,7 +36,8 @@ "inserted": "已插入", "editApplied": "已应用编辑", "newTab": "新建标签页", - "newSession": "新建会话(当前标签页)", + "newSession": "在当前标签页开始新会话", + "closeTab": "关闭标签页", "closeCurrentTab": "关闭当前标签页" }, "nav": { diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 2c246dc..ba7b8d6 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -23,7 +23,8 @@ }, "composer": { "send": "傳送訊息", - "stop": "停止生成" + "stop": "停止生成", + "resize": "拖曳調整輸入框高度;按兩下恢復自動高度" }, "restore": { "failed": "部分分頁或工作階段未能恢復({count} 個問題)。詳情已輸出到開發者主控台。" @@ -35,7 +36,8 @@ "inserted": "已插入", "editApplied": "已套用編輯", "newTab": "新增分頁", - "newSession": "新增工作階段(目前分頁)", + "newSession": "在目前分頁開始新對話", + "closeTab": "關閉分頁", "closeCurrentTab": "關閉目前分頁" }, "nav": { diff --git a/src/i18n/types.ts b/src/i18n/types.ts index 54df1b8..feaaff3 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -9,6 +9,7 @@ export type TranslationKey = | 'commands.editApplied' | 'commands.newTab' | 'commands.newSession' + | 'commands.closeTab' | 'commands.closeCurrentTab' // Nav row buttons and tab badges @@ -41,6 +42,7 @@ export type TranslationKey = // Composer - send/stop action button | 'composer.send' | 'composer.stop' + | 'composer.resize' | 'restore.failed' // Chat - Rewind diff --git a/src/style/components/composer.css b/src/style/components/composer.css index 6d34f31..e263f8e 100644 --- a/src/style/components/composer.css +++ b/src/style/components/composer.css @@ -36,6 +36,27 @@ font-family: inherit; line-height: 1.4; overflow-y: auto; + /* Reserve the scrollbar track before the content reaches its cap. This + avoids a wrap-width feedback loop that made long input flicker. */ + scrollbar-gutter: stable; +} + +/* Once the user chooses a height, flex the editor into that stable box and + scroll its content instead of continuing the content-driven growth path. */ +.qoderian-input-wrapper--resized .qoderian-live-composer-host { + min-height: 0; + overflow: hidden; +} + +.qoderian-input-wrapper--resized .qoderian-live-composer.cm-editor { + min-height: 0; + max-height: none; + height: 100%; +} + +.qoderian-input-wrapper--resized textarea.qoderian-input { + min-height: 0; + max-height: none; } .qoderian-input-wrapper .qoderian-live-composer .cm-content { diff --git a/src/style/components/input.css b/src/style/components/input.css index b4f0e2e..c56e48e 100644 --- a/src/style/components/input.css +++ b/src/style/components/input.css @@ -29,6 +29,56 @@ box-shadow: var(--qoderian-input-wrapper-box-shadow); } +.qoderian-input-wrapper--resized { + height: var(--qoderian-input-wrapper-height); +} + +/* Dragging this top-edge grip grows the bottom-anchored composer upward. */ +.qoderian-composer-resize-handle { + position: absolute; + z-index: 4; + inset-block-start: -6px; + inset-inline: 18%; + height: 12px; + cursor: ns-resize; + touch-action: none; +} + +.qoderian-composer-resize-handle::after { + content: ''; + position: absolute; + inset-block-start: 4px; + inset-inline: 0; + height: 3px; + border-radius: 999px; + background: var(--background-modifier-border); + opacity: 0; + transition: opacity 0.12s ease, background 0.12s ease; +} + +.qoderian-input-wrapper:hover > .qoderian-composer-resize-handle::after, +.qoderian-composer-resize-handle:hover::after, +.qoderian-composer-resize-handle:focus-visible::after, +.qoderian-composer-resizing .qoderian-composer-resize-handle::after { + opacity: 1; +} + +.qoderian-composer-resize-handle:hover::after, +.qoderian-composer-resize-handle:focus-visible::after, +.qoderian-composer-resizing .qoderian-composer-resize-handle::after { + background: var(--interactive-accent); +} + +.qoderian-composer-resize-handle:focus-visible { + outline: none; +} + +body.qoderian-composer-resizing, +body.qoderian-composer-resizing * { + cursor: ns-resize; + user-select: none; +} + /* Context row (context chips: files, images, selections) - inside input wrapper at top */ /* Collapsed by default; expanded via .has-content class; textarea fills remaining space */ .qoderian-context-row { diff --git a/src/style/components/tabs.css b/src/style/components/tabs.css index ae3e3bb..0bfcb04 100644 --- a/src/style/components/tabs.css +++ b/src/style/components/tabs.css @@ -26,6 +26,7 @@ } .qoderian-tab-badge { + position: relative; display: flex; align-items: center; justify-content: center; @@ -48,14 +49,70 @@ transition: border-color 0.15s ease, color 0.15s ease, background 0.15s ease; } +.qoderian-tab-badge-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + transition: opacity 0.12s ease; +} + +.qoderian-tab-badge-close { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + border-radius: 3px; + opacity: 0; + pointer-events: none; + color: var(--text-muted); + transition: opacity 0.12s ease, color 0.12s ease; +} + +.qoderian-tab-badge-close svg { + width: 13px; + height: 13px; +} + +.qoderian-tab-badge-closable:hover > .qoderian-tab-badge-label, +.qoderian-tab-badge-closable:focus-within > .qoderian-tab-badge-label { + opacity: 0; +} + +.qoderian-tab-badge-closable:hover > .qoderian-tab-badge-close, +.qoderian-tab-badge-closable:focus-within > .qoderian-tab-badge-close { + opacity: 1; + pointer-events: auto; +} + +.qoderian-tab-badge-close:hover, +.qoderian-tab-badge-close:focus-visible { + color: var(--text-error); + outline: none; +} + .qoderian-tab-badge-expanded { flex: 0 0 auto; justify-content: flex-start; + gap: 4px; width: auto; max-width: none; padding: 0 8px; } +.qoderian-tab-badge-expanded > .qoderian-tab-badge-close { + position: static; + flex: 0 0 14px; + width: 14px; + height: 18px; +} + +.qoderian-tab-badge-expanded.qoderian-tab-badge-closable:hover > .qoderian-tab-badge-label, +.qoderian-tab-badge-expanded.qoderian-tab-badge-closable:focus-within > .qoderian-tab-badge-label { + opacity: 1; +} + .qoderian-tab-badge:hover { background: var(--background-modifier-hover); color: var(--text-normal); diff --git a/tests/unit/features/chat/tabs/tab-bar.test.ts b/tests/unit/features/chat/tabs/tab-bar.test.ts new file mode 100644 index 0000000..da0af45 --- /dev/null +++ b/tests/unit/features/chat/tabs/tab-bar.test.ts @@ -0,0 +1,91 @@ +import { createMockEl } from '@test/helpers/mock-element'; + +import { TabBar } from '@/features/chat/tabs/tab-bar'; +import type { TabBarItem } from '@/features/chat/tabs/types'; + +function item(overrides: Partial = {}): TabBarItem { + return { + id: 'tab-1', + index: 1, + title: 'Conversation one', + isActive: true, + isStreaming: false, + needsAttention: false, + canClose: true, + ...overrides, + }; +} + +describe('TabBar', () => { + it('renders a dedicated close control only for closable tabs', () => { + const container = createMockEl(); + const tabBar = new TabBar(container, { + onTabClick: jest.fn(), + onTabClose: jest.fn(), + onNewTab: jest.fn(), + }); + + tabBar.update([ + item(), + item({ id: 'tab-2', index: 2, canClose: false }), + ]); + + const badges = container.querySelectorAll('.qoderian-tab-badge'); + expect(badges[0].querySelector('.qoderian-tab-badge-close')).not.toBeNull(); + expect(badges[1].querySelector('.qoderian-tab-badge-close')).toBeNull(); + }); + + it('closes the target tab without activating it', () => { + const container = createMockEl(); + const onTabClick = jest.fn(); + const onTabClose = jest.fn(); + const tabBar = new TabBar(container, { + onTabClick, + onTabClose, + onNewTab: jest.fn(), + }); + tabBar.update([item()]); + + const closeEl = container.querySelector('.qoderian-tab-badge-close')!; + const event = { + type: 'click', + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + }; + closeEl.dispatchEvent(event); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(event.stopPropagation).toHaveBeenCalled(); + expect(onTabClose).toHaveBeenCalledWith('tab-1'); + expect(onTabClick).not.toHaveBeenCalled(); + }); + + it('keeps the close control when expanding and collapsing a title', () => { + const container = createMockEl(); + const tabBar = new TabBar(container, { + onTabClick: jest.fn(), + onTabClose: jest.fn(), + onNewTab: jest.fn(), + }); + tabBar.update([item()]); + + const badge = container.querySelector('.qoderian-tab-badge')!; + badge.dispatchEvent({ + type: 'dblclick', + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + }); + + expect(badge.querySelector('.qoderian-tab-badge-label')?.textContent).toBe('Conversation one'); + expect(badge.querySelector('.qoderian-tab-badge-close')).not.toBeNull(); + + badge.dispatchEvent({ + type: 'dblclick', + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + }); + + expect(badge.querySelector('.qoderian-tab-badge-label')?.textContent).toBe('1'); + expect(badge.querySelector('.qoderian-tab-badge-close')).not.toBeNull(); + }); +}); diff --git a/tests/unit/features/chat/ui/composer-resize.test.ts b/tests/unit/features/chat/ui/composer-resize.test.ts new file mode 100644 index 0000000..9bcdcd4 --- /dev/null +++ b/tests/unit/features/chat/ui/composer-resize.test.ts @@ -0,0 +1,94 @@ +/** + * @jest-environment jsdom + */ + +import { + attachComposerResize, + calculateComposerMaxHeight, + clampComposerHeight, + COMPOSER_KEYBOARD_RESIZE_STEP, + COMPOSER_MAX_HEIGHT_PERCENT, + COMPOSER_MIN_HEIGHT, +} from '@/features/chat/ui/composer-resize'; + +describe('composerResize', () => { + it('clamps manual height to the available view range', () => { + expect(calculateComposerMaxHeight(800)).toBe(800 * COMPOSER_MAX_HEIGHT_PERCENT); + expect(clampComposerHeight(80, 800)).toBe(COMPOSER_MIN_HEIGHT); + expect(clampComposerHeight(900, 800)).toBe(600); + }); + + it('grows upward while dragging and resets on double-click', () => { + const { container, wrapper, handle } = createHarness(800, 200); + const onResize = jest.fn(); + const controller = attachComposerResize(wrapper, handle, onResize); + + handle.dispatchEvent(new MouseEvent('pointerdown', { + bubbles: true, + button: 0, + clientY: 400, + })); + document.dispatchEvent(new MouseEvent('pointermove', { clientY: 300 })); + document.dispatchEvent(new MouseEvent('pointerup')); + + expect(wrapper.classList.contains('qoderian-input-wrapper--resized')).toBe(true); + expect(wrapper.style.getPropertyValue('--qoderian-input-wrapper-height')).toBe('300px'); + expect(handle.getAttribute('aria-valuenow')).toBe('300'); + expect(document.body.classList.contains('qoderian-composer-resizing')).toBe(false); + expect(onResize).toHaveBeenCalled(); + + handle.dispatchEvent(new MouseEvent('dblclick')); + + expect(wrapper.classList.contains('qoderian-input-wrapper--resized')).toBe(false); + expect(wrapper.style.getPropertyValue('--qoderian-input-wrapper-height')).toBe(''); + + controller.destroy(); + container.remove(); + }); + + it('supports keyboard resizing and Home to restore automatic height', () => { + const { container, wrapper, handle } = createHarness(800, 200); + const controller = attachComposerResize(wrapper, handle); + + handle.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp' })); + expect(wrapper.style.getPropertyValue('--qoderian-input-wrapper-height')) + .toBe(`${200 + COMPOSER_KEYBOARD_RESIZE_STEP}px`); + + handle.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home' })); + expect(wrapper.classList.contains('qoderian-input-wrapper--resized')).toBe(false); + + controller.destroy(); + container.remove(); + }); +}); + +function createHarness(viewHeight: number, wrapperHeight: number): { + container: HTMLElement; + wrapper: HTMLElement; + handle: HTMLElement; +} { + const container = document.createElement('div'); + const wrapper = document.createElement('div'); + const handle = document.createElement('div'); + container.classList.add('qoderian-container'); + wrapper.classList.add('qoderian-input-wrapper'); + wrapper.appendChild(handle); + container.appendChild(wrapper); + document.body.appendChild(container); + + Object.defineProperty(container, 'clientHeight', { configurable: true, value: viewHeight }); + Object.defineProperty(wrapper, 'clientHeight', { configurable: true, value: wrapperHeight }); + wrapper.getBoundingClientRect = () => ({ + x: 0, + y: 0, + top: 0, + right: 0, + bottom: wrapperHeight, + left: 0, + width: 0, + height: wrapperHeight, + toJSON: () => {}, + }); + + return { container, wrapper, handle }; +} From a7d30796b892086b3de82f558df67ea98e9e5943 Mon Sep 17 00:00:00 2001 From: wangweijia0204 Date: Sat, 5 Sep 2026 14:16:45 +0800 Subject: [PATCH 2/3] fix(chat): refine compact controls and update cues --- CHANGELOG.md | 24 ++- src/app/settings/settings-storage.ts | 4 +- src/features/chat/chat-view.ts | 141 +++++++++++++----- src/features/chat/tabs/tab-bar.ts | 44 ++++-- src/features/chat/tabs/types.ts | 6 +- src/features/chat/ui/credits-usage-button.ts | 6 +- src/features/chat/ui/input-toolbar.ts | 2 +- .../chat/ui/toolbar/toolbar-selectors.ts | 1 + src/features/settings/settings-modal.ts | 31 ++++ src/features/settings/settings-tab.ts | 46 ++++-- src/features/update/plugin-update-checker.ts | 54 +++++++ src/i18n/i18n.ts | 29 ++++ src/i18n/locales/de.json | 8 +- src/i18n/locales/en.json | 8 +- src/i18n/locales/es.json | 8 +- src/i18n/locales/fr.json | 8 +- src/i18n/locales/ja.json | 8 +- src/i18n/locales/ko.json | 8 +- src/i18n/locales/pt.json | 8 +- src/i18n/locales/ru.json | 8 +- src/i18n/locales/zh-CN.json | 8 +- src/i18n/locales/zh-TW.json | 8 +- src/i18n/types.ts | 8 +- src/main.ts | 32 +++- src/style/base/variables.css | 4 + src/style/components/header.css | 72 ++++++++- src/style/components/history.css | 13 +- src/style/components/input.css | 14 +- src/style/components/tabs.css | 63 ++++---- src/style/settings/base.css | 12 ++ src/style/toolbar/credits-usage.css | 16 +- src/style/toolbar/external-context.css | 17 ++- src/style/toolbar/mcp-selector.css | 15 +- src/style/toolbar/model-selector.css | 42 +++--- tests/__mocks__/obsidian.ts | 16 ++ tests/integration/main.test.ts | 9 +- .../app/settings/settings-storage.test.ts | 2 + tests/unit/features/chat/chat-view.test.ts | 49 +++++- tests/unit/features/chat/tabs/tab-bar.test.ts | 37 +++-- .../update/plugin-update-checker.test.ts | 45 ++++++ tests/unit/i18n/i18n.test.ts | 17 +++ 41 files changed, 749 insertions(+), 202 deletions(-) create mode 100644 src/features/settings/settings-modal.ts create mode 100644 src/features/update/plugin-update-checker.ts create mode 100644 tests/unit/features/update/plugin-update-checker.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ad19d0..20aac82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,22 +13,36 @@ version with its date and start a fresh empty `[Unreleased]` above it. ### Added -- Tabs now reveal a dedicated close button on hover (with keyboard access), - making tab removal discoverable without relying on a context-menu gesture. +- Compact tabs are now wide enough to keep their label easy to click, with + closing moved into a hover-revealed actions menu to prevent accidental + removal. - The message composer can be resized by dragging its top edge or using the Up/Down arrow keys on the resize handle; double-click or Home restores automatic content-based sizing. +- History, credits usage, and a standalone Qoderian settings window are now + always available together in the top-right view header. +- A non-blocking update badge now appears beside the Qoderian mark when GitHub + has a newer stable release. ### Changed -- The ambiguous square-edit action now uses a restart icon and explicitly says - that it starts a new conversation in the current tab, distinguishing it from - the adjacent action that opens a separate tab. +- Fresh installations follow Obsidian's interface language and allow the + maximum supported number of chat tabs by default. +- The core chat chrome now uses calmer rounded controls, a layered composer, + and consistent Lucide-style icons. + +### Removed + +- The redundant restart-current-conversation icon from the composer toolbar; + the command-palette action remains available for existing keyboard workflows. ### Fixed - Long composer input no longer shifts its wrapping width when the vertical scrollbar appears, preventing the input height from flickering at its cap. +- The composer resize indicator now spans the complete top edge of the input. +- Composer popovers now stay within the Qoderian pane in narrow sidebars, and + the external-context action uses an unclipped add-folder icon. ## [1.0.7] - 2026-09-02 diff --git a/src/app/settings/settings-storage.ts b/src/app/settings/settings-storage.ts index a5dde3b..ac755e9 100644 --- a/src/app/settings/settings-storage.ts +++ b/src/app/settings/settings-storage.ts @@ -38,11 +38,11 @@ export const DEFAULT_QODERIAN_SETTINGS: QoderianSettings = { }, requireCommandOrControlEnterToSend: false, - locale: 'en', + locale: 'auto', qoder: { ...DEFAULT_QODER_SETTINGS }, - maxTabs: 3, + maxTabs: 10, enableAutoScroll: true, deferMathRenderingDuringStreaming: true, expandFileEditsByDefault: false, diff --git a/src/features/chat/chat-view.ts b/src/features/chat/chat-view.ts index ffa2b04..7563f72 100644 --- a/src/features/chat/chat-view.ts +++ b/src/features/chat/chat-view.ts @@ -6,20 +6,22 @@ import { VIEW_TYPE_QODERIAN } from '../../core/types'; import { t } from '../../i18n/i18n'; import type QoderianPlugin from '../../main'; import { fetchCreditsUsage } from '../../qoder/services/credits-usage'; +import { openExternalBrowserUrl } from '../../qoder/services/qoder-login-service'; import { cancelScheduledAnimationFrame, scheduleAnimationFrame, type ScheduledAnimationFrame, } from '../../shared/dom/animation-frame'; import { setButtonTooltip } from '../../shared/dom/tooltip'; -import { createIconSvg, QODER_ICON,QODERIAN_ICON_ID } from '../../shared/icons'; +import { createIconSvg, QODER_ICON, QODERIAN_ICON_ID } from '../../shared/icons'; +import { QoderianSettingsModal } from '../settings/settings-modal'; import type { HistoryConversationStatus } from './controllers/conversation-controller'; import { sendTabInputMessageFromExplicitEnterShortcut, } from './tabs/tab'; import { TabBar } from './tabs/tab-bar'; import { TabManager } from './tabs/tab-manager'; -import type { TabData, TabId } from './tabs/types'; +import { DEFAULT_MAX_TABS, type TabData, type TabId } from './tabs/types'; import { CreditsUsageButton } from './ui/credits-usage-button'; type LoadableView = { @@ -44,9 +46,10 @@ export class QoderianView extends ItemView { // DOM Elements private viewContainerEl: HTMLElement | null = null; private logoEl: HTMLElement | null = null; + private updateBadgeEl: HTMLElement | null = null; private newTabButtonEl: HTMLElement | null = null; - private newConversationButtonEl: HTMLElement | null = null; private historyButtonEl: HTMLElement | null = null; + private settingsButtonEl: HTMLElement | null = null; // Header elements private historyDropdown: HTMLElement | null = null; @@ -223,6 +226,69 @@ export class QoderianView extends ItemView { this.syncHeaderLogo(); titleEl.createEl('h4', { text: 'Qoder', cls: 'qoderian-title-text' }); + + this.updateBadgeEl = titleEl.createEl('button', { + cls: 'qoderian-update-badge qoderian-hidden', + attr: { type: 'button' }, + }); + void this.refreshUpdateBadge(); + + const actionsEl = header.createDiv({ cls: 'qoderian-header-actions' }); + + // History belongs to the view-level chrome rather than the active tab's + // composer, so it stays reachable while the input is tall or scrolled. + const historyContainer = actionsEl.createDiv({ cls: 'qoderian-history-container' }); + const historyBtn = historyContainer.createDiv({ + cls: 'qoderian-input-nav-btn qoderian-header-action qoderian-history-btn', + }); + setIcon(historyBtn, 'history'); + setButtonTooltip(historyBtn, t('nav.chatHistory')); + historyBtn.setAttribute('role', 'button'); + historyBtn.setAttribute('tabindex', '0'); + historyBtn.setAttribute('aria-haspopup', 'listbox'); + historyBtn.setAttribute('aria-expanded', 'false'); + this.historyButtonEl = historyBtn; + + this.historyDropdown = historyContainer.createDiv({ cls: 'qoderian-history-menu' }); + this.historyDropdown.setAttribute('role', 'listbox'); + + const toggleHistory = (event: Event): void => { + event.preventDefault(); + event.stopPropagation(); + this.toggleHistoryDropdown(); + }; + historyBtn.addEventListener('click', toggleHistory); + historyBtn.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ') toggleHistory(event); + }); + + // Credits usage is account-level, so it sits beside history in the fixed + // header instead of moving with the active tab's composer. + const agentCatalog = this.plugin.qoderServices.agentCatalog; + this.creditsUsageButton = new CreditsUsageButton(actionsEl, { + getCachedUsage: () => agentCatalog.getUsageInfo(), + fetchUsage: () => fetchCreditsUsage(this.plugin), + subscribeRuntimeStatus: (listener) => agentCatalog.subscribeRuntimeStatus(listener), + }); + + const settingsBtn = actionsEl.createDiv({ + cls: 'qoderian-input-nav-btn qoderian-header-action qoderian-settings-btn', + }); + setIcon(settingsBtn, 'settings'); + setButtonTooltip(settingsBtn, t('common.settings')); + settingsBtn.setAttribute('role', 'button'); + settingsBtn.setAttribute('tabindex', '0'); + this.settingsButtonEl = settingsBtn; + + const openSettings = (event: Event): void => { + event.preventDefault(); + event.stopPropagation(); + this.openSettings(); + }; + settingsBtn.addEventListener('click', openSettings); + settingsBtn.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ') openSettings(event); + }); } /** @@ -252,39 +318,6 @@ export class QoderianView extends ItemView { void this.createNewTab().catch(() => new Notice('Failed to create tab')); }); - const newBtn = navActionsEl.createDiv({ cls: 'qoderian-input-nav-btn' }); - setIcon(newBtn, 'refresh-cw'); - setButtonTooltip(newBtn, t('commands.newSession')); - this.newConversationButtonEl = newBtn; - newBtn.addEventListener('click', () => { - void (async () => { - await this.tabManager?.createNewConversation(); - this.updateHistoryDropdown(); - })().catch(() => new Notice('Failed to create conversation')); - }); - - // History dropdown - const historyContainer = navActionsEl.createDiv({ cls: 'qoderian-history-container' }); - const historyBtn = historyContainer.createDiv({ cls: 'qoderian-input-nav-btn' }); - setIcon(historyBtn, 'history'); - setButtonTooltip(historyBtn, t('nav.chatHistory')); - this.historyButtonEl = historyBtn; - - this.historyDropdown = historyContainer.createDiv({ cls: 'qoderian-history-menu' }); - - historyBtn.addEventListener('click', (e) => { - e.stopPropagation(); - this.toggleHistoryDropdown(); - }); - - // Credits usage popover (account-level, shared across tabs) - const agentCatalog = this.plugin.qoderServices.agentCatalog; - this.creditsUsageButton = new CreditsUsageButton(navActionsEl, { - getCachedUsage: () => agentCatalog.getUsageInfo(), - fetchUsage: () => fetchCreditsUsage(this.plugin), - subscribeRuntimeStatus: (listener) => agentCatalog.subscribeRuntimeStatus(listener), - }); - return wrapper; } @@ -354,10 +387,15 @@ export class QoderianView extends ItemView { /** Re-applies locale-dependent static text after a language change. */ refreshLocalizedChrome(): void { if (this.newTabButtonEl) setButtonTooltip(this.newTabButtonEl, t('commands.newTab')); - if (this.newConversationButtonEl) { - setButtonTooltip(this.newConversationButtonEl, t('commands.newSession')); - } if (this.historyButtonEl) setButtonTooltip(this.historyButtonEl, t('nav.chatHistory')); + if (this.settingsButtonEl) setButtonTooltip(this.settingsButtonEl, t('common.settings')); + if (this.updateBadgeEl?.dataset.version) { + this.updateBadgeEl.setText(t('updates.available', { version: this.updateBadgeEl.dataset.version })); + this.updateBadgeEl.setAttribute( + 'aria-label', + t('updates.openRelease', { version: this.updateBadgeEl.dataset.version }), + ); + } this.creditsUsageButton?.refreshLocale(); for (const tab of this.tabManager?.getAllTabs() ?? []) { tab.ui.composerResize?.refreshLocale(); @@ -365,6 +403,20 @@ export class QoderianView extends ItemView { this.updateTabBar(); } + private async refreshUpdateBadge(): Promise { + const badge = this.updateBadgeEl; + if (!badge) return; + + const update = await this.plugin.getAvailableUpdate(); + if (!update || this.updateBadgeEl !== badge) return; + + badge.dataset.version = update.version; + badge.setText(t('updates.available', { version: update.version })); + badge.setAttribute('aria-label', t('updates.openRelease', { version: update.version })); + badge.removeClass('qoderian-hidden'); + badge.addEventListener('click', () => openExternalBrowserUrl(update.url), { once: true }); + } + // ============================================ // Tab Management // ============================================ @@ -391,7 +443,7 @@ export class QoderianView extends ItemView { async createNewTab(): Promise { const tab = await this.tabManager?.createTab(); if (!tab) { - const maxTabs = this.plugin.settings.maxTabs ?? 3; + const maxTabs = this.plugin.settings.maxTabs ?? DEFAULT_MAX_TABS; new Notice(`Maximum ${maxTabs} tabs allowed`); this.updateTabBarVisibility(); return; @@ -467,12 +519,18 @@ export class QoderianView extends ItemView { const isVisible = this.historyDropdown.hasClass('visible'); if (isVisible) { this.historyDropdown.removeClass('visible'); + this.historyButtonEl?.setAttribute('aria-expanded', 'false'); } else { this.updateHistoryDropdown(); this.historyDropdown.addClass('visible'); + this.historyButtonEl?.setAttribute('aria-expanded', 'true'); } } + private openSettings(): void { + new QoderianSettingsModal(this.plugin).open(); + } + private updateHistoryDropdown(): void { if (!this.historyDropdown) return; this.historyDropdown.empty(); @@ -493,6 +551,7 @@ export class QoderianView extends ItemView { private async openHistoryConversation(conversationId: string): Promise { await this.tabManager?.openConversation(conversationId); this.historyDropdown?.removeClass('visible'); + this.historyButtonEl?.setAttribute('aria-expanded', 'false'); } private async openHistoryConversationInNewTab( @@ -504,6 +563,7 @@ export class QoderianView extends ItemView { activate, }); this.historyDropdown?.removeClass('visible'); + this.historyButtonEl?.setAttribute('aria-expanded', 'false'); } private getHistoryConversationStatus(conversationId: string): HistoryConversationStatus { @@ -564,6 +624,7 @@ export class QoderianView extends ItemView { // Document-level click to close dropdowns this.registerDomEvent(activeDocument, 'click', () => { this.historyDropdown?.removeClass('visible'); + this.historyButtonEl?.setAttribute('aria-expanded', 'false'); }); // View scopes are the Obsidian-owned boundary for main-area tab hotkeys. diff --git a/src/features/chat/tabs/tab-bar.ts b/src/features/chat/tabs/tab-bar.ts index 2be3511..2bb0080 100644 --- a/src/features/chat/tabs/tab-bar.ts +++ b/src/features/chat/tabs/tab-bar.ts @@ -1,4 +1,4 @@ -import { setIcon } from 'obsidian'; +import { Menu, setIcon } from 'obsidian'; import { t } from '../../../i18n/i18n'; import { scheduleAnimationFrame } from '../../../shared/dom/animation-frame'; @@ -94,25 +94,26 @@ export class TabBar { badgeEl.setAttribute('data-title-expanded', isTitleExpanded ? 'true' : 'false'); if (item.canClose) { - const closeEl = badgeEl.createSpan({ cls: 'qoderian-tab-badge-close' }); - closeEl.setAttribute('role', 'button'); - closeEl.setAttribute('tabindex', '0'); - setIcon(closeEl, 'x'); - setButtonTooltip(closeEl, t('commands.closeTab')); - - const closeTab = (event: Event): void => { + const menuEl = badgeEl.createSpan({ cls: 'qoderian-tab-badge-menu' }); + menuEl.setAttribute('role', 'button'); + menuEl.setAttribute('tabindex', '0'); + menuEl.setAttribute('aria-haspopup', 'menu'); + setIcon(menuEl, 'ellipsis'); + setButtonTooltip(menuEl, t('commands.tabActions')); + + const openMenu = (event: Event): void => { event.preventDefault(); event.stopPropagation(); - this.callbacks.onTabClose(item.id); + this.showTabMenu(item, menuEl); }; - closeEl.addEventListener('click', closeTab); - closeEl.addEventListener('dblclick', (event) => { + menuEl.addEventListener('click', openMenu); + menuEl.addEventListener('dblclick', (event) => { event.preventDefault(); event.stopPropagation(); }); - closeEl.addEventListener('keydown', (event) => { + menuEl.addEventListener('keydown', (event) => { if (event.key === 'Enter' || event.key === ' ') { - closeTab(event); + openMenu(event); } }); } @@ -129,15 +130,28 @@ export class TabBar { this.toggleBadgeTitle(item, badgeEl, labelEl); }); - // Right-click to close (if allowed) + // Right-click opens the same menu instead of deleting immediately. if (item.canClose) { badgeEl.addEventListener('contextmenu', (e) => { e.preventDefault(); - this.callbacks.onTabClose(item.id); + e.stopPropagation(); + this.showTabMenu(item, badgeEl); }); } } + private showTabMenu(item: TabBarItem, anchorEl: HTMLElement): void { + const menu = new Menu().setUseNativeMenu(false); + menu.addItem(menuItem => menuItem + .setTitle(t('commands.closeTab')) + .setIcon('x') + .setWarning(true) + .onClick(() => this.callbacks.onTabClose(item.id))); + + const rect = anchorEl.getBoundingClientRect(); + menu.showAtPosition({ x: rect.right, y: rect.bottom }, anchorEl.ownerDocument); + } + /** Destroys the tab bar. */ destroy(): void { this.containerEl.empty(); diff --git a/src/features/chat/tabs/types.ts b/src/features/chat/tabs/types.ts index 0e3b784..e0706b5 100644 --- a/src/features/chat/tabs/types.ts +++ b/src/features/chat/tabs/types.ts @@ -35,12 +35,12 @@ import type { VaultDropController } from '../ui/vault-drop'; /** * Default number of tabs allowed. * - * Set to 3 to balance usability with resource usage: + * Fresh installations allow the full supported range by default: * - Each tab has its own chat runtime and persistent query * - More tabs = more memory and potential SDK processes - * - 3 tabs allows multi-tasking without excessive overhead + * - Users can lower this limit in settings when needed */ -export const DEFAULT_MAX_TABS = 3; +export const DEFAULT_MAX_TABS = 10; /** * Minimum number of tabs allowed (settings floor). diff --git a/src/features/chat/ui/credits-usage-button.ts b/src/features/chat/ui/credits-usage-button.ts index 7dc334e..2d16a6b 100644 --- a/src/features/chat/ui/credits-usage-button.ts +++ b/src/features/chat/ui/credits-usage-button.ts @@ -50,7 +50,7 @@ function isSentinelExpiry(timestamp: number): boolean { } /** - * Nav-row button showing overall credits usage with a popover panel that + * Header button showing overall credits usage with a popover panel that * mirrors the Qoder IDE usage view: plan quota, add-on quota and the * organization resource package, each with a segmented progress bar. */ @@ -68,7 +68,7 @@ export class CreditsUsageButton { constructor(parentEl: HTMLElement, private readonly callbacks: CreditsUsageButtonCallbacks) { this.container = parentEl.createDiv({ cls: 'qoderian-credits-container' }); this.buttonEl = this.container.createDiv({ cls: 'qoderian-input-nav-btn qoderian-credits-btn' }); - setIcon(this.buttonEl, 'gauge'); + setIcon(this.buttonEl, 'pie-chart'); this.panelEl = this.container.createDiv({ cls: 'qoderian-credits-panel' }); this.popover = new ClickPopover( @@ -132,7 +132,7 @@ export class CreditsUsageButton { }; private updateButton(): void { - // Same tooltip path as the other nav-row buttons (aria-label + 300ms + // Same tooltip path as the other header buttons (aria-label + 300ms // delay); only the click behavior (popover) differs. setButtonTooltip(this.buttonEl, t('credits.trigger')); } diff --git a/src/features/chat/ui/input-toolbar.ts b/src/features/chat/ui/input-toolbar.ts index 0ec25d8..a86148e 100644 --- a/src/features/chat/ui/input-toolbar.ts +++ b/src/features/chat/ui/input-toolbar.ts @@ -223,7 +223,7 @@ export class ExternalContextSelector { const iconWrapper = this.container.createDiv({ cls: 'qoderian-external-context-icon-wrapper' }); this.iconEl = iconWrapper.createDiv({ cls: 'qoderian-external-context-icon' }); - setIcon(this.iconEl, 'folder'); + setIcon(this.iconEl, 'folder-plus'); this.badgeEl = iconWrapper.createDiv({ cls: 'qoderian-external-context-badge' }); diff --git a/src/features/chat/ui/toolbar/toolbar-selectors.ts b/src/features/chat/ui/toolbar/toolbar-selectors.ts index 2a44675..fe1775c 100644 --- a/src/features/chat/ui/toolbar/toolbar-selectors.ts +++ b/src/features/chat/ui/toolbar/toolbar-selectors.ts @@ -419,6 +419,7 @@ export class ModelSelector { const view = this.container.ownerDocument.defaultView; if (!view) return; const anchor = (dropdownEl.offsetParent ?? this.container).getBoundingClientRect(); + dropdownEl.toggleClass('qoderian-model-dropdown--narrow', anchor.width < 600); dropdownEl.toggleClass( 'qoderian-model-dropdown--flip', shouldFlipModelDropdown(anchor, dropdownEl.offsetWidth, view.innerWidth), diff --git a/src/features/settings/settings-modal.ts b/src/features/settings/settings-modal.ts new file mode 100644 index 0000000..c868a73 --- /dev/null +++ b/src/features/settings/settings-modal.ts @@ -0,0 +1,31 @@ +import { Modal } from 'obsidian'; + +import { t } from '../../i18n/i18n'; +import type QoderianPlugin from '../../main'; +import { QoderianSettingTab } from './settings-tab'; + +/** Qoderian-owned settings window that does not navigate into Obsidian settings. */ +export class QoderianSettingsModal extends Modal { + private settingsTab: QoderianSettingTab | null = null; + + constructor(private readonly plugin: QoderianPlugin) { + super(plugin.app); + } + + onOpen(): void { + this.modalEl.addClass('qoderian-settings-modal'); + this.contentEl.addClass('qoderian-settings-modal-content'); + this.titleEl.setText(t('settings.title')); + + const settingsTab = new QoderianSettingTab(this.app, this.plugin); + settingsTab.containerEl = this.contentEl; + settingsTab.display(); + this.settingsTab = settingsTab; + } + + onClose(): void { + (this.settingsTab as { hide?: () => void } | null)?.hide?.(); + this.settingsTab = null; + this.contentEl.empty(); + } +} diff --git a/src/features/settings/settings-tab.ts b/src/features/settings/settings-tab.ts index f2afd65..4fddb15 100644 --- a/src/features/settings/settings-tab.ts +++ b/src/features/settings/settings-tab.ts @@ -1,9 +1,22 @@ import type { App, SettingDefinitionItem, SettingGroup } from 'obsidian'; -import { Notice, PluginSettingTab, requireApiVersion, Setting } from 'obsidian'; +import { + getLanguage, + moment, + Notice, + PluginSettingTab, + requireApiVersion, + Setting, +} from 'obsidian'; import type { ChatViewPlacement } from '../../core/types/settings'; -import { getAvailableLocales, getLocaleDisplayName, setLocale, t } from '../../i18n/i18n'; -import type { Locale } from '../../i18n/types'; +import { + FOLLOW_OBSIDIAN_LOCALE, + getAvailableLocales, + getLocaleDisplayName, + resolveLocalePreference, + setLocale, + t, +} from '../../i18n/i18n'; import type QoderianPlugin from '../../main'; import { getQoderSettings, updateQoderSettings } from '../../qoder/config/settings'; import { buildNavMappingText, parseNavMappings } from './keyboard-navigation'; @@ -24,6 +37,10 @@ import { /** Keys whose edits affect the prompt and require a debounced service restart. */ const PROMPT_SETTING_KEYS = new Set(['userName', 'systemPrompt', 'mediaFolder']); +function getObsidianLanguage(): string { + return requireApiVersion('1.8.7') ? getLanguage() : moment.locale(); +} + export class QoderianSettingTab extends PluginSettingTab { plugin: QoderianPlugin; private promptRestartTimer: number | null = null; @@ -47,12 +64,14 @@ export class QoderianSettingTab extends PluginSettingTab { * minAppVersion. */ getSettingDefinitions(): SettingDefinitionItem[] { - setLocale(this.plugin.settings.locale as Locale); + setLocale(resolveLocalePreference(this.plugin.settings.locale, getObsidianLanguage())); const settingsBag = this.plugin.settings as unknown as Record; const qoderSettings = getQoderSettings(settingsBag); - const localeOptions: Record = {}; + const localeOptions: Record = { + [FOLLOW_OBSIDIAN_LOCALE]: t('settings.language.followObsidian'), + }; for (const locale of getAvailableLocales()) { localeOptions[locale] = getLocaleDisplayName(locale); } @@ -97,7 +116,7 @@ export class QoderianSettingTab extends PluginSettingTab { defaultValue: this.plugin.settings.locale, validate: (value: string) => { const locales: string[] = getAvailableLocales(); - if (!locales.includes(value)) { + if (value !== FOLLOW_OBSIDIAN_LOCALE && !locales.includes(value)) { return t('common.error'); } }, @@ -440,7 +459,7 @@ export class QoderianSettingTab extends PluginSettingTab { if (key === 'locale') { const previousName = t('settings.language.name'); const previousDesc = t('settings.language.desc'); - setLocale(this.plugin.settings.locale as Locale); + setLocale(resolveLocalePreference(this.plugin.settings.locale, getObsidianLanguage())); this.update(); this.refreshLocalizedLanguageRow(previousName, previousDesc); this.refreshViewChrome(); @@ -517,7 +536,7 @@ export class QoderianSettingTab extends PluginSettingTab { containerEl.empty(); containerEl.addClass('qoderian-settings'); - setLocale(this.plugin.settings.locale as Locale); + setLocale(resolveLocalePreference(this.plugin.settings.locale, getObsidianLanguage())); // CLI availability determines whether the rest of the plugin can work, so // keep its host-specific path at the very top of the settings page. @@ -551,18 +570,19 @@ export class QoderianSettingTab extends PluginSettingTab { .setDesc(t('settings.language.desc')) .addDropdown((dropdown) => { const locales = getAvailableLocales(); + dropdown.addOption(FOLLOW_OBSIDIAN_LOCALE, t('settings.language.followObsidian')); for (const locale of locales) { dropdown.addOption(locale, getLocaleDisplayName(locale)); } dropdown .setValue(this.plugin.settings.locale) .onChange(async (value) => { - const locale = value as Locale; - if (!setLocale(locale)) { + if (value !== FOLLOW_OBSIDIAN_LOCALE && !locales.some(locale => locale === value)) { dropdown.setValue(this.plugin.settings.locale); return; } - this.plugin.settings.locale = locale; + this.plugin.settings.locale = value; + setLocale(resolveLocalePreference(value, getObsidianLanguage())); await this.plugin.saveSettings(); this.renderLegacySettings(); this.refreshViewChrome(); @@ -748,7 +768,7 @@ export class QoderianSettingTab extends PluginSettingTab { setting.addSlider((slider) => { slider .setLimits(3, 10, 1) - .setValue(this.plugin.settings.maxTabs ?? 3) + .setValue(this.plugin.settings.maxTabs ?? 10) .onChange(async (value) => { this.plugin.settings.maxTabs = value; await this.plugin.saveSettings(); @@ -757,7 +777,7 @@ export class QoderianSettingTab extends PluginSettingTab { view.refreshTabControls(); } }); - updateMaxTabsWarning(this.plugin.settings.maxTabs ?? 3); + updateMaxTabsWarning(this.plugin.settings.maxTabs ?? 10); }); return maxTabsWarningEl; diff --git a/src/features/update/plugin-update-checker.ts b/src/features/update/plugin-update-checker.ts new file mode 100644 index 0000000..9edda68 --- /dev/null +++ b/src/features/update/plugin-update-checker.ts @@ -0,0 +1,54 @@ +import { requestUrl } from 'obsidian'; + +const LATEST_RELEASE_API_URL = 'https://api.github.com/repos/QoderAI/Qoderian/releases/latest'; + +export interface QoderianUpdate { + version: string; + url: string; +} + +interface GitHubReleaseResponse { + html_url?: unknown; + tag_name?: unknown; +} + +function parseVersion(value: string): number[] | null { + const match = value.trim().replace(/^v/i, '').match(/^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/); + if (!match) return null; + return match.slice(1).map(Number); +} + +/** Semver comparison for the three-part release tags used by Qoderian. */ +export function isNewerQoderianVersion(currentVersion: string, latestVersion: string): boolean { + const current = parseVersion(currentVersion); + const latest = parseVersion(latestVersion); + if (!current || !latest) return false; + + for (let index = 0; index < latest.length; index += 1) { + if (latest[index] !== current[index]) return latest[index] > current[index]; + } + return false; +} + +/** + * Checks the latest stable GitHub release. Network and malformed-response + * failures are intentionally silent so update discovery never blocks chat. + */ +export async function fetchAvailableQoderianUpdate( + currentVersion: string, +): Promise { + try { + const response = await requestUrl({ + url: LATEST_RELEASE_API_URL, + headers: { Accept: 'application/vnd.github+json' }, + }); + const release = response.json as GitHubReleaseResponse; + if (typeof release.tag_name !== 'string' || typeof release.html_url !== 'string') return null; + + const version = release.tag_name.replace(/^v/i, ''); + if (!isNewerQoderianVersion(currentVersion, version)) return null; + return { version, url: release.html_url }; + } catch { + return null; + } +} diff --git a/src/i18n/i18n.ts b/src/i18n/i18n.ts index fa762ef..fdda844 100644 --- a/src/i18n/i18n.ts +++ b/src/i18n/i18n.ts @@ -31,8 +31,37 @@ const translations: Record = { }; const DEFAULT_LOCALE: Locale = 'en'; +export const FOLLOW_OBSIDIAN_LOCALE = 'auto'; let currentLocale: Locale = DEFAULT_LOCALE; +/** + * Resolves a stored locale preference to one of Qoderian's supported locales. + * Obsidian exposes regional language codes, while Qoderian intentionally uses + * a smaller translation set and falls back to the base language where useful. + */ +export function resolveLocalePreference( + preference: string, + obsidianLanguage: string, +): Locale { + if (preference !== FOLLOW_OBSIDIAN_LOCALE && preference in translations) { + return preference as Locale; + } + + const normalized = obsidianLanguage.trim().replaceAll('_', '-').toLowerCase(); + if (normalized === 'zh-tw' || normalized === 'zh-hk' || normalized.startsWith('zh-hant')) { + return 'zh-TW'; + } + if (normalized === 'zh' || normalized === 'zh-cn' || normalized.startsWith('zh-hans')) { + return 'zh-CN'; + } + + const baseLanguage = normalized.split('-')[0]; + const matchingLocale = getAvailableLocales().find( + locale => locale.toLowerCase() === normalized || locale.toLowerCase() === baseLanguage, + ); + return matchingLocale ?? DEFAULT_LOCALE; +} + /** * Get a translation by key with optional parameters */ diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 119ee2b..0759c3b 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -38,6 +38,7 @@ "newTab": "Neuer Tab", "newSession": "Neue Unterhaltung im aktuellen Tab starten", "closeTab": "Tab schließen", + "tabActions": "Tab-Aktionen", "closeCurrentTab": "Aktuellen Tab schließen" }, "nav": { @@ -45,6 +46,10 @@ "chatHistory": "Chatverlauf", "newChat": "Neuer Chat" }, + "updates": { + "available": "Update {version}", + "openRelease": "Qoderian-Version {version} öffnen" + }, "chat": { "rewind": { "confirmMessage": "Zu diesem Punkt zurückspulen? Dateiänderungen nach dieser Nachricht werden rückgängig gemacht. Das Zurückspulen betrifft keine manuell oder über Bash bearbeiteten Dateien.", @@ -354,7 +359,8 @@ }, "language": { "name": "Sprache", - "desc": "Anzeigesprache der Plugin-Oberfläche ändern" + "desc": "Anzeigesprache der Plugin-Oberfläche ändern", + "followObsidian": "Obsidian folgen" } }, "credits": { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index fda3926..1d46f5c 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -38,6 +38,7 @@ "newTab": "New tab", "newSession": "Start new conversation in current tab", "closeTab": "Close tab", + "tabActions": "Tab actions", "closeCurrentTab": "Close current tab" }, "nav": { @@ -45,6 +46,10 @@ "chatHistory": "Chat history", "newChat": "New Chat" }, + "updates": { + "available": "Update {version}", + "openRelease": "Open Qoderian release {version}" + }, "chat": { "rewind": { "confirmMessage": "Rewind to this point? File changes after this message will be reverted. Rewinding does not affect files edited manually or via bash.", @@ -354,7 +359,8 @@ }, "language": { "name": "Language", - "desc": "Change the display language of the plugin interface" + "desc": "Change the display language of the plugin interface", + "followObsidian": "Follow Obsidian" } }, "credits": { diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 6d76594..62ab1c6 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -38,6 +38,7 @@ "newTab": "Nueva pestaña", "newSession": "Iniciar una conversación nueva en la pestaña actual", "closeTab": "Cerrar pestaña", + "tabActions": "Acciones de pestaña", "closeCurrentTab": "Cerrar pestaña actual" }, "nav": { @@ -45,6 +46,10 @@ "chatHistory": "Historial del chat", "newChat": "Nuevo chat" }, + "updates": { + "available": "Actualizar {version}", + "openRelease": "Abrir la versión {version} de Qoderian" + }, "chat": { "rewind": { "confirmMessage": "¿Rebobinar a este punto? Los cambios de archivos después de este mensaje serán revertidos. El rebobinado no afecta archivos editados manualmente o mediante bash.", @@ -354,7 +359,8 @@ }, "language": { "name": "Idioma", - "desc": "Cambiar el idioma de visualización de la interfaz del plugin" + "desc": "Cambiar el idioma de visualización de la interfaz del plugin", + "followObsidian": "Seguir Obsidian" } }, "credits": { diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 950ded0..77a6176 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -38,6 +38,7 @@ "newTab": "Nouvel onglet", "newSession": "Démarrer une nouvelle conversation dans l'onglet actuel", "closeTab": "Fermer l'onglet", + "tabActions": "Actions de l'onglet", "closeCurrentTab": "Fermer l'onglet actuel" }, "nav": { @@ -45,6 +46,10 @@ "chatHistory": "Historique du chat", "newChat": "Nouveau chat" }, + "updates": { + "available": "Mise à jour {version}", + "openRelease": "Ouvrir la version {version} de Qoderian" + }, "chat": { "rewind": { "confirmMessage": "Rembobiner jusqu'à ce point ? Les modifications de fichiers après ce message seront annulées. Le rembobinage n'affecte pas les fichiers modifiés manuellement ou via bash.", @@ -354,7 +359,8 @@ }, "language": { "name": "Langue", - "desc": "Changer la langue d'affichage de l'interface du plugin" + "desc": "Changer la langue d'affichage de l'interface du plugin", + "followObsidian": "Suivre Obsidian" } }, "credits": { diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index a98c55b..766dda7 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -38,6 +38,7 @@ "newTab": "新しいタブ", "newSession": "現在のタブで新しい会話を開始", "closeTab": "タブを閉じる", + "tabActions": "タブ操作", "closeCurrentTab": "現在のタブを閉じる" }, "nav": { @@ -45,6 +46,10 @@ "chatHistory": "チャット履歴", "newChat": "新しいチャット" }, + "updates": { + "available": "更新 {version}", + "openRelease": "Qoderian {version} リリースを開く" + }, "chat": { "rewind": { "confirmMessage": "この時点に巻き戻しますか?このメッセージ以降のファイル変更が元に戻されます。手動またはbashで編集されたファイルには影響しません。", @@ -354,7 +359,8 @@ }, "language": { "name": "言語", - "desc": "プラグインインターフェースの表示言語を変更" + "desc": "プラグインインターフェースの表示言語を変更", + "followObsidian": "Obsidian に従う" } }, "credits": { diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index d4a4a4d..339cb85 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -38,6 +38,7 @@ "newTab": "새 탭", "newSession": "현재 탭에서 새 대화 시작", "closeTab": "탭 닫기", + "tabActions": "탭 작업", "closeCurrentTab": "현재 탭 닫기" }, "nav": { @@ -45,6 +46,10 @@ "chatHistory": "대화 기록", "newChat": "새 채팅" }, + "updates": { + "available": "업데이트 {version}", + "openRelease": "Qoderian {version} 릴리스 열기" + }, "chat": { "rewind": { "confirmMessage": "이 시점으로 되감으시겠습니까? 이 메시지 이후의 파일 변경 사항이 되돌려집니다. 수동으로 또는 bash를 통해 편집된 파일에는 영향을 미치지 않습니다.", @@ -354,7 +359,8 @@ }, "language": { "name": "언어", - "desc": "플러그인 인터페이스의 표시 언어 변경" + "desc": "플러그인 인터페이스의 표시 언어 변경", + "followObsidian": "Obsidian 언어 따르기" } }, "credits": { diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index a7d0274..1eea1a2 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -38,6 +38,7 @@ "newTab": "Nova aba", "newSession": "Iniciar nova conversa na aba atual", "closeTab": "Fechar aba", + "tabActions": "Ações da aba", "closeCurrentTab": "Fechar aba atual" }, "nav": { @@ -45,6 +46,10 @@ "chatHistory": "Histórico de conversas", "newChat": "Novo chat" }, + "updates": { + "available": "Atualizar {version}", + "openRelease": "Abrir a versão {version} do Qoderian" + }, "chat": { "rewind": { "confirmMessage": "Retroceder até este ponto? As alterações de arquivos após esta mensagem serão revertidas. O retrocesso não afeta arquivos editados manualmente ou via bash.", @@ -354,7 +359,8 @@ }, "language": { "name": "Idioma", - "desc": "Alterar o idioma de exibição da interface do plugin" + "desc": "Alterar o idioma de exibição da interface do plugin", + "followObsidian": "Seguir o Obsidian" } }, "credits": { diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 47f8e7d..29b690d 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -38,6 +38,7 @@ "newTab": "Новая вкладка", "newSession": "Начать новый разговор в текущей вкладке", "closeTab": "Закрыть вкладку", + "tabActions": "Действия с вкладкой", "closeCurrentTab": "Закрыть текущую вкладку" }, "nav": { @@ -45,6 +46,10 @@ "chatHistory": "История чата", "newChat": "Новый чат" }, + "updates": { + "available": "Обновить {version}", + "openRelease": "Открыть выпуск Qoderian {version}" + }, "chat": { "rewind": { "confirmMessage": "Откатить до этой точки? Изменения файлов после этого сообщения будут отменены. Откат не затрагивает файлы, отредактированные вручную или через bash.", @@ -354,7 +359,8 @@ }, "language": { "name": "Язык", - "desc": "Изменить язык интерфейса плагина" + "desc": "Изменить язык интерфейса плагина", + "followObsidian": "Как в Obsidian" } }, "credits": { diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 5b49141..0cc7471 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -38,6 +38,7 @@ "newTab": "新建标签页", "newSession": "在当前标签页开始新会话", "closeTab": "关闭标签页", + "tabActions": "标签页操作", "closeCurrentTab": "关闭当前标签页" }, "nav": { @@ -45,6 +46,10 @@ "chatHistory": "聊天历史", "newChat": "新会话" }, + "updates": { + "available": "更新 {version}", + "openRelease": "查看 Qoderian {version} 更新" + }, "chat": { "rewind": { "confirmMessage": "回退到此处?此消息之后的文件更改将被还原。回退不会影响手动或通过 bash 编辑的文件。", @@ -354,7 +359,8 @@ }, "language": { "name": "语言", - "desc": "更改插件界面的显示语言" + "desc": "更改插件界面的显示语言", + "followObsidian": "跟随 Obsidian" } }, "credits": { diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index ba7b8d6..c754131 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -38,6 +38,7 @@ "newTab": "新增分頁", "newSession": "在目前分頁開始新對話", "closeTab": "關閉分頁", + "tabActions": "分頁操作", "closeCurrentTab": "關閉目前分頁" }, "nav": { @@ -45,6 +46,10 @@ "chatHistory": "聊天歷史", "newChat": "新對話" }, + "updates": { + "available": "更新 {version}", + "openRelease": "檢視 Qoderian {version} 更新" + }, "chat": { "rewind": { "confirmMessage": "回退到此處?此訊息之後的檔案變更將被還原。回退不會影響手動或透過 bash 編輯的檔案。", @@ -354,7 +359,8 @@ }, "language": { "name": "語言", - "desc": "變更外掛程式介面的顯示語言" + "desc": "變更外掛程式介面的顯示語言", + "followObsidian": "跟隨 Obsidian" } }, "credits": { diff --git a/src/i18n/types.ts b/src/i18n/types.ts index feaaff3..4ddc271 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -10,6 +10,7 @@ export type TranslationKey = | 'commands.newTab' | 'commands.newSession' | 'commands.closeTab' + | 'commands.tabActions' | 'commands.closeCurrentTab' // Nav row buttons and tab badges @@ -17,6 +18,10 @@ export type TranslationKey = | 'nav.chatHistory' | 'nav.newChat' + // Plugin updates + | 'updates.available' + | 'updates.openRelease' + // Common UI elements | 'common.save' | 'common.cancel' @@ -321,4 +326,5 @@ export type TranslationKey = // Settings - Language | 'settings.language.name' - | 'settings.language.desc'; + | 'settings.language.desc' + | 'settings.language.followObsidian'; diff --git a/src/main.ts b/src/main.ts index 536afd9..955d16e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,7 +3,15 @@ import { patchSetMaxListenersForElectron } from './app/electron-compat'; patchSetMaxListenersForElectron(); import type { Editor, WorkspaceLeaf } from 'obsidian'; -import { addIcon, MarkdownView, Notice, Plugin } from 'obsidian'; +import { + addIcon, + getLanguage, + MarkdownView, + moment, + Notice, + Plugin, + requireApiVersion, +} from 'obsidian'; import { QoderianStorage } from './app/storage/app-storage'; import { beginRestoreReport, reportRestoreIssue } from './core/diagnostics/restore-report'; @@ -23,8 +31,11 @@ import type { ChatViewPlacement } from './core/types/settings'; import { QoderianView } from './features/chat/chat-view'; import { type InlineEditContext, InlineEditModal } from './features/inline-edit/ui/modal'; import { QoderianSettingTab } from './features/settings/settings-tab'; -import { setLocale, t } from './i18n/i18n'; -import type { Locale } from './i18n/types'; +import { + fetchAvailableQoderianUpdate, + type QoderianUpdate, +} from './features/update/plugin-update-checker'; +import { resolveLocalePreference, setLocale, t } from './i18n/i18n'; import { getActiveQoderCliEdition, setActiveQoderCliEdition } from './qoder/config/cli-edition'; import { normalizeQoderSettings } from './qoder/config/qoder-settings-reconciler'; import { getQoderSettings } from './qoder/config/settings'; @@ -44,12 +55,23 @@ function isQoderianView(value: unknown): value is QoderianView { && typeof (value as { getTabManager?: unknown }).getTabManager === 'function'; } +function getObsidianLanguage(): string { + return requireApiVersion('1.8.7') ? getLanguage() : moment.locale(); +} + export default class QoderianPlugin extends Plugin { settings!: QoderianSettings; storage!: QoderianStorage; qoderServices!: QoderServices; private conversations: Conversation[] = []; private lastKnownTabManagerState: AppTabManagerState | null = null; + private updateCheckPromise: Promise | null = null; + + /** Checks once per plugin session so reopening the view does not hit GitHub repeatedly. */ + getAvailableUpdate(): Promise { + this.updateCheckPromise ??= fetchAvailableQoderianUpdate(this.manifest.version); + return this.updateCheckPromise; + } async onload() { await this.loadSettings(); @@ -421,7 +443,7 @@ export default class QoderianPlugin extends Plugin { const legacyLocations = await this.probeLegacyEditionLocations(allMetadata); await this.migrateLegacySessionEditions(allMetadata, legacyLocations); this.conversations = await this.buildConversationIndex(allMetadata, legacyLocations); - setLocale(this.settings.locale as Locale); + setLocale(resolveLocalePreference(this.settings.locale, getObsidianLanguage())); if (didNormalizeModelVariants) { await this.saveSettings(); @@ -771,7 +793,7 @@ export default class QoderianPlugin extends Plugin { } private getMaxTabsLimit(): number { - const maxTabs = this.settings.maxTabs ?? 3; + const maxTabs = this.settings.maxTabs ?? 10; return Math.max(3, Math.min(10, maxTabs)); } diff --git a/src/style/base/variables.css b/src/style/base/variables.css index 147c81e..c21c1a6 100644 --- a/src/style/base/variables.css +++ b/src/style/base/variables.css @@ -5,6 +5,10 @@ --qoderian-error: #dc3545; --qoderian-error-rgb: 220, 53, 69; --qoderian-compact: #5bc0de; + --qoderian-radius-control: 8px; + --qoderian-radius-panel: 12px; + --qoderian-radius-composer: 14px; + --qoderian-shadow-raised: 0 10px 32px rgba(0, 0, 0, 0.18); } body.theme-light .qoderian-container { diff --git a/src/style/components/header.css b/src/style/components/header.css index f927eab..00a2492 100644 --- a/src/style/components/header.css +++ b/src/style/components/header.css @@ -3,21 +3,23 @@ position: relative; display: flex; align-items: center; - padding: 0 12px 12px 12px; + gap: 8px; + padding: 4px 10px 14px; } .qoderian-title { display: flex; align-items: center; - gap: 8px; + gap: 7px; flex: 1; min-width: 0; } .qoderian-title-text { margin: 0; - font-size: 14px; - font-weight: 600; + font-size: 13px; + font-weight: 650; + letter-spacing: -0.01em; } .qoderian-logo { @@ -25,3 +27,65 @@ align-items: center; color: var(--qoderian-brand); } + +.qoderian-logo .qoderian-icon { + width: 20px; + height: 20px; +} + +.qoderian-update-badge { + flex: none; + min-height: 22px; + padding: 2px 7px; + border: 1px solid rgba(var(--qoderian-brand-rgb), 0.32); + border-radius: 999px; + background: rgba(var(--qoderian-brand-rgb), 0.1); + box-shadow: none; + color: var(--qoderian-brand); + font: inherit; + font-size: 10px; + font-weight: 600; + line-height: 1; + white-space: nowrap; + cursor: pointer; +} + +.qoderian-update-badge:hover, +.qoderian-update-badge:focus-visible { + border-color: rgba(var(--qoderian-brand-rgb), 0.55); + background: rgba(var(--qoderian-brand-rgb), 0.16); + box-shadow: none; + outline: none; +} + +.qoderian-header-actions { + display: flex; + align-items: center; + flex: none; + gap: 4px; + margin-inline-start: auto; +} + +.qoderian-header-action, +.qoderian-header-actions .qoderian-credits-btn { + width: 28px; + height: 28px; + border: 1px solid transparent; + border-radius: var(--qoderian-radius-control); + color: var(--text-muted); +} + +.qoderian-header-actions .qoderian-input-nav-btn svg { + display: block; + overflow: visible; +} + +.qoderian-header-action:hover, +.qoderian-header-action:focus-visible, +.qoderian-header-actions .qoderian-credits-btn:hover, +.qoderian-header-actions .qoderian-credits-btn:focus-visible { + color: var(--text-normal); + background: var(--background-modifier-hover); + border-color: var(--background-modifier-border); + outline: none; +} diff --git a/src/style/components/history.css b/src/style/components/history.css index 555ddf3..4459faf 100644 --- a/src/style/components/history.css +++ b/src/style/components/history.css @@ -2,23 +2,22 @@ position: static; } -/* History dropup menu (opens upward since it's at bottom of view) */ +/* View-level history menu, anchored under the fixed header. */ .qoderian-history-menu { display: none; position: absolute; - bottom: 100%; - margin-bottom: 4px; + top: calc(100% + 4px); background: var(--background-secondary); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); border: 1px solid var(--background-modifier-border); - border-radius: 6px; - box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.25); + border-radius: var(--qoderian-radius-panel); + box-shadow: var(--qoderian-shadow-raised); z-index: 1000; max-height: 400px; overflow: hidden; - inset-inline-start: 0; - inset-inline-end: 0; + inset-inline-start: 12px; + inset-inline-end: 12px; } .qoderian-history-menu.visible { diff --git a/src/style/components/input.css b/src/style/components/input.css index c56e48e..949d658 100644 --- a/src/style/components/input.css +++ b/src/style/components/input.css @@ -24,9 +24,15 @@ flex-direction: column; min-height: 140px; border: 1px solid var(--qoderian-input-wrapper-border-color); - border-radius: 6px; - background: var(--background-primary); + border-radius: var(--qoderian-radius-composer); + background: var(--background-secondary); box-shadow: var(--qoderian-input-wrapper-box-shadow); + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.qoderian-input-wrapper:focus-within { + --qoderian-input-wrapper-border-color: var(--background-modifier-border-focus); + --qoderian-input-wrapper-box-shadow: 0 0 0 2px rgba(var(--qoderian-brand-rgb), 0.1); } .qoderian-input-wrapper--resized { @@ -38,7 +44,7 @@ position: absolute; z-index: 4; inset-block-start: -6px; - inset-inline: 18%; + inset-inline: 0; height: 12px; cursor: ns-resize; touch-action: none; @@ -225,7 +231,7 @@ body.qoderian-composer-resizing * { resize: none; padding: 8px 10px 10px 10px; border: none; - border-radius: 6px; + border-radius: var(--qoderian-radius-composer); background: transparent; color: var(--text-normal); font-family: inherit; diff --git a/src/style/components/tabs.css b/src/style/components/tabs.css index 0bfcb04..6f8df69 100644 --- a/src/style/components/tabs.css +++ b/src/style/components/tabs.css @@ -30,19 +30,20 @@ display: flex; align-items: center; justify-content: center; - flex: 0 0 24px; - width: 24px; - max-width: 24px; - height: 24px; + flex: 0 0 56px; + width: 56px; + max-width: 56px; + height: 30px; box-sizing: border-box; - padding: 0; - border-radius: 4px; - border: 2px solid var(--background-modifier-border); + gap: 4px; + padding: 0 5px 0 8px; + border-radius: var(--qoderian-radius-control); + border: 1px solid var(--background-modifier-border); font-size: 12px; font-weight: 500; cursor: pointer; color: var(--text-muted); - background: var(--background-primary); + background: var(--background-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -50,19 +51,22 @@ } .qoderian-tab-badge-label { + flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; + text-align: center; white-space: nowrap; - transition: opacity 0.12s ease; } -.qoderian-tab-badge-close { - position: absolute; - inset: 0; +.qoderian-tab-badge-menu { + position: static; display: flex; align-items: center; justify-content: center; + flex: 0 0 14px; + width: 14px; + height: 18px; border-radius: 3px; opacity: 0; pointer-events: none; @@ -70,47 +74,38 @@ transition: opacity 0.12s ease, color 0.12s ease; } -.qoderian-tab-badge-close svg { +.qoderian-tab-badge-menu svg { width: 13px; height: 13px; } -.qoderian-tab-badge-closable:hover > .qoderian-tab-badge-label, -.qoderian-tab-badge-closable:focus-within > .qoderian-tab-badge-label { - opacity: 0; -} - -.qoderian-tab-badge-closable:hover > .qoderian-tab-badge-close, -.qoderian-tab-badge-closable:focus-within > .qoderian-tab-badge-close { +.qoderian-tab-badge-closable:hover > .qoderian-tab-badge-menu, +.qoderian-tab-badge-closable:focus-within > .qoderian-tab-badge-menu { opacity: 1; pointer-events: auto; } -.qoderian-tab-badge-close:hover, -.qoderian-tab-badge-close:focus-visible { - color: var(--text-error); +.qoderian-tab-badge-menu:hover, +.qoderian-tab-badge-menu:focus-visible { + color: var(--text-normal); + background: var(--background-modifier-hover); outline: none; } .qoderian-tab-badge-expanded { flex: 0 0 auto; justify-content: flex-start; - gap: 4px; width: auto; - max-width: none; + max-width: 220px; padding: 0 8px; } -.qoderian-tab-badge-expanded > .qoderian-tab-badge-close { - position: static; - flex: 0 0 14px; - width: 14px; - height: 18px; +.qoderian-tab-badge-expanded > .qoderian-tab-badge-label { + text-align: start; } -.qoderian-tab-badge-expanded.qoderian-tab-badge-closable:hover > .qoderian-tab-badge-label, -.qoderian-tab-badge-expanded.qoderian-tab-badge-closable:focus-within > .qoderian-tab-badge-label { - opacity: 1; +.qoderian-tab-badge:not(.qoderian-tab-badge-closable) { + padding-inline: 8px; } .qoderian-tab-badge:hover { @@ -121,6 +116,8 @@ .qoderian-tab-badge-active { border-color: var(--interactive-accent); color: var(--text-normal); + background: var(--background-primary); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12); } .qoderian-tab-badge-streaming { diff --git a/src/style/settings/base.css b/src/style/settings/base.css index dde82b8..039b2e5 100644 --- a/src/style/settings/base.css +++ b/src/style/settings/base.css @@ -1,4 +1,16 @@ /* Settings page - remove separator lines from setting items */ +.qoderian-settings-modal { + width: min(920px, calc(100vw - 48px)); + max-width: 920px; + height: min(86vh, 900px); +} + +.qoderian-settings-modal-content { + overflow-y: auto; + overscroll-behavior: contain; + padding-inline-end: 8px; +} + .qoderian-settings .setting-item { border-top: none; } diff --git a/src/style/toolbar/credits-usage.css b/src/style/toolbar/credits-usage.css index d69f63f..abf976b 100644 --- a/src/style/toolbar/credits-usage.css +++ b/src/style/toolbar/credits-usage.css @@ -1,4 +1,4 @@ -/* Credits usage button + popover panel (nav row, mirrors Qoder IDE usage view) */ +/* Credits usage button + popover panel (header, mirrors Qoder IDE usage view) */ .qoderian-credits-container { position: static; @@ -9,22 +9,20 @@ align-items: center; } -/* Panel opens upward from the input footer, right-aligned. Width tracks the - footer so narrow sidebars clip neither edge (same approach as #18). */ +/* Panel opens below the view header, right-aligned. */ .qoderian-credits-panel { display: none; position: absolute; - bottom: 100%; - margin-bottom: 4px; - inset-inline-end: 0; - width: min(320px, calc(100% - 16px)); + top: calc(100% + 4px); + inset-inline-end: 12px; + width: min(320px, calc(100% - 24px)); padding: 12px; background: var(--background-secondary); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); border: 1px solid var(--background-modifier-border); - border-radius: 8px; - box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.25); + border-radius: var(--qoderian-radius-panel); + box-shadow: var(--qoderian-shadow-raised); z-index: 1000; } diff --git a/src/style/toolbar/external-context.css b/src/style/toolbar/external-context.css index fcee2a9..86ab9c1 100644 --- a/src/style/toolbar/external-context.css +++ b/src/style/toolbar/external-context.css @@ -1,6 +1,6 @@ /* External Context Selector */ .qoderian-external-context-selector { - position: relative; + position: static; display: flex; align-items: center; margin-left: 8px; @@ -34,8 +34,10 @@ } .qoderian-external-context-icon svg { + display: block; width: 16px; height: 16px; + overflow: visible; } .qoderian-external-context-badge { @@ -56,16 +58,17 @@ .qoderian-external-context-dropdown { position: absolute; - left: 50%; - transform: translateX(-50%); + inset-inline: 6px; bottom: 100%; margin-bottom: 4px; - min-width: 260px; - max-width: 320px; + min-width: 0; + width: auto; + max-width: none; + box-sizing: border-box; background: var(--background-secondary); border: 1px solid var(--background-modifier-border); - border-radius: 8px; - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25); + border-radius: var(--qoderian-radius-panel); + box-shadow: var(--qoderian-shadow-raised); opacity: 0; visibility: hidden; transition: opacity 0.15s ease, visibility 0.15s ease; diff --git a/src/style/toolbar/mcp-selector.css b/src/style/toolbar/mcp-selector.css index 5fbf690..3e41ac3 100644 --- a/src/style/toolbar/mcp-selector.css +++ b/src/style/toolbar/mcp-selector.css @@ -1,6 +1,6 @@ /* MCP Server Selector */ .qoderian-mcp-selector { - position: relative; + position: static; display: flex; align-items: center; margin-left: 8px; @@ -56,16 +56,17 @@ .qoderian-mcp-selector-dropdown { position: absolute; - left: 50%; - transform: translateX(-50%); + inset-inline: 6px; bottom: 100%; margin-bottom: 4px; - min-width: 200px; - max-width: 280px; + min-width: 0; + width: auto; + max-width: none; + box-sizing: border-box; background: var(--background-secondary); border: 1px solid var(--background-modifier-border); - border-radius: 8px; - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25); + border-radius: var(--qoderian-radius-panel); + box-shadow: var(--qoderian-shadow-raised); opacity: 0; visibility: hidden; transition: opacity 0.15s ease, visibility 0.15s ease; diff --git a/src/style/toolbar/model-selector.css b/src/style/toolbar/model-selector.css index 432dfe5..729f83f 100644 --- a/src/style/toolbar/model-selector.css +++ b/src/style/toolbar/model-selector.css @@ -41,15 +41,16 @@ .qoderian-model-dropdown { position: absolute; bottom: 100%; - inset-inline-start: 0; + inset-inline: 6px; margin-bottom: 0; display: flex; flex-direction: row; align-items: flex-start; gap: 8px; z-index: 1000; - width: max-content; - max-width: calc(100vw - 16px); + width: auto; + max-width: none; + box-sizing: border-box; opacity: 0; visibility: hidden; transition: opacity 0.15s ease, visibility 0.15s ease; @@ -62,17 +63,17 @@ gap: 2px; min-width: 0; min-height: 0; - flex: 0 1 auto; + flex: 1 1 0; overflow-y: auto; - max-width: min(340px, calc(100vw - 24px)); + max-width: none; /* Measured by updateDropdownPlacement(); fallback matches the repo-wide popover height convention. */ max-height: var(--qoderian-model-dropdown-max-height, 420px); padding: 4px; background: var(--background-secondary); border: 1px solid var(--background-modifier-border); - border-radius: 8px; - box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.15); + border-radius: var(--qoderian-radius-panel); + box-shadow: var(--qoderian-shadow-raised); } /* Compact editor card: height fits its content and it flyouts beside the @@ -84,13 +85,13 @@ min-height: 0; overflow-y: auto; width: 260px; - max-width: calc(100vw - 24px); + max-width: 100%; margin-top: var(--qoderian-model-editor-offset, 0px); padding: 4px 8px 8px; background: var(--background-secondary); border: 1px solid var(--background-modifier-border); - border-radius: 8px; - box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.15); + border-radius: var(--qoderian-radius-panel); + box-shadow: var(--qoderian-shadow-raised); } .qoderian-model-dropdown--editing .qoderian-model-editor-pane { @@ -100,20 +101,18 @@ /* Flips only when start alignment would overflow the viewport; decided by shouldFlipModelDropdown() against real measurements. */ .qoderian-model-dropdown--flip { - inset-inline-start: auto; - inset-inline-end: 0; + inset-inline: 6px; } -/* Very narrow windows: stack the editor card under the list card. */ -@media (max-width: 640px) { - .qoderian-model-dropdown--editing { - flex-direction: column; - } +/* Narrow Qoderian panes stack the model editor below the list even when the + overall Obsidian window is wide. */ +.qoderian-model-dropdown--narrow.qoderian-model-dropdown--editing { + flex-direction: column; +} - .qoderian-model-dropdown--editing .qoderian-model-editor-pane { - width: auto; - margin-top: 0; - } +.qoderian-model-dropdown--narrow.qoderian-model-dropdown--editing .qoderian-model-editor-pane { + width: auto; + margin-top: 0; } .qoderian-model-runtime-status { @@ -136,6 +135,7 @@ .qoderian-model-runtime-message { font-size: 11px; line-height: 1.4; + overflow-wrap: anywhere; } .qoderian-model-runtime-command { diff --git a/tests/__mocks__/obsidian.ts b/tests/__mocks__/obsidian.ts index df7c678..c873fb1 100644 --- a/tests/__mocks__/obsidian.ts +++ b/tests/__mocks__/obsidian.ts @@ -33,6 +33,7 @@ export class PluginSettingTab { } display() {} + hide = jest.fn(); // Mirrors the 1.13 declarative base: mutates plugin.settings in place. getControlValue(key: string): unknown { @@ -109,6 +110,12 @@ export const Platform = { isMacOS: true, }; +export const getLanguage = jest.fn(() => 'en'); + +export const moment = { + locale: jest.fn(() => 'en'), +}; + export class App { vault: any = { adapter: { @@ -175,6 +182,8 @@ export class TextAreaComponent { } export class Modal { + static instances: Modal[] = []; + app: any; containerEl: any = { createDiv: jest.fn().mockReturnValue({ @@ -211,6 +220,7 @@ export class Modal { constructor(app: any) { this.app = app; + Modal.instances.push(this); } open = jest.fn(); @@ -240,6 +250,8 @@ class MockMenuItem { return this; }); + setWarning = jest.fn().mockReturnThis(); + onClick = jest.fn((handler: () => void) => { this.clickHandler = handler; return this; @@ -251,6 +263,8 @@ export class Menu { items: MockMenuItem[] = []; showAtMouseEvent = jest.fn(); + showAtPosition = jest.fn(); + setUseNativeMenu = jest.fn().mockReturnThis(); constructor() { Menu.instances.push(this); @@ -288,6 +302,8 @@ export function setTooltip(el: HTMLElement, tooltip: string, options?: { delay?: // modern branch by default. export const requireApiVersion = jest.fn(() => true); +export const requestUrl = jest.fn(); + export const addIcon = jest.fn(); // Notice mock that tracks constructor calls diff --git a/tests/integration/main.test.ts b/tests/integration/main.test.ts index 7b936a7..f50600b 100644 --- a/tests/integration/main.test.ts +++ b/tests/integration/main.test.ts @@ -606,8 +606,15 @@ describe('QoderianPlugin', () => { { tabId: 'tab-1', conversationId: null }, { tabId: 'tab-2', conversationId: null }, { tabId: 'tab-3', conversationId: null }, + { tabId: 'tab-4', conversationId: null }, + { tabId: 'tab-5', conversationId: null }, + { tabId: 'tab-6', conversationId: null }, + { tabId: 'tab-7', conversationId: null }, + { tabId: 'tab-8', conversationId: null }, + { tabId: 'tab-9', conversationId: null }, + { tabId: 'tab-10', conversationId: null }, ], - activeTabId: 'tab-3', + activeTabId: 'tab-10', }, }); diff --git a/tests/unit/app/settings/settings-storage.test.ts b/tests/unit/app/settings/settings-storage.test.ts index f369b7f..d426404 100644 --- a/tests/unit/app/settings/settings-storage.test.ts +++ b/tests/unit/app/settings/settings-storage.test.ts @@ -24,6 +24,8 @@ describe('QoderianSettingsStorage', () => { const settings = await storage.load(); expect(settings.permissionMode).toBe('auto'); + expect(settings.locale).toBe('auto'); + expect(settings.maxTabs).toBe(10); expect(settings).toEqual(DEFAULT_QODERIAN_SETTINGS); // Must be an owned copy: mutating loaded settings must not poison the // module-level defaults. diff --git a/tests/unit/features/chat/chat-view.test.ts b/tests/unit/features/chat/chat-view.test.ts index 0286885..bac58d5 100644 --- a/tests/unit/features/chat/chat-view.test.ts +++ b/tests/unit/features/chat/chat-view.test.ts @@ -1,9 +1,10 @@ import { createMockEl } from '@test/helpers/mock-element'; -import { Platform, Scope } from 'obsidian'; +import { Modal, Platform, Scope } from 'obsidian'; import { QoderianView } from '@/features/chat/chat-view'; const MockScope = Scope as typeof Scope & { instances: Scope[] }; +const MockModal = Modal as typeof Modal & { instances: Modal[] }; function createViewHarness(options: { canCreateTab: boolean; @@ -174,6 +175,52 @@ describe('QoderianView tab controls', () => { expect(historyDropdown.hasClass('visible')).toBe(false); }); + + it('opens a standalone Qoderian settings modal instead of Obsidian settings', () => { + const view = Object.create(QoderianView.prototype) as any; + const openObsidianSettings = jest.fn(); + MockModal.instances.length = 0; + + view.app = { setting: { open: openObsidianSettings } }; + view.plugin = { app: view.app }; + + view.openSettings(); + + expect(MockModal.instances).toHaveLength(1); + expect(MockModal.instances[0].open).toHaveBeenCalledTimes(1); + expect(openObsidianSettings).not.toHaveBeenCalled(); + }); + + it('shows a localized update badge only when a newer release is available', async () => { + const view = Object.create(QoderianView.prototype) as any; + const badge = createMockEl('button'); + badge.addClass('qoderian-hidden'); + view.updateBadgeEl = badge; + view.plugin = { + getAvailableUpdate: jest.fn().mockResolvedValue({ + version: '1.1.0', + url: 'https://github.com/QoderAI/Qoderian/releases/tag/1.1.0', + }), + }; + + await view.refreshUpdateBadge(); + + expect(badge.hasClass('qoderian-hidden')).toBe(false); + expect(badge.textContent).toBe('Update 1.1.0'); + expect(badge.getAttribute('aria-label')).toBe('Open Qoderian release 1.1.0'); + }); + + it('keeps the update badge hidden when the installed release is current', async () => { + const view = Object.create(QoderianView.prototype) as any; + const badge = createMockEl('button'); + badge.addClass('qoderian-hidden'); + view.updateBadgeEl = badge; + view.plugin = { getAvailableUpdate: jest.fn().mockResolvedValue(null) }; + + await view.refreshUpdateBadge(); + + expect(badge.hasClass('qoderian-hidden')).toBe(true); + }); }); describe('QoderianView Escape handling', () => { diff --git a/tests/unit/features/chat/tabs/tab-bar.test.ts b/tests/unit/features/chat/tabs/tab-bar.test.ts index da0af45..40470e2 100644 --- a/tests/unit/features/chat/tabs/tab-bar.test.ts +++ b/tests/unit/features/chat/tabs/tab-bar.test.ts @@ -1,8 +1,16 @@ import { createMockEl } from '@test/helpers/mock-element'; +import { Menu } from 'obsidian'; import { TabBar } from '@/features/chat/tabs/tab-bar'; import type { TabBarItem } from '@/features/chat/tabs/types'; +type MockMenuInstance = Menu & { + items: Array<{ clickHandler: (() => void) | null }>; + showAtPosition: jest.Mock; +}; + +const MockMenu = Menu as typeof Menu & { instances: MockMenuInstance[] }; + function item(overrides: Partial = {}): TabBarItem { return { id: 'tab-1', @@ -17,7 +25,11 @@ function item(overrides: Partial = {}): TabBarItem { } describe('TabBar', () => { - it('renders a dedicated close control only for closable tabs', () => { + beforeEach(() => { + MockMenu.instances.length = 0; + }); + + it('renders a dedicated actions menu only for closable tabs', () => { const container = createMockEl(); const tabBar = new TabBar(container, { onTabClick: jest.fn(), @@ -31,11 +43,11 @@ describe('TabBar', () => { ]); const badges = container.querySelectorAll('.qoderian-tab-badge'); - expect(badges[0].querySelector('.qoderian-tab-badge-close')).not.toBeNull(); - expect(badges[1].querySelector('.qoderian-tab-badge-close')).toBeNull(); + expect(badges[0].querySelector('.qoderian-tab-badge-menu')).not.toBeNull(); + expect(badges[1].querySelector('.qoderian-tab-badge-menu')).toBeNull(); }); - it('closes the target tab without activating it', () => { + it('opens a menu without activating or immediately closing the tab', () => { const container = createMockEl(); const onTabClick = jest.fn(); const onTabClose = jest.fn(); @@ -46,21 +58,26 @@ describe('TabBar', () => { }); tabBar.update([item()]); - const closeEl = container.querySelector('.qoderian-tab-badge-close')!; + const menuEl = container.querySelector('.qoderian-tab-badge-menu')!; const event = { type: 'click', preventDefault: jest.fn(), stopPropagation: jest.fn(), }; - closeEl.dispatchEvent(event); + menuEl.dispatchEvent(event); expect(event.preventDefault).toHaveBeenCalled(); expect(event.stopPropagation).toHaveBeenCalled(); - expect(onTabClose).toHaveBeenCalledWith('tab-1'); + expect(onTabClose).not.toHaveBeenCalled(); expect(onTabClick).not.toHaveBeenCalled(); + expect(MockMenu.instances).toHaveLength(1); + expect(MockMenu.instances[0].showAtPosition).toHaveBeenCalledTimes(1); + + MockMenu.instances[0].items[0].clickHandler?.(); + expect(onTabClose).toHaveBeenCalledWith('tab-1'); }); - it('keeps the close control when expanding and collapsing a title', () => { + it('keeps the actions menu when expanding and collapsing a title', () => { const container = createMockEl(); const tabBar = new TabBar(container, { onTabClick: jest.fn(), @@ -77,7 +94,7 @@ describe('TabBar', () => { }); expect(badge.querySelector('.qoderian-tab-badge-label')?.textContent).toBe('Conversation one'); - expect(badge.querySelector('.qoderian-tab-badge-close')).not.toBeNull(); + expect(badge.querySelector('.qoderian-tab-badge-menu')).not.toBeNull(); badge.dispatchEvent({ type: 'dblclick', @@ -86,6 +103,6 @@ describe('TabBar', () => { }); expect(badge.querySelector('.qoderian-tab-badge-label')?.textContent).toBe('1'); - expect(badge.querySelector('.qoderian-tab-badge-close')).not.toBeNull(); + expect(badge.querySelector('.qoderian-tab-badge-menu')).not.toBeNull(); }); }); diff --git a/tests/unit/features/update/plugin-update-checker.test.ts b/tests/unit/features/update/plugin-update-checker.test.ts new file mode 100644 index 0000000..d449680 --- /dev/null +++ b/tests/unit/features/update/plugin-update-checker.test.ts @@ -0,0 +1,45 @@ +import { requestUrl } from 'obsidian'; + +import { + fetchAvailableQoderianUpdate, + isNewerQoderianVersion, +} from '@/features/update/plugin-update-checker'; + +const mockRequestUrl = requestUrl as jest.Mock; + +describe('plugin update checker', () => { + beforeEach(() => { + mockRequestUrl.mockReset(); + }); + + it.each([ + ['1.0.7', '1.0.8', true], + ['1.0.7', '1.1.0', true], + ['1.9.9', '2.0.0', true], + ['1.0.7', '1.0.7', false], + ['1.0.8', '1.0.7', false], + ['invalid', '1.0.8', false], + ])('compares %s with %s', (current, latest, expected) => { + expect(isNewerQoderianVersion(current, latest)).toBe(expected); + }); + + it('returns the newer stable GitHub release', async () => { + mockRequestUrl.mockResolvedValue({ + json: { + tag_name: 'v1.1.0', + html_url: 'https://github.com/QoderAI/Qoderian/releases/tag/v1.1.0', + }, + }); + + await expect(fetchAvailableQoderianUpdate('1.0.7')).resolves.toEqual({ + version: '1.1.0', + url: 'https://github.com/QoderAI/Qoderian/releases/tag/v1.1.0', + }); + }); + + it('fails silently when offline', async () => { + mockRequestUrl.mockRejectedValue(new Error('offline')); + + await expect(fetchAvailableQoderianUpdate('1.0.7')).resolves.toBeNull(); + }); +}); diff --git a/tests/unit/i18n/i18n.test.ts b/tests/unit/i18n/i18n.test.ts index a3a86d0..e3b05e5 100644 --- a/tests/unit/i18n/i18n.test.ts +++ b/tests/unit/i18n/i18n.test.ts @@ -2,6 +2,7 @@ import { getAvailableLocales, getLocale, getLocaleDisplayName, + resolveLocalePreference, setLocale, t, } from '@/i18n/i18n'; @@ -104,6 +105,22 @@ describe('i18n', () => { }); }); + describe('resolveLocalePreference', () => { + it('follows the Obsidian language for the auto preference', () => { + expect(resolveLocalePreference('auto', 'zh')).toBe('zh-CN'); + expect(resolveLocalePreference('auto', 'zh-TW')).toBe('zh-TW'); + expect(resolveLocalePreference('auto', 'pt-BR')).toBe('pt'); + }); + + it('keeps an explicit Qoderian language', () => { + expect(resolveLocalePreference('de', 'zh-CN')).toBe('de'); + }); + + it('falls back to English for unsupported Obsidian languages', () => { + expect(resolveLocalePreference('auto', 'it')).toBe('en'); + }); + }); + describe('getLocale', () => { it('returns default locale initially', () => { expect(getLocale()).toBe('en'); From ff0fbc3a53bb6e07df54d3414a89001d1f96b3d1 Mon Sep 17 00:00:00 2001 From: wangweijia0204 Date: Sat, 5 Sep 2026 14:40:13 +0800 Subject: [PATCH 3/3] feat(chat): apply codex-inspired visual polish --- CHANGELOG.md | 8 +- src/features/chat/chat-view.ts | 9 ++- src/features/chat/ui/input-toolbar.ts | 38 ++++----- .../chat/ui/toolbar/toolbar-selectors.ts | 5 +- src/style/base/container.css | 5 ++ src/style/base/variables.css | 8 +- src/style/components/context-footer.css | 25 +++--- src/style/components/header.css | 17 ++-- src/style/components/history.css | 2 +- src/style/components/input.css | 77 ++++++++++++------- src/style/components/messages.css | 17 ++-- src/style/components/tabs.css | 14 ++-- src/style/components/toolcalls.css | 6 +- src/style/toolbar/credits-usage.css | 2 +- src/style/toolbar/external-context.css | 20 +++-- src/style/toolbar/mcp-selector.css | 19 +++-- src/style/toolbar/model-selector.css | 23 ++++-- src/style/toolbar/permission-toggle.css | 36 ++++++--- .../ui/input-toolbar.context-meter.test.ts | 20 +++++ .../ui/input-toolbar.external-context.test.ts | 8 ++ 20 files changed, 225 insertions(+), 134 deletions(-) create mode 100644 tests/unit/features/chat/ui/input-toolbar.context-meter.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 20aac82..9b2456a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,9 @@ version with its date and start a fresh empty `[Unreleased]` above it. maximum supported number of chat tabs by default. - The core chat chrome now uses calmer rounded controls, a layered composer, and consistent Lucide-style icons. +- The Codex-inspired visual pass now includes neutral active tabs, adjacent + borderless new-tab action, sans-serif welcome typography, compact tool + rows, a high-contrast circular send button, and a complete context ring. ### Removed @@ -41,8 +44,9 @@ version with its date and start a fresh empty `[Unreleased]` above it. - Long composer input no longer shifts its wrapping width when the vertical scrollbar appears, preventing the input height from flickering at its cap. - The composer resize indicator now spans the complete top edge of the input. -- Composer popovers now stay within the Qoderian pane in narrow sidebars, and - the external-context action uses an unclipped add-folder icon. +- Composer popovers now stay within the Qoderian pane in narrow sidebars. +- The context-window meter now renders as a complete circular progress ring, + and the external-context action uses an unclipped paperclip icon. ## [1.0.7] - 2026-09-02 diff --git a/src/features/chat/chat-view.ts b/src/features/chat/chat-view.ts index 7563f72..09a7eb8 100644 --- a/src/features/chat/chat-view.ts +++ b/src/features/chat/chat-view.ts @@ -309,10 +309,11 @@ export class QoderianView extends ItemView { }, }); - const navActionsEl = wrapper.createDiv({ cls: 'qoderian-input-nav-actions' }); - - this.newTabButtonEl = navActionsEl.createDiv({ cls: 'qoderian-input-nav-btn qoderian-new-tab-btn' }); - setIcon(this.newTabButtonEl, 'square-plus'); + this.newTabButtonEl = wrapper.createEl('button', { + cls: 'qoderian-input-nav-btn qoderian-new-tab-btn', + attr: { type: 'button' }, + }); + setIcon(this.newTabButtonEl, 'plus'); setButtonTooltip(this.newTabButtonEl, t('commands.newTab')); this.newTabButtonEl.addEventListener('click', () => { void this.createNewTab().catch(() => new Notice('Failed to create tab')); diff --git a/src/features/chat/ui/input-toolbar.ts b/src/features/chat/ui/input-toolbar.ts index a86148e..5d7cf78 100644 --- a/src/features/chat/ui/input-toolbar.ts +++ b/src/features/chat/ui/input-toolbar.ts @@ -223,7 +223,7 @@ export class ExternalContextSelector { const iconWrapper = this.container.createDiv({ cls: 'qoderian-external-context-icon-wrapper' }); this.iconEl = iconWrapper.createDiv({ cls: 'qoderian-external-context-icon' }); - setIcon(this.iconEl, 'folder-plus'); + setIcon(this.iconEl, 'paperclip'); this.badgeEl = iconWrapper.createDiv({ cls: 'qoderian-external-context-badge' }); @@ -301,7 +301,7 @@ export class ExternalContextSelector { if (this.externalContextPaths.length === 0) { const emptyEl = listEl.createDiv({ cls: 'qoderian-external-context-empty' }); - emptyEl.setText('Click folder icon to add'); + emptyEl.setText('Click the attachment icon to add context'); } else { for (const pathStr of this.externalContextPaths) { const itemEl = listEl.createDiv({ cls: 'qoderian-external-context-item' }); @@ -626,25 +626,12 @@ export class ContextUsageMeter { } private render() { - const size = 16; + const size = 18; const strokeWidth = 2; - const radius = (size - strokeWidth) / 2; + const radius = (size - strokeWidth * 2) / 2; const cx = size / 2; const cy = size / 2; - - // 240° arc: from 150° to 390° (upper-left through bottom to upper-right) - const startAngle = 150; - const endAngle = 390; - const arcDegrees = endAngle - startAngle; - const arcRadians = (arcDegrees * Math.PI) / 180; - this.circumference = radius * arcRadians; - - const startRad = (startAngle * Math.PI) / 180; - const endRad = (endAngle * Math.PI) / 180; - const x1 = cx + radius * Math.cos(startRad); - const y1 = cy + radius * Math.sin(startRad); - const x2 = cx + radius * Math.cos(endRad); - const y2 = cy + radius * Math.sin(endRad); + this.circumference = 2 * Math.PI * radius; const gaugeEl = this.container.createDiv({ cls: 'qoderian-context-meter-gauge' }); const svg = gaugeEl.createSvg('svg', { @@ -655,26 +642,29 @@ export class ContextUsageMeter { }, }); - const pathData = `M ${x1} ${y1} A ${radius} ${radius} 0 1 1 ${x2} ${y2}`; - svg.createSvg('path', { + svg.createSvg('circle', { cls: 'qoderian-meter-bg', attr: { - d: pathData, + cx: String(cx), + cy: String(cy), + r: String(radius), fill: 'none', 'stroke-width': String(strokeWidth), - 'stroke-linecap': 'round', }, }); - const fillPath = svg.createSvg('path', { + const fillPath = svg.createSvg('circle', { cls: 'qoderian-meter-fill', attr: { - d: pathData, + cx: String(cx), + cy: String(cy), + r: String(radius), fill: 'none', 'stroke-width': String(strokeWidth), 'stroke-linecap': 'round', 'stroke-dasharray': String(this.circumference), 'stroke-dashoffset': String(this.circumference), + transform: `rotate(-90 ${cx} ${cy})`, }, }); this.fillPath = fillPath; diff --git a/src/features/chat/ui/toolbar/toolbar-selectors.ts b/src/features/chat/ui/toolbar/toolbar-selectors.ts index fe1775c..95e4501 100644 --- a/src/features/chat/ui/toolbar/toolbar-selectors.ts +++ b/src/features/chat/ui/toolbar/toolbar-selectors.ts @@ -1,4 +1,4 @@ -import { Notice } from 'obsidian'; +import { Notice, setIcon } from 'obsidian'; import type { QoderRuntimeStatus } from '../../../../core/types/services'; import type { PermissionMode, QoderModelOverride } from '../../../../core/types/settings'; @@ -644,7 +644,8 @@ export class PermissionToggle { this.buttonEl.empty(); this.buttonEl.createSpan({ cls: 'qoderian-permission-dot' }); this.buttonEl.createSpan({ cls: 'qoderian-permission-label', text: t(descriptor.labelKey) }); - this.buttonEl.createSpan({ cls: 'qoderian-permission-chevron', text: '⌄' }); + const chevronEl = this.buttonEl.createSpan({ cls: 'qoderian-permission-chevron' }); + setIcon(chevronEl, 'chevron-down'); this.buttonEl.setAttribute('title', t(descriptor.descriptionKey)); // Tab switches and SDK-driven changes only reach updateDisplay, so the // option highlight has to follow the button or the two drift apart. diff --git a/src/style/base/container.css b/src/style/base/container.css index da73e66..56053c2 100644 --- a/src/style/base/container.css +++ b/src/style/base/container.css @@ -6,3 +6,8 @@ overflow: hidden; font-family: var(--font-text); } + +/* Codex-like icon rhythm: light, consistent strokes instead of mixed visual weights. */ +.qoderian-container .svg-icon { + stroke-width: 1.8; +} diff --git a/src/style/base/variables.css b/src/style/base/variables.css index c21c1a6..b8e1aab 100644 --- a/src/style/base/variables.css +++ b/src/style/base/variables.css @@ -6,9 +6,11 @@ --qoderian-error-rgb: 220, 53, 69; --qoderian-compact: #5bc0de; --qoderian-radius-control: 8px; - --qoderian-radius-panel: 12px; - --qoderian-radius-composer: 14px; - --qoderian-shadow-raised: 0 10px 32px rgba(0, 0, 0, 0.18); + --qoderian-radius-panel: 14px; + --qoderian-radius-composer: 16px; + --qoderian-surface-raised: color-mix(in srgb, var(--background-primary) 92%, var(--text-normal) 8%); + --qoderian-surface-subtle: color-mix(in srgb, var(--background-secondary) 78%, var(--background-primary) 22%); + --qoderian-shadow-raised: 0 12px 36px rgba(0, 0, 0, 0.2), 0 2px 8px rgba(0, 0, 0, 0.08); } body.theme-light .qoderian-container { diff --git a/src/style/components/context-footer.css b/src/style/components/context-footer.css index a1e2c06..32da70f 100644 --- a/src/style/components/context-footer.css +++ b/src/style/components/context-footer.css @@ -5,7 +5,8 @@ display: flex; align-items: center; gap: 4px; - margin-inline-start: 8px; + margin-inline-start: 4px; + flex: 0 0 auto; cursor: default; } @@ -14,16 +15,18 @@ content: attr(data-tooltip); position: absolute; bottom: 100%; - left: 50%; - transform: translateX(-50%); + left: 0; margin-bottom: 6px; padding: 4px 8px; font-size: 11px; color: var(--text-normal); background: var(--background-secondary); border: 1px solid var(--background-modifier-border); - border-radius: 4px; - white-space: nowrap; + width: max-content; + max-width: min(240px, calc(100vw - 24px)); + border-radius: var(--qoderian-radius-control); + white-space: normal; + overflow-wrap: anywhere; opacity: 0; visibility: hidden; transition: opacity 0.15s ease, visibility 0.15s ease; @@ -40,13 +43,15 @@ display: flex; align-items: center; justify-content: center; - width: 24px; - height: 24px; + width: 28px; + height: 28px; } .qoderian-context-meter-gauge svg { - width: 16px; - height: 16px; + display: block; + width: 18px; + height: 18px; + overflow: visible; } .qoderian-meter-bg { @@ -60,7 +65,7 @@ .qoderian-context-meter-percent { font-size: 11px; - color: var(--qoderian-brand); + color: var(--text-muted); min-width: 24px; text-align: end; transition: color 0.3s ease; diff --git a/src/style/components/header.css b/src/style/components/header.css index 00a2492..bcd37c2 100644 --- a/src/style/components/header.css +++ b/src/style/components/header.css @@ -4,7 +4,7 @@ display: flex; align-items: center; gap: 8px; - padding: 4px 10px 14px; + padding: 6px 10px 16px; } .qoderian-title { @@ -66,12 +66,12 @@ margin-inline-start: auto; } -.qoderian-header-action, +.qoderian-header-actions .qoderian-header-action, .qoderian-header-actions .qoderian-credits-btn { - width: 28px; - height: 28px; - border: 1px solid transparent; - border-radius: var(--qoderian-radius-control); + width: 30px; + height: 30px; + border: 0; + border-radius: 9px; color: var(--text-muted); } @@ -80,12 +80,11 @@ overflow: visible; } -.qoderian-header-action:hover, -.qoderian-header-action:focus-visible, +.qoderian-header-actions .qoderian-header-action:hover, +.qoderian-header-actions .qoderian-header-action:focus-visible, .qoderian-header-actions .qoderian-credits-btn:hover, .qoderian-header-actions .qoderian-credits-btn:focus-visible { color: var(--text-normal); background: var(--background-modifier-hover); - border-color: var(--background-modifier-border); outline: none; } diff --git a/src/style/components/history.css b/src/style/components/history.css index 4459faf..ed5960e 100644 --- a/src/style/components/history.css +++ b/src/style/components/history.css @@ -7,7 +7,7 @@ display: none; position: absolute; top: calc(100% + 4px); - background: var(--background-secondary); + background: var(--qoderian-surface-raised); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); border: 1px solid var(--background-modifier-border); diff --git a/src/style/components/input.css b/src/style/components/input.css index 949d658..2f8b488 100644 --- a/src/style/components/input.css +++ b/src/style/components/input.css @@ -25,14 +25,14 @@ min-height: 140px; border: 1px solid var(--qoderian-input-wrapper-border-color); border-radius: var(--qoderian-radius-composer); - background: var(--background-secondary); + background: var(--qoderian-surface-subtle); box-shadow: var(--qoderian-input-wrapper-box-shadow); transition: border-color 0.15s ease, box-shadow 0.15s ease; } .qoderian-input-wrapper:focus-within { - --qoderian-input-wrapper-border-color: var(--background-modifier-border-focus); - --qoderian-input-wrapper-box-shadow: 0 0 0 2px rgba(var(--qoderian-brand-rgb), 0.1); + --qoderian-input-wrapper-border-color: var(--text-faint); + --qoderian-input-wrapper-box-shadow: 0 0 0 1px color-mix(in srgb, var(--text-faint) 24%, transparent), 0 8px 24px rgba(0, 0, 0, 0.08); } .qoderian-input-wrapper--resized { @@ -162,19 +162,12 @@ body.qoderian-composer-resizing * { .qoderian-input-nav-content { display: flex; align-items: center; - justify-content: space-between; + justify-content: flex-start; + gap: 4px; width: 100%; min-width: 0; } -.qoderian-input-nav-actions { - display: flex; - align-items: center; - gap: 12px; - flex-shrink: 0; - margin-inline-start: auto; -} - .qoderian-input-nav-btn { display: flex; align-items: center; @@ -193,9 +186,32 @@ body.qoderian-composer-resizing * { height: 16px; } +.qoderian-input-nav-content button.qoderian-new-tab-btn { + display: inline-flex; + flex: 0 0 30px; + width: 30px; + height: 30px; + margin: 0; + padding: 0; + border: 0; + border-radius: 9px; + background: transparent; + box-shadow: none; + color: var(--text-muted); +} + +.qoderian-input-nav-content button.qoderian-new-tab-btn:hover, +.qoderian-input-nav-content button.qoderian-new-tab-btn:focus-visible { + border: 0; + background: var(--background-modifier-hover); + box-shadow: none; + color: var(--text-normal); + outline: none; +} + .qoderian-new-tab-btn svg { - width: 16.8px; - height: 16.8px; + width: 18px; + height: 18px; } /* Selection chips (editor / browser / canvas selection context) */ @@ -261,10 +277,12 @@ body.qoderian-composer-resizing * { display: flex; align-items: center; justify-content: flex-start; - flex-wrap: wrap; - row-gap: 2px; + flex-wrap: nowrap; + gap: 2px; flex-shrink: 0; - padding: 4px 6px 6px 6px; + min-height: 42px; + padding: 6px 8px 8px; + overflow: visible; } /* File indicator (attached files) */ @@ -529,19 +547,19 @@ body.qoderian-composer-resizing * { height: 14px; } -/* Send/stop action button - IDE-style green rounded square with dark icon */ +/* Codex-style send/stop action: high-contrast circular control. */ .qoderian-composer-action-btn { display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; - width: 28px; - height: 28px; + width: 32px; + height: 32px; padding: 0; border: none; - border-radius: 8px; + border-radius: 50%; cursor: pointer; - transition: filter 0.15s ease, opacity 0.15s ease; + transition: transform 0.15s ease, background 0.15s ease, color 0.15s ease; } /* Obsidian's native button defaults (interactive-normal gray) out-specify a @@ -549,8 +567,8 @@ body.qoderian-composer-resizing * { .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); + background: var(--text-normal); + color: var(--background-primary); } /* When the permission toggle is hidden the button inherits the row's @@ -560,12 +578,13 @@ body.qoderian-composer-resizing * { } .qoderian-composer-action-btn:hover:not(:disabled) { - filter: brightness(1.08); + transform: translateY(-1px); } .qoderian-composer-action-btn svg { - width: 16px; - height: 16px; + width: 17px; + height: 17px; + stroke-width: 2.2; } /* Stop mode: filled square icon, slightly emphasized */ @@ -578,7 +597,9 @@ body.qoderian-composer-resizing * { /* Idle with empty composer: dimmed, not clickable */ .qoderian-composer-action-btn.is-disabled, .qoderian-composer-action-btn:disabled { - opacity: 0.4; + background: var(--background-modifier-border); + color: var(--text-faint); + opacity: 1; cursor: default; } diff --git a/src/style/components/messages.css b/src/style/components/messages.css index c16803e..ada34e3 100644 --- a/src/style/components/messages.css +++ b/src/style/components/messages.css @@ -35,25 +35,26 @@ } .qoderian-welcome-greeting { - font-family: 'Copernicus', 'Tiempos Headline', 'Tiempos', Georgia, 'Times New Roman', serif; - font-size: 28px; - font-weight: 300; - color: var(--text-muted); - letter-spacing: -0.01em; + font-family: inherit; + font-size: 24px; + font-weight: 550; + color: var(--text-normal); + letter-spacing: -0.025em; } .qoderian-message { padding: 10px 14px; - border-radius: 8px; + border-radius: 16px; max-width: 95%; word-wrap: break-word; } .qoderian-message-user { position: relative; - background: rgba(0, 0, 0, 0.3); + background: var(--background-modifier-hover); + border: 1px solid var(--background-modifier-border); align-self: flex-end; - border-end-end-radius: 4px; + border-end-end-radius: 6px; } /* Text selection in user messages - visible highlight */ diff --git a/src/style/components/tabs.css b/src/style/components/tabs.css index 6f8df69..37b1eed 100644 --- a/src/style/components/tabs.css +++ b/src/style/components/tabs.css @@ -1,10 +1,10 @@ .qoderian-tab-bar-container { display: flex; align-items: center; - flex: 1 1 auto; + flex: 0 1 auto; gap: 4px; min-width: 0; - max-width: 100%; + max-width: calc(100% - 34px); } .qoderian-tab-badges { @@ -37,13 +37,13 @@ box-sizing: border-box; gap: 4px; padding: 0 5px 0 8px; - border-radius: var(--qoderian-radius-control); + border-radius: 10px; border: 1px solid var(--background-modifier-border); font-size: 12px; font-weight: 500; cursor: pointer; color: var(--text-muted); - background: var(--background-secondary); + background: transparent; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -114,10 +114,10 @@ } .qoderian-tab-badge-active { - border-color: var(--interactive-accent); + border-color: var(--text-faint); color: var(--text-normal); - background: var(--background-primary); - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12); + background: var(--background-modifier-hover); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text-faint) 28%, transparent); } .qoderian-tab-badge-streaming { diff --git a/src/style/components/toolcalls.css b/src/style/components/toolcalls.css index 0a21f56..11d6210 100644 --- a/src/style/components/toolcalls.css +++ b/src/style/components/toolcalls.css @@ -6,13 +6,15 @@ display: flex; align-items: center; gap: 8px; - padding: 4px 0; + margin-inline: -6px; + padding: 6px; + border-radius: var(--qoderian-radius-control); cursor: pointer; overflow: hidden; } .qoderian-tool-header:hover { - opacity: 0.85; + background: var(--background-modifier-hover); } .qoderian-tool-icon { diff --git a/src/style/toolbar/credits-usage.css b/src/style/toolbar/credits-usage.css index abf976b..7ac34ed 100644 --- a/src/style/toolbar/credits-usage.css +++ b/src/style/toolbar/credits-usage.css @@ -17,7 +17,7 @@ inset-inline-end: 12px; width: min(320px, calc(100% - 24px)); padding: 12px; - background: var(--background-secondary); + background: var(--qoderian-surface-raised); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); border: 1px solid var(--background-modifier-border); diff --git a/src/style/toolbar/external-context.css b/src/style/toolbar/external-context.css index 86ab9c1..45ee9e9 100644 --- a/src/style/toolbar/external-context.css +++ b/src/style/toolbar/external-context.css @@ -3,7 +3,8 @@ position: static; display: flex; align-items: center; - margin-left: 8px; + margin-left: 2px; + flex: 0 0 auto; } .qoderian-external-context-icon-wrapper { @@ -12,14 +13,20 @@ align-items: center; justify-content: center; cursor: pointer; + overflow: visible; + border-radius: var(--qoderian-radius-control); +} + +.qoderian-external-context-icon-wrapper:hover { + background: var(--background-modifier-hover); } .qoderian-external-context-icon { display: flex; align-items: center; justify-content: center; - width: 24px; - height: 24px; + width: 28px; + height: 28px; color: var(--text-faint); transition: color 0.15s ease; } @@ -30,13 +37,12 @@ .qoderian-external-context-icon.active { color: var(--qoderian-brand); - animation: external-context-glow 2s ease-in-out infinite; } .qoderian-external-context-icon svg { display: block; - width: 16px; - height: 16px; + width: 18px; + height: 18px; overflow: visible; } @@ -65,7 +71,7 @@ width: auto; max-width: none; box-sizing: border-box; - background: var(--background-secondary); + background: var(--qoderian-surface-raised); border: 1px solid var(--background-modifier-border); border-radius: var(--qoderian-radius-panel); box-shadow: var(--qoderian-shadow-raised); diff --git a/src/style/toolbar/mcp-selector.css b/src/style/toolbar/mcp-selector.css index 3e41ac3..c72738a 100644 --- a/src/style/toolbar/mcp-selector.css +++ b/src/style/toolbar/mcp-selector.css @@ -3,7 +3,8 @@ position: static; display: flex; align-items: center; - margin-left: 8px; + margin-left: 2px; + flex: 0 0 auto; } .qoderian-mcp-selector-icon-wrapper { @@ -12,14 +13,19 @@ align-items: center; justify-content: center; cursor: pointer; + border-radius: var(--qoderian-radius-control); +} + +.qoderian-mcp-selector-icon-wrapper:hover { + background: var(--background-modifier-hover); } .qoderian-mcp-selector-icon { display: flex; align-items: center; justify-content: center; - width: 24px; - height: 24px; + width: 28px; + height: 28px; color: var(--text-faint); transition: color 0.15s ease; } @@ -30,12 +36,11 @@ .qoderian-mcp-selector-icon.active { color: var(--qoderian-brand); - animation: mcp-glow 2s ease-in-out infinite; } .qoderian-mcp-selector-icon svg { - width: 16px; - height: 16px; + width: 18px; + height: 18px; } .qoderian-mcp-selector-badge { @@ -63,7 +68,7 @@ width: auto; max-width: none; box-sizing: border-box; - background: var(--background-secondary); + background: var(--qoderian-surface-raised); border: 1px solid var(--background-modifier-border); border-radius: var(--qoderian-radius-panel); box-shadow: var(--qoderian-shadow-raised); diff --git a/src/style/toolbar/model-selector.css b/src/style/toolbar/model-selector.css index 729f83f..4c76608 100644 --- a/src/style/toolbar/model-selector.css +++ b/src/style/toolbar/model-selector.css @@ -1,7 +1,9 @@ /* Model selector */ .qoderian-model-selector { + flex: 1 1 72px; min-width: 0; max-width: 100%; + overflow: hidden; } .qoderian-model-btn { @@ -10,13 +12,20 @@ gap: 4px; min-width: 0; max-width: 100%; + overflow: hidden; padding: 4px 8px; - border-radius: 4px; + border-radius: var(--qoderian-radius-control); cursor: pointer; - color: var(--qoderian-brand); + color: var(--text-muted); font-size: 12px; } +.qoderian-model-btn:hover, +.qoderian-model-selector--open .qoderian-model-btn { + background: var(--background-modifier-hover); + color: var(--text-normal); +} + .qoderian-model-btn--warning { color: var(--text-warning); } @@ -70,7 +79,7 @@ popover height convention. */ max-height: var(--qoderian-model-dropdown-max-height, 420px); padding: 4px; - background: var(--background-secondary); + background: var(--qoderian-surface-raised); border: 1px solid var(--background-modifier-border); border-radius: var(--qoderian-radius-panel); box-shadow: var(--qoderian-shadow-raised); @@ -88,7 +97,7 @@ max-width: 100%; margin-top: var(--qoderian-model-editor-offset, 0px); padding: 4px 8px 8px; - background: var(--background-secondary); + background: var(--qoderian-surface-raised); border: 1px solid var(--background-modifier-border); border-radius: var(--qoderian-radius-panel); box-shadow: var(--qoderian-shadow-raised); @@ -229,7 +238,7 @@ cursor: pointer; font-size: 12px; color: var(--text-muted); - border-radius: 3px; + border-radius: var(--qoderian-radius-control); transition: background 0.1s ease, color 0.1s ease; white-space: nowrap; } @@ -245,8 +254,8 @@ } .qoderian-model-option.selected { - background: rgba(var(--qoderian-brand-rgb), 0.15); - color: var(--qoderian-brand); + background: var(--background-modifier-hover); + color: var(--text-normal); font-weight: 500; } diff --git a/src/style/toolbar/permission-toggle.css b/src/style/toolbar/permission-toggle.css index d887e73..111a3fb 100644 --- a/src/style/toolbar/permission-toggle.css +++ b/src/style/toolbar/permission-toggle.css @@ -2,18 +2,21 @@ .qoderian-permission-toggle { display: flex; align-items: center; + flex: 0 1 auto; + min-width: 0; margin-left: auto; - padding-left: 12px; - padding-right: 8px; + padding-left: 6px; + padding-right: 4px; } .qoderian-permission-button { display: flex; align-items: center; gap: 5px; + min-width: 0; min-height: 26px; padding: 3px 7px; - border-radius: 5px; + border-radius: var(--qoderian-radius-control); color: var(--text-muted); cursor: pointer; font-size: 11px; @@ -26,14 +29,23 @@ } .qoderian-permission-label { + min-width: 0; + max-width: 76px; font-weight: 600; white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .qoderian-permission-chevron { + display: inline-flex; + align-items: center; color: var(--text-faint); - font-size: 10px; - line-height: 1; +} + +.qoderian-permission-chevron svg { + width: 13px; + height: 13px; } .qoderian-permission-dot { @@ -44,10 +56,10 @@ flex-shrink: 0; } -.qoderian-permission-button--default { color: var(--text-success); } -.qoderian-permission-button--auto { color: rgb(222, 139, 66); } -.qoderian-permission-button--plan { color: rgb(92, 148, 140); } -.qoderian-permission-button--yolo { color: var(--text-error); } +.qoderian-permission-button--default, +.qoderian-permission-button--auto, +.qoderian-permission-button--plan, +.qoderian-permission-button--yolo { color: var(--text-muted); } .qoderian-permission-button--default .qoderian-permission-dot, .qoderian-permission-option--default .qoderian-permission-dot { background: var(--text-success); } @@ -69,9 +81,9 @@ margin-bottom: 4px; padding: 4px; border: 1px solid var(--background-modifier-border); - border-radius: 6px; - background: var(--background-secondary); - box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.15); + border-radius: var(--qoderian-radius-panel); + background: var(--qoderian-surface-raised); + box-shadow: var(--qoderian-shadow-raised); opacity: 0; visibility: hidden; transition: opacity 0.15s ease, visibility 0.15s ease; diff --git a/tests/unit/features/chat/ui/input-toolbar.context-meter.test.ts b/tests/unit/features/chat/ui/input-toolbar.context-meter.test.ts new file mode 100644 index 0000000..c9ce0b9 --- /dev/null +++ b/tests/unit/features/chat/ui/input-toolbar.context-meter.test.ts @@ -0,0 +1,20 @@ +import { createMockEl } from '@test/helpers/mock-element'; + +import { ContextUsageMeter } from '@/features/chat/ui/input-toolbar'; + +describe('ContextUsageMeter', () => { + it('renders a complete circular track instead of an open gauge arc', () => { + const parentEl = createMockEl(); + + new ContextUsageMeter(parentEl); + + const background = parentEl.querySelector('.qoderian-meter-bg'); + const fill = parentEl.querySelector('.qoderian-meter-fill'); + expect(background?.tagName).toBe('CIRCLE'); + expect(fill?.tagName).toBe('CIRCLE'); + expect(background?.getAttribute('cx')).toBe('9'); + expect(background?.getAttribute('cy')).toBe('9'); + expect(fill?.getAttribute('transform')).toBe('rotate(-90 9 9)'); + expect(fill?.getAttribute('d')).toBeNull(); + }); +}); diff --git a/tests/unit/features/chat/ui/input-toolbar.external-context.test.ts b/tests/unit/features/chat/ui/input-toolbar.external-context.test.ts index ab98c56..ae53397 100644 --- a/tests/unit/features/chat/ui/input-toolbar.external-context.test.ts +++ b/tests/unit/features/chat/ui/input-toolbar.external-context.test.ts @@ -1,5 +1,6 @@ import { createMockEl } from '@test/helpers/mock-element'; import * as fs from 'fs'; +import { setIcon } from 'obsidian'; import * as os from 'os'; import * as path from 'path'; @@ -57,6 +58,13 @@ describe('ExternalContextSelector', () => { selector = new ExternalContextSelector(parentEl, callbacks); }); + it('uses an unclipped attachment-style icon', () => { + expect(setIcon).toHaveBeenCalledWith( + parentEl.querySelector('.qoderian-external-context-icon'), + 'paperclip', + ); + }); + describe('Persistent Paths Management', () => { it('should initialize with empty persistent paths', () => { expect(selector.getPersistentPaths()).toEqual([]);