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
4 changes: 4 additions & 0 deletions apps/frontend/src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ import { PatternsPilotView } from './components/PatternsPilotView';
import { AnalyticsPilotView } from './components/AnalyticsPilotView';
import { ProductivityPilotView } from './components/ProductivityPilotView';
import { MergeAnalyticsPilotView } from './components/MergeAnalyticsPilotView';
import { ModelUsagePilotView } from './components/ModelUsagePilotView';
import { useSettingsStore, loadSettings, loadProfiles, saveSettings } from './stores/settings-store';
import { useClaudeProfileStore } from './stores/claude-profile-store';
import { useTerminalStore, restoreTerminalSessions } from './stores/terminal-store';
Expand Down Expand Up @@ -1151,6 +1152,9 @@ export function App() {
{activeView === 'model-usage' && (activeProjectId || selectedProjectId) && (
<ModelUsageDashboard projectId={activeProjectId || selectedProjectId!} />
)}
{activeView === 'model-usage-next' && (activeProjectId || selectedProjectId) && (
<ModelUsagePilotView projectId={(activeProjectId || selectedProjectId)!} />
)}
{activeView === 'merge-analytics' && (activeProjectId || selectedProjectId) && (
<MergeAnalyticsDashboard projectId={activeProjectId || selectedProjectId!} />
)}
Expand Down
145 changes: 145 additions & 0 deletions apps/frontend/src/renderer/__tests__/model-usage-ui.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/**
* Tests for the ModelUsage{Summary,Trends} → AnalyticsDashboard mappers.
*/

import { describe, expect, it } from 'vitest';
import {
buildModelBarLists,
buildModelCharts,
buildModelKpis,
buildModelSections,
shortModelName,
} from '../lib/model-usage-ui';
import type {
ModelMetrics,
ModelUsageSummary,
ModelUsageTrendPoint,
} from '../../shared/types';

function makeModel(overrides: Partial<ModelMetrics> = {}): ModelMetrics {
return {
model: 'anthropic/claude-sonnet-4-5-20250929',
provider: 'anthropic',
total_input_tokens: 800_000,
total_output_tokens: 200_000,
usage_by_agent: {},
total_usage_count: 40,
total_tokens: 1_000_000,
total_cost: 12.5,
first_used: null,
last_used: null,
...overrides,
};
}

function makeSummary(overrides: Partial<ModelUsageSummary> = {}): ModelUsageSummary {
return {
period_start: '2026-05-01T00:00:00Z',
period_end: '2026-05-22T00:00:00Z',
total_usage_count: 64,
total_tokens: 1_680_000,
total_cost: 18.7,
models: [
makeModel(),
makeModel({
model: 'anthropic/claude-haiku-4-5-20251001',
total_tokens: 680_000,
total_input_tokens: 500_000,
total_output_tokens: 180_000,
total_cost: 6.2,
}),
],
agents: [],
top_models: [],
...overrides,
} as ModelUsageSummary;
}

const TRENDS: ModelUsageTrendPoint[] = [
{ date: '2026-05-01', total_tokens: 400_000, total_cost: 4, usage_count: 12, unique_models: 2 },
{ date: '2026-05-08', total_tokens: 620_000, total_cost: 7, usage_count: 20, unique_models: 2 },
{ date: '2026-05-15', total_tokens: 660_000, total_cost: 8, usage_count: 22, unique_models: 3 },
];

describe('shortModelName', () => {
it('drops the provider prefix and date suffix', () => {
expect(shortModelName('anthropic/claude-sonnet-4-5-20250929')).toBe('claude-sonnet-4-5');
expect(shortModelName('gpt-4o')).toBe('gpt-4o');
});
});

describe('buildModelKpis', () => {
const labels = {
calls: 'calls',
callsSub: 'all',
tokens: 'tokens',
cost: 'cost',
models: 'models',
modelsSub: 'distinct',
};

it('maps the summary + trend sparklines onto four tiles', () => {
const kpis = buildModelKpis(makeSummary(), TRENDS, labels);
expect(kpis.map((k) => k.value)).toEqual(['64', '1.7M', '$18.70', '2']);
expect(kpis[0].trend).toEqual([12, 20, 22]);
expect(kpis[2].trend).toEqual([4, 7, 8]);
});

it('omits sparklines when trends are too short', () => {
const kpis = buildModelKpis(makeSummary(), [TRENDS[0]], labels);
expect(kpis[0].trend).toBeUndefined();
});
});

describe('buildModelCharts', () => {
const labels = {
tokensTitle: 'Tokens',
tokensAria: 't',
tokensSeries: 'Tokens',
costTitle: 'Cost',
costAria: 'c',
costSeries: 'Cost',
};

it('builds token + cost trend charts with UTC labels', () => {
const charts = buildModelCharts(TRENDS, labels, 'en-US');
expect(charts.map((c) => c.title)).toEqual(['Tokens', 'Cost']);
expect(charts[0].series[0].values).toEqual([400_000, 620_000, 660_000]);
expect(charts[0].xLabels).toEqual(['May 1', 'May 8', 'May 15']);
});

it('returns no charts without at least two trend points', () => {
expect(buildModelCharts([TRENDS[0]], labels)).toEqual([]);
});
});

describe('buildModelBarLists', () => {
it('ranks models by tokens with formatted value labels', () => {
const lists = buildModelBarLists(makeSummary().models, 'Tokens by model', 'aria');
expect(lists).toHaveLength(1);
expect(lists[0].items).toEqual([
{ label: 'claude-sonnet-4-5', value: 1_000_000, valueLabel: '1.0M', tone: 'info' },
{ label: 'claude-haiku-4-5', value: 680_000, valueLabel: '680.0k', tone: 'info' },
]);
});

it('returns no card when there are no models', () => {
expect(buildModelBarLists([], 't', 'a')).toEqual([]);
});
});

describe('buildModelSections', () => {
it('sums the input/output token split and counts providers', () => {
const sections = buildModelSections(makeSummary(), {
splitTitle: 'Split',
inputTokens: 'Input',
outputTokens: 'Output',
providers: 'Providers',
});
expect(sections[0].rows).toEqual([
{ label: 'Input', value: '1.3M' },
{ label: 'Output', value: '380.0k' },
{ label: 'Providers', value: '1' },
]);
});
});
170 changes: 170 additions & 0 deletions apps/frontend/src/renderer/components/ModelUsagePilotView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
/**
* Model Usage pilot on the shared design system (U5 B2).
*
* Renders `libs/ui`'s AnalyticsDashboard from the existing
* `getModelUsageSummary` + `getModelUsageTrends` IPC, mapped with the pure
* `model-usage-ui` helpers. KPIs + token/cost trend charts + a per-model
* BarList. Read-only — the legacy Model Usage view keeps its export flow.
* Reachable via the "Model Usage (new UI)" sidebar item.
*/

import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { AnalyticsDashboard } from '@auto-code/ui';
import type {
UiBarListCard,
UiChartCard,
UiKpi,
UiMetaSection,
} from '@auto-code/ui';
import type {
ModelUsageSummary,
ModelUsageTrendPoint,
} from '../../shared/types';
import {
buildModelBarLists,
buildModelCharts,
buildModelKpis,
buildModelSections,
} from '../lib/model-usage-ui';

interface ModelUsagePilotViewProps {
projectId: string;
}

interface PilotState {
summary: ModelUsageSummary | null;
trends: ModelUsageTrendPoint[];
loading: boolean;
error: Error | null;
}

export function ModelUsagePilotView({
projectId,
}: Readonly<ModelUsagePilotViewProps>) {
const { t, i18n } = useTranslation(['model-usage']);
const [state, setState] = useState<PilotState>({
summary: null,
trends: [],
loading: true,
error: null,
});
const [reloadKey, setReloadKey] = useState(0);

useEffect(() => {
let active = true;
setState({ summary: null, trends: [], loading: true, error: null });
Promise.all([
window.electronAPI.getModelUsageSummary(projectId),
window.electronAPI.getModelUsageTrends(projectId),
])
.then(([summaryResult, trendsResult]) => {
if (!active) return;
if (!summaryResult.success || summaryResult.data == null) {
console.error(
'[ModelUsagePilotView] Failed to load summary:',
summaryResult.success ? 'no data' : summaryResult.error,
);
setState({
summary: null,
trends: [],
loading: false,
error: new Error(t('model-usage:modelUsagePilot.error')),
});
return;
}
setState({
summary: summaryResult.data,
trends: trendsResult.success ? (trendsResult.data ?? []) : [],
loading: false,
error: null,
});
})
.catch((err: unknown) => {
if (!active) return;
console.error('[ModelUsagePilotView] Failed to load model usage:', err);
setState({
summary: null,
trends: [],
loading: false,
error: new Error(t('model-usage:modelUsagePilot.error')),
});
});
return () => {
active = false;
};
}, [projectId, reloadKey, t]);

const reload = useCallback(() => setReloadKey((key) => key + 1), []);

const kpis = useMemo<UiKpi[] | null>(() => {
if (state.summary == null) return null;
return buildModelKpis(state.summary, state.trends, {
calls: t('model-usage:modelUsagePilot.kpis.calls'),
callsSub: t('model-usage:modelUsagePilot.kpis.callsSub'),
tokens: t('model-usage:modelUsagePilot.kpis.tokens'),
cost: t('model-usage:modelUsagePilot.kpis.cost'),
models: t('model-usage:modelUsagePilot.kpis.models'),
modelsSub: t('model-usage:modelUsagePilot.kpis.modelsSub'),
});
}, [state.summary, state.trends, t]);

const charts = useMemo<UiChartCard[] | undefined>(() => {
if (state.summary == null) return undefined;
return buildModelCharts(
state.trends,
{
tokensTitle: t('model-usage:modelUsagePilot.charts.tokensTitle'),
tokensAria: t('model-usage:modelUsagePilot.charts.tokensAria'),
tokensSeries: t('model-usage:modelUsagePilot.charts.tokensSeries'),
costTitle: t('model-usage:modelUsagePilot.charts.costTitle'),
costAria: t('model-usage:modelUsagePilot.charts.costAria'),
costSeries: t('model-usage:modelUsagePilot.charts.costSeries'),
},
i18n.language,
);
}, [state.summary, state.trends, t, i18n.language]);

const barLists = useMemo<UiBarListCard[] | undefined>(() => {
if (state.summary == null) return undefined;
return buildModelBarLists(
state.summary.models,
t('model-usage:modelUsagePilot.models.title'),
t('model-usage:modelUsagePilot.models.aria'),
);
}, [state.summary, t]);

const sections = useMemo<UiMetaSection[] | undefined>(() => {
if (state.summary == null) return undefined;
return buildModelSections(state.summary, {
splitTitle: t('model-usage:modelUsagePilot.sections.splitTitle'),
inputTokens: t('model-usage:modelUsagePilot.sections.inputTokens'),
outputTokens: t('model-usage:modelUsagePilot.sections.outputTokens'),
providers: t('model-usage:modelUsagePilot.sections.providers'),
});
}, [state.summary, t]);

const stateLabels = useMemo(
() => ({
loading: t('model-usage:modelUsagePilot.states.loading'),
retry: t('model-usage:modelUsagePilot.states.retry'),
empty: t('model-usage:modelUsagePilot.states.empty'),
}),
[t],
);

return (
<div className="h-full overflow-hidden">
<AnalyticsDashboard
kpis={kpis}
charts={charts}
barLists={barLists}
sections={sections}
loading={state.loading}
error={state.error}
onRetry={reload}
stateLabels={stateLabels}
/>
</div>
);
}
8 changes: 5 additions & 3 deletions apps/frontend/src/renderer/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ import {
MessageSquare,
Code,
Brain,
LayoutDashboard
LayoutDashboard,
Cpu
} from 'lucide-react';
import { Button } from './ui/button';
import { ScrollArea } from './ui/scroll-area';
Expand Down Expand Up @@ -71,7 +72,7 @@ import { SessionContextIndicator } from './SessionContextIndicator';
import { NavIndicator } from './NavIndicator';
import type { Project, AutoBuildVersionInfo, GitStatus, ProjectEnvConfig } from '../../shared/types';

export type SidebarView = 'kanban' | 'kanban-next' | 'terminals' | 'roadmap' | 'context' | 'ideation' | 'webhooks' | 'github-issues' | 'github-issues-next' | 'gitlab-issues' | 'github-prs' | 'github-prs-next' | 'gitlab-merge-requests' | 'changelog' | 'changelog-next' | 'insights' | 'worktrees' | 'agent-tools' | 'plugins' | 'analytics' | 'analytics-next' | 'productivity' | 'productivity-next' | 'merge-analytics' | 'merge-analytics-next' | 'sessions' | 'scheduler' | 'feedback' | 'patterns' | 'patterns-next' | 'model-usage' | 'agent-inspector';
export type SidebarView = 'kanban' | 'kanban-next' | 'terminals' | 'roadmap' | 'context' | 'ideation' | 'webhooks' | 'github-issues' | 'github-issues-next' | 'gitlab-issues' | 'github-prs' | 'github-prs-next' | 'gitlab-merge-requests' | 'changelog' | 'changelog-next' | 'insights' | 'worktrees' | 'agent-tools' | 'plugins' | 'analytics' | 'analytics-next' | 'productivity' | 'productivity-next' | 'merge-analytics' | 'merge-analytics-next' | 'sessions' | 'scheduler' | 'feedback' | 'patterns' | 'patterns-next' | 'model-usage' | 'model-usage-next' | 'agent-inspector';

interface SidebarProps {
onSettingsClick: () => void;
Expand Down Expand Up @@ -114,7 +115,8 @@ const baseNavItems: NavItem[] = [
{ id: 'sessions', labelKey: 'navigation:items.sessions', icon: Play },
{ id: 'agent-inspector', labelKey: 'navigation:items.agentInspector', icon: Brain },
{ id: 'feedback', labelKey: 'navigation:items.feedback', icon: MessageSquare, shortcut: 'F' },
{ id: 'model-usage', labelKey: 'navigation:items.modelUsage', icon: Database, shortcut: 'O' }
{ id: 'model-usage', labelKey: 'navigation:items.modelUsage', icon: Database, shortcut: 'O' },
{ id: 'model-usage-next', labelKey: 'navigation:items.modelUsageNext', icon: Cpu },
];

// GitHub nav items shown when GitHub is enabled
Expand Down
Loading
Loading