From 585d635d0e8895247875913ee3d9da98b0ead28d Mon Sep 17 00:00:00 2001 From: lyy <3483820747@qq.com> Date: Sat, 3 Oct 2026 18:48:21 +0800 Subject: [PATCH 1/5] feat(desktop): show design usage totals by day and week --- .changeset/usage-budget.md | 6 ++ apps/desktop/src/main/ipc/generate.ts | 28 ++++++- apps/desktop/src/main/run-journal.test.ts | 22 +++++ apps/desktop/src/main/run-journal.ts | 48 +++++++++++ apps/desktop/src/main/usage-budget.test.ts | 82 +++++++++++++++++++ apps/desktop/src/main/usage-budget.ts | 71 ++++++++++++++++ apps/desktop/src/preload/index.ts | 18 ++++ .../src/renderer/src/components/Sidebar.tsx | 73 ++++++++++++++++- apps/desktop/tsconfig.web.json | 2 +- packages/i18n/src/locales/en.json | 4 + packages/i18n/src/locales/es.json | 4 + packages/i18n/src/locales/pt-BR.json | 4 + packages/i18n/src/locales/zh-CN.json | 4 + 13 files changed, 363 insertions(+), 3 deletions(-) create mode 100644 .changeset/usage-budget.md create mode 100644 apps/desktop/src/main/usage-budget.test.ts create mode 100644 apps/desktop/src/main/usage-budget.ts diff --git a/.changeset/usage-budget.md b/.changeset/usage-budget.md new file mode 100644 index 000000000..249d19cba --- /dev/null +++ b/.changeset/usage-budget.md @@ -0,0 +1,6 @@ +--- +"@open-codesign/desktop": minor +"@open-codesign/i18n": patch +--- + +Show recorded token and USD totals for the current design, including today and the local week, beside the existing per-run token count. diff --git a/apps/desktop/src/main/ipc/generate.ts b/apps/desktop/src/main/ipc/generate.ts index cb976e630..1e7585851 100644 --- a/apps/desktop/src/main/ipc/generate.ts +++ b/apps/desktop/src/main/ipc/generate.ts @@ -58,7 +58,6 @@ import { workspaceNameFromPath, } from '../memory-ipc'; import { getApiKeyForProvider, getCachedConfig, hasApiKeyForProvider } from '../onboarding-ipc'; - import { readPersisted as readPreferences } from '../preferences-ipc'; import { runPreview } from '../preview-runtime'; import { preparePromptContext } from '../prompt-context'; @@ -89,6 +88,7 @@ import { } from '../snapshots-db'; import { registerSourceEditBusyCheck } from '../source-edits-ipc'; import { withTlsBypass } from '../tls-override'; +import { summarizeUsageBudget, type UsageTotals } from '../usage-budget'; import { createResearchHost, createWebResearchAuthorization } from '../web-research'; import { createWebResearchRun } from '../web-research-run'; import { withStableWorkspacePath } from '../workspace-path-lock'; @@ -510,6 +510,32 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe return recovered; }); + const emptyUsage = (): UsageTotals => ({ inputTokens: 0, outputTokens: 0, costUsd: 0 }); + ipcMain.handle('codesign:v1:usage-budget', async (_event, raw: unknown) => { + const input = raw as { schemaVersion?: unknown; designId?: unknown } | null; + if ( + !input || + input.schemaVersion !== 1 || + typeof input.designId !== 'string' || + input.designId.length === 0 || + input.designId.length > 512 + ) { + throw new CodesignError('Invalid usage budget request', 'IPC_BAD_INPUT'); + } + const empty = { + schemaVersion: 1 as const, + design: emptyUsage(), + today: emptyUsage(), + week: emptyUsage(), + }; + if (!journal) return empty; + const records = await journal.usageRecords(); + return { + schemaVersion: 1 as const, + ...summarizeUsageBudget(records, input.designId, Date.now()), + }; + }); + const recordFinalError = (scope: string, runId: string, err: unknown): void => { if (db === null) return; const code = err instanceof CodesignError ? (err.code as string) : 'PROVIDER_UPSTREAM_ERROR'; diff --git a/apps/desktop/src/main/run-journal.test.ts b/apps/desktop/src/main/run-journal.test.ts index 81ecce211..02b360096 100644 --- a/apps/desktop/src/main/run-journal.test.ts +++ b/apps/desktop/src/main/run-journal.test.ts @@ -325,4 +325,26 @@ describe('RunJournal', () => { expect.objectContaining({ type: 'run_settled', seq: 1, outcome: 'interrupted' }), ]); }); + + it('sums settled provider usage after the journal is reopened', async () => { + const journal = new RunJournal(directory); + await start(journal, 'run-a', 'design-a', 10); + const response: GenerateResponse = { + message: 'done', + artifacts: [], + inputTokens: 12, + outputTokens: 8, + costUsd: 0.03, + }; + await journal.append( + event('run-a', 'design-a', { type: 'run_settled', outcome: 'completed', response }), + ); + await start(journal, 'run-b', 'design-b', 11); + await journal.append(event('run-b', 'design-b', { type: 'run_settled', outcome: 'failed' })); + + const reopened = new RunJournal(directory); + await expect(reopened.usageRecords()).resolves.toEqual([ + { designId: 'design-a', startedAt: 10, inputTokens: 12, outputTokens: 8, costUsd: 0.03 }, + ]); + }); }); diff --git a/apps/desktop/src/main/run-journal.ts b/apps/desktop/src/main/run-journal.ts index ad99a8950..ca689ae28 100644 --- a/apps/desktop/src/main/run-journal.ts +++ b/apps/desktop/src/main/run-journal.ts @@ -16,11 +16,18 @@ export interface RunJournalRecovery { type JournalEvent = AgentStreamEvent & { schemaVersion: 1; runId: string; seq: number }; +interface RunUsage { + inputTokens: number; + outputTokens: number; + costUsd: number; +} + interface RunState extends RunJournalStart { seq: number; bytes: number; settled: boolean; writeError?: Error; + usage?: RunUsage; } const EVENT_TYPES = new Set([ @@ -89,6 +96,20 @@ function validateEvent(value: unknown, run: RunState, seq: number): JournalEvent return record as unknown as JournalEvent; } +function finiteUsage(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0; +} + +function usageFromResponse(response: unknown): RunUsage | undefined { + if (response === null || typeof response !== 'object') return undefined; + const record = response as Record; + return { + inputTokens: finiteUsage(record['inputTokens']), + outputTokens: finiteUsage(record['outputTokens']), + costUsd: finiteUsage(record['costUsd']), + }; +} + function encode(value: unknown): Buffer { const data = Buffer.from(`${JSON.stringify(value)}\n`, 'utf8'); if (data.length > MAX_RECORD_BYTES) throw invalid('record exceeds the 64 MiB size limit'); @@ -173,6 +194,25 @@ export class RunJournal { }); } + /** Settled runs that recorded a provider response. Totals are derived from these rows. */ + usageRecords(): Promise> { + return this.serialize(async () => { + await this.initialize(); + const records: Array<{ designId: string; startedAt: number } & RunUsage> = []; + for (const run of this.sortedRuns()) { + if (!run.usage) continue; + records.push({ + designId: run.designId, + startedAt: run.startedAt, + inputTokens: run.usage.inputTokens, + outputTokens: run.usage.outputTokens, + costUsd: run.usage.costUsd, + }); + } + return records; + }); + } + /** Startup projection repair; history is streamed from disk and never retained by this instance. */ allEvents(): Promise { return this.serialize(async () => { @@ -278,6 +318,10 @@ export class RunJournal { run.seq = event.seq; run.bytes += bytes.length; run.settled = event.type === 'run_settled'; + if (event.type === 'run_settled') { + const usage = usageFromResponse(event.response); + if (usage) run.usage = usage; + } return committed; } @@ -319,6 +363,10 @@ export class RunJournal { const event = validateEvent(record, run, run.seq + 1); run.seq = event.seq; run.settled = event.type === 'run_settled'; + if (event.type === 'run_settled') { + const usage = usageFromResponse(event.response); + if (usage) run.usage = usage; + } if (events && event.seq > after) events.push(event); } run.bytes += newline + 1; diff --git a/apps/desktop/src/main/usage-budget.test.ts b/apps/desktop/src/main/usage-budget.test.ts new file mode 100644 index 000000000..68819be72 --- /dev/null +++ b/apps/desktop/src/main/usage-budget.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; +import { formatUsageCost, formatUsageTokens, summarizeUsageBudget } from './usage-budget'; + +const now = new Date(2024, 0, 7, 12).getTime(); + +describe('summarizeUsageBudget', () => { + it('keeps another design out of the current design totals', () => { + const budget = summarizeUsageBudget( + [ + { + designId: 'a', + startedAt: new Date(2024, 0, 7, 9).getTime(), + inputTokens: 10, + outputTokens: 5, + costUsd: 0.25, + }, + { + designId: 'b', + startedAt: new Date(2024, 0, 7, 9).getTime(), + inputTokens: 100, + outputTokens: 50, + costUsd: 9, + }, + { + designId: 'a', + startedAt: new Date(2024, 0, 2, 9).getTime(), + inputTokens: 4, + outputTokens: 1, + costUsd: 0.5, + }, + { + designId: 'a', + startedAt: new Date(2023, 11, 31, 23).getTime(), + inputTokens: 7, + outputTokens: 3, + costUsd: 0.125, + }, + { + designId: 'a', + startedAt: new Date(2024, 0, 8, 1).getTime(), + inputTokens: 1, + outputTokens: 1, + costUsd: 0.25, + }, + ], + 'a', + now, + ); + + expect(budget.design).toEqual({ inputTokens: 22, outputTokens: 10, costUsd: 1.125 }); + expect(budget.today).toEqual({ inputTokens: 10, outputTokens: 5, costUsd: 0.25 }); + expect(budget.week).toEqual({ inputTokens: 14, outputTokens: 6, costUsd: 0.75 }); + }); + + it('counts a record that starts at the local week boundary', () => { + const budget = summarizeUsageBudget( + [ + { + designId: 'a', + startedAt: new Date(2024, 0, 1).getTime(), + inputTokens: 2, + outputTokens: 3, + costUsd: 0.5, + }, + ], + 'a', + now, + ); + expect(budget.week).toEqual({ inputTokens: 2, outputTokens: 3, costUsd: 0.5 }); + expect(budget.today).toEqual({ inputTokens: 0, outputTokens: 0, costUsd: 0 }); + }); +}); + +describe('usage formatting', () => { + it('formats token counts and small USD amounts', () => { + expect(formatUsageTokens(1234)).toBe('1,234'); + expect(formatUsageCost(0)).toBe('$0'); + expect(formatUsageCost(1.2)).toBe('$1.2'); + expect(formatUsageCost(0.0125)).toBe('$0.0125'); + expect(formatUsageCost(0.000012)).toBe('$0.000012'); + }); +}); diff --git a/apps/desktop/src/main/usage-budget.ts b/apps/desktop/src/main/usage-budget.ts new file mode 100644 index 000000000..551250f8a --- /dev/null +++ b/apps/desktop/src/main/usage-budget.ts @@ -0,0 +1,71 @@ +export interface UsageRecord { + designId: string; + startedAt: number; + inputTokens: number; + outputTokens: number; + costUsd: number; +} + +export interface UsageTotals { + inputTokens: number; + outputTokens: number; + costUsd: number; +} + +export interface UsageBudget { + design: UsageTotals; + today: UsageTotals; + week: UsageTotals; +} + +function startOfLocalDay(now: number): number { + const date = new Date(now); + return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime(); +} + +/** Local weeks start on Monday. */ +function startOfLocalWeek(now: number): number { + const date = new Date(now); + const daysSinceMonday = (date.getDay() + 6) % 7; + return new Date(date.getFullYear(), date.getMonth(), date.getDate() - daysSinceMonday).getTime(); +} + +function add(total: UsageTotals, record: UsageRecord): void { + total.inputTokens += record.inputTokens; + total.outputTokens += record.outputTokens; + total.costUsd += record.costUsd; +} + +export function summarizeUsageBudget( + records: readonly UsageRecord[], + designId: string, + now: number, +): UsageBudget { + const design: UsageTotals = { inputTokens: 0, outputTokens: 0, costUsd: 0 }; + const today: UsageTotals = { inputTokens: 0, outputTokens: 0, costUsd: 0 }; + const week: UsageTotals = { inputTokens: 0, outputTokens: 0, costUsd: 0 }; + const dayStart = startOfLocalDay(now); + const weekStart = startOfLocalWeek(now); + for (const record of records) { + if (record.designId !== designId || !Number.isFinite(record.startedAt)) continue; + add(design, record); + if (record.startedAt > now) continue; + if (record.startedAt >= weekStart) add(week, record); + if (record.startedAt >= dayStart) add(today, record); + } + return { design, today, week }; +} + +export function formatUsageTokens(value: number): string { + return new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(value); +} + +export function formatUsageCost(value: number): string { + if (!Number.isFinite(value) || value <= 0) return '$0'; + const digits = value >= 1 ? 2 : value >= 0.01 ? 4 : 6; + const text = value + .toFixed(digits) + .replace(/(\.\d*?)0+$/, '$1') + .replace(/\.$/, ''); + return `$${text}`; +} diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 7feedfce9..6ecfbf78c 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -439,6 +439,19 @@ export interface RunRecoveryResult { events: AgentStreamEvent[]; } +export interface UsageBudgetTotals { + inputTokens: number; + outputTokens: number; + costUsd: number; +} + +export interface UsageBudgetResult { + schemaVersion: 1; + design: UsageBudgetTotals; + today: UsageBudgetTotals; + week: UsageBudgetTotals; +} + const api = { detectProvider: (key: string) => ipcRenderer.invoke('codesign:detect-provider', key) as Promise, @@ -471,6 +484,11 @@ const api = { schemaVersion: 1, cursors, }) as Promise, + usageBudget: (designId: string) => + ipcRenderer.invoke('codesign:v1:usage-budget', { + schemaVersion: 1, + designId, + }) as Promise, generationStatus: () => ipcRenderer.invoke('codesign:v1:generation-status') as Promise, sendActiveMessage: (payload: ActiveRunMessageInputV1) => diff --git a/apps/desktop/src/renderer/src/components/Sidebar.tsx b/apps/desktop/src/renderer/src/components/Sidebar.tsx index a079d54d3..5b2a03cb1 100644 --- a/apps/desktop/src/renderer/src/components/Sidebar.tsx +++ b/apps/desktop/src/renderer/src/components/Sidebar.tsx @@ -1,7 +1,9 @@ import { getCurrentLocale, useT, useTranslation } from '@open-codesign/i18n'; import type { LocalInputFile, OnboardingState } from '@open-codesign/shared'; import { FolderOpen, Link2, Paperclip, X } from 'lucide-react'; -import { useCallback, useEffect, useRef } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { formatUsageCost, formatUsageTokens } from '../../../main/usage-budget'; +import type { UsageBudgetResult } from '../../../preload/index'; import { useCodesignStore } from '../store'; import { AskModal } from './AskModal'; import { AddMenu } from './chat/AddMenu'; @@ -90,6 +92,10 @@ export function Sidebar({ prefillPrompt }: SidebarProps) { const pickDesignSystemDirectory = useCodesignStore((s) => s.pickDesignSystemDirectory); const clearDesignSystem = useCodesignStore((s) => s.clearDesignSystem); const lastUsage = useCodesignStore((s) => s.lastUsage); + const [usageBudget, setUsageBudget] = useState(null); + const pushToast = useCodesignStore((s) => s.pushToast); + const reportedUsageError = useRef(null); + const usageDesignId = useRef(null); const chatMessages = useCodesignStore((s) => s.chatMessages); const chatLoaded = useCodesignStore((s) => s.chatLoaded); @@ -141,6 +147,43 @@ export function Sidebar({ prefillPrompt }: SidebarProps) { config?.hasKey && config.modelPrimary ? config.modelPrimary : t('sidebar.chat.noModel'); const lastTokens = lastUsage ? lastUsage.inputTokens + lastUsage.outputTokens : null; + // lastUsage is the signal that a run finished recording provider usage. + // biome-ignore lint/correctness/useExhaustiveDependencies: refetch when that usage changes + useEffect(() => { + const designId = currentDesignId; + if (!designId || typeof window.codesign?.usageBudget !== 'function') { + setUsageBudget(null); + return; + } + if (usageDesignId.current !== designId) { + usageDesignId.current = designId; + setUsageBudget(null); + } + let active = true; + void window.codesign.usageBudget(designId).then( + (budget) => { + if (!active) return; + reportedUsageError.current = null; + setUsageBudget(budget); + }, + (error: unknown) => { + if (!active) return; + setUsageBudget(null); + const description = error instanceof Error ? error.message : String(error); + if (reportedUsageError.current === description) return; + reportedUsageError.current = description; + pushToast({ + variant: 'error', + title: t('sidebar.chat.usageLoadFailed'), + description, + }); + }, + ); + return () => { + active = false; + }; + }, [currentDesignId, lastUsage, pushToast, t]); + return ( ); diff --git a/apps/desktop/tsconfig.web.json b/apps/desktop/tsconfig.web.json index 48fc8ff03..1a74bde66 100644 --- a/apps/desktop/tsconfig.web.json +++ b/apps/desktop/tsconfig.web.json @@ -1,6 +1,6 @@ { "extends": "../../tsconfig.base.json", - "include": ["src/renderer/**/*"], + "include": ["src/renderer/**/*", "src/main/usage-budget.ts"], "compilerOptions": { "outDir": "out/renderer", "jsx": "react-jsx", diff --git a/packages/i18n/src/locales/en.json b/packages/i18n/src/locales/en.json index 2ee9630d9..9dd248268 100644 --- a/packages/i18n/src/locales/en.json +++ b/packages/i18n/src/locales/en.json @@ -374,6 +374,10 @@ "thinking": "Thinking", "streamingLabel": "Assistant is typing", "tokensLine": "~{{count}} tokens", + "usageLabel": "Usage for this design", + "usageLoadFailed": "Could not read usage totals", + "usageDesign": "Design {{tokens}} tokens · {{cost}}", + "usageWindow": "Today {{todayTokens}} · {{todayCost}} · Week {{weekTokens}} · {{weekCost}}", "artifactDelivered": "delivered", "artifactDefaultLabel": "design.html", "tool": { diff --git a/packages/i18n/src/locales/es.json b/packages/i18n/src/locales/es.json index b9140674b..2c9d51a00 100644 --- a/packages/i18n/src/locales/es.json +++ b/packages/i18n/src/locales/es.json @@ -294,6 +294,10 @@ "thinking": "Pensando", "streamingLabel": "El asistente está escribiendo", "tokensLine": "~{{count}} tokens", + "usageLabel": "Uso de este diseño", + "usageLoadFailed": "No se pudieron leer los totales de uso", + "usageDesign": "Diseño {{tokens}} tokens · {{cost}}", + "usageWindow": "Hoy {{todayTokens}} · {{todayCost}} · Semana {{weekTokens}} · {{weekCost}}", "artifactDelivered": "entregado", "artifactDefaultLabel": "diseño.html", "tool": { diff --git a/packages/i18n/src/locales/pt-BR.json b/packages/i18n/src/locales/pt-BR.json index 9beab78f4..1f9430595 100644 --- a/packages/i18n/src/locales/pt-BR.json +++ b/packages/i18n/src/locales/pt-BR.json @@ -290,6 +290,10 @@ "noModel": "Nenhum modelo selecionado", "thinking": "Pensando", "tokensLine": "~{{count}} tokens", + "usageLabel": "Uso deste design", + "usageLoadFailed": "Não foi possível ler os totais de uso", + "usageDesign": "Design {{tokens}} tokens · {{cost}}", + "usageWindow": "Hoje {{todayTokens}} · {{todayCost}} · Semana {{weekTokens}} · {{weekCost}}", "artifactDelivered": "entregue", "artifactDefaultLabel": "design.html", "tool": { diff --git a/packages/i18n/src/locales/zh-CN.json b/packages/i18n/src/locales/zh-CN.json index 05ab45826..fddc41495 100644 --- a/packages/i18n/src/locales/zh-CN.json +++ b/packages/i18n/src/locales/zh-CN.json @@ -374,6 +374,10 @@ "thinking": "正在思考", "streamingLabel": "助手正在回复", "tokensLine": "约 {{count}} tokens", + "usageLabel": "当前设计用量", + "usageLoadFailed": "无法读取用量统计", + "usageDesign": "本设计 {{tokens}} tokens · {{cost}}", + "usageWindow": "今日 {{todayTokens}} · {{todayCost}} · 本周 {{weekTokens}} · {{weekCost}}", "artifactDelivered": "已生成", "artifactDefaultLabel": "design.html", "tool": { From 01c8a95fe0cb07eeb2e01908281397728f336dc0 Mon Sep 17 00:00:00 2001 From: lyy <3483820747@qq.com> Date: Sat, 3 Oct 2026 18:57:25 +0800 Subject: [PATCH 2/5] test(desktop): select existing browser input on macOS --- .../src/components/SourceEditPanel.browser.test.ts | 8 ++++---- .../renderer/src/components/TweakPanel.browser.test.ts | 6 +++--- .../src/components/chat/PromptInput.browser.test.ts | 6 +++--- apps/desktop/vitest.config.ts | 4 ++-- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/renderer/src/components/SourceEditPanel.browser.test.ts b/apps/desktop/src/renderer/src/components/SourceEditPanel.browser.test.ts index 443859a44..b3126c18f 100644 --- a/apps/desktop/src/renderer/src/components/SourceEditPanel.browser.test.ts +++ b/apps/desktop/src/renderer/src/components/SourceEditPanel.browser.test.ts @@ -394,11 +394,11 @@ describe('full FilesTab source edit browser + real IPC handlers over HTTP', () = const element = input.asElement(); if (!element) throw new Error('Missing edit textarea'); await element.evaluate((node) => { - if (node instanceof HTMLTextAreaElement) node.focus(); + if (node instanceof HTMLTextAreaElement) { + node.focus(); + node.select(); + } }); - await page.keyboard.down('Control'); - await page.keyboard.press('A'); - await page.keyboard.up('Control'); await page.keyboard.type(value); await page.click(selector, { delay: clickDelay }); } diff --git a/apps/desktop/src/renderer/src/components/TweakPanel.browser.test.ts b/apps/desktop/src/renderer/src/components/TweakPanel.browser.test.ts index c8d0e719f..a04233a36 100644 --- a/apps/desktop/src/renderer/src/components/TweakPanel.browser.test.ts +++ b/apps/desktop/src/renderer/src/components/TweakPanel.browser.test.ts @@ -216,9 +216,9 @@ describe.skipIf(!chrome)('tweak keyboard persistence in system Chrome', () => { const input = await page.$(selector); if (!input) throw new Error('Missing input'); await input.focus(); - await page.keyboard.down('Control'); - await page.keyboard.press('A'); - await page.keyboard.up('Control'); + await input.evaluate((node) => { + if (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) node.select(); + }); await page.keyboard.type(value, { delay: 20 }); expect( await input.evaluate((node) => node.isConnected && document.activeElement === node), diff --git a/apps/desktop/src/renderer/src/components/chat/PromptInput.browser.test.ts b/apps/desktop/src/renderer/src/components/chat/PromptInput.browser.test.ts index 96b7b8d80..bdfa8ea83 100644 --- a/apps/desktop/src/renderer/src/components/chat/PromptInput.browser.test.ts +++ b/apps/desktop/src/renderer/src/components/chat/PromptInput.browser.test.ts @@ -188,9 +188,9 @@ describe.skipIf(!chrome)('active composer fake gate in system Chrome', () => { ), ).toBe(true); await page.focus('textarea'); - await page.keyboard.down('Control'); - await page.keyboard.press('A'); - await page.keyboard.up('Control'); + await page.$eval('textarea', (node) => { + if (node instanceof HTMLTextAreaElement) node.select(); + }); await page.keyboard.press('Backspace'); await page.waitForSelector('.codesign-active-message-actions', { hidden: true }); expect(await page.$('.codesign-active-message-help')).toBeNull(); diff --git a/apps/desktop/vitest.config.ts b/apps/desktop/vitest.config.ts index 93f3906d9..b3687485c 100644 --- a/apps/desktop/vitest.config.ts +++ b/apps/desktop/vitest.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { - // Concurrent Chromium and filesystem suites otherwise exhaust Windows hosts. - maxWorkers: process.platform === 'win32' ? 2 : undefined, + // Concurrent Chromium and filesystem suites exhaust Windows and macOS hosts. + maxWorkers: process.platform === 'linux' ? undefined : 2, }, }); From e2822c9a6d5c1b00567bf8321e2c6cce3082184f Mon Sep 17 00:00:00 2001 From: lyy <3483820747@qq.com> Date: Sat, 3 Oct 2026 19:28:11 +0800 Subject: [PATCH 3/5] Revert "test(desktop): select existing browser input on macOS" This reverts commit 01c8a95fe0cb07eeb2e01908281397728f336dc0. --- .../src/components/SourceEditPanel.browser.test.ts | 8 ++++---- .../renderer/src/components/TweakPanel.browser.test.ts | 6 +++--- .../src/components/chat/PromptInput.browser.test.ts | 6 +++--- apps/desktop/vitest.config.ts | 4 ++-- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/renderer/src/components/SourceEditPanel.browser.test.ts b/apps/desktop/src/renderer/src/components/SourceEditPanel.browser.test.ts index b3126c18f..443859a44 100644 --- a/apps/desktop/src/renderer/src/components/SourceEditPanel.browser.test.ts +++ b/apps/desktop/src/renderer/src/components/SourceEditPanel.browser.test.ts @@ -394,11 +394,11 @@ describe('full FilesTab source edit browser + real IPC handlers over HTTP', () = const element = input.asElement(); if (!element) throw new Error('Missing edit textarea'); await element.evaluate((node) => { - if (node instanceof HTMLTextAreaElement) { - node.focus(); - node.select(); - } + if (node instanceof HTMLTextAreaElement) node.focus(); }); + await page.keyboard.down('Control'); + await page.keyboard.press('A'); + await page.keyboard.up('Control'); await page.keyboard.type(value); await page.click(selector, { delay: clickDelay }); } diff --git a/apps/desktop/src/renderer/src/components/TweakPanel.browser.test.ts b/apps/desktop/src/renderer/src/components/TweakPanel.browser.test.ts index a04233a36..c8d0e719f 100644 --- a/apps/desktop/src/renderer/src/components/TweakPanel.browser.test.ts +++ b/apps/desktop/src/renderer/src/components/TweakPanel.browser.test.ts @@ -216,9 +216,9 @@ describe.skipIf(!chrome)('tweak keyboard persistence in system Chrome', () => { const input = await page.$(selector); if (!input) throw new Error('Missing input'); await input.focus(); - await input.evaluate((node) => { - if (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) node.select(); - }); + await page.keyboard.down('Control'); + await page.keyboard.press('A'); + await page.keyboard.up('Control'); await page.keyboard.type(value, { delay: 20 }); expect( await input.evaluate((node) => node.isConnected && document.activeElement === node), diff --git a/apps/desktop/src/renderer/src/components/chat/PromptInput.browser.test.ts b/apps/desktop/src/renderer/src/components/chat/PromptInput.browser.test.ts index bdfa8ea83..96b7b8d80 100644 --- a/apps/desktop/src/renderer/src/components/chat/PromptInput.browser.test.ts +++ b/apps/desktop/src/renderer/src/components/chat/PromptInput.browser.test.ts @@ -188,9 +188,9 @@ describe.skipIf(!chrome)('active composer fake gate in system Chrome', () => { ), ).toBe(true); await page.focus('textarea'); - await page.$eval('textarea', (node) => { - if (node instanceof HTMLTextAreaElement) node.select(); - }); + await page.keyboard.down('Control'); + await page.keyboard.press('A'); + await page.keyboard.up('Control'); await page.keyboard.press('Backspace'); await page.waitForSelector('.codesign-active-message-actions', { hidden: true }); expect(await page.$('.codesign-active-message-help')).toBeNull(); diff --git a/apps/desktop/vitest.config.ts b/apps/desktop/vitest.config.ts index b3687485c..93f3906d9 100644 --- a/apps/desktop/vitest.config.ts +++ b/apps/desktop/vitest.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { - // Concurrent Chromium and filesystem suites exhaust Windows and macOS hosts. - maxWorkers: process.platform === 'linux' ? undefined : 2, + // Concurrent Chromium and filesystem suites otherwise exhaust Windows hosts. + maxWorkers: process.platform === 'win32' ? 2 : undefined, }, }); From 094eb9838cad1670d052cbd9a195730f3c246d6c Mon Sep 17 00:00:00 2001 From: lyy <3483820747@qq.com> Date: Sat, 3 Oct 2026 20:37:05 +0800 Subject: [PATCH 4/5] fix(desktop): refresh design usage after cancel and local midnight --- apps/desktop/src/main/usage-budget.test.ts | 19 +- apps/desktop/src/main/usage-budget.ts | 6 + .../src/renderer/src/components/Sidebar.tsx | 56 ++--- .../components/usage-budget-refresh.test.tsx | 201 ++++++++++++++++++ .../src/components/usage-budget-refresh.ts | 83 ++++++++ 5 files changed, 322 insertions(+), 43 deletions(-) create mode 100644 apps/desktop/src/renderer/src/components/usage-budget-refresh.test.tsx create mode 100644 apps/desktop/src/renderer/src/components/usage-budget-refresh.ts diff --git a/apps/desktop/src/main/usage-budget.test.ts b/apps/desktop/src/main/usage-budget.test.ts index 68819be72..eb6cbe496 100644 --- a/apps/desktop/src/main/usage-budget.test.ts +++ b/apps/desktop/src/main/usage-budget.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { formatUsageCost, formatUsageTokens, summarizeUsageBudget } from './usage-budget'; +import { + formatUsageCost, + formatUsageTokens, + nextLocalMidnight, + summarizeUsageBudget, +} from './usage-budget'; const now = new Date(2024, 0, 7, 12).getTime(); @@ -80,3 +85,15 @@ describe('usage formatting', () => { expect(formatUsageCost(0.000012)).toBe('$0.000012'); }); }); + +describe('nextLocalMidnight', () => { + it('lands on the next local midnight and on Monday when the week turns', () => { + expect(nextLocalMidnight(new Date(2024, 0, 6, 23).getTime())).toBe( + new Date(2024, 0, 7).getTime(), + ); + expect(nextLocalMidnight(new Date(2024, 0, 7, 12).getTime())).toBe( + new Date(2024, 0, 8).getTime(), + ); + expect(nextLocalMidnight(new Date(2024, 0, 8).getTime())).toBe(new Date(2024, 0, 9).getTime()); + }); +}); diff --git a/apps/desktop/src/main/usage-budget.ts b/apps/desktop/src/main/usage-budget.ts index 551250f8a..5c02bc97d 100644 --- a/apps/desktop/src/main/usage-budget.ts +++ b/apps/desktop/src/main/usage-budget.ts @@ -23,6 +23,12 @@ function startOfLocalDay(now: number): number { return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime(); } +/** The next local midnight after now. Monday 00:00 is the week boundary too. */ +export function nextLocalMidnight(now: number): number { + const date = new Date(now); + return new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1).getTime(); +} + /** Local weeks start on Monday. */ function startOfLocalWeek(now: number): number { const date = new Date(now); diff --git a/apps/desktop/src/renderer/src/components/Sidebar.tsx b/apps/desktop/src/renderer/src/components/Sidebar.tsx index 5b2a03cb1..a44afc41b 100644 --- a/apps/desktop/src/renderer/src/components/Sidebar.tsx +++ b/apps/desktop/src/renderer/src/components/Sidebar.tsx @@ -1,9 +1,8 @@ import { getCurrentLocale, useT, useTranslation } from '@open-codesign/i18n'; import type { LocalInputFile, OnboardingState } from '@open-codesign/shared'; import { FolderOpen, Link2, Paperclip, X } from 'lucide-react'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { formatUsageCost, formatUsageTokens } from '../../../main/usage-budget'; -import type { UsageBudgetResult } from '../../../preload/index'; import { useCodesignStore } from '../store'; import { AskModal } from './AskModal'; import { AddMenu } from './chat/AddMenu'; @@ -12,6 +11,7 @@ import { CommentChipBar } from './chat/CommentChipBar'; import { EmptyState } from './chat/EmptyState'; import { PromptInput, type PromptInputHandle } from './chat/PromptInput'; import { ModelSwitcher } from './ModelSwitcher'; +import { useDesignUsageBudget } from './usage-budget-refresh'; export interface SidebarProps { prefillPrompt: { id: number; text: string } | null; @@ -92,10 +92,19 @@ export function Sidebar({ prefillPrompt }: SidebarProps) { const pickDesignSystemDirectory = useCodesignStore((s) => s.pickDesignSystemDirectory); const clearDesignSystem = useCodesignStore((s) => s.clearDesignSystem); const lastUsage = useCodesignStore((s) => s.lastUsage); - const [usageBudget, setUsageBudget] = useState(null); const pushToast = useCodesignStore((s) => s.pushToast); - const reportedUsageError = useRef(null); - const usageDesignId = useRef(null); + const reportUsageError = useCallback( + (error: unknown) => { + const description = error instanceof Error ? error.message : String(error); + pushToast({ + variant: 'error', + title: t('sidebar.chat.usageLoadFailed'), + description, + }); + }, + [pushToast, t], + ); + const usageBudget = useDesignUsageBudget(reportUsageError); const chatMessages = useCodesignStore((s) => s.chatMessages); const chatLoaded = useCodesignStore((s) => s.chatLoaded); @@ -147,43 +156,6 @@ export function Sidebar({ prefillPrompt }: SidebarProps) { config?.hasKey && config.modelPrimary ? config.modelPrimary : t('sidebar.chat.noModel'); const lastTokens = lastUsage ? lastUsage.inputTokens + lastUsage.outputTokens : null; - // lastUsage is the signal that a run finished recording provider usage. - // biome-ignore lint/correctness/useExhaustiveDependencies: refetch when that usage changes - useEffect(() => { - const designId = currentDesignId; - if (!designId || typeof window.codesign?.usageBudget !== 'function') { - setUsageBudget(null); - return; - } - if (usageDesignId.current !== designId) { - usageDesignId.current = designId; - setUsageBudget(null); - } - let active = true; - void window.codesign.usageBudget(designId).then( - (budget) => { - if (!active) return; - reportedUsageError.current = null; - setUsageBudget(budget); - }, - (error: unknown) => { - if (!active) return; - setUsageBudget(null); - const description = error instanceof Error ? error.message : String(error); - if (reportedUsageError.current === description) return; - reportedUsageError.current = description; - pushToast({ - variant: 'error', - title: t('sidebar.chat.usageLoadFailed'), - description, - }); - }, - ); - return () => { - active = false; - }; - }, [currentDesignId, lastUsage, pushToast, t]); - return (