From 444746b284b31789199cb15a2011427a16940fd4 Mon Sep 17 00:00:00 2001 From: liuxuezhuo Date: Fri, 14 Aug 2026 21:14:50 +0800 Subject: [PATCH 1/3] feat: add credits usage panel mirroring the Qoder IDE usage view - Startup probe piggybacks getUsageInfo() so the runtime catalog caches account usage; a gauge button next to the session history opens a dropup panel with plan quota, personal/add-on pack and org resource package, each with a segmented progress bar - Personal accounts render like the IDE: plain tier label instead of the org badge, no renewal line for the year-9999 sentinel expiry - "View Details" links to the edition account usage page (qoder.com vs qoder.com.cn); panel width tracks the footer so narrow sidebars clip neither edge - 10 locales plus unit tests for the button, service, probe and catalog Co-authored-by: QoderAI (Qwen 3.8 Max) --- src/core/types/services.ts | 25 ++ src/features/chat/chat-view.ts | 14 ++ src/features/chat/ui/credits-usage-button.ts | 237 ++++++++++++++++++ src/i18n/locales/de.json | 13 + src/i18n/locales/en.json | 13 + src/i18n/locales/es.json | 13 + src/i18n/locales/fr.json | 13 + src/i18n/locales/ja.json | 13 + src/i18n/locales/ko.json | 13 + src/i18n/locales/pt.json | 13 + src/i18n/locales/ru.json | 13 + src/i18n/locales/zh-CN.json | 13 + src/i18n/locales/zh-TW.json | 13 + src/i18n/types.ts | 13 + src/qoder/commands/probe-runtime-commands.ts | 16 +- src/qoder/commands/qoder-runtime-catalog.ts | 10 +- src/qoder/config/cli-edition.ts | 12 + src/qoder/services/credits-usage.ts | 47 ++++ src/style/index.css | 1 + src/style/toolbar/credits-usage.css | 175 +++++++++++++ tests/__mocks__/qoder-agent-sdk.ts | 13 + .../chat/ui/credits-usage-button.test.ts | 153 +++++++++++ .../commands/probe-runtime-commands.test.ts | 22 ++ .../commands/qoder-runtime-catalog.test.ts | 34 +++ .../unit/qoder/services/credits-usage.test.ts | 80 ++++++ 25 files changed, 977 insertions(+), 5 deletions(-) create mode 100644 src/features/chat/ui/credits-usage-button.ts create mode 100644 src/qoder/services/credits-usage.ts create mode 100644 src/style/toolbar/credits-usage.css create mode 100644 tests/unit/features/chat/ui/credits-usage-button.test.ts create mode 100644 tests/unit/qoder/services/credits-usage.test.ts diff --git a/src/core/types/services.ts b/src/core/types/services.ts index 626db84..104939e 100644 --- a/src/core/types/services.ts +++ b/src/core/types/services.ts @@ -50,6 +50,29 @@ export interface AppPluginManager { disablePlugin(pluginId: string): Promise; } +/** One quota bucket of the account credits usage snapshot. */ +export interface CreditsUsageQuota { + total?: number; + used?: number; + remaining?: number; + percentage?: number; +} + +/** + * Account credits usage snapshot as reported by the Qoder SDK. + * Mirrors the SDK `UsageInfo` shape without coupling core types to the SDK. + */ +export interface CreditsUsageSnapshot { + userType?: string; + totalUsagePercentage?: number; + expiresAt?: number; + upgradeUrl?: string; + isQuotaExceeded?: boolean; + userQuota?: CreditsUsageQuota; + addOnQuota?: CreditsUsageQuota; + orgResourcePackage?: CreditsUsageQuota & { cap?: number; available?: boolean }; +} + /** Runtime catalog of agents discovered from the Qoder CLI. */ export interface AppAgentCatalog extends AgentMentionIndex { /** @@ -66,6 +89,8 @@ export interface AppAgentCatalog extends AgentMentionIndex { getRuntimeStatus(): QoderRuntimeStatus; /** Receives runtime availability changes, including background refreshes. */ subscribeRuntimeStatus(listener: (status: QoderRuntimeStatus) => void): () => void; + /** Latest account credits usage from the last successful probe, if any. */ + getUsageInfo(): CreditsUsageSnapshot | null; } export type QoderRuntimeStatusKind = diff --git a/src/features/chat/chat-view.ts b/src/features/chat/chat-view.ts index 5d0810a..5df345a 100644 --- a/src/features/chat/chat-view.ts +++ b/src/features/chat/chat-view.ts @@ -3,6 +3,7 @@ import { ItemView, Notice, Scope, setIcon } from 'obsidian'; import { VIEW_TYPE_QODERIAN } from '../../core/types'; import type QoderianPlugin from '../../main'; +import { fetchCreditsUsage } from '../../qoder/services/credits-usage'; import { cancelScheduledAnimationFrame, scheduleAnimationFrame, @@ -16,6 +17,7 @@ import { import { TabBar } from './tabs/tab-bar'; import { TabManager } from './tabs/tab-manager'; import type { TabData, TabId } from './tabs/types'; +import { CreditsUsageButton } from './ui/credits-usage-button'; type LoadableView = { containerEl?: HTMLElement; @@ -43,6 +45,7 @@ export class QoderianView extends ItemView { // Header elements private historyDropdown: HTMLElement | null = null; + private creditsUsageButton: CreditsUsageButton | null = null; // Event refs for cleanup private eventRefs: EventRef[] = []; @@ -198,6 +201,9 @@ export class QoderianView extends ItemView { this.tabBar?.destroy(); this.tabBar = null; + + this.creditsUsageButton?.destroy(); + this.creditsUsageButton = null; this.scope = null; } @@ -264,6 +270,14 @@ export class QoderianView extends ItemView { 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; } diff --git a/src/features/chat/ui/credits-usage-button.ts b/src/features/chat/ui/credits-usage-button.ts new file mode 100644 index 0000000..14e1398 --- /dev/null +++ b/src/features/chat/ui/credits-usage-button.ts @@ -0,0 +1,237 @@ +import { setIcon } from 'obsidian'; + +import type { + CreditsUsageQuota, + CreditsUsageSnapshot, + QoderRuntimeStatus, +} from '../../../core/types/services'; +import { getLocale, t } from '../../../i18n/i18n'; +import { getQoderAccountUsageUrl } from '../../../qoder/config/cli-edition'; +import { ClickPopover } from './toolbar/click-popover'; + +/** Usage snapshots older than this are refreshed when the panel opens. */ +const USAGE_CACHE_TTL_MS = 60_000; + +export interface CreditsUsageButtonCallbacks { + /** Latest snapshot known to the runtime catalog (from the startup probe). */ + getCachedUsage: () => CreditsUsageSnapshot | null; + /** Runs a dedicated idle query against the CLI; null when unavailable. */ + fetchUsage: () => Promise; + subscribeRuntimeStatus?: (listener: (status: QoderRuntimeStatus) => void) => () => void; +} + +function formatCount(value: number | undefined): string { + return String(value ?? 0); +} + +function formatRenewalDate(timestamp: number): string { + try { + return new Date(timestamp).toLocaleDateString(getLocale(), { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + } catch { + return new Date(timestamp).toDateString(); + } +} + +/** IDE-style tier label: org editions get the green pill, personal tiers plain text. */ +function describeUserTier(userType: string | undefined): { text: string; plain: boolean } | null { + if (userType === 'teams') return { text: 'Teams', plain: false }; + if (userType === 'personal_standard') return { text: t('credits.tierTrial'), plain: true }; + return null; +} + +/** The server uses a year-9999 timestamp for plans that never renew. */ +function isSentinelExpiry(timestamp: number): boolean { + return new Date(timestamp).getUTCFullYear() >= 9999; +} + +/** + * Nav-row 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. + */ +export class CreditsUsageButton { + private readonly container: HTMLElement; + private readonly buttonEl: HTMLElement; + private readonly panelEl: HTMLElement; + private readonly popover: ClickPopover; + private readonly unsubscribeRuntimeStatus: (() => void) | null; + private refreshBtnEl: HTMLElement | null = null; + private snapshot: CreditsUsageSnapshot | null; + private fetchedAt = 0; + private loading = false; + + 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'); + + this.panelEl = this.container.createDiv({ cls: 'qoderian-credits-panel' }); + this.popover = new ClickPopover( + this.container, + this.buttonEl, + this.panelEl, + 'qoderian-credits--open', + ); + this.buttonEl.addEventListener('click', this.handleButtonClick); + + this.snapshot = callbacks.getCachedUsage(); + this.unsubscribeRuntimeStatus = callbacks.subscribeRuntimeStatus?.((status) => { + this.snapshot = callbacks.getCachedUsage() ?? this.snapshot; + this.updateButton(); + this.renderPanel(); + if (status.kind === 'ready' && !this.snapshot) void this.refresh(false); + }) ?? null; + + this.updateButton(); + this.renderPanel(); + } + + destroy(): void { + this.unsubscribeRuntimeStatus?.(); + this.buttonEl.removeEventListener('click', this.handleButtonClick); + this.popover.destroy(); + this.container.remove(); + } + + /** Fetches a fresh snapshot; cached snapshots within the TTL are kept. */ + async refresh(force: boolean): Promise { + if (this.loading) return; + if (!force && this.snapshot && Date.now() - this.fetchedAt < USAGE_CACHE_TTL_MS) return; + + this.loading = true; + this.refreshBtnEl?.addClass('qoderian-credits-refresh--spinning'); + try { + const snapshot = await this.callbacks.fetchUsage(); + if (snapshot) { + this.snapshot = snapshot; + this.fetchedAt = Date.now(); + } + } finally { + this.loading = false; + this.updateButton(); + this.renderPanel(); + } + } + + private readonly handleButtonClick = (): void => { + // ClickPopover's own handler runs first and flips aria-expanded. + if (this.buttonEl.getAttribute('aria-expanded') === 'true') { + void this.refresh(false); + } + }; + + 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')); + } + } + + private renderPanel(): void { + this.panelEl.empty(); + + const header = this.panelEl.createDiv({ cls: 'qoderian-credits-header' }); + header.createSpan({ cls: 'qoderian-credits-title', text: t('credits.title') }); + const actions = header.createDiv({ cls: 'qoderian-credits-header-actions' }); + actions.createEl('a', { + cls: 'qoderian-credits-details-link', + text: t('credits.viewDetails'), + attr: { href: getQoderAccountUsageUrl() }, + }); + this.refreshBtnEl = actions.createDiv({ cls: 'qoderian-credits-refresh' }); + setIcon(this.refreshBtnEl, 'refresh-cw'); + this.refreshBtnEl.setAttribute('role', 'button'); + this.refreshBtnEl.setAttribute('tabindex', '0'); + this.refreshBtnEl.setAttribute('aria-label', t('common.refresh')); + if (this.loading) this.refreshBtnEl.addClass('qoderian-credits-refresh--spinning'); + this.refreshBtnEl.addEventListener('click', (event) => { + event.stopPropagation(); + void this.refresh(true); + }); + + if (!this.snapshot) { + this.panelEl.createDiv({ cls: 'qoderian-credits-empty', text: t('credits.unavailable') }); + return; + } + + if (this.snapshot.userQuota) { + const tier = describeUserTier(this.snapshot.userType); + this.renderQuotaSection(this.snapshot.userQuota, { + title: t('credits.planCredits'), + ...(tier ? { badge: tier.text, badgePlain: tier.plain } : {}), + ...(typeof this.snapshot.expiresAt === 'number' && !isSentinelExpiry(this.snapshot.expiresAt) + ? { trailing: t('credits.renewsOn', { date: formatRenewalDate(this.snapshot.expiresAt) }) } + : {}), + }); + } + if (this.snapshot.addOnQuota && hasQuotaNumbers(this.snapshot.addOnQuota)) { + this.renderQuotaSection(this.snapshot.addOnQuota, { title: t('credits.addOnCredits') }); + } + const orgPackage = this.snapshot.orgResourcePackage; + if (orgPackage?.available && hasQuotaNumbers(orgPackage)) { + this.renderQuotaSection({ + total: orgPackage.cap, + used: orgPackage.used, + remaining: orgPackage.remaining, + percentage: orgPackage.percentage, + }, { title: t('credits.resourcePackage') }); + } + } + + private renderQuotaSection( + quota: CreditsUsageQuota, + options: { title: string; badge?: string; badgePlain?: boolean; trailing?: string }, + ): void { + const section = this.panelEl.createDiv({ cls: 'qoderian-credits-section' }); + + const head = section.createDiv({ cls: 'qoderian-credits-section-head' }); + const titleWrap = head.createDiv({ cls: 'qoderian-credits-section-title-wrap' }); + titleWrap.createSpan({ cls: 'qoderian-credits-section-title', text: options.title }); + if (options.badge) { + const badge = titleWrap.createSpan({ cls: 'qoderian-credits-badge', text: options.badge }); + if (options.badgePlain) badge.addClass('qoderian-credits-badge--plain'); + } + if (options.trailing) { + head.createSpan({ cls: 'qoderian-credits-trailing', text: options.trailing }); + } + + const percent = clampPercent(quota); + const bar = section.createDiv({ cls: 'qoderian-credits-bar' }); + const fill = bar.createDiv({ cls: 'qoderian-credits-bar-fill' }); + fill.style.width = `${percent}%`; + + const numbers = section.createDiv({ cls: 'qoderian-credits-numbers' }); + const usageNums = numbers.createDiv({ cls: 'qoderian-credits-usage-nums' }); + usageNums.createSpan({ cls: 'qoderian-credits-used-num', text: formatCount(quota.used) }); + usageNums.createSpan({ + cls: 'qoderian-credits-total-num', + text: ` / ${formatCount(quota.total)} `, + }); + usageNums.createSpan({ + cls: 'qoderian-credits-used-percent', + text: `(${t('credits.usedPercent', { percent })})`, + }); + numbers.createSpan({ + cls: 'qoderian-credits-left', + text: t('credits.left', { count: formatCount(quota.remaining) }), + }); + } +} + +function hasQuotaNumbers(quota: CreditsUsageQuota): boolean { + return typeof quota.total === 'number' || typeof quota.used === 'number'; +} + +function clampPercent(quota: CreditsUsageQuota): number { + const derived = typeof quota.total === 'number' && quota.total > 0 + ? ((quota.used ?? 0) / quota.total) * 100 + : 0; + const percent = Math.round(quota.percentage ?? derived); + return Math.min(100, Math.max(0, percent)); +} diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index a2a0d2f..464b556 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -274,5 +274,18 @@ "name": "Sprache", "desc": "Anzeigesprache der Plugin-Oberfläche ändern" } + }, + "credits": { + "title": "Credit-Nutzung", + "viewDetails": "Details ansehen", + "planCredits": "Plan-Credits", + "addOnCredits": "Persönliches Ressourcenpaket", + "resourcePackage": "Add-on-Credits", + "tierTrial": "Testversion", + "renewsOn": "Verlängert am {date}", + "usedPercent": "{percent}% verwendet", + "left": "{count} übrig", + "trigger": "Nutzung - {percent}%", + "unavailable": "Nutzung nicht verfügbar" } } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index f6e8654..35fd6c9 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -274,5 +274,18 @@ "name": "Language", "desc": "Change the display language of the plugin interface" } + }, + "credits": { + "title": "Credits Usage", + "viewDetails": "View Details", + "planCredits": "Plan Credits", + "addOnCredits": "Personal Resource Pack", + "resourcePackage": "Add-on Credits", + "tierTrial": "Trial", + "renewsOn": "Renews on {date}", + "usedPercent": "{percent}% used", + "left": "{count} left", + "trigger": "Usage - {percent}%", + "unavailable": "Usage unavailable" } } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index d4bc9da..8f9fad6 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -274,5 +274,18 @@ "name": "Idioma", "desc": "Cambiar el idioma de visualización de la interfaz del plugin" } + }, + "credits": { + "title": "Uso de créditos", + "viewDetails": "Ver detalles", + "planCredits": "Créditos del plan", + "addOnCredits": "Paquete de recursos personal", + "resourcePackage": "Créditos adicionales", + "tierTrial": "Versión de prueba", + "renewsOn": "Se renueva el {date}", + "usedPercent": "{percent}% usado", + "left": "{count} restantes", + "trigger": "Uso - {percent}%", + "unavailable": "Uso no disponible" } } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index be4cc7e..1da0976 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -274,5 +274,18 @@ "name": "Langue", "desc": "Changer la langue d'affichage de l'interface du plugin" } + }, + "credits": { + "title": "Utilisation des crédits", + "viewDetails": "Voir les détails", + "planCredits": "Crédits du plan", + "addOnCredits": "Pack de ressources personnel", + "resourcePackage": "Crédits supplémentaires", + "tierTrial": "Version d'essai", + "renewsOn": "Renouvellement le {date}", + "usedPercent": "{percent}% utilisés", + "left": "{count} restants", + "trigger": "Utilisation - {percent}%", + "unavailable": "Utilisation indisponible" } } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 9236bdc..6611924 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -274,5 +274,18 @@ "name": "言語", "desc": "プラグインインターフェースの表示言語を変更" } + }, + "credits": { + "title": "利用量", + "viewDetails": "詳細を見る", + "planCredits": "プラン内クレジット", + "addOnCredits": "個人リソースパック", + "resourcePackage": "追加クレジット", + "tierTrial": "体験版", + "renewsOn": "{date} に更新", + "usedPercent": "{percent}% 使用済み", + "left": "残り {count}", + "trigger": "利用量 - {percent}%", + "unavailable": "利用量情報を取得できません" } } diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index ac73874..b4c1742 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -274,5 +274,18 @@ "name": "언어", "desc": "플러그인 인터페이스의 표시 언어 변경" } + }, + "credits": { + "title": "사용량", + "viewDetails": "자세히 보기", + "planCredits": "플랜 크레딧", + "addOnCredits": "개인 리소스 팩", + "resourcePackage": "추가 크레딧", + "tierTrial": "체험판", + "renewsOn": "{date}에 갱신", + "usedPercent": "{percent}% 사용", + "left": "{count} 남음", + "trigger": "사용량 - {percent}%", + "unavailable": "사용량을 확인할 수 없음" } } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 92848bc..9216a04 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -274,5 +274,18 @@ "name": "Idioma", "desc": "Alterar o idioma de exibição da interface do plugin" } + }, + "credits": { + "title": "Uso de créditos", + "viewDetails": "Ver detalhes", + "planCredits": "Créditos do plano", + "addOnCredits": "Pacote de recursos pessoal", + "resourcePackage": "Créditos adicionais", + "tierTrial": "Versão de teste", + "renewsOn": "Renova em {date}", + "usedPercent": "{percent}% usado", + "left": "{count} restantes", + "trigger": "Uso - {percent}%", + "unavailable": "Uso indisponível" } } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 1fbddc5..a8d54be 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -274,5 +274,18 @@ "name": "Язык", "desc": "Изменить язык интерфейса плагина" } + }, + "credits": { + "title": "Использование кредитов", + "viewDetails": "Подробнее", + "planCredits": "Кредиты плана", + "addOnCredits": "Персональный пакет ресурсов", + "resourcePackage": "Дополнительные кредиты", + "tierTrial": "Пробная версия", + "renewsOn": "Обновление {date}", + "usedPercent": "использовано {percent}%", + "left": "осталось {count}", + "trigger": "Использование - {percent}%", + "unavailable": "Использование недоступно" } } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index c7a4936..9406b85 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -274,5 +274,18 @@ "name": "语言", "desc": "更改插件界面的显示语言" } + }, + "credits": { + "title": "我的用量", + "viewDetails": "查看详情", + "planCredits": "套餐内 Credits", + "addOnCredits": "个人资源包", + "resourcePackage": "资源包", + "tierTrial": "体验版", + "renewsOn": "将于 {date} 刷新", + "usedPercent": "已使用 {percent}%", + "left": "剩余 {count}", + "trigger": "用量 - {percent}%", + "unavailable": "用量暂不可用" } } diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index cc0da4e..4d6f874 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -274,5 +274,18 @@ "name": "語言", "desc": "變更外掛程式介面的顯示語言" } + }, + "credits": { + "title": "我的用量", + "viewDetails": "查看詳情", + "planCredits": "套餐內 Credits", + "addOnCredits": "個人資源包", + "resourcePackage": "資源包", + "tierTrial": "體驗版", + "renewsOn": "將於 {date} 刷新", + "usedPercent": "已使用 {percent}%", + "left": "剩餘 {count}", + "trigger": "用量 - {percent}%", + "unavailable": "用量暫不可用" } } diff --git a/src/i18n/types.ts b/src/i18n/types.ts index 585d19d..0e24778 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -64,6 +64,19 @@ export type TranslationKey = | 'chat.view.moveBlockedStreaming' | 'chat.view.moveFailed' + // Credits usage panel + | 'credits.title' + | 'credits.viewDetails' + | 'credits.planCredits' + | 'credits.addOnCredits' + | 'credits.resourcePackage' + | 'credits.tierTrial' + | 'credits.renewsOn' + | 'credits.usedPercent' + | 'credits.left' + | 'credits.trigger' + | 'credits.unavailable' + // Chat - Conversation | 'chat.conversation.emptyPreview' | 'chat.conversation.deleteFailed' diff --git a/src/qoder/commands/probe-runtime-commands.ts b/src/qoder/commands/probe-runtime-commands.ts index e561af9..a52f1ed 100644 --- a/src/qoder/commands/probe-runtime-commands.ts +++ b/src/qoder/commands/probe-runtime-commands.ts @@ -1,4 +1,4 @@ -import type { AgentInfo, ModelInfo, Options, SDKUserMessage, SlashCommand as SDKSlashCommand } from '@qoder-ai/qoder-agent-sdk'; +import type { AgentInfo, ModelInfo, Options, SDKUserMessage, SlashCommand as SDKSlashCommand, UsageInfo } from '@qoder-ai/qoder-agent-sdk'; import { qodercliAuth, query as agentQuery } from '@qoder-ai/qoder-agent-sdk'; import { getEnhancedPath, getMissingNodeError } from '../../core/env/environment'; @@ -106,7 +106,7 @@ function mapSdkModels(models: ModelInfo[]): QoderDiscoveredModel[] { }); } -class ProbeInput implements AsyncIterable { +export class ProbeInput implements AsyncIterable { private resolveEnd: (() => void) | null = null; private readonly ended = new Promise((resolve) => { this.resolveEnd = resolve; @@ -128,7 +128,7 @@ class ProbeInput implements AsyncIterable { } } -function buildProbeOptions( +export function buildProbeOptions( plugin: QoderHostContext, vaultPath: string, cliPath: string, @@ -155,6 +155,7 @@ export interface QoderRuntimeProbeResult { commands: SlashCommand[]; agents: QoderDiscoveredAgent[]; models: QoderDiscoveredModel[]; + usageInfo?: UsageInfo; } export type QoderRuntimeProbeOutcome = @@ -291,7 +292,14 @@ export async function probeRuntimeCatalog( } } - return { commands, agents, models }; + let usageInfo: UsageInfo | undefined; + try { + usageInfo = await conversation.getUsageInfo() ?? undefined; + } catch { + // Usage is best-effort; a failure must not fail the catalog probe. + } + + return { commands, agents, models, ...(usageInfo ? { usageInfo } : {}) }; } catch (error) { return { error: classifyQoderProbeError(error, timedOut) }; } finally { diff --git a/src/qoder/commands/qoder-runtime-catalog.ts b/src/qoder/commands/qoder-runtime-catalog.ts index 2655d5b..7994aeb 100644 --- a/src/qoder/commands/qoder-runtime-catalog.ts +++ b/src/qoder/commands/qoder-runtime-catalog.ts @@ -1,5 +1,5 @@ import type { AgentDefinition, SlashCommand } from '../../core/types'; -import type { QoderRuntimeStatus } from '../../core/types/services'; +import type { CreditsUsageSnapshot, QoderRuntimeStatus } from '../../core/types/services'; import { getQoderSettings, type QoderDiscoveredAgent, @@ -42,6 +42,7 @@ function agentFromDiscovered(agent: QoderDiscoveredAgent): AgentDefinition { export class QoderRuntimeCatalog { private commands: SlashCommand[] = []; private agents: AgentDefinition[] = []; + private usageInfo: CreditsUsageSnapshot | null = null; private refreshPromise: Promise | null = null; private runtimeStatus: QoderRuntimeStatus = { kind: 'checking', @@ -132,6 +133,10 @@ export class QoderRuntimeCatalog { return { ...this.runtimeStatus }; } + getUsageInfo(): CreditsUsageSnapshot | null { + return this.usageInfo; + } + subscribeRuntimeStatus(listener: (status: QoderRuntimeStatus) => void): () => void { this.runtimeStatusListeners.add(listener); return () => this.runtimeStatusListeners.delete(listener); @@ -155,6 +160,9 @@ export class QoderRuntimeCatalog { if (result.agents.length > 0) { this.agents = result.agents.map(agentFromDiscovered); } + if (result.usageInfo) { + this.usageInfo = result.usageInfo; + } if (this.onPersist && (result.agents.length > 0 || result.models.length > 0)) { try { diff --git a/src/qoder/config/cli-edition.ts b/src/qoder/config/cli-edition.ts index a6970b4..cfb8af8 100644 --- a/src/qoder/config/cli-edition.ts +++ b/src/qoder/config/cli-edition.ts @@ -34,6 +34,18 @@ export function getQoderCliBinaryBaseName(edition: QoderCliEdition): string { return QODER_CLI_BINARY_NAMES[edition]; } +/** Web account usage page, per edition. */ +export const QODER_ACCOUNT_USAGE_URLS: Record = { + global: 'https://qoder.com/account/usage', + cn: 'https://qoder.com.cn/account/usage', +}; + +export function getQoderAccountUsageUrl( + edition: QoderCliEdition = getActiveQoderCliEdition(), +): string { + return QODER_ACCOUNT_USAGE_URLS[edition]; +} + /** Terminal login hint shown when the CLI reports missing credentials. */ export function getQoderCliLoginCommand(edition: QoderCliEdition): string { return `${QODER_CLI_BINARY_NAMES[edition]} login`; diff --git a/src/qoder/services/credits-usage.ts b/src/qoder/services/credits-usage.ts new file mode 100644 index 0000000..3dfa8b8 --- /dev/null +++ b/src/qoder/services/credits-usage.ts @@ -0,0 +1,47 @@ +import { query as agentQuery } from '@qoder-ai/qoder-agent-sdk'; + +import { getVaultPath } from '../../core/fs/path'; +import type { CreditsUsageSnapshot } from '../../core/types/services'; +import { + buildProbeOptions, + ProbeInput, +} from '../commands/probe-runtime-commands'; +import type { QoderHostContext } from '../qoder-host-context'; + +/** + * Fetches the account credits usage snapshot via a short-lived idle SDK query. + * + * Reuses the runtime probe's spawn options so edition switching, custom CLI + * paths and safe-mode settings behave exactly like a normal probe. Returns + * null when the CLI is missing or the lookup fails; callers keep their last + * snapshot instead of surfacing an error. + */ +export async function fetchCreditsUsage( + plugin: QoderHostContext, + options?: { timeoutMs?: number }, +): Promise { + const vaultPath = getVaultPath(plugin.app); + const cliPath = plugin.getResolvedQoderCliPath(); + if (!cliPath || !vaultPath) return null; + + const abortController = new AbortController(); + const input = new ProbeInput(); + const timeout = window.setTimeout(() => { + abortController.abort(); + }, options?.timeoutMs ?? 10_000); + const conversation = agentQuery({ + prompt: input, + options: buildProbeOptions(plugin, vaultPath, cliPath, abortController), + }); + try { + await conversation.initializationResult(); + const usage = await conversation.getUsageInfo(); + return usage ?? null; + } catch { + return null; + } finally { + window.clearTimeout(timeout); + input.end(); + await conversation.close().catch(() => {}); + } +} diff --git a/src/style/index.css b/src/style/index.css index 5261337..538bef4 100644 --- a/src/style/index.css +++ b/src/style/index.css @@ -26,6 +26,7 @@ @import "./toolbar/permission-toggle.css"; @import "./toolbar/external-context.css"; @import "./toolbar/mcp-selector.css"; +@import "./toolbar/credits-usage.css"; /* Features */ @import "./features/file-context.css"; diff --git a/src/style/toolbar/credits-usage.css b/src/style/toolbar/credits-usage.css new file mode 100644 index 0000000..d69f63f --- /dev/null +++ b/src/style/toolbar/credits-usage.css @@ -0,0 +1,175 @@ +/* Credits usage button + popover panel (nav row, mirrors Qoder IDE usage view) */ + +.qoderian-credits-container { + position: static; +} + +.qoderian-credits-btn { + display: flex; + 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). */ +.qoderian-credits-panel { + display: none; + position: absolute; + bottom: 100%; + margin-bottom: 4px; + inset-inline-end: 0; + width: min(320px, calc(100% - 16px)); + 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); + z-index: 1000; +} + +.qoderian-credits--open .qoderian-credits-panel { + display: block; +} + +.qoderian-credits-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; +} + +.qoderian-credits-title { + font-size: 12px; + font-weight: 600; + color: var(--text-muted); +} + +.qoderian-credits-header-actions { + display: flex; + align-items: center; + gap: 10px; +} + +.qoderian-credits-details-link { + font-size: 12px; + color: var(--text-accent); + cursor: pointer; +} + +.qoderian-credits-refresh { + display: flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + color: var(--text-muted); + cursor: pointer; + border-radius: 4px; +} + +.qoderian-credits-refresh:hover { + color: var(--text-normal); + background: var(--background-modifier-hover); +} + +.qoderian-credits-refresh svg { + width: 13px; + height: 13px; +} + +.qoderian-credits-refresh--spinning svg { + animation: qoderian-spin 1s linear infinite; +} + +.qoderian-credits-empty { + padding: 12px 0; + text-align: center; + color: var(--text-muted); + font-size: 12px; +} + +.qoderian-credits-section + .qoderian-credits-section { + margin-top: 14px; +} + +.qoderian-credits-section-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + flex-wrap: wrap; +} + +.qoderian-credits-section-title-wrap { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.qoderian-credits-section-title { + font-size: 12px; + font-weight: 600; + color: var(--text-normal); +} + +.qoderian-credits-badge { + padding: 1px 6px; + border-radius: 4px; + font-size: 11px; + background: rgba(34, 197, 94, 0.18); + color: #3fa96b; +} + +.qoderian-credits-badge--plain { + background: none; + color: var(--text-muted); +} + +.qoderian-credits-trailing { + font-size: 11px; + color: var(--text-muted); + white-space: nowrap; +} + +.qoderian-credits-bar { + height: 6px; + margin: 8px 0 6px; + border-radius: 3px; + background: var(--background-modifier-border); + overflow: hidden; +} + +.qoderian-credits-bar-fill { + height: 100%; + border-radius: 3px; + background: repeating-linear-gradient(90deg, var(--text-muted) 0 3px, transparent 3px 5px); +} + +.qoderian-credits-numbers { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + font-size: 12px; +} + +.qoderian-credits-usage-nums { + min-width: 0; +} + +.qoderian-credits-used-num { + color: var(--text-normal); + font-weight: 600; +} + +.qoderian-credits-total-num, +.qoderian-credits-used-percent { + color: var(--text-muted); +} + +.qoderian-credits-left { + color: var(--text-muted); + white-space: nowrap; +} diff --git a/tests/__mocks__/qoder-agent-sdk.ts b/tests/__mocks__/qoder-agent-sdk.ts index 3eb8ae5..abf6545 100644 --- a/tests/__mocks__/qoder-agent-sdk.ts +++ b/tests/__mocks__/qoder-agent-sdk.ts @@ -126,6 +126,7 @@ let lastOptions: Options | undefined; let mockSupportedCommands: Array<{ name: string; description: string; argumentHint?: string }> = []; let mockAvailableModels: any[] = []; let mockInitializationModels: any[] | undefined; +let mockUsageInfo: any = undefined; let lastResponse: (AsyncGenerator & { interrupt: jest.Mock; setModel: jest.Mock; @@ -136,6 +137,7 @@ let lastResponse: (AsyncGenerator & { supportedCommands: jest.Mock; getAvailableModels: jest.Mock; initializationResult: jest.Mock; + getUsageInfo: jest.Mock; close: jest.Mock; }) | null = null; @@ -157,6 +159,7 @@ export function resetMockMessages() { mockSupportedCommands = []; mockAvailableModels = []; mockInitializationModels = undefined; + mockUsageInfo = undefined; lastResponse = null; shouldThrowOnIteration = false; throwAfterChunks = 0; @@ -177,6 +180,11 @@ export function setMockInitializationModels(models: any[]) { mockInitializationModels = models; } +/** Sets the usage snapshot returned by getUsageInfo(); pass an Error to make it reject. */ +export function setMockUsageInfo(usageInfo: any) { + mockUsageInfo = usageInfo; +} + /** * Configure the mock to throw an error during iteration. * @param afterChunks - Number of chunks to emit before throwing (0 = throw immediately) @@ -331,6 +339,7 @@ export function query({ prompt, options }: { prompt: any; options: Options }): A supportedCommands: jest.Mock; getAvailableModels: jest.Mock; initializationResult: jest.Mock; + getUsageInfo: jest.Mock; close: jest.Mock; }; gen.interrupt = jest.fn().mockResolvedValue(undefined); @@ -369,6 +378,10 @@ export function query({ prompt, options }: { prompt: any; options: Options }): A gen.close = jest.fn().mockImplementation(async () => { await gen.return(undefined); }); + gen.getUsageInfo = jest.fn().mockImplementation(async () => { + if (mockUsageInfo instanceof Error) throw mockUsageInfo; + return mockUsageInfo; + }); lastResponse = gen; return gen; diff --git a/tests/unit/features/chat/ui/credits-usage-button.test.ts b/tests/unit/features/chat/ui/credits-usage-button.test.ts new file mode 100644 index 0000000..b5ec70c --- /dev/null +++ b/tests/unit/features/chat/ui/credits-usage-button.test.ts @@ -0,0 +1,153 @@ +import { createMockEl } from '@test/helpers/mock-element'; + +import type { CreditsUsageSnapshot } from '@/core/types/services'; +import { CreditsUsageButton } from '@/features/chat/ui/credits-usage-button'; +import { setActiveQoderCliEdition } from '@/qoder/config/cli-edition'; + +const SNAPSHOT: CreditsUsageSnapshot = { + userType: 'teams', + totalUsagePercentage: 100, + expiresAt: 1787414400000, + upgradeUrl: 'https://qoder.com/pricing?client=qoder', + isQuotaExceeded: false, + userQuota: { total: 6000, used: 6000, remaining: 0, percentage: 100 }, + orgResourcePackage: { + cap: 214000, + used: 190309, + remaining: 23691, + percentage: 89, + available: true, + }, +}; + +function flushPromises(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +interface ButtonHarness { + parentEl: ReturnType; + fetchUsage: jest.Mock; +} + +function createButton(cached: CreditsUsageSnapshot | null = SNAPSHOT): ButtonHarness { + const parentEl = createMockEl(); + const fetchUsage = jest.fn().mockResolvedValue(SNAPSHOT); + new CreditsUsageButton(parentEl, { + getCachedUsage: () => cached, + fetchUsage, + subscribeRuntimeStatus: jest.fn().mockReturnValue(() => {}), + }); + return { parentEl, fetchUsage }; +} + +describe('CreditsUsageButton', () => { + it('shows the overall percentage as the button tooltip', () => { + const { parentEl } = createButton(); + + expect(parentEl.querySelector('.qoderian-credits-btn')?.getAttribute('title')) + .toBe('Usage - 100%'); + }); + + it('renders plan and resource package sections like the IDE usage panel', () => { + const { parentEl } = createButton(); + + const titles = parentEl.querySelectorAll('.qoderian-credits-section-title') + .map((el: { textContent: string }) => el.textContent); + expect(titles).toEqual(['Plan Credits', 'Add-on Credits']); + + const teamsBadge = parentEl.querySelector('.qoderian-credits-badge') as ReturnType; + expect(teamsBadge.textContent).toBe('Teams'); + expect(teamsBadge.hasClass('qoderian-credits-badge--plain')).toBe(false); + expect(parentEl.querySelector('.qoderian-credits-trailing')?.textContent) + .toContain('Renews on'); + + const numbers = parentEl.querySelectorAll('.qoderian-credits-numbers'); + expect(numbers).toHaveLength(2); + const planNums = numbers[0] as ReturnType; + expect(planNums.querySelector('.qoderian-credits-used-num')?.textContent).toBe('6000'); + expect(planNums.querySelector('.qoderian-credits-total-num')?.textContent) + .toContain('/ 6000'); + expect(planNums.querySelector('.qoderian-credits-used-percent')?.textContent) + .toBe('(100% used)'); + expect(planNums.querySelector('.qoderian-credits-left')?.textContent).toBe('0 left'); + + // Fully used plan bar is clamped at 100% and keeps the unified color + const fills = parentEl.querySelectorAll('.qoderian-credits-bar-fill'); + expect((fills[0] as ReturnType).style.width).toBe('100%'); + expect((fills[1] as ReturnType).style.width).toBe('89%'); + }); + + it('renders personal accounts like the IDE usage panel', () => { + const personal: CreditsUsageSnapshot = { + userType: 'personal_standard', + totalUsagePercentage: 0, + expiresAt: 253402214400000, + userQuota: { total: 0, used: 0, remaining: 0, percentage: 0 }, + addOnQuota: { total: 1000, used: 0, remaining: 1000, percentage: 0 }, + }; + const { parentEl } = createButton(personal); + + // Personal tier shows a plain-text label instead of the green pill. + const badge = parentEl.querySelector('.qoderian-credits-badge') as ReturnType; + expect(badge.textContent).toBe('Trial'); + expect(badge.hasClass('qoderian-credits-badge--plain')).toBe(true); + + // The year-9999 sentinel expiry hides the renewal line. + expect(parentEl.querySelector('.qoderian-credits-trailing')).toBeNull(); + + const titles = parentEl.querySelectorAll('.qoderian-credits-section-title') + .map((el: { textContent: string }) => el.textContent); + expect(titles).toEqual(['Plan Credits', 'Personal Resource Pack']); + }); + + it('links to the edition account usage page', () => { + const { parentEl } = createButton(); + expect(parentEl.querySelector('.qoderian-credits-details-link')?.getAttribute('href')) + .toBe('https://qoder.com/account/usage'); + + setActiveQoderCliEdition('cn'); + try { + const cn = createButton(); + expect(cn.parentEl.querySelector('.qoderian-credits-details-link')?.getAttribute('href')) + .toBe('https://qoder.com.cn/account/usage'); + } finally { + setActiveQoderCliEdition('global'); + } + }); + + 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-empty')?.textContent) + .toBe('Usage unavailable'); + }); + + it('refreshes from the CLI when the refresh button is clicked', async () => { + const { parentEl, fetchUsage } = createButton(null); + + parentEl.querySelector('.qoderian-credits-refresh')?.click(); + await flushPromises(); + + expect(fetchUsage).toHaveBeenCalledTimes(1); + expect(parentEl.querySelector('.qoderian-credits-btn')?.getAttribute('title')) + .toBe('Usage - 100%'); + expect(parentEl.querySelector('.qoderian-credits-empty')).toBeNull(); + }); + + it('validates the cached snapshot on first open, then respects the TTL', async () => { + const { parentEl, fetchUsage } = createButton(); + + // First open has no self-fetch yet, so it validates against the CLI. + parentEl.querySelector('.qoderian-credits-btn')?.click(); + await flushPromises(); + expect(fetchUsage).toHaveBeenCalledTimes(1); + + // Close + reopen within the TTL keeps the fresh snapshot. + parentEl.querySelector('.qoderian-credits-btn')?.click(); + parentEl.querySelector('.qoderian-credits-btn')?.click(); + await flushPromises(); + expect(fetchUsage).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/qoder/commands/probe-runtime-commands.test.ts b/tests/unit/qoder/commands/probe-runtime-commands.test.ts index cf4cf2c..8962b10 100644 --- a/tests/unit/qoder/commands/probe-runtime-commands.test.ts +++ b/tests/unit/qoder/commands/probe-runtime-commands.test.ts @@ -12,6 +12,7 @@ const sdkMock = sdkModule as unknown as { setMockMessages: (messages: any[], options?: { appendResult?: boolean }) => void; setMockAvailableModels: (models: any[]) => void; setMockInitializationModels: (models: any[]) => void; + setMockUsageInfo: (usageInfo: unknown) => void; resetMockMessages: () => void; getLastOptions: () => sdkModule.Options | undefined; getLastResponse: () => { @@ -83,6 +84,27 @@ describe('probeRuntimeCatalog', () => { expect(sdkMock.getLastResponse()?.close).toHaveBeenCalled(); }); + it('includes the credits usage snapshot when the SDK reports it', async () => { + setInitMessage(); + sdkMock.setMockUsageInfo({ totalUsagePercentage: 7 }); + + const result = await probeRuntimeCatalog(createMockPlugin()); + + expectSuccessfulProbe(result); + expect(result.usageInfo).toEqual({ totalUsagePercentage: 7 }); + }); + + it('omits usage info when getUsageInfo fails', async () => { + setInitMessage(); + sdkMock.setMockUsageInfo(new Error('usage unavailable')); + + const result = await probeRuntimeCatalog(createMockPlugin()); + + expectSuccessfulProbe(result); + expect(result.usageInfo).toBeUndefined(); + expect(result.commands).toEqual([]); + }); + it('uses the same settingSources as the Qoder runtime when user settings are disabled', async () => { setInitMessage(); diff --git a/tests/unit/qoder/commands/qoder-runtime-catalog.test.ts b/tests/unit/qoder/commands/qoder-runtime-catalog.test.ts index e9de79f..5f95e47 100644 --- a/tests/unit/qoder/commands/qoder-runtime-catalog.test.ts +++ b/tests/unit/qoder/commands/qoder-runtime-catalog.test.ts @@ -65,6 +65,40 @@ describe('QoderRuntimeCatalog', () => { }); }); + it('exposes the usage snapshot from a successful probe', async () => { + probeMock.mockResolvedValue({ + commands: [], + agents: [], + models: [], + usageInfo: { totalUsagePercentage: 42, userQuota: { total: 100, used: 42 } }, + }); + const catalog = new QoderRuntimeCatalog(createPlugin()); + + expect(catalog.getUsageInfo()).toBeNull(); + await catalog.refresh(); + + expect(catalog.getUsageInfo()).toEqual({ + totalUsagePercentage: 42, + userQuota: { total: 100, used: 42 }, + }); + }); + + it('keeps the last usage snapshot when a later probe has none', async () => { + probeMock.mockResolvedValueOnce({ + commands: [], + agents: [], + models: [], + usageInfo: { totalUsagePercentage: 42 }, + }); + const catalog = new QoderRuntimeCatalog(createPlugin()); + await catalog.refresh(); + + probeMock.mockResolvedValueOnce({ commands: [], agents: [], models: [] }); + await catalog.refresh(); + + expect(catalog.getUsageInfo()).toEqual({ totalUsagePercentage: 42 }); + }); + it('keeps the previous snapshot when the probe fails', async () => { probeMock.mockResolvedValueOnce({ commands: [{ id: 'sdk:commit', name: 'commit', description: '', content: '', source: 'sdk' }], diff --git a/tests/unit/qoder/services/credits-usage.test.ts b/tests/unit/qoder/services/credits-usage.test.ts new file mode 100644 index 0000000..38049d7 --- /dev/null +++ b/tests/unit/qoder/services/credits-usage.test.ts @@ -0,0 +1,80 @@ +import * as sdkModule from '@qoder-ai/qoder-agent-sdk'; + +import type QoderianPlugin from '@/main'; +import { fetchCreditsUsage } from '@/qoder/services/credits-usage'; + +const sdkMock = sdkModule as unknown as { + setMockMessages: (messages: any[], options?: { appendResult?: boolean }) => void; + setMockUsageInfo: (usageInfo: unknown) => void; + resetMockMessages: () => void; + getLastResponse: () => { + initializationResult: jest.Mock; + getUsageInfo: jest.Mock; + close: jest.Mock; + } | null; +}; + +jest.mock('@/core/fs/path', () => ({ + getVaultPath: jest.fn().mockReturnValue('/test/vault'), +})); + +jest.mock('@/core/env/environment', () => ({ + getEnhancedPath: jest.fn().mockReturnValue('/usr/bin:/mock/bin'), + getMissingNodeError: jest.fn().mockReturnValue(null), + findNodeExecutable: jest.fn().mockReturnValue('/usr/bin/node'), +})); + +function createMockPlugin(cliPath: string | null = '/mock/qoder'): QoderianPlugin { + return { + app: {}, + settings: {}, + getResolvedQoderCliPath: jest.fn().mockReturnValue(cliPath), + } as unknown as QoderianPlugin; +} + +describe('fetchCreditsUsage', () => { + beforeEach(() => { + sdkMock.resetMockMessages(); + sdkMock.setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'usage-session' }, + ], { appendResult: false }); + }); + + it('returns the usage snapshot from an idle query and closes it', async () => { + sdkMock.setMockUsageInfo({ + userType: 'teams', + totalUsagePercentage: 42, + userQuota: { total: 6000, used: 2500, remaining: 3500, percentage: 42 }, + }); + + const usage = await fetchCreditsUsage(createMockPlugin()); + + expect(usage?.totalUsagePercentage).toBe(42); + expect(usage?.userQuota?.remaining).toBe(3500); + expect(sdkMock.getLastResponse()?.initializationResult).toHaveBeenCalled(); + expect(sdkMock.getLastResponse()?.getUsageInfo).toHaveBeenCalled(); + expect(sdkMock.getLastResponse()?.close).toHaveBeenCalled(); + }); + + it('returns null when no CLI path is resolved', async () => { + const usage = await fetchCreditsUsage(createMockPlugin(null)); + + expect(usage).toBeNull(); + expect(sdkMock.getLastResponse()).toBeNull(); + }); + + it('returns null when the SDK reports no usage info', async () => { + const usage = await fetchCreditsUsage(createMockPlugin()); + + expect(usage).toBeNull(); + }); + + it('returns null when getUsageInfo rejects', async () => { + sdkMock.setMockUsageInfo(new Error('usage unavailable')); + + const usage = await fetchCreditsUsage(createMockPlugin()); + + expect(usage).toBeNull(); + expect(sdkMock.getLastResponse()?.close).toHaveBeenCalled(); + }); +}); From 7f0d7cc100c67a44278c5204fa3efb9fba087aca Mon Sep 17 00:00:00 2001 From: liuxuezhuo Date: Fri, 14 Aug 2026 21:15:05 +0800 Subject: [PATCH 2/3] fix: anchor toolbar dropdowns to logical edges - Model dropdown opens above the model button (inset-inline-start on the toolbar) instead of flying to the far edge on wide views, a regression from the narrow-sidebar adaptivity change - Permission dropdown switches right to inset-inline-end so both dropdowns anchor correctly in right-to-left layouts while keeping the adaptive max-width Co-authored-by: QoderAI (Qwen 3.8 Max) --- src/style/toolbar/model-selector.css | 2 +- src/style/toolbar/permission-toggle.css | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/style/toolbar/model-selector.css b/src/style/toolbar/model-selector.css index 734a163..f064ba4 100644 --- a/src/style/toolbar/model-selector.css +++ b/src/style/toolbar/model-selector.css @@ -41,7 +41,7 @@ .qoderian-model-dropdown { position: absolute; bottom: 100%; - right: 8px; + inset-inline-start: 0; margin-bottom: 0; display: flex; flex-direction: column; diff --git a/src/style/toolbar/permission-toggle.css b/src/style/toolbar/permission-toggle.css index 2f8ac0b..e624d53 100644 --- a/src/style/toolbar/permission-toggle.css +++ b/src/style/toolbar/permission-toggle.css @@ -63,7 +63,7 @@ .qoderian-permission-dropdown { position: absolute; - right: 8px; + inset-inline-end: 8px; bottom: 100%; display: flex; flex-direction: column; From dc35f33ce09967ca919460b400d2481cde041799 Mon Sep 17 00:00:00 2001 From: liuxuezhuo Date: Fri, 14 Aug 2026 21:15:05 +0800 Subject: [PATCH 3/3] docs: note credits usage panel and dropdown anchoring in changelog Co-authored-by: QoderAI (Qwen 3.8 Max) --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8007b63..80542c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,11 @@ version with its date and start a fresh empty `[Unreleased]` above it. ### Added +- Credits usage panel in the chat view: a gauge button next to the + session history opens a usage popover that mirrors the Qoder IDE + view (plan credits with edition badge, personal/add-on resource + pack, organization resource package, renewal date) and links to the + edition's account usage page. - Qoder CLI edition switch in settings (Setup section): choose the international build (`qodercli`, config under `~/.qoder`) or the China build (`qoderclicn`, config under `~/.qoder-cn`). Auto- @@ -21,6 +26,10 @@ version with its date and start a fresh empty `[Unreleased]` above it. ### Fixed +- The model selector dropdown opens above the model button again on + wide views instead of anchoring to the far edge of the toolbar, and + toolbar dropdowns anchor with logical edges so right-to-left + layouts position correctly. - The composer now adapts to narrow sidebars: context chips that do not fit collapse behind a "+N more" pill (click to expand or collapse), the toolbar wraps instead of clipping, and the permission