From 075b053b2123b6c44aaf88b7688edbb12cca1db3 Mon Sep 17 00:00:00 2001 From: liuxuezhuo <44692579+luoxuanzao@users.noreply.github.com> Date: Tue, 18 Aug 2026 07:32:43 +0800 Subject: [PATCH 1/5] feat: speed up icon-button tooltips with a 300ms hover delay Obsidian's native aria-label tooltips use a 1000ms cold-start delay and a 100ms warm window, so hovering across the chat toolbar feels inconsistent and sluggish compared to common web toolbars (200-700ms initial, ~300ms warm). Route the navigation buttons (new tab, new conversation, chat history) and the tab-bar badges through a shared setButtonTooltip helper built on the official setTooltip API with a 300ms delay, keeping native rendering and accessibility intact. The obsidian test mock mirrors setTooltip so unit tests exercise the new path. Co-authored-by: QoderAI (Qwen 3.8 Max) --- src/features/chat/chat-view.ts | 7 ++++--- src/features/chat/tabs/tab-bar.ts | 3 ++- src/shared/dom/tooltip.ts | 16 ++++++++++++++++ tests/__mocks__/obsidian.ts | 9 +++++++++ 4 files changed, 31 insertions(+), 4 deletions(-) create mode 100644 src/shared/dom/tooltip.ts diff --git a/src/features/chat/chat-view.ts b/src/features/chat/chat-view.ts index 968f267..d6ab9fb 100644 --- a/src/features/chat/chat-view.ts +++ b/src/features/chat/chat-view.ts @@ -11,6 +11,7 @@ import { 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 type { HistoryConversationStatus } from './controllers/conversation-controller'; import { @@ -244,14 +245,14 @@ export class QoderianView extends ItemView { this.newTabButtonEl = navActionsEl.createDiv({ cls: 'qoderian-input-nav-btn qoderian-new-tab-btn' }); setIcon(this.newTabButtonEl, 'square-plus'); - this.newTabButtonEl.setAttribute('aria-label', 'New tab'); + setButtonTooltip(this.newTabButtonEl, 'New tab'); this.newTabButtonEl.addEventListener('click', () => { void this.createNewTab().catch(() => new Notice('Failed to create tab')); }); const newBtn = navActionsEl.createDiv({ cls: 'qoderian-input-nav-btn' }); setIcon(newBtn, 'square-pen'); - newBtn.setAttribute('aria-label', 'New conversation'); + setButtonTooltip(newBtn, 'New conversation'); newBtn.addEventListener('click', () => { void (async () => { await this.tabManager?.createNewConversation(); @@ -263,7 +264,7 @@ export class QoderianView extends ItemView { const historyContainer = navActionsEl.createDiv({ cls: 'qoderian-history-container' }); const historyBtn = historyContainer.createDiv({ cls: 'qoderian-input-nav-btn' }); setIcon(historyBtn, 'history'); - historyBtn.setAttribute('aria-label', 'Chat history'); + setButtonTooltip(historyBtn, 'Chat history'); this.historyDropdown = historyContainer.createDiv({ cls: 'qoderian-history-menu' }); diff --git a/src/features/chat/tabs/tab-bar.ts b/src/features/chat/tabs/tab-bar.ts index 7876c64..40a2c55 100644 --- a/src/features/chat/tabs/tab-bar.ts +++ b/src/features/chat/tabs/tab-bar.ts @@ -1,4 +1,5 @@ import { scheduleAnimationFrame } from '../../../shared/dom/animation-frame'; +import { setButtonTooltip } from '../../../shared/dom/tooltip'; import type { TabBarItem, TabId } from './types'; const EXPANDED_TITLE_MAX_LENGTH = 32; @@ -82,7 +83,7 @@ export class TabBar { }); // Obsidian uses aria-label for hover tooltips here; adding title causes duplicate tooltip text. - badgeEl.setAttribute('aria-label', item.title); + setButtonTooltip(badgeEl, item.title); badgeEl.setAttribute('data-title-expanded', isTitleExpanded ? 'true' : 'false'); // Click handler to switch tab diff --git a/src/shared/dom/tooltip.ts b/src/shared/dom/tooltip.ts new file mode 100644 index 0000000..1e4164f --- /dev/null +++ b/src/shared/dom/tooltip.ts @@ -0,0 +1,16 @@ +import { setTooltip } from 'obsidian'; + +/** + * Hover delay (ms) for icon-button tooltips. + * Obsidian's native aria-label tooltips use a 1000ms cold-start delay, which + * feels sluggish next to the 200-500ms range common in web toolbars. + */ +const BUTTON_TOOLTIP_DELAY = 300; + +/** + * Sets a hover tooltip on an icon button with a snappier-than-default delay. + * Keeps the native Obsidian tooltip rendering (placement, arrow, a11y) intact. + */ +export function setButtonTooltip(el: HTMLElement, text: string): void { + setTooltip(el, text, { delay: BUTTON_TOOLTIP_DELAY }); +} diff --git a/tests/__mocks__/obsidian.ts b/tests/__mocks__/obsidian.ts index e6a0974..f0c8fbd 100644 --- a/tests/__mocks__/obsidian.ts +++ b/tests/__mocks__/obsidian.ts @@ -263,6 +263,15 @@ export const MarkdownRenderer = { export const setIcon = jest.fn(); +// Mirrors the real helper: writes aria-label plus any delay override that the +// native tooltip system would read back on hover. +export function setTooltip(el: HTMLElement, tooltip: string, options?: { delay?: number }): void { + el.setAttribute('aria-label', tooltip); + if (options?.delay !== undefined) { + el.setAttribute('data-tooltip-delay', String(options.delay)); + } +} + // Tests run against the newest API surface, so version gates take the // modern branch by default. export const requireApiVersion = jest.fn(() => true); From 1e60a5559369b7bdd5f14ee0c5d3dde526204f76 Mon Sep 17 00:00:00 2001 From: liuxuezhuo <44692579+luoxuanzao@users.noreply.github.com> Date: Tue, 18 Aug 2026 07:50:34 +0800 Subject: [PATCH 2/5] fix: give the credits button a static native tooltip like other nav buttons The credits usage button set a browser `title` attribute with the live usage percentage, so its tooltip rendered in the OS/browser style and read differently from the other nav-row buttons, which use aria-label and Obsidian's native tooltip. Drop the percentage from the trigger copy in all ten locales and set a static aria-label ("Usage") instead, so the gauge button looks and behaves like its neighbors; the popover panel keeps the detailed numbers. Co-authored-by: QoderAI (Qwen 3.8 Max) --- CHANGELOG.md | 6 ++++++ src/features/chat/ui/credits-usage-button.ts | 9 +++------ src/i18n/locales/de.json | 2 +- src/i18n/locales/en.json | 2 +- src/i18n/locales/es.json | 2 +- src/i18n/locales/fr.json | 2 +- src/i18n/locales/ja.json | 2 +- src/i18n/locales/ko.json | 2 +- src/i18n/locales/pt.json | 2 +- src/i18n/locales/ru.json | 2 +- src/i18n/locales/zh-CN.json | 2 +- src/i18n/locales/zh-TW.json | 2 +- .../features/chat/ui/credits-usage-button.test.ts | 15 ++++++++------- 13 files changed, 27 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 523deca..bea3fc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,12 @@ version with its date and start a fresh empty `[Unreleased]` above it. snapshots now carry the configured context-window tier, and zeroed snapshots can no longer overwrite an existing reading. +### Changed + +- The credits usage button now shows a static "Usage" tooltip through + Obsidian's native tooltip (aria-label), matching the other nav-row + buttons, instead of a browser title tooltip with the live percentage. + ## [1.0.4] - 2026-08-12 ### Fixed diff --git a/src/features/chat/ui/credits-usage-button.ts b/src/features/chat/ui/credits-usage-button.ts index 14e1398..9ad07f4 100644 --- a/src/features/chat/ui/credits-usage-button.ts +++ b/src/features/chat/ui/credits-usage-button.ts @@ -125,12 +125,9 @@ export class CreditsUsageButton { }; private updateButton(): void { - const percent = this.snapshot?.totalUsagePercentage; - if (typeof percent === 'number') { - this.buttonEl.setAttribute('title', t('credits.trigger', { percent: Math.round(percent) })); - } else { - this.buttonEl.setAttribute('title', t('credits.unavailable')); - } + // Static label via aria-label so the button gets the same native Obsidian + // tooltip as the other nav-row buttons (title would use the browser one). + this.buttonEl.setAttribute('aria-label', t('credits.trigger')); } private renderPanel(): void { diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 128c14f..df74a8b 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -292,7 +292,7 @@ "renewsOn": "Verlängert am {date}", "usedPercent": "{percent}% verwendet", "left": "{count} übrig", - "trigger": "Nutzung - {percent}%", + "trigger": "Nutzung", "unavailable": "Nutzung nicht verfügbar" }, "model": { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 1646cc4..d323b54 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -292,7 +292,7 @@ "renewsOn": "Renews on {date}", "usedPercent": "{percent}% used", "left": "{count} left", - "trigger": "Usage - {percent}%", + "trigger": "Usage", "unavailable": "Usage unavailable" }, "model": { diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 07d9e6c..03b4691 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -292,7 +292,7 @@ "renewsOn": "Se renueva el {date}", "usedPercent": "{percent}% usado", "left": "{count} restantes", - "trigger": "Uso - {percent}%", + "trigger": "Uso", "unavailable": "Uso no disponible" }, "model": { diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 77ac30a..9829574 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -292,7 +292,7 @@ "renewsOn": "Renouvellement le {date}", "usedPercent": "{percent}% utilisés", "left": "{count} restants", - "trigger": "Utilisation - {percent}%", + "trigger": "Utilisation", "unavailable": "Utilisation indisponible" }, "model": { diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 4d7910b..3c8fead 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -292,7 +292,7 @@ "renewsOn": "{date} に更新", "usedPercent": "{percent}% 使用済み", "left": "残り {count}", - "trigger": "利用量 - {percent}%", + "trigger": "利用量", "unavailable": "利用量情報を取得できません" }, "model": { diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 8271196..f5786a4 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -292,7 +292,7 @@ "renewsOn": "{date}에 갱신", "usedPercent": "{percent}% 사용", "left": "{count} 남음", - "trigger": "사용량 - {percent}%", + "trigger": "사용량", "unavailable": "사용량을 확인할 수 없음" }, "model": { diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index be38e07..61b1885 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -292,7 +292,7 @@ "renewsOn": "Renova em {date}", "usedPercent": "{percent}% usado", "left": "{count} restantes", - "trigger": "Uso - {percent}%", + "trigger": "Uso", "unavailable": "Uso indisponível" }, "model": { diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 71874bc..d991814 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -292,7 +292,7 @@ "renewsOn": "Обновление {date}", "usedPercent": "использовано {percent}%", "left": "осталось {count}", - "trigger": "Использование - {percent}%", + "trigger": "Использование", "unavailable": "Использование недоступно" }, "model": { diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 6c9644d..a482cd2 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -292,7 +292,7 @@ "renewsOn": "将于 {date} 刷新", "usedPercent": "已使用 {percent}%", "left": "剩余 {count}", - "trigger": "用量 - {percent}%", + "trigger": "用量", "unavailable": "用量暂不可用" }, "model": { diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 7d04f1c..5d7cc95 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -292,7 +292,7 @@ "renewsOn": "將於 {date} 刷新", "usedPercent": "已使用 {percent}%", "left": "剩餘 {count}", - "trigger": "用量 - {percent}%", + "trigger": "用量", "unavailable": "用量暫不可用" }, "model": { diff --git a/tests/unit/features/chat/ui/credits-usage-button.test.ts b/tests/unit/features/chat/ui/credits-usage-button.test.ts index b5ec70c..585c57a 100644 --- a/tests/unit/features/chat/ui/credits-usage-button.test.ts +++ b/tests/unit/features/chat/ui/credits-usage-button.test.ts @@ -41,11 +41,12 @@ function createButton(cached: CreditsUsageSnapshot | null = SNAPSHOT): ButtonHar } describe('CreditsUsageButton', () => { - it('shows the overall percentage as the button tooltip', () => { + it('shows a static usage tooltip like the other nav-row buttons', () => { const { parentEl } = createButton(); - expect(parentEl.querySelector('.qoderian-credits-btn')?.getAttribute('title')) - .toBe('Usage - 100%'); + expect(parentEl.querySelector('.qoderian-credits-btn')?.getAttribute('aria-label')) + .toBe('Usage'); + expect(parentEl.querySelector('.qoderian-credits-btn')?.getAttribute('title')).toBeNull(); }); it('renders plan and resource package sections like the IDE usage panel', () => { @@ -118,8 +119,8 @@ describe('CreditsUsageButton', () => { it('shows an unavailable state without a snapshot', () => { const { parentEl } = createButton(null); - expect(parentEl.querySelector('.qoderian-credits-btn')?.getAttribute('title')) - .toBe('Usage unavailable'); + expect(parentEl.querySelector('.qoderian-credits-btn')?.getAttribute('aria-label')) + .toBe('Usage'); expect(parentEl.querySelector('.qoderian-credits-empty')?.textContent) .toBe('Usage unavailable'); }); @@ -131,8 +132,8 @@ describe('CreditsUsageButton', () => { await flushPromises(); expect(fetchUsage).toHaveBeenCalledTimes(1); - expect(parentEl.querySelector('.qoderian-credits-btn')?.getAttribute('title')) - .toBe('Usage - 100%'); + expect(parentEl.querySelector('.qoderian-credits-btn')?.getAttribute('aria-label')) + .toBe('Usage'); expect(parentEl.querySelector('.qoderian-credits-empty')).toBeNull(); }); From ac6e935f0c63746483ded2c1a8202456b3d23cab Mon Sep 17 00:00:00 2001 From: liuxuezhuo <44692579+luoxuanzao@users.noreply.github.com> Date: Tue, 18 Aug 2026 07:56:20 +0800 Subject: [PATCH 3/5] fix: localize nav-row button tooltips and the blank tab title The nav-row buttons hardcoded English strings ("New tab", "New conversation", "Chat history") and getTabTitle fell back to a hardcoded "New Chat", so non-English locales (e.g. zh-CN) saw English tooltips next to the localized credits button. Route them through the i18n catalog: reuse commands.newTab for the new-tab button and add a nav group (newConversation / chatHistory / newChat) in all ten locales. Co-authored-by: QoderAI (Qwen 3.8 Max) --- src/features/chat/chat-view.ts | 6 +++--- src/features/chat/tabs/tab-lifecycle.ts | 3 ++- src/i18n/locales/de.json | 5 +++++ src/i18n/locales/en.json | 5 +++++ src/i18n/locales/es.json | 5 +++++ src/i18n/locales/fr.json | 5 +++++ src/i18n/locales/ja.json | 5 +++++ src/i18n/locales/ko.json | 5 +++++ src/i18n/locales/pt.json | 5 +++++ src/i18n/locales/ru.json | 5 +++++ src/i18n/locales/zh-CN.json | 5 +++++ src/i18n/locales/zh-TW.json | 5 +++++ src/i18n/types.ts | 5 +++++ 13 files changed, 60 insertions(+), 4 deletions(-) diff --git a/src/features/chat/chat-view.ts b/src/features/chat/chat-view.ts index d6ab9fb..9760a6c 100644 --- a/src/features/chat/chat-view.ts +++ b/src/features/chat/chat-view.ts @@ -245,14 +245,14 @@ export class QoderianView extends ItemView { this.newTabButtonEl = navActionsEl.createDiv({ cls: 'qoderian-input-nav-btn qoderian-new-tab-btn' }); setIcon(this.newTabButtonEl, 'square-plus'); - setButtonTooltip(this.newTabButtonEl, 'New tab'); + setButtonTooltip(this.newTabButtonEl, t('commands.newTab')); this.newTabButtonEl.addEventListener('click', () => { void this.createNewTab().catch(() => new Notice('Failed to create tab')); }); const newBtn = navActionsEl.createDiv({ cls: 'qoderian-input-nav-btn' }); setIcon(newBtn, 'square-pen'); - setButtonTooltip(newBtn, 'New conversation'); + setButtonTooltip(newBtn, t('nav.newConversation')); newBtn.addEventListener('click', () => { void (async () => { await this.tabManager?.createNewConversation(); @@ -264,7 +264,7 @@ export class QoderianView extends ItemView { const historyContainer = navActionsEl.createDiv({ cls: 'qoderian-history-container' }); const historyBtn = historyContainer.createDiv({ cls: 'qoderian-input-nav-btn' }); setIcon(historyBtn, 'history'); - setButtonTooltip(historyBtn, 'Chat history'); + setButtonTooltip(historyBtn, t('nav.chatHistory')); this.historyDropdown = historyContainer.createDiv({ cls: 'qoderian-history-menu' }); diff --git a/src/features/chat/tabs/tab-lifecycle.ts b/src/features/chat/tabs/tab-lifecycle.ts index 3e9477b..eb443ab 100644 --- a/src/features/chat/tabs/tab-lifecycle.ts +++ b/src/features/chat/tabs/tab-lifecycle.ts @@ -1,3 +1,4 @@ +import { t } from '../../../i18n/i18n'; import type QoderianPlugin from '../../../main'; import { cleanupThinkingBlock } from '../rendering/thinking-block-renderer'; import type { TabData } from './types'; @@ -71,5 +72,5 @@ export function getTabTitle(tab: TabData, plugin: QoderianPlugin): string { const conversation = plugin.getConversationSync(tab.conversationId); if (conversation?.title) return conversation.title; } - return 'New Chat'; + return t('nav.newChat'); } diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index df74a8b..dda8c80 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -38,6 +38,11 @@ "newSession": "Neue Sitzung (im aktuellen Tab)", "closeCurrentTab": "Aktuellen Tab schließen" }, + "nav": { + "newConversation": "Neue Unterhaltung", + "chatHistory": "Chatverlauf", + "newChat": "Neuer Chat" + }, "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.", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index d323b54..a753846 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -38,6 +38,11 @@ "newSession": "New session (in current tab)", "closeCurrentTab": "Close current tab" }, + "nav": { + "newConversation": "New conversation", + "chatHistory": "Chat history", + "newChat": "New Chat" + }, "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.", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 03b4691..4a91e5f 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -38,6 +38,11 @@ "newSession": "Nueva sesión (en la pestaña actual)", "closeCurrentTab": "Cerrar pestaña actual" }, + "nav": { + "newConversation": "Nueva conversación", + "chatHistory": "Historial del chat", + "newChat": "Nuevo chat" + }, "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.", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 9829574..7b48beb 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -38,6 +38,11 @@ "newSession": "Nouvelle session (dans l'onglet actuel)", "closeCurrentTab": "Fermer l'onglet actuel" }, + "nav": { + "newConversation": "Nouvelle conversation", + "chatHistory": "Historique du chat", + "newChat": "Nouveau chat" + }, "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.", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 3c8fead..0b21cc7 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -38,6 +38,11 @@ "newSession": "新しいセッション(現在のタブ)", "closeCurrentTab": "現在のタブを閉じる" }, + "nav": { + "newConversation": "新しい会話", + "chatHistory": "チャット履歴", + "newChat": "新しいチャット" + }, "chat": { "rewind": { "confirmMessage": "この時点に巻き戻しますか?このメッセージ以降のファイル変更が元に戻されます。手動またはbashで編集されたファイルには影響しません。", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index f5786a4..046862d 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -38,6 +38,11 @@ "newSession": "새 세션 (현재 탭)", "closeCurrentTab": "현재 탭 닫기" }, + "nav": { + "newConversation": "새 대화", + "chatHistory": "대화 기록", + "newChat": "새 채팅" + }, "chat": { "rewind": { "confirmMessage": "이 시점으로 되감으시겠습니까? 이 메시지 이후의 파일 변경 사항이 되돌려집니다. 수동으로 또는 bash를 통해 편집된 파일에는 영향을 미치지 않습니다.", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 61b1885..a5e1589 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -38,6 +38,11 @@ "newSession": "Nova sessão (na aba atual)", "closeCurrentTab": "Fechar aba atual" }, + "nav": { + "newConversation": "Nova conversa", + "chatHistory": "Histórico de conversas", + "newChat": "Novo chat" + }, "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.", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index d991814..2ccff00 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -38,6 +38,11 @@ "newSession": "Новая сессия (в текущей вкладке)", "closeCurrentTab": "Закрыть текущую вкладку" }, + "nav": { + "newConversation": "Новый разговор", + "chatHistory": "История чата", + "newChat": "Новый чат" + }, "chat": { "rewind": { "confirmMessage": "Откатить до этой точки? Изменения файлов после этого сообщения будут отменены. Откат не затрагивает файлы, отредактированные вручную или через bash.", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index a482cd2..57b45a8 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -38,6 +38,11 @@ "newSession": "新建会话(当前标签页)", "closeCurrentTab": "关闭当前标签页" }, + "nav": { + "newConversation": "新建会话", + "chatHistory": "聊天历史", + "newChat": "新会话" + }, "chat": { "rewind": { "confirmMessage": "回退到此处?此消息之后的文件更改将被还原。回退不会影响手动或通过 bash 编辑的文件。", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 5d7cc95..73517db 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -38,6 +38,11 @@ "newSession": "新增工作階段(目前分頁)", "closeCurrentTab": "關閉目前分頁" }, + "nav": { + "newConversation": "新建對話", + "chatHistory": "聊天歷史", + "newChat": "新對話" + }, "chat": { "rewind": { "confirmMessage": "回退到此處?此訊息之後的檔案變更將被還原。回退不會影響手動或透過 bash 編輯的檔案。", diff --git a/src/i18n/types.ts b/src/i18n/types.ts index 3c4aed1..1487f76 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -11,6 +11,11 @@ export type TranslationKey = | 'commands.newSession' | 'commands.closeCurrentTab' + // Nav row buttons and tab badges + | 'nav.newConversation' + | 'nav.chatHistory' + | 'nav.newChat' + // Common UI elements | 'common.save' | 'common.cancel' From fe6eb591f68735dc3393806447798dcaa7f4cb9c Mon Sep 17 00:00:00 2001 From: liuxuezhuo <44692579+luoxuanzao@users.noreply.github.com> Date: Tue, 18 Aug 2026 08:07:00 +0800 Subject: [PATCH 4/5] fix: persist declarative setting changes and live-localize views on switch On Obsidian 1.13+ the declarative settings base class persists control changes into the plugin data file, but Qoderian loads its settings from .qoderian/qoderian-settings.json, so simple controls (language, auto-scroll, and the other toggles) silently reverted on restart. Per the API contract, override setControlValue to write to our own data source: mutate the settings bag and save once through saveSettings(), skipping the base class' data.json write. After a language change, open chat views also re-apply localized static text (nav-row tooltips, tab titles, and the credits button label/panel) via a new refreshLocalizedChrome() instead of keeping the old language until the view reopens. Co-authored-by: QoderAI (Qwen 3.8 Max) --- CHANGELOG.md | 10 +++++ src/features/chat/chat-view.ts | 15 +++++++ src/features/chat/ui/credits-usage-button.ts | 6 +++ src/features/settings/settings-tab.ts | 32 +++++++++++--- tests/__mocks__/obsidian.ts | 11 +++++ .../chat/ui/credits-usage-button.test.ts | 19 ++++++++- .../features/settings/settings-tab.test.ts | 42 +++++++++++++++++++ 7 files changed, 127 insertions(+), 8 deletions(-) create mode 100644 tests/unit/features/settings/settings-tab.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index bea3fc3..7fc6594 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,6 +81,16 @@ version with its date and start a fresh empty `[Unreleased]` above it. Obsidian's native tooltip (aria-label), matching the other nav-row buttons, instead of a browser title tooltip with the live percentage. +### Fixed + +- Settings changed on Obsidian 1.13+ (language, auto-scroll, and the + other simple toggles) now persist across restarts: the declarative + control writes were only mirrored into the plugin data file, not the + `.qoderian/qoderian-settings.json` store Qoderian loads at startup. + Changing the language also re-localizes open chat views immediately + (nav tooltips and tab titles) instead of keeping the old language + until the view is reopened. + ## [1.0.4] - 2026-08-12 ### Fixed diff --git a/src/features/chat/chat-view.ts b/src/features/chat/chat-view.ts index 9760a6c..c1a4309 100644 --- a/src/features/chat/chat-view.ts +++ b/src/features/chat/chat-view.ts @@ -45,6 +45,8 @@ export class QoderianView extends ItemView { private viewContainerEl: HTMLElement | null = null; private logoEl: HTMLElement | null = null; private newTabButtonEl: HTMLElement | null = null; + private newConversationButtonEl: HTMLElement | null = null; + private historyButtonEl: HTMLElement | null = null; // Header elements private historyDropdown: HTMLElement | null = null; @@ -253,6 +255,7 @@ export class QoderianView extends ItemView { const newBtn = navActionsEl.createDiv({ cls: 'qoderian-input-nav-btn' }); setIcon(newBtn, 'square-pen'); setButtonTooltip(newBtn, t('nav.newConversation')); + this.newConversationButtonEl = newBtn; newBtn.addEventListener('click', () => { void (async () => { await this.tabManager?.createNewConversation(); @@ -265,6 +268,7 @@ export class QoderianView extends ItemView { 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' }); @@ -347,6 +351,17 @@ export class QoderianView extends ItemView { this.updateTabBarVisibility(); } + /** 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('nav.newConversation')); + } + if (this.historyButtonEl) setButtonTooltip(this.historyButtonEl, t('nav.chatHistory')); + this.creditsUsageButton?.refreshLocale(); + this.updateTabBar(); + } + // ============================================ // Tab Management // ============================================ diff --git a/src/features/chat/ui/credits-usage-button.ts b/src/features/chat/ui/credits-usage-button.ts index 9ad07f4..eab39a9 100644 --- a/src/features/chat/ui/credits-usage-button.ts +++ b/src/features/chat/ui/credits-usage-button.ts @@ -97,6 +97,12 @@ export class CreditsUsageButton { this.container.remove(); } + /** Re-applies locale-dependent text after a language change. */ + refreshLocale(): void { + this.updateButton(); + this.renderPanel(); + } + /** Fetches a fresh snapshot; cached snapshots within the TTL are kept. */ async refresh(force: boolean): Promise { if (this.loading) return; diff --git a/src/features/settings/settings-tab.ts b/src/features/settings/settings-tab.ts index 096c6f5..6ba12d5 100644 --- a/src/features/settings/settings-tab.ts +++ b/src/features/settings/settings-tab.ts @@ -417,15 +417,30 @@ export class QoderianSettingTab extends PluginSettingTab { return this.plugin.saveSettings(); } - if (key === 'mediaFolder') { - return super.setControlValue(key, String(value).trim()); - } + return this.persistControlValue(key, value); + } + // Unreachable: Obsidian < 1.13 never calls setControlValue. + } - const result = super.setControlValue(key, value); + /** + * The declarative base class persists into the plugin data file, but + * Qoderian keeps its settings in .qoderian/qoderian-settings.json, so per + * the API contract ("override to write to a different data source") we + * mutate the settings bag ourselves and save once through the store that + * loadSettings() actually reads — no duplicate write to data.json. + */ + private async persistControlValue(key: string, value: unknown): Promise { + // Only reached from setControlValue on Obsidian 1.13+; re-checked here so + // the 1.13-only view-refresh calls stay behind an explicit version guard. + if (requireApiVersion('1.13.0')) { + const settings = this.plugin.settings as unknown as Record; + settings[key] = key === 'mediaFolder' ? String(value).trim() : value; + await this.plugin.saveSettings(); if (key === 'locale') { setLocale(this.plugin.settings.locale as Locale); this.update(); + this.refreshViewChrome(); } else if (key === 'maxTabs') { for (const view of this.plugin.getAllViews()) { view.refreshTabControls(); @@ -435,10 +450,14 @@ export class QoderianSettingTab extends PluginSettingTab { } else if (PROMPT_SETTING_KEYS.has(key)) { this.schedulePromptRestart(); } + } + } - return result; + /** Re-applies locale-dependent text in open chat views after a language change. */ + private refreshViewChrome(): void { + for (const view of this.plugin.getAllViews()) { + view.refreshLocalizedChrome(); } - // Unreachable: Obsidian < 1.13 never calls setControlValue. } /** @@ -518,6 +537,7 @@ export class QoderianSettingTab extends PluginSettingTab { this.plugin.settings.locale = locale; await this.plugin.saveSettings(); this.display(); + this.refreshViewChrome(); }); }); diff --git a/tests/__mocks__/obsidian.ts b/tests/__mocks__/obsidian.ts index f0c8fbd..0cf7c23 100644 --- a/tests/__mocks__/obsidian.ts +++ b/tests/__mocks__/obsidian.ts @@ -32,6 +32,17 @@ export class PluginSettingTab { } display() {} + + // Mirrors the 1.13 declarative base: mutates plugin.settings in place. + getControlValue(key: string): unknown { + return (this.plugin.settings as Record)[key]; + } + + setControlValue(key: string, value: unknown): void { + (this.plugin.settings as Record)[key] = value; + } + + update() {} } export class ItemView { diff --git a/tests/unit/features/chat/ui/credits-usage-button.test.ts b/tests/unit/features/chat/ui/credits-usage-button.test.ts index 585c57a..019b2f1 100644 --- a/tests/unit/features/chat/ui/credits-usage-button.test.ts +++ b/tests/unit/features/chat/ui/credits-usage-button.test.ts @@ -2,6 +2,7 @@ import { createMockEl } from '@test/helpers/mock-element'; import type { CreditsUsageSnapshot } from '@/core/types/services'; import { CreditsUsageButton } from '@/features/chat/ui/credits-usage-button'; +import { setLocale } from '@/i18n/i18n'; import { setActiveQoderCliEdition } from '@/qoder/config/cli-edition'; const SNAPSHOT: CreditsUsageSnapshot = { @@ -27,17 +28,18 @@ function flushPromises(): Promise { interface ButtonHarness { parentEl: ReturnType; fetchUsage: jest.Mock; + button: CreditsUsageButton; } function createButton(cached: CreditsUsageSnapshot | null = SNAPSHOT): ButtonHarness { const parentEl = createMockEl(); const fetchUsage = jest.fn().mockResolvedValue(SNAPSHOT); - new CreditsUsageButton(parentEl, { + const button = new CreditsUsageButton(parentEl, { getCachedUsage: () => cached, fetchUsage, subscribeRuntimeStatus: jest.fn().mockReturnValue(() => {}), }); - return { parentEl, fetchUsage }; + return { parentEl, fetchUsage, button }; } describe('CreditsUsageButton', () => { @@ -49,6 +51,19 @@ describe('CreditsUsageButton', () => { expect(parentEl.querySelector('.qoderian-credits-btn')?.getAttribute('title')).toBeNull(); }); + it('re-applies localized text when the locale changes', () => { + const { parentEl, button } = createButton(); + + setLocale('zh-CN'); + try { + button.refreshLocale(); + expect(parentEl.querySelector('.qoderian-credits-btn')?.getAttribute('aria-label')) + .toBe('用量'); + } finally { + setLocale('en'); + } + }); + it('renders plan and resource package sections like the IDE usage panel', () => { const { parentEl } = createButton(); diff --git a/tests/unit/features/settings/settings-tab.test.ts b/tests/unit/features/settings/settings-tab.test.ts new file mode 100644 index 0000000..faebdb6 --- /dev/null +++ b/tests/unit/features/settings/settings-tab.test.ts @@ -0,0 +1,42 @@ +import { QoderianSettingTab } from '@/features/settings/settings-tab'; + +function createTab() { + const view = { + refreshLocalizedChrome: jest.fn(), + refreshTabControls: jest.fn(), + }; + const plugin = { + settings: { + locale: 'zh-CN', + enableAutoScroll: true, + excludedTags: [] as string[], + mediaFolder: '', + }, + saveSettings: jest.fn().mockResolvedValue(undefined), + getAllViews: jest.fn(() => [view]), + }; + const tab = new QoderianSettingTab({} as any, plugin as any); + return { tab, plugin, view }; +} + +describe('QoderianSettingTab declarative controls', () => { + it('persists locale changes to the Qoderian settings store', async () => { + const { tab, plugin, view } = createTab(); + + await tab.setControlValue('locale', 'en'); + + expect(plugin.settings.locale).toBe('en'); + expect(plugin.saveSettings).toHaveBeenCalledTimes(1); + // Open views re-apply localized static text immediately. + expect(view.refreshLocalizedChrome).toHaveBeenCalledTimes(1); + }); + + it('persists plain declarative controls too', async () => { + const { tab, plugin } = createTab(); + + await tab.setControlValue('enableAutoScroll', false); + + expect(plugin.settings.enableAutoScroll).toBe(false); + expect(plugin.saveSettings).toHaveBeenCalledTimes(1); + }); +}); From b441916f0e1470d6022a4f61fbbdef4c7649a37d Mon Sep 17 00:00:00 2001 From: liuxuezhuo <44692579+luoxuanzao@users.noreply.github.com> Date: Tue, 18 Aug 2026 08:37:02 +0800 Subject: [PATCH 5/5] refactor: route the credits button tooltip through the shared helper The credits button still wrote its aria-label directly, keeping the default 1s native delay while the other nav-row buttons use the shared setButtonTooltip helper (300ms). Use the same helper so the button is built and hinted exactly like its neighbors; only its click behavior (opening the usage popover) differs. Co-authored-by: QoderAI (Qwen 3.8 Max) --- src/features/chat/ui/credits-usage-button.ts | 7 ++++--- tests/unit/features/chat/ui/credits-usage-button.test.ts | 8 +++++--- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/features/chat/ui/credits-usage-button.ts b/src/features/chat/ui/credits-usage-button.ts index eab39a9..7dc334e 100644 --- a/src/features/chat/ui/credits-usage-button.ts +++ b/src/features/chat/ui/credits-usage-button.ts @@ -7,6 +7,7 @@ import type { } from '../../../core/types/services'; import { getLocale, t } from '../../../i18n/i18n'; import { getQoderAccountUsageUrl } from '../../../qoder/config/cli-edition'; +import { setButtonTooltip } from '../../../shared/dom/tooltip'; import { ClickPopover } from './toolbar/click-popover'; /** Usage snapshots older than this are refreshed when the panel opens. */ @@ -131,9 +132,9 @@ export class CreditsUsageButton { }; private updateButton(): void { - // Static label via aria-label so the button gets the same native Obsidian - // tooltip as the other nav-row buttons (title would use the browser one). - this.buttonEl.setAttribute('aria-label', t('credits.trigger')); + // Same tooltip path as the other nav-row buttons (aria-label + 300ms + // delay); only the click behavior (popover) differs. + setButtonTooltip(this.buttonEl, t('credits.trigger')); } private renderPanel(): void { diff --git a/tests/unit/features/chat/ui/credits-usage-button.test.ts b/tests/unit/features/chat/ui/credits-usage-button.test.ts index 019b2f1..e983c3d 100644 --- a/tests/unit/features/chat/ui/credits-usage-button.test.ts +++ b/tests/unit/features/chat/ui/credits-usage-button.test.ts @@ -46,9 +46,11 @@ describe('CreditsUsageButton', () => { it('shows a static usage tooltip like the other nav-row buttons', () => { const { parentEl } = createButton(); - expect(parentEl.querySelector('.qoderian-credits-btn')?.getAttribute('aria-label')) - .toBe('Usage'); - expect(parentEl.querySelector('.qoderian-credits-btn')?.getAttribute('title')).toBeNull(); + const btn = parentEl.querySelector('.qoderian-credits-btn'); + expect(btn?.getAttribute('aria-label')).toBe('Usage'); + // Same shared helper as the other nav buttons: 300ms hover delay. + expect(btn?.getAttribute('data-tooltip-delay')).toBe('300'); + expect(btn?.getAttribute('title')).toBeNull(); }); it('re-applies localized text when the locale changes', () => {