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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions apps/desktop/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,23 @@ export interface AppPaths {

export type UpdateChannel = 'stable' | 'beta';

export interface GenerateArtifact {
id: string;
type: string;
title: string;
content: string;
designParams: unknown[];
createdAt: string;
}

export interface GenerateResponse {
message: string;
artifacts: GenerateArtifact[];
inputTokens: number;
outputTokens: number;
costUsd: number;
}

export interface Preferences {
updateChannel: UpdateChannel;
generationTimeoutSec: number;
Expand All @@ -76,7 +93,7 @@ const api = {
ipcRenderer.invoke('codesign:v1:generate', {
schemaVersion: 1,
...payload,
} satisfies GeneratePayloadV1),
} satisfies GeneratePayloadV1) as Promise<GenerateResponse>,
cancelGeneration: (generationId: string) =>
ipcRenderer.invoke('codesign:v1:cancel-generation', {
schemaVersion: 1,
Expand All @@ -89,7 +106,7 @@ const api = {
model?: ModelRef;
referenceUrl?: string;
attachments?: LocalInputFile[];
}) => ipcRenderer.invoke('codesign:apply-comment', payload),
}) => ipcRenderer.invoke('codesign:apply-comment', payload) as Promise<GenerateResponse>,
pickInputFiles: () =>
ipcRenderer.invoke('codesign:pick-input-files') as Promise<LocalInputFile[]>,
pickDesignSystemDirectory: () =>
Expand Down
11 changes: 8 additions & 3 deletions apps/desktop/src/renderer/src/components/PreviewPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
isIframeErrorMessage,
isOverlayMessage,
} from '@open-codesign/runtime';
import { useEffect, useRef } from 'react';
import { useEffect, useMemo, useRef } from 'react';
import { EmptyState } from '../preview/EmptyState';
import { ErrorState } from '../preview/ErrorState';
import { LoadingState } from '../preview/LoadingState';
Expand Down Expand Up @@ -137,6 +137,12 @@ export function PreviewPane({ onPickStarter }: PreviewPaneProps) {
const interactionMode = useCodesignStore((s) => s.interactionMode);
const iframeRef = useRef<HTMLIFrameElement>(null);

// Memoize the srcdoc string so we only re-run the (regex-heavy) builder when
// previewHtml actually changes. Without this, every unrelated store update
// (toasts, theme, errors) re-ran buildSrcdoc and React still diffed an
// identical srcDoc prop — but the regex cost is non-trivial on large designs.
const srcDoc = useMemo(() => (previewHtml ? buildSrcdoc(previewHtml) : null), [previewHtml]);

useEffect(() => {
postModeToPreviewWindow(iframeRef.current?.contentWindow, interactionMode, pushIframeError);
}, [interactionMode, pushIframeError]);
Expand Down Expand Up @@ -185,10 +191,9 @@ export function PreviewPane({ onPickStarter }: PreviewPaneProps) {
const rawIframe = (
<iframe
ref={iframeRef}
key={previewHtml.length}
title="design-preview"
sandbox="allow-scripts"
srcDoc={buildSrcdoc(previewHtml)}
srcDoc={srcDoc ?? ''}
onLoad={() => {
postModeToPreviewWindow(
iframeRef.current?.contentWindow,
Expand Down
22 changes: 22 additions & 0 deletions apps/desktop/src/renderer/src/components/TopBar.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { formatCostUsd } from './TopBar';

describe('formatCostUsd', () => {
it('returns 0.00 for zero or non-finite values', () => {
expect(formatCostUsd(0)).toBe('0.00');
expect(formatCostUsd(-1)).toBe('0.00');
expect(formatCostUsd(Number.NaN)).toBe('0.00');
expect(formatCostUsd(Number.POSITIVE_INFINITY)).toBe('0.00');
});

it('uses 4 decimals for sub-cent values so users see non-zero spend', () => {
expect(formatCostUsd(0.0042)).toBe('0.0042');
expect(formatCostUsd(0.0001)).toBe('0.0001');
});

it('uses 2 decimals once spend reaches a cent or more', () => {
expect(formatCostUsd(0.01)).toBe('0.01');
expect(formatCostUsd(1.234)).toBe('1.23');
expect(formatCostUsd(12.5)).toBe('12.50');
});
});
30 changes: 27 additions & 3 deletions apps/desktop/src/renderer/src/components/TopBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,13 @@ import { ThemeToggle } from './ThemeToggle';
const dragStyle = { WebkitAppRegion: 'drag' } as CSSProperties;
const noDragStyle = { WebkitAppRegion: 'no-drag' } as CSSProperties;

// Shell badge — mock data. Full cost accounting tracked separately.
// Display the BYOK provider chip and accumulated weekly cost. Weekly totals are
// persisted in localStorage under an ISO-week bucket; resets automatically.
function ByokBadge() {
const t = useT();
const config = useCodesignStore((s) => s.config);
const weekCostUsd = useCodesignStore((s) => s.weekUsage.costUsd);
const lastUsage = useCodesignStore((s) => s.lastUsage);

const provider = config?.provider ?? null;
const model = config?.modelPrimary ?? null;
Expand All @@ -39,6 +42,18 @@ function ByokBadge() {
: modelLabel;
const hasFullForm = shortModelLabel !== modelLabel;

const costLabel = formatCostUsd(weekCostUsd);
const tooltipParts: string[] = [t('topbar.spendTooltip')];
if (lastUsage) {
tooltipParts.push(
t('topbar.lastUsageTooltip', {
input: lastUsage.inputTokens.toLocaleString(),
output: lastUsage.outputTokens.toLocaleString(),
cost: formatCostUsd(lastUsage.costUsd),
}),
);
}

return (
<div
className="group flex items-center gap-[var(--space-2)] rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface)] px-[var(--space-2)] py-[var(--space-1)] select-none"
Expand All @@ -65,12 +80,12 @@ function ByokBadge() {
<span className="w-px h-[var(--size-icon-xs)] bg-[var(--color-border)]" aria-hidden="true" />

{/* Cost this week — tabular mono numerals */}
<Tooltip label={t('topbar.spendTooltip')}>
<Tooltip label={tooltipParts.join(' — ')}>
<span
className="text-[var(--text-xs)] text-[var(--color-text-secondary)] leading-none"
style={{ fontFamily: 'var(--font-mono)', fontFeatureSettings: "'tnum'" }}
>
$0.00
${costLabel}
<span
className="ml-[var(--space-1)] text-[var(--color-text-muted)]"
style={{ fontFamily: 'var(--font-sans)' }}
Expand All @@ -83,6 +98,15 @@ function ByokBadge() {
);
}

export function formatCostUsd(value: number): string {
if (!Number.isFinite(value) || value <= 0) return '0.00';
// Sub-cent costs are common for cheap providers — show 4 decimals so the
// user sees a non-zero number after the first request, then collapse to
// 2 decimals once the bucket grows past a cent.
if (value < 0.01) return value.toFixed(4);
return value.toFixed(2);
}

export function TopBar() {
const t = useT();
const setView = useCodesignStore((s) => s.setView);
Expand Down
189 changes: 188 additions & 1 deletion apps/desktop/src/renderer/src/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { initI18n } from '@open-codesign/i18n';
import type { LocalInputFile, OnboardingState, SelectedElement } from '@open-codesign/shared';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import type { GenerationStage } from './store';
import { useCodesignStore } from './store';
import { accumulateWeekUsage, coerceUsageSnapshot, isoWeekKey, useCodesignStore } from './store';

const READY_CONFIG: OnboardingState = {
hasKey: true,
Expand Down Expand Up @@ -250,6 +250,193 @@ describe('useCodesignStore view navigation', () => {
});
});

describe('useCodesignStore token usage tracking', () => {
beforeAll(async () => {
await initI18n('en');
});

it('records lastUsage and accumulates weekUsage when generate resolves with usage fields', async () => {
const generate = vi.fn(() =>
Promise.resolve({
artifacts: [{ content: '<html>ok</html>' }],
message: 'Done.',
inputTokens: 1200,
outputTokens: 800,
costUsd: 0.0125,
}),
);

vi.stubGlobal('window', {
codesign: { generate },
setTimeout,
localStorage: {
getItem: () => null,
setItem: () => {
/* noop */
},
},
});

useCodesignStore.setState({
weekUsage: { isoWeek: '2099-W01', inputTokens: 0, outputTokens: 0, costUsd: 0 },
lastUsage: null,
});

await useCodesignStore.getState().sendPrompt({ prompt: 'design landing' });

const state = useCodesignStore.getState();
expect(state.lastUsage).toEqual({ inputTokens: 1200, outputTokens: 800, costUsd: 0.0125 });
expect(state.weekUsage.inputTokens).toBe(1200);
expect(state.weekUsage.outputTokens).toBe(800);
expect(state.weekUsage.costUsd).toBeCloseTo(0.0125, 6);
expect(state.streamingTokenCount).toBe(2000);
});

it('treats missing usage fields as zero without crashing', async () => {
const generate = vi.fn(() =>
Promise.resolve({
artifacts: [{ content: '<html>ok</html>' }],
message: 'Done.',
}),
);

vi.stubGlobal('window', {
codesign: { generate },
setTimeout,
localStorage: { getItem: () => null, setItem: () => undefined },
});

await useCodesignStore.getState().sendPrompt({ prompt: 'fallback' });

const state = useCodesignStore.getState();
expect(state.lastUsage).toEqual({ inputTokens: 0, outputTokens: 0, costUsd: 0 });
});

it('surfaces a toast and preserves in-memory weekUsage when localStorage write throws', async () => {
const generate = vi.fn(() =>
Promise.resolve({
artifacts: [{ content: '<html>ok</html>' }],
message: 'Done.',
inputTokens: 100,
outputTokens: 50,
costUsd: 0.01,
}),
);

const setItem = vi.fn(() => {
throw new Error('QuotaExceeded');
});
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);

vi.stubGlobal('window', {
codesign: { generate },
setTimeout,
localStorage: { getItem: () => null, setItem },
});

useCodesignStore.setState({
weekUsage: {
isoWeek: isoWeekKey(new Date()),
inputTokens: 7,
outputTokens: 3,
costUsd: 0.02,
},
lastUsage: null,
});

await useCodesignStore.getState().sendPrompt({ prompt: 'persist failure' });

const state = useCodesignStore.getState();
expect(setItem).toHaveBeenCalled();
expect(state.weekUsage.inputTokens).toBe(107);
expect(state.weekUsage.outputTokens).toBe(53);
expect(state.weekUsage.costUsd).toBeCloseTo(0.03, 6);
expect(state.lastUsage).toEqual({ inputTokens: 100, outputTokens: 50, costUsd: 0.01 });
expect(state.toasts.at(-1)).toMatchObject({
variant: 'error',
description: 'QuotaExceeded',
});
expect(warnSpy).toHaveBeenCalled();

warnSpy.mockRestore();
});
});

describe('accumulateWeekUsage', () => {
it('adds delta to current bucket when isoWeek matches', () => {
const prev = { isoWeek: '2026-W16', inputTokens: 100, outputTokens: 50, costUsd: 0.5 };
const next = accumulateWeekUsage(
prev,
{ inputTokens: 30, outputTokens: 20, costUsd: 0.25 },
new Date('2026-04-19T12:00:00Z'),
);
expect(next.isoWeek).toBe('2026-W16');
expect(next.inputTokens).toBe(130);
expect(next.outputTokens).toBe(70);
expect(next.costUsd).toBeCloseTo(0.75, 6);
});

it('resets bucket when the ISO week has rolled over', () => {
const prev = { isoWeek: '2025-W01', inputTokens: 999, outputTokens: 999, costUsd: 9 };
const next = accumulateWeekUsage(
prev,
{ inputTokens: 5, outputTokens: 7, costUsd: 0.01 },
new Date('2026-04-19T12:00:00Z'),
);
expect(next.isoWeek).not.toBe('2025-W01');
expect(next.inputTokens).toBe(5);
expect(next.outputTokens).toBe(7);
expect(next.costUsd).toBeCloseTo(0.01, 6);
});

it('clamps negative deltas to zero', () => {
const prev = { isoWeek: isoWeekKey(new Date()), inputTokens: 10, outputTokens: 10, costUsd: 1 };
const next = accumulateWeekUsage(
prev,
{ inputTokens: -5, outputTokens: -3, costUsd: -0.5 },
new Date(),
);
expect(next.inputTokens).toBe(10);
expect(next.outputTokens).toBe(10);
expect(next.costUsd).toBe(1);
});
});

describe('coerceUsageSnapshot', () => {
it('rejects NaN inputs and reports the field', () => {
const { usage, rejected } = coerceUsageSnapshot({
inputTokens: Number.NaN,
outputTokens: 200,
costUsd: 0.01,
});
expect(usage.inputTokens).toBe(0);
expect(usage.outputTokens).toBe(200);
expect(usage.costUsd).toBe(0.01);
expect(rejected).toEqual(['inputTokens']);
});

it('rejects Infinity inputs and reports the field', () => {
const { usage, rejected } = coerceUsageSnapshot({
inputTokens: 100,
outputTokens: Number.POSITIVE_INFINITY,
costUsd: Number.NEGATIVE_INFINITY,
});
expect(usage.outputTokens).toBe(0);
expect(usage.costUsd).toBe(0);
expect(rejected).toEqual(['outputTokens', 'costUsd']);
});

it('accepts finite zero without rejecting', () => {
const { usage, rejected } = coerceUsageSnapshot({
inputTokens: 0,
outputTokens: 0,
costUsd: 0,
});
expect(usage).toEqual({ inputTokens: 0, outputTokens: 0, costUsd: 0 });
expect(rejected).toEqual([]);
});
});

// Simulate the escape handler logic from App.tsx to verify priority:
// commandPaletteOpen → close palette (view unchanged)
// palette closed + view=settings → go to workspace
Expand Down
Loading
Loading