diff --git a/frontend-modern/src/components/AI/AICostDashboard.tsx b/frontend-modern/src/components/AI/AICostDashboard.tsx index d67bf4973..497d3c5c0 100644 --- a/frontend-modern/src/components/AI/AICostDashboard.tsx +++ b/frontend-modern/src/components/AI/AICostDashboard.tsx @@ -25,8 +25,6 @@ import { AI_COST_PANEL_DESCRIPTION, AI_COST_PANEL_TITLE, AI_COST_PATROL_USE_CASE_LABEL, - AI_COST_PROVIDER_MODEL_PAIR_LABEL, - AI_COST_TARGET_TABLE_LABEL, AI_COST_RESET_HISTORY_LABEL, buildAICostExportFilename, getAICostBudgetNote, @@ -37,7 +35,6 @@ import { getAICostResetHistoryConfirmationMessage, getAICostResetHistoryErrorMessage, getAICostResetHistorySuccessMessage, - getAICostTargetPresentation, } from '@/utils/aiCostPresentation'; const usdFormatter = new Intl.NumberFormat(undefined, { @@ -142,16 +139,34 @@ export const AICostDashboard: Component = () => { const dailyTokenValues = createMemo(() => dailyTotals().map((d) => d.total_tokens)); const dailyUSDValues = createMemo(() => dailyTotals().map((d) => d.estimated_usd ?? 0)); - const lastDailyTokens = createMemo(() => { + // Trend cards characterise the range by its daily average, not the last + // data point. Showing the last point read as "you spent $0 today" when + // earlier days in the range carried the actual spend. + const avgDailyTokens = createMemo(() => { const values = dailyTokenValues(); if (values.length === 0) return null; - return values[values.length - 1]; + return Math.round(values.reduce((sum, v) => sum + v, 0) / values.length); }); - const lastDailyUSD = createMemo(() => { + const avgDailyUSD = createMemo(() => { const values = dailyUSDValues(); if (values.length === 0) return null; - return values[values.length - 1]; + return values.reduce((sum, v) => sum + v, 0) / values.length; + }); + + // Surface the real cost driver first: priced models by spend (desc), + // then unpriced models by tokens (desc) so the rows the operator can + // actually act on aren't buried under flat / unknown rows. + const sortedProviderModels = createMemo(() => { + const data = summary(); + if (!data) return []; + return [...data.provider_models].sort((a, b) => { + const aPriced = a.pricing_known ? 1 : 0; + const bPriced = b.pricing_known ? 1 : 0; + if (aPriced !== bPriced) return bPriced - aPriced; + if (aPriced) return (b.estimated_usd ?? 0) - (a.estimated_usd ?? 0); + return (b.total_tokens ?? 0) - (a.total_tokens ?? 0); + }); }); const formatUSD = (usd: number) => usdFormatter.format(usd); @@ -377,7 +392,7 @@ export const AICostDashboard: Component = () => { {(data) => ( <> -
+
Estimated spend
@@ -388,22 +403,27 @@ export const AICostDashboard: Component = () => { {formatUSD(estimatedTotalUSD() ?? 0)}
+
over {data().effective_days} days
-
Total tokens
-
- {formatNumber(data().totals.total_tokens)} +
{AI_COST_BUDGET_LABEL}
+
+
-
-
-
{AI_COST_PROVIDER_MODEL_PAIR_LABEL}
-
- {formatNumber(data().provider_models.length)} +
+ No budget set}> + {getAICostBudgetNote(days())} {formatUSD(budgetForRange() ?? 0)} +
-
+
{AI_COST_ASSISTANT_USE_CASE_LABEL}
@@ -426,35 +446,18 @@ export const AICostDashboard: Component = () => {
-
-
{AI_COST_BUDGET_LABEL}
-
- -
-
- {getAICostBudgetNote(days())}{' '} - —}> - {formatUSD(budgetForRange() ?? 0)} - -
-
-
Daily estimated USD
+
Spend trend
—} > - {formatUSD(lastDailyUSD() ?? 0)} + {formatUSD(avgDailyUSD() ?? 0)} avg/day
@@ -471,10 +474,10 @@ export const AICostDashboard: Component = () => {
-
Daily total tokens
+
Token trend
- —}> - {formatNumber(lastDailyTokens() ?? 0)} + —}> + {formatNumber(avgDailyTokens() ?? 0)} avg/day
@@ -494,19 +497,9 @@ export const AICostDashboard: Component = () => {
USD is an estimate based on public list prices. It may differ from billing. 0}> - - Estimated spend is partial. Pricing is unknown for{' '} - {unpricedProviderModels() - .slice(0, 6) - .map( - (pm) => - `${getAIProviderDisplayName(pm.provider) || pm.provider}/${pm.model}`, - ) - .join(', ')} - 6}> - (+{unpricedProviderModels().length - 6} more) - - . + `${getAIProviderDisplayName(pm.provider) || pm.provider}/${pm.model}`).join('\n')}`}> + Estimated spend is partial — {unpricedProviderModels().length} model + {unpricedProviderModels().length === 1 ? '' : 's'} have unknown pricing. @@ -546,56 +539,6 @@ export const AICostDashboard: Component = () => {
- 0}> - - - - - {AI_COST_TARGET_TABLE_LABEL} - - Est. USD - Calls - Tokens - - - - - {(t) => { - const target = getAICostTargetPresentation(t); - return ( - - - {target.label} - - {target.detail} - - - - - - - {formatNumber(t.calls)} - - - {formatNumber(t.total_tokens)} - - - ); - }} - - -
-
- @@ -608,7 +551,7 @@ export const AICostDashboard: Component = () => { - + {(pm) => ( diff --git a/frontend-modern/src/components/AI/__tests__/AICostDashboard.test.tsx b/frontend-modern/src/components/AI/__tests__/AICostDashboard.test.tsx index 5deef8e43..5b55834b9 100644 --- a/frontend-modern/src/components/AI/__tests__/AICostDashboard.test.tsx +++ b/frontend-modern/src/components/AI/__tests__/AICostDashboard.test.tsx @@ -190,26 +190,6 @@ describe('AICostDashboard', () => { // ---- summary cards ---- - it('displays total tokens', async () => { - renderDashboard(); - await waitFor(() => { - expect(screen.getByText('Total tokens')).toBeInTheDocument(); - }); - // 60000 formatted — appears in summary card and provider table, so use getAllByText - const matches = screen.getAllByText('60,000'); - expect(matches.length).toBeGreaterThanOrEqual(1); - }); - - it('displays model/provider pair count', async () => { - renderDashboard(); - await waitFor(() => { - expect(screen.getByText('Provider/model pairs')).toBeInTheDocument(); - }); - // Verify the count is rendered in the same card container - const pairCard = screen.getByText('Provider/model pairs').closest('.p-3')!; - expect(pairCard.textContent).toContain('1'); - }); - it('displays estimated spend in USD', async () => { renderDashboard(); await waitFor(() => { @@ -419,47 +399,11 @@ describe('AICostDashboard', () => { }), ); renderDashboard(); - await waitFor(() => { - expect(screen.getByText(/Pricing is unknown for/)).toBeInTheDocument(); - }); - expect(screen.getByText(/Ollama\/llama3/)).toBeInTheDocument(); - }); - - // ---- targets table ---- - - it('renders target table when targets are present', async () => { - getCostSummaryMock.mockResolvedValue( - baseSummary({ - targets: [ - { - target_type: 'assistant_session_title', - target_id: '7f5941d9-a503-416d-b84e-5a46c9e1e11f', - calls: 5, - input_tokens: 10000, - output_tokens: 2000, - total_tokens: 12000, - estimated_usd: 0.08, - pricing_known: true, - }, - ], - }), - ); - renderDashboard(); - await waitFor(() => { - expect(screen.getByText('Usage by task')).toBeInTheDocument(); - }); - expect(screen.getByText('Assistant sessions')).toBeInTheDocument(); - expect(screen.queryByText(/assistant_session_title/)).not.toBeInTheDocument(); - expect(screen.queryByText(/7f5941d9/)).not.toBeInTheDocument(); - expect(screen.getByText('12,000')).toBeInTheDocument(); - }); - - it('does not render target table when no targets', async () => { - renderDashboard(); - await waitFor(() => { - expect(screen.getByText('Estimated spend')).toBeInTheDocument(); - }); - expect(screen.queryByText('Usage by task')).not.toBeInTheDocument(); + const note = await screen.findByText(/unknown pricing/); + expect(note.textContent).toMatch(/partial/); + // The full unpriced-model list is carried in a hover tooltip so the + // visible flow stays concise. + expect(note.getAttribute('title')).toContain('Ollama/llama3'); }); // ---- error states ---- @@ -726,14 +670,26 @@ describe('AICostDashboard', () => { it('shows daily trend sparklines when multiple daily totals exist', async () => { renderDashboard(); await waitFor(() => { - expect(screen.getByText('Daily estimated USD')).toBeInTheDocument(); - expect(screen.getByText('Daily total tokens')).toBeInTheDocument(); + expect(screen.getByText('Spend trend')).toBeInTheDocument(); + expect(screen.getByText('Token trend')).toBeInTheDocument(); }); // With 2 daily totals, sparklines should render (SVGs present) const svgs = document.querySelectorAll('svg'); expect(svgs.length).toBeGreaterThanOrEqual(2); }); + it('labels trend cards with the daily average, not a single last point', async () => { + // daily_totals fixture: two days, $0.21 each → avg $0.21/day. A last-point + // label previously read as the headline and could misleadingly show $0. + renderDashboard(); + await waitFor(() => { + expect(screen.getByText('Spend trend')).toBeInTheDocument(); + }); + const spendCard = screen.getByText('Spend trend').closest('.p-3')!; + expect(spendCard.textContent).toMatch(/avg\/day/); + expect(spendCard.textContent).toMatch(/0\.21/); + }); + it('shows the shared daily token empty state when less than 2 daily totals', async () => { getCostSummaryMock.mockResolvedValue(baseSummary({ daily_totals: [] })); renderDashboard(); diff --git a/frontend-modern/src/components/Settings/__tests__/AISettings.test.tsx b/frontend-modern/src/components/Settings/__tests__/AISettings.test.tsx index c764ef7a9..520247bce 100644 --- a/frontend-modern/src/components/Settings/__tests__/AISettings.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/AISettings.test.tsx @@ -867,6 +867,9 @@ describe('AISettings Ollama provider options', () => { renderComponent(); + // Configured providers start collapsed; open the Ollama accordion to edit + // its advanced options, the same way an operator would. + fireEvent.click(await screen.findByRole('button', { name: /ollama/i })); fireEvent.input(await screen.findByLabelText('Ollama Keep Alive'), { target: { value: '24h' }, }); diff --git a/frontend-modern/src/components/Settings/useAISettingsState.ts b/frontend-modern/src/components/Settings/useAISettingsState.ts index 9e8dde95f..fe8f1b384 100644 --- a/frontend-modern/src/components/Settings/useAISettingsState.ts +++ b/frontend-modern/src/components/Settings/useAISettingsState.ts @@ -245,6 +245,11 @@ export const useAISettingsState = (options: AISettingsStateOptions = {}) => { const [expandedProviders, setExpandedProviders] = createSignal>( new Set(['anthropic']), ); + // Tracks whether the initial expand policy has been applied, so later + // resetForm calls (after every save) preserve the operator's own + // expand/collapse choices instead of clobbering them. + const [providersExpandedInitialized, setProvidersExpandedInitialized] = + createSignal(false); const [testingProvider, setTestingProvider] = createSignal(null); const [providerTestResult, setProviderTestResult] = createSignal(null); @@ -482,8 +487,19 @@ export const useAISettingsState = (options: AISettingsStateOptions = {}) => { if (data.together_configured) configured.add('together'); if (data.fireworks_configured) configured.add('fireworks'); if (data.ollama_configured) configured.add('ollama'); - if (configured.size === 0) configured.add('anthropic'); - setExpandedProviders(configured); + // Apply the expand policy only on the first load. Guide first-time + // setup by expanding the default provider when nothing is configured + // yet; once at least one provider is configured, keep the list + // collapsed — the per-provider badges and the health callout already + // convey status, and expanding every configured provider just produces + // a wall of API-key inputs. Later saves preserve the operator's own + // expand/collapse selections. + if (!providersExpandedInitialized()) { + setProvidersExpandedInitialized(true); + setExpandedProviders( + configured.size === 0 ? new Set(['anthropic']) : new Set(), + ); + } }; const loadModels = async () => {