## CHANGES

- Added per-agent per-day stats breakdown powering richer provider charts 📊
- Upgraded AgentUsageChart to multi-provider lines with query/time toggle 📈
- Normalized cumulative usage events into per-turn deltas for Claude/Codex 🧮
- Fixed context token display to use agent-specific calculation pipeline 🧠
- Reworked session aggregation: distinct session counts plus closed-session averages 🗃️
- Hardened tool execution rendering to ignore non-string inputs safely 🛡️
- Added comprehensive tests preventing React error #31 from tool metadata 🧪
- Refreshed summary cards into cleaner 2×3 grid layout across breakpoints 🧩
- Improved metric card readability by wrapping long agent names instead 📝
- Updated types and IPC contracts to include new `byAgentByDay` payload 🔌
This commit is contained in:
Pedram Amini
2026-01-18 15:58:49 -06:00
parent ead7b7d538
commit a0ebf02e4a
21 changed files with 508 additions and 369 deletions

View File

@@ -67,6 +67,7 @@ describe('stats IPC handlers', () => {
sessionsByAgent: {},
sessionsByDay: [],
avgSessionDuration: 0,
byAgentByDay: {},
}),
exportToCsv: vi.fn().mockReturnValue('id,sessionId,...'),
clearOldData: vi.fn().mockReturnValue({ success: true, deletedCount: 0 }),

View File

@@ -6925,7 +6925,7 @@ describe('Database VACUUM functionality', () => {
db.getAggregatedStats('year');
// Verify exactly 6 queries were prepared for aggregation
// Verify exactly 8 queries were prepared for aggregation
const aggregationQueries = preparedQueries.filter(
(sql) =>
sql.includes('query_events') &&
@@ -6934,7 +6934,7 @@ describe('Database VACUUM functionality', () => {
!sql.includes('ALTER')
);
expect(aggregationQueries.length).toBe(6);
expect(aggregationQueries.length).toBe(8);
// Query 1: Total count and sum
expect(aggregationQueries[0]).toContain('COUNT(*)');
@@ -6952,8 +6952,15 @@ describe('Database VACUUM functionality', () => {
// Query 5: Group by date
expect(aggregationQueries[4]).toContain('GROUP BY date');
// Query 6: Group by hour (for peak hours chart)
expect(aggregationQueries[5]).toContain('GROUP BY hour');
// Query 6: Group by agent and date (for provider usage chart)
expect(aggregationQueries[5]).toContain('GROUP BY agent_type');
expect(aggregationQueries[5]).toContain('date');
// Query 7: Group by hour (for peak hours chart)
expect(aggregationQueries[6]).toContain('GROUP BY hour');
// Query 8: Count distinct sessions
expect(aggregationQueries[7]).toContain('COUNT(DISTINCT session_id)');
});
it('should use indexed column (start_time) in WHERE clause for all aggregation queries', async () => {
@@ -6977,14 +6984,14 @@ describe('Database VACUUM functionality', () => {
db.getAggregatedStats('year');
// All 6 aggregation queries should filter by start_time (indexed column)
// All 8 aggregation queries should filter by start_time (indexed column)
const aggregationQueries = preparedQueries.filter(
(sql) =>
sql.includes('query_events') &&
sql.includes('WHERE start_time')
);
expect(aggregationQueries.length).toBe(6);
expect(aggregationQueries.length).toBe(8);
});
it('should compute time range correctly for year filter (365 days)', async () => {
@@ -8056,7 +8063,7 @@ describe('Database VACUUM functionality', () => {
!sql.includes('ALTER')
);
expect(aggregationQueries.length).toBe(6);
expect(aggregationQueries.length).toBe(8);
// All should filter by start_time (enables index usage)
for (const query of aggregationQueries) {

View File

@@ -976,5 +976,84 @@ describe('WizardConversationView', () => {
expect(screen.getByTestId('wizard-streaming-response')).toBeInTheDocument();
expect(screen.queryByTestId('wizard-thinking-display')).not.toBeInTheDocument();
});
it('renders tool executions with string details', () => {
render(
<WizardConversationView
theme={mockTheme}
conversationHistory={[]}
isLoading={true}
showThinking={true}
thinkingContent="Working..."
toolExecutions={[
{
toolName: 'Glob',
state: { status: 'complete', input: { pattern: '**/*.ts' } },
timestamp: Date.now(),
},
]}
/>
);
expect(screen.getByText('Glob')).toBeInTheDocument();
expect(screen.getByText('**/*.ts')).toBeInTheDocument();
});
it('safely handles tool executions where input properties are objects (not strings)', () => {
// This test verifies the fix for React error #31 where objects like
// {type: "glob", enable_fuzzy_matching: true} were being rendered as React children
render(
<WizardConversationView
theme={mockTheme}
conversationHistory={[]}
isLoading={true}
showThinking={true}
thinkingContent="Working..."
toolExecutions={[
{
toolName: 'Glob',
state: {
status: 'running',
input: {
// All properties are objects, not strings - this should not crash
pattern: { type: 'glob', enable_fuzzy_matching: true },
command: { nested: 'object' },
file_path: 123, // number, not string
query: null,
path: undefined,
},
},
timestamp: Date.now(),
},
]}
/>
);
// Should render without crashing, tool name should appear
expect(screen.getByText('Glob')).toBeInTheDocument();
// The detail text should NOT be rendered since none of the properties are strings
expect(screen.queryByText(/enable_fuzzy_matching/)).not.toBeInTheDocument();
});
it('renders tool execution with no input at all', () => {
render(
<WizardConversationView
theme={mockTheme}
conversationHistory={[]}
isLoading={true}
showThinking={true}
thinkingContent="Working..."
toolExecutions={[
{
toolName: 'Read',
state: { status: 'complete' }, // no input property
timestamp: Date.now(),
},
]}
/>
);
expect(screen.getByText('Read')).toBeInTheDocument();
});
});
});

View File

@@ -40,6 +40,7 @@ const mockData: StatsAggregation = {
sessionsByAgent: { 'claude-code': 15, 'aider': 10 },
sessionsByDay: [],
avgSessionDuration: 288000,
byAgentByDay: {},
};
// Empty data for edge case testing
@@ -56,6 +57,7 @@ const emptyData: StatsAggregation = {
sessionsByAgent: {},
sessionsByDay: [],
avgSessionDuration: 0,
byAgentByDay: {},
};
// Data with large numbers
@@ -75,6 +77,7 @@ const largeNumbersData: StatsAggregation = {
sessionsByAgent: { 'claude-code': 30000, 'openai-codex': 20000 },
sessionsByDay: [],
avgSessionDuration: 7200000,
byAgentByDay: {},
};
// Single agent data
@@ -93,6 +96,7 @@ const singleAgentData: StatsAggregation = {
sessionsByAgent: { 'terminal': 5 },
sessionsByDay: [],
avgSessionDuration: 360000,
byAgentByDay: {},
};
// Only auto queries
@@ -111,6 +115,7 @@ const onlyAutoData: StatsAggregation = {
sessionsByAgent: { 'claude-code': 10 },
sessionsByDay: [],
avgSessionDuration: 360000,
byAgentByDay: {},
};
describe('SummaryCards', () => {
@@ -326,13 +331,13 @@ describe('SummaryCards', () => {
});
describe('Grid Layout', () => {
it('uses 6-column grid layout by default', () => {
it('uses 3-column grid layout by default (2 rows × 3 cols)', () => {
render(<SummaryCards data={mockData} theme={theme} />);
const container = screen.getByTestId('summary-cards');
expect(container).toHaveClass('grid');
expect(container).toHaveStyle({
gridTemplateColumns: 'repeat(6, minmax(0, 1fr))',
gridTemplateColumns: 'repeat(3, minmax(0, 1fr))',
});
});
@@ -372,7 +377,7 @@ describe('SummaryCards', () => {
expect(screen.getByText('100h 0m')).toBeInTheDocument();
});
it('handles long agent names with truncation', () => {
it('handles long agent names without truncation', () => {
const dataWithLongName: StatsAggregation = {
...mockData,
byAgent: {
@@ -381,8 +386,9 @@ describe('SummaryCards', () => {
};
render(<SummaryCards data={dataWithLongName} theme={theme} />);
// Long agent names should now wrap to next line instead of truncating
const agentValue = screen.getByText('very-long-agent-name-that-might-overflow');
expect(agentValue).toHaveClass('truncate');
expect(agentValue).toBeInTheDocument();
});
});

View File

@@ -68,6 +68,7 @@ const mockStatsData: StatsAggregation = {
{ date: '2025-01-22', count: 8 },
],
avgSessionDuration: 288000,
byAgentByDay: {},
};
describe('Chart Accessibility - AgentComparisonChart', () => {
@@ -397,6 +398,7 @@ describe('Chart Accessibility - General ARIA Patterns', () => {
sessionsByAgent: {},
sessionsByDay: [],
avgSessionDuration: 0,
byAgentByDay: {},
};
render(<AgentComparisonChart data={emptyData} theme={mockTheme} />);

View File

@@ -208,6 +208,7 @@ const createSampleData = () => ({
{ date: '2024-01-18', count: 7 },
],
avgSessionDuration: 144000,
byAgentByDay: {},
});
describe('UsageDashboard Responsive Layout', () => {
@@ -334,9 +335,9 @@ describe('UsageDashboard Responsive Layout', () => {
simulateContainerResize(1000);
await waitFor(() => {
// In wide mode, summary cards should have 5 columns
// In wide mode, summary cards should have 3 columns (2 rows × 3 cols layout)
const summaryCards = screen.getByTestId('summary-cards');
expect(summaryCards).toHaveStyle({ gridTemplateColumns: 'repeat(5, minmax(0, 1fr))' });
expect(summaryCards).toHaveStyle({ gridTemplateColumns: 'repeat(3, minmax(0, 1fr))' });
});
});
});
@@ -380,7 +381,7 @@ describe('UsageDashboard Responsive Layout', () => {
});
});
it('displays 5 columns in wide mode (>=900px)', async () => {
it('displays 3 columns in wide mode (>=900px) for 2×3 layout', async () => {
mockOffsetWidth = 1200;
render(
@@ -395,7 +396,7 @@ describe('UsageDashboard Responsive Layout', () => {
await waitFor(() => {
const summaryCards = screen.getByTestId('summary-cards');
expect(summaryCards).toHaveStyle({ gridTemplateColumns: 'repeat(5, minmax(0, 1fr))' });
expect(summaryCards).toHaveStyle({ gridTemplateColumns: 'repeat(3, minmax(0, 1fr))' });
});
});
@@ -513,8 +514,8 @@ describe('UsageDashboard Responsive Layout', () => {
await waitFor(() => {
const summaryCards = screen.getByTestId('summary-cards');
// 900px is >= 900, so wide (5 columns)
expect(summaryCards).toHaveStyle({ gridTemplateColumns: 'repeat(5, minmax(0, 1fr))' });
// 900px is >= 900, so wide layout uses 3 columns (2×3 grid)
expect(summaryCards).toHaveStyle({ gridTemplateColumns: 'repeat(3, minmax(0, 1fr))' });
});
});
@@ -576,7 +577,7 @@ describe('UsageDashboard Responsive Layout', () => {
await waitFor(() => {
const summaryCards = screen.getByTestId('summary-cards');
expect(summaryCards).toHaveStyle({ gridTemplateColumns: 'repeat(5, minmax(0, 1fr))' });
expect(summaryCards).toHaveStyle({ gridTemplateColumns: 'repeat(3, minmax(0, 1fr))' });
});
// Resize to narrow
@@ -614,7 +615,7 @@ describe('UsageDashboard Responsive Layout', () => {
await waitFor(() => {
const summaryCards = screen.getByTestId('summary-cards');
expect(summaryCards).toHaveStyle({ gridTemplateColumns: 'repeat(5, minmax(0, 1fr))' });
expect(summaryCards).toHaveStyle({ gridTemplateColumns: 'repeat(3, minmax(0, 1fr))' });
});
});
@@ -939,10 +940,10 @@ describe('UsageDashboard Responsive Layout', () => {
simulateContainerResize(2000);
// Should still use wide layout (5 columns for summary)
// Should still use wide layout (3 columns for 2×3 summary grid)
await waitFor(() => {
const summaryCards = screen.getByTestId('summary-cards');
expect(summaryCards).toHaveStyle({ gridTemplateColumns: 'repeat(5, minmax(0, 1fr))' });
expect(summaryCards).toHaveStyle({ gridTemplateColumns: 'repeat(3, minmax(0, 1fr))' });
});
});
});

View File

@@ -156,6 +156,7 @@ beforeEach(() => {
{ date: '2024-01-02', count: 10 },
],
avgSessionDuration: 180000,
byAgentByDay: {},
});
mockStats.getDatabaseSize.mockResolvedValue(1024 * 1024); // 1 MB
});
@@ -294,6 +295,7 @@ describe('Usage Dashboard State Transition Animations', () => {
sessionsByAgent: { 'claude-code': 15 },
sessionsByDay: [],
avgSessionDuration: 240000,
byAgentByDay: {},
};
it('applies dashboard-card-enter class to metric cards', () => {

View File

@@ -143,6 +143,7 @@ const createSampleData = () => ({
{ date: '2024-01-18', count: 7 },
],
avgSessionDuration: 144000,
byAgentByDay: {},
});
describe('UsageDashboardModal', () => {

View File

@@ -154,7 +154,18 @@ function parseDataUrl(
};
}
function normalizeCodexUsage(
/**
* Normalize cumulative usage stats to per-turn deltas.
*
* Some agents (Codex, Claude Code) report cumulative session totals in each usage event.
* This function detects cumulative reporting by checking if values monotonically increase,
* and converts them to per-turn deltas for accurate context percentage calculation.
*
* The first usage event is passed through unchanged (used as baseline).
* Subsequent events are converted to deltas if cumulative reporting is detected.
* If values ever decrease (non-monotonic), we assume per-turn reporting and pass through.
*/
function normalizeCumulativeUsage(
managedProcess: ManagedProcess,
usageStats: {
inputTokens: number;
@@ -1124,10 +1135,14 @@ export class ProcessManager extends EventEmitter {
0,
reasoningTokens: usage.reasoningTokens
};
const normalizedUsageStats =
managedProcess.toolType === 'codex'
? normalizeCodexUsage(managedProcess, usageStats)
: usageStats;
// Normalize cumulative usage to per-turn deltas for agents that report cumulative totals
// Both Codex and Claude Code report cumulative session totals in each usage event
const normalizedUsageStats =
managedProcess.toolType === 'codex' ||
managedProcess.toolType === 'claude-code' ||
managedProcess.toolType === 'claude'
? normalizeCumulativeUsage(managedProcess, usageStats)
: usageStats;
this.emit('usage', sessionId, normalizedUsageStats);
}

View File

@@ -1416,6 +1416,38 @@ export class StatsDB {
}>;
perfMetrics.end(byDayStart, 'getAggregatedStats:byDay', { range, dayCount: byDayRows.length });
// By agent by day (for provider usage chart)
const byAgentByDayStart = perfMetrics.start();
const byAgentByDayStmt = this.db.prepare(`
SELECT agent_type,
date(start_time / 1000, 'unixepoch', 'localtime') as date,
COUNT(*) as count,
SUM(duration) as duration
FROM query_events
WHERE start_time >= ?
GROUP BY agent_type, date(start_time / 1000, 'unixepoch', 'localtime')
ORDER BY agent_type, date ASC
`);
const byAgentByDayRows = byAgentByDayStmt.all(startTime) as Array<{
agent_type: string;
date: string;
count: number;
duration: number;
}>;
// Group by agent type
const byAgentByDay: Record<string, Array<{ date: string; count: number; duration: number }>> = {};
for (const row of byAgentByDayRows) {
if (!byAgentByDay[row.agent_type]) {
byAgentByDay[row.agent_type] = [];
}
byAgentByDay[row.agent_type].push({
date: row.date,
count: row.count,
duration: row.duration,
});
}
perfMetrics.end(byAgentByDayStart, 'getAggregatedStats:byAgentByDay', { range });
// By hour (for peak hours chart)
const byHourStart = perfMetrics.start();
const byHourStmt = this.db.prepare(`
@@ -1434,16 +1466,24 @@ export class StatsDB {
}>;
perfMetrics.end(byHourStart, 'getAggregatedStats:byHour', { range });
// Session lifecycle stats
// Session stats (counting unique session IDs from query_events, which includes tab GUIDs)
const sessionsStart = perfMetrics.start();
// Total sessions and average duration
// Total unique sessions with queries (counts tabs that have had at least one query)
const sessionTotalsStmt = this.db.prepare(`
SELECT COUNT(*) as count, COALESCE(AVG(duration), 0) as avg_duration
FROM session_lifecycle
WHERE created_at >= ?
SELECT COUNT(DISTINCT session_id) as count
FROM query_events
WHERE start_time >= ?
`);
const sessionTotals = sessionTotalsStmt.get(startTime) as { count: number; avg_duration: number };
const sessionTotals = sessionTotalsStmt.get(startTime) as { count: number };
// Average session duration from lifecycle table (for sessions that have been closed)
const avgSessionDurationStmt = this.db.prepare(`
SELECT COALESCE(AVG(duration), 0) as avg_duration
FROM session_lifecycle
WHERE created_at >= ? AND duration IS NOT NULL
`);
const avgSessionDurationResult = avgSessionDurationStmt.get(startTime) as { avg_duration: number };
// Sessions by agent type
const sessionsByAgentStmt = this.db.prepare(`
@@ -1503,7 +1543,8 @@ export class StatsDB {
totalSessions: sessionTotals.count,
sessionsByAgent,
sessionsByDay: sessionsByDayRows,
avgSessionDuration: Math.round(sessionTotals.avg_duration),
avgSessionDuration: Math.round(avgSessionDurationResult.avg_duration),
byAgentByDay,
};
}

View File

@@ -197,21 +197,32 @@ function TypingIndicator({
}
/**
* Extract a descriptive detail string from tool input
* Looks for common properties like command, pattern, file_path, query
* Safely convert a value to a string for rendering.
* Returns the string if it's already a string, otherwise null.
* This prevents objects from being passed to React as children.
*/
function safeString(value: unknown): string | null {
return typeof value === 'string' ? value : null;
}
/**
* Extract a descriptive detail string from tool input.
* Looks for common properties like command, pattern, file_path, query.
* Only returns actual strings - objects are safely ignored to prevent React errors.
*/
function getToolDetail(input: unknown): string | null {
if (!input || typeof input !== 'object') return null;
const inputObj = input as Record<string, unknown>;
// Check common tool input properties in order of preference
const detail =
(inputObj.command as string) ||
(inputObj.pattern as string) ||
(inputObj.file_path as string) ||
(inputObj.query as string) ||
(inputObj.path as string) ||
null;
return detail;
// Use safeString to ensure we only return actual strings, not objects
return (
safeString(inputObj.command) ||
safeString(inputObj.pattern) ||
safeString(inputObj.file_path) ||
safeString(inputObj.query) ||
safeString(inputObj.path) ||
null
);
}
/**

View File

@@ -403,12 +403,12 @@ export const MainPanel = React.memo(forwardRef<MainPanelHandle, MainPanelProps>(
return configured > 0 ? configured : reported;
}, [configuredContextWindow, activeTab?.usageStats?.contextWindow]);
// Compute context usage percentage from active tab's usage stats
// Uses agent-specific calculation (Codex includes output tokens, Claude doesn't)
const activeTabContextUsage = useMemo(() => {
// Compute context tokens using agent-specific calculation
// Claude: input + cacheCreation (excludes cacheRead which is cumulative)
// Codex: input + output (combined limit)
const activeTabContextTokens = useMemo(() => {
if (!activeTab?.usageStats) return 0;
if (!activeTabContextWindow || activeTabContextWindow === 0) return 0;
const contextTokens = calculateContextTokens(
return calculateContextTokens(
{
inputTokens: activeTab.usageStats.inputTokens,
outputTokens: activeTab.usageStats.outputTokens,
@@ -417,8 +417,14 @@ export const MainPanel = React.memo(forwardRef<MainPanelHandle, MainPanelProps>(
},
activeSession?.toolType
);
return Math.min(Math.round((contextTokens / activeTabContextWindow) * 100), 100);
}, [activeTab?.usageStats, activeTabContextWindow, activeSession?.toolType]);
}, [activeTab?.usageStats, activeSession?.toolType]);
// Compute context usage percentage from context tokens and window size
const activeTabContextUsage = useMemo(() => {
if (!activeTabContextWindow || activeTabContextWindow === 0) return 0;
if (activeTabContextTokens === 0) return 0;
return Math.min(Math.round((activeTabContextTokens / activeTabContextWindow) * 100), 100);
}, [activeTabContextTokens, activeTabContextWindow]);
// PERF: Track panel width for responsive widget hiding with threshold-based updates
// Only update state when width crosses a meaningful threshold (20px) to prevent
@@ -963,10 +969,7 @@ export const MainPanel = React.memo(forwardRef<MainPanelHandle, MainPanelProps>(
<div className="flex justify-between items-center">
<span className="text-xs font-bold" style={{ color: theme.colors.textDim }}>Context Tokens</span>
<span className="text-xs font-mono font-bold" style={{ color: theme.colors.accent }}>
{(
(activeTab?.usageStats?.inputTokens ?? 0) +
(activeTab?.usageStats?.outputTokens ?? 0)
).toLocaleString()}
{activeTabContextTokens.toLocaleString()}
</span>
</div>
<div className="flex justify-between items-center mt-1">

View File

@@ -424,9 +424,11 @@ const LogItemComponent = memo(({
{/* Special rendering for tool execution events (shown alongside thinking) */}
{log.source === 'tool' && (() => {
// Extract tool input details for display
// Use type checks to ensure we only render strings (prevents React error #31)
const toolInput = log.metadata?.toolState?.input as Record<string, unknown> | undefined;
const safeStr = (v: unknown): string | null => typeof v === 'string' ? v : null;
const toolDetail = toolInput
? (toolInput.command as string) || (toolInput.pattern as string) || (toolInput.file_path as string) || (toolInput.query as string) || null
? safeStr(toolInput.command) || safeStr(toolInput.pattern) || safeStr(toolInput.file_path) || safeStr(toolInput.query) || null
: null;
return (

View File

@@ -1,12 +1,13 @@
/**
* AgentUsageChart
*
* Line chart showing agent usage over time with dual metrics.
* Displays both session count and total time on the same chart.
* Line chart showing provider usage over time with one line per provider.
* Displays query counts and duration for each provider (claude-code, codex, opencode).
*
* Features:
* - Dual Y-axes: sessions (left) and time (right)
* - Color-coded lines for each metric
* - One line per provider
* - Dual Y-axes: queries (left) and time (right)
* - Provider-specific colors
* - Hover tooltips with exact values
* - Responsive SVG rendering
* - Theme-aware styling
@@ -16,14 +17,28 @@ import React, { useState, useMemo, useCallback } from 'react';
import { format, parseISO } from 'date-fns';
import type { Theme } from '../../types';
import type { StatsTimeRange, StatsAggregation } from '../../hooks/useStats';
import { COLORBLIND_LINE_COLORS } from '../../constants/colorblindPalettes';
import { COLORBLIND_AGENT_PALETTE, COLORBLIND_LINE_COLORS } from '../../constants/colorblindPalettes';
// Data point for the chart
interface DataPoint {
// Provider colors (matching AgentComparisonChart)
const PROVIDER_COLORS: Record<string, string> = {
'claude-code': '#a78bfa', // violet
'codex': '#34d399', // emerald
'opencode': '#60a5fa', // blue
};
// Data point for a single provider on a single day
interface ProviderDayData {
date: string;
formattedDate: string;
sessions: number;
duration: number; // Total duration in ms
count: number;
duration: number;
}
// All providers' data for a single day
interface DayData {
date: string;
formattedDate: string;
providers: Record<string, { count: number; duration: number }>;
}
interface AgentUsageChartProps {
@@ -98,120 +113,142 @@ function formatXAxisDate(dateStr: string, timeRange: StatsTimeRange): string {
}
}
/**
* Get provider color, with colorblind mode support
*/
function getProviderColor(provider: string, index: number, colorBlindMode: boolean): string {
if (colorBlindMode) {
return COLORBLIND_AGENT_PALETTE[index % COLORBLIND_AGENT_PALETTE.length];
}
return PROVIDER_COLORS[provider] || COLORBLIND_LINE_COLORS.primary;
}
export function AgentUsageChart({ data, timeRange, theme, colorBlindMode = false }: AgentUsageChartProps) {
const [hoveredPoint, setHoveredPoint] = useState<{ point: DataPoint; index: number } | null>(null);
const [hoveredDay, setHoveredDay] = useState<{ dayIndex: number; provider?: string } | null>(null);
const [tooltipPos, setTooltipPos] = useState<{ x: number; y: number } | null>(null);
const [metricMode, setMetricMode] = useState<'count' | 'duration'>('count');
// Chart dimensions
const chartWidth = 600;
const chartHeight = 220;
const padding = { top: 20, right: 60, bottom: 40, left: 50 };
const padding = { top: 20, right: 50, bottom: 40, left: 50 };
const innerWidth = chartWidth - padding.left - padding.right;
const innerHeight = chartHeight - padding.top - padding.bottom;
// Colors for the two metrics
const sessionColor = useMemo(() => {
return colorBlindMode ? COLORBLIND_LINE_COLORS.primary : theme.colors.accent;
}, [colorBlindMode, theme.colors.accent]);
// Get list of providers and their data
const { providers, chartData, allDates } = useMemo(() => {
const byAgentByDay = data.byAgentByDay || {};
const providerList = Object.keys(byAgentByDay).sort();
const durationColor = useMemo(() => {
return colorBlindMode ? COLORBLIND_LINE_COLORS.secondary : '#10b981'; // emerald
}, [colorBlindMode]);
// Collect all unique dates
const dateSet = new Set<string>();
for (const provider of providerList) {
for (const day of byAgentByDay[provider]) {
dateSet.add(day.date);
}
}
const sortedDates = Array.from(dateSet).sort();
// Combine byDay data with sessionsByDay to get both metrics per day
const chartData = useMemo((): DataPoint[] => {
if (data.byDay.length === 0) return [];
// Build per-provider arrays aligned to all dates
const providerData: Record<string, ProviderDayData[]> = {};
for (const provider of providerList) {
const dayMap = new Map<string, { count: number; duration: number }>();
for (const day of byAgentByDay[provider]) {
dayMap.set(day.date, { count: day.count, duration: day.duration });
}
// Create a map of sessions by date
const sessionsByDateMap = new Map<string, number>();
for (const day of data.sessionsByDay || []) {
sessionsByDateMap.set(day.date, day.count);
providerData[provider] = sortedDates.map(date => ({
date,
formattedDate: format(parseISO(date), 'EEEE, MMM d, yyyy'),
count: dayMap.get(date)?.count || 0,
duration: dayMap.get(date)?.duration || 0,
}));
}
return data.byDay.map((day) => ({
date: day.date,
formattedDate: format(parseISO(day.date), 'EEEE, MMM d, yyyy'),
sessions: sessionsByDateMap.get(day.date) || 0,
duration: day.duration,
}));
}, [data.byDay, data.sessionsByDay]);
// Build combined day data for tooltips
const combinedData: DayData[] = sortedDates.map(date => {
const providers: Record<string, { count: number; duration: number }> = {};
for (const provider of providerList) {
const dayData = providerData[provider].find(d => d.date === date);
if (dayData) {
providers[provider] = { count: dayData.count, duration: dayData.duration };
}
}
return {
date,
formattedDate: format(parseISO(date), 'EEEE, MMM d, yyyy'),
providers,
};
});
// Calculate scales for both Y-axes
const { xScale, yScaleSessions, yScaleDuration, yTicksSessions, yTicksDuration } = useMemo(() => {
if (chartData.length === 0) {
return {
providers: providerList,
chartData: providerData,
allDates: combinedData,
};
}, [data.byAgentByDay]);
// Calculate scales
const { xScale, yScale, yTicks } = useMemo(() => {
if (allDates.length === 0) {
return {
xScale: (_: number) => padding.left,
yScaleSessions: (_: number) => chartHeight - padding.bottom,
yScaleDuration: (_: number) => chartHeight - padding.bottom,
yTicksSessions: [0],
yTicksDuration: [0],
yScale: (_: number) => chartHeight - padding.bottom,
yTicks: [0],
};
}
const maxSessions = Math.max(...chartData.map((d) => d.sessions), 1);
const maxDuration = Math.max(...chartData.map((d) => d.duration), 1);
// Find max value across all providers
let maxValue = 1;
for (const provider of providers) {
const providerMax = Math.max(
...chartData[provider].map(d => metricMode === 'count' ? d.count : d.duration)
);
maxValue = Math.max(maxValue, providerMax);
}
// Add 10% padding to max values
const sessionMax = Math.ceil(maxSessions * 1.1);
const durationMax = maxDuration * 1.1;
// Add 10% padding
const yMax = metricMode === 'count' ? Math.ceil(maxValue * 1.1) : maxValue * 1.1;
// X scale - linear across data points
// X scale
const xScaleFn = (index: number) =>
padding.left + (index / Math.max(chartData.length - 1, 1)) * innerWidth;
padding.left + (index / Math.max(allDates.length - 1, 1)) * innerWidth;
// Y scale for sessions (left axis) - inverted for SVG coordinates
const yScaleSessionsFn = (value: number) =>
chartHeight - padding.bottom - (value / sessionMax) * innerHeight;
// Y scale
const yScaleFn = (value: number) =>
chartHeight - padding.bottom - (value / yMax) * innerHeight;
// Y scale for duration (right axis)
const yScaleDurationFn = (value: number) =>
chartHeight - padding.bottom - (value / durationMax) * innerHeight;
// Generate nice Y-axis ticks
// Y ticks
const tickCount = 5;
const yTicksSessionsArr = Array.from({ length: tickCount }, (_, i) =>
Math.round((sessionMax / (tickCount - 1)) * i)
);
const yTicksDurationArr = Array.from({ length: tickCount }, (_, i) =>
(durationMax / (tickCount - 1)) * i
);
const yTicksArr = metricMode === 'count'
? Array.from({ length: tickCount }, (_, i) => Math.round((yMax / (tickCount - 1)) * i))
: Array.from({ length: tickCount }, (_, i) => (yMax / (tickCount - 1)) * i);
return {
xScale: xScaleFn,
yScaleSessions: yScaleSessionsFn,
yScaleDuration: yScaleDurationFn,
yTicksSessions: yTicksSessionsArr,
yTicksDuration: yTicksDurationArr,
};
}, [chartData, chartHeight, innerWidth, innerHeight, padding]);
return { xScale: xScaleFn, yScale: yScaleFn, yTicks: yTicksArr };
}, [allDates, providers, chartData, metricMode, chartHeight, innerWidth, innerHeight, padding]);
// Generate line paths for both metrics
const sessionsPath = useMemo(() => {
if (chartData.length === 0) return '';
return chartData
.map((point, idx) => {
const x = xScale(idx);
const y = yScaleSessions(point.sessions);
return `${idx === 0 ? 'M' : 'L'} ${x} ${y}`;
})
.join(' ');
}, [chartData, xScale, yScaleSessions]);
// Generate line paths for each provider
const linePaths = useMemo(() => {
const paths: Record<string, string> = {};
for (const provider of providers) {
const providerDays = chartData[provider];
if (providerDays.length === 0) continue;
const durationPath = useMemo(() => {
if (chartData.length === 0) return '';
return chartData
.map((point, idx) => {
const x = xScale(idx);
const y = yScaleDuration(point.duration);
return `${idx === 0 ? 'M' : 'L'} ${x} ${y}`;
})
.join(' ');
}, [chartData, xScale, yScaleDuration]);
paths[provider] = providerDays
.map((day, idx) => {
const x = xScale(idx);
const y = yScale(metricMode === 'count' ? day.count : day.duration);
return `${idx === 0 ? 'M' : 'L'} ${x} ${y}`;
})
.join(' ');
}
return paths;
}, [providers, chartData, xScale, yScale, metricMode]);
// Handle mouse events for tooltip
// Handle mouse events
const handleMouseEnter = useCallback(
(point: DataPoint, index: number, event: React.MouseEvent<SVGCircleElement>) => {
setHoveredPoint({ point, index });
(dayIndex: number, provider: string, event: React.MouseEvent<SVGCircleElement>) => {
setHoveredDay({ dayIndex, provider });
const rect = event.currentTarget.getBoundingClientRect();
setTooltipPos({
x: rect.left + rect.width / 2,
@@ -222,65 +259,18 @@ export function AgentUsageChart({ data, timeRange, theme, colorBlindMode = false
);
const handleMouseLeave = useCallback(() => {
setHoveredPoint(null);
setHoveredDay(null);
setTooltipPos(null);
}, []);
// Generate unique IDs for gradients
const gradientIdSessions = useMemo(() => `sessions-gradient-${Math.random().toString(36).slice(2, 9)}`, []);
const gradientIdDuration = useMemo(() => `duration-gradient-${Math.random().toString(36).slice(2, 9)}`, []);
// Parse colors for gradients
const sessionRgb = useMemo(() => {
const color = sessionColor;
if (color.startsWith('#')) {
const hex = color.slice(1);
return {
r: parseInt(hex.slice(0, 2), 16),
g: parseInt(hex.slice(2, 4), 16),
b: parseInt(hex.slice(4, 6), 16),
};
}
return { r: 100, g: 149, b: 237 };
}, [sessionColor]);
const durationRgb = useMemo(() => {
const color = durationColor;
if (color.startsWith('#')) {
const hex = color.slice(1);
return {
r: parseInt(hex.slice(0, 2), 16),
g: parseInt(hex.slice(2, 4), 16),
b: parseInt(hex.slice(4, 6), 16),
};
}
return { r: 16, g: 185, b: 129 };
}, [durationColor]);
// Area paths for gradient fills
const sessionsAreaPath = useMemo(() => {
if (chartData.length === 0) return '';
const pathStart = chartData
.map((point, idx) => {
const x = xScale(idx);
const y = yScaleSessions(point.sessions);
return `${idx === 0 ? 'M' : 'L'} ${x} ${y}`;
})
.join(' ');
const lastX = xScale(chartData.length - 1);
const firstX = xScale(0);
const baseline = chartHeight - padding.bottom;
return `${pathStart} L ${lastX} ${baseline} L ${firstX} ${baseline} Z`;
}, [chartData, xScale, yScaleSessions, chartHeight, padding.bottom]);
return (
<div
className="p-4 rounded-lg"
style={{ backgroundColor: theme.colors.bgMain }}
role="figure"
aria-label={`Agent usage chart showing sessions and time over time. ${chartData.length} data points displayed.`}
aria-label={`Provider usage chart showing ${metricMode === 'count' ? 'query counts' : 'duration'} over time. ${providers.length} providers displayed.`}
>
{/* Header */}
{/* Header with title and metric toggle */}
<div className="flex items-center justify-between mb-4">
<h3
className="text-sm font-medium"
@@ -288,11 +278,41 @@ export function AgentUsageChart({ data, timeRange, theme, colorBlindMode = false
>
Agent Usage Over Time
</h3>
<div className="flex items-center gap-2">
<span className="text-xs" style={{ color: theme.colors.textDim }}>
Show:
</span>
<div
className="flex rounded overflow-hidden border"
style={{ borderColor: theme.colors.border }}
>
<button
onClick={() => setMetricMode('count')}
className="px-2 py-1 text-xs transition-colors"
style={{
backgroundColor: metricMode === 'count' ? theme.colors.accent : 'transparent',
color: metricMode === 'count' ? theme.colors.bgMain : theme.colors.textDim,
}}
>
Queries
</button>
<button
onClick={() => setMetricMode('duration')}
className="px-2 py-1 text-xs transition-colors"
style={{
backgroundColor: metricMode === 'duration' ? theme.colors.accent : 'transparent',
color: metricMode === 'duration' ? theme.colors.bgMain : theme.colors.textDim,
}}
>
Time
</button>
</div>
</div>
</div>
{/* Chart container */}
<div className="relative">
{chartData.length === 0 ? (
{allDates.length === 0 || providers.length === 0 ? (
<div
className="flex items-center justify-center"
style={{ height: chartHeight, color: theme.colors.textDim }}
@@ -305,71 +325,44 @@ export function AgentUsageChart({ data, timeRange, theme, colorBlindMode = false
viewBox={`0 0 ${chartWidth} ${chartHeight}`}
preserveAspectRatio="xMidYMid meet"
role="img"
aria-label={`Dual-axis chart showing sessions and time usage over time`}
aria-label={`Line chart showing ${metricMode === 'count' ? 'query counts' : 'duration'} per provider over time`}
>
{/* Gradient definitions */}
<defs>
<linearGradient id={gradientIdSessions} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={`rgba(${sessionRgb.r}, ${sessionRgb.g}, ${sessionRgb.b}, 0.2)`} />
<stop offset="100%" stopColor={`rgba(${sessionRgb.r}, ${sessionRgb.g}, ${sessionRgb.b}, 0)`} />
</linearGradient>
<linearGradient id={gradientIdDuration} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={`rgba(${durationRgb.r}, ${durationRgb.g}, ${durationRgb.b}, 0.2)`} />
<stop offset="100%" stopColor={`rgba(${durationRgb.r}, ${durationRgb.g}, ${durationRgb.b}, 0)`} />
</linearGradient>
</defs>
{/* Grid lines (horizontal) */}
{yTicksSessions.map((tick, idx) => (
{/* Grid lines */}
{yTicks.map((tick, idx) => (
<line
key={`grid-${idx}`}
x1={padding.left}
y1={yScaleSessions(tick)}
y1={yScale(tick)}
x2={chartWidth - padding.right}
y2={yScaleSessions(tick)}
y2={yScale(tick)}
stroke={theme.colors.border}
strokeOpacity={0.3}
strokeDasharray="4,4"
/>
))}
{/* Left Y-axis labels (Sessions) */}
{yTicksSessions.map((tick, idx) => (
{/* Y-axis labels */}
{yTicks.map((tick, idx) => (
<text
key={`y-left-${idx}`}
key={`y-${idx}`}
x={padding.left - 8}
y={yScaleSessions(tick)}
y={yScale(tick)}
textAnchor="end"
dominantBaseline="middle"
fontSize={10}
fill={sessionColor}
fill={theme.colors.textDim}
>
{tick}
</text>
))}
{/* Right Y-axis labels (Duration) */}
{yTicksDuration.map((tick, idx) => (
<text
key={`y-right-${idx}`}
x={chartWidth - padding.right + 8}
y={yScaleDuration(tick)}
textAnchor="start"
dominantBaseline="middle"
fontSize={10}
fill={durationColor}
>
{formatYAxisDuration(tick)}
{metricMode === 'count' ? tick : formatYAxisDuration(tick)}
</text>
))}
{/* X-axis labels */}
{chartData.map((point, idx) => {
const labelInterval = chartData.length > 14
? Math.ceil(chartData.length / 7)
: chartData.length > 7 ? 2 : 1;
{allDates.map((day, idx) => {
const labelInterval = allDates.length > 14
? Math.ceil(allDates.length / 7)
: allDates.length > 7 ? 2 : 1;
if (idx % labelInterval !== 0 && idx !== chartData.length - 1) {
if (idx % labelInterval !== 0 && idx !== allDates.length - 1) {
return null;
}
@@ -382,119 +375,73 @@ export function AgentUsageChart({ data, timeRange, theme, colorBlindMode = false
fontSize={10}
fill={theme.colors.textDim}
>
{formatXAxisDate(point.date, timeRange)}
{formatXAxisDate(day.date, timeRange)}
</text>
);
})}
{/* Sessions area fill */}
<path
d={sessionsAreaPath}
fill={`url(#${gradientIdSessions})`}
style={{ transition: 'd 0.5s cubic-bezier(0.4, 0, 0.2, 1)' }}
/>
{/* Sessions line */}
<path
d={sessionsPath}
fill="none"
stroke={sessionColor}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
style={{ transition: 'd 0.5s cubic-bezier(0.4, 0, 0.2, 1)' }}
/>
{/* Duration line */}
<path
d={durationPath}
fill="none"
stroke={durationColor}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
strokeDasharray="6,3"
style={{ transition: 'd 0.5s cubic-bezier(0.4, 0, 0.2, 1)' }}
/>
{/* Data points for sessions */}
{chartData.map((point, idx) => {
const x = xScale(idx);
const y = yScaleSessions(point.sessions);
const isHovered = hoveredPoint?.index === idx;
{/* Lines for each provider */}
{providers.map((provider, providerIdx) => {
const color = getProviderColor(provider, providerIdx, colorBlindMode);
return (
<circle
key={`session-point-${idx}`}
cx={x}
cy={y}
r={isHovered ? 6 : 4}
fill={isHovered ? sessionColor : theme.colors.bgMain}
stroke={sessionColor}
<path
key={`line-${provider}`}
d={linePaths[provider]}
fill="none"
stroke={color}
strokeWidth={2}
style={{
cursor: 'pointer',
transition: 'cx 0.5s, cy 0.5s, r 0.15s ease',
}}
onMouseEnter={(e) => handleMouseEnter(point, idx, e)}
onMouseLeave={handleMouseLeave}
strokeLinecap="round"
strokeLinejoin="round"
style={{ transition: 'd 0.5s cubic-bezier(0.4, 0, 0.2, 1)' }}
/>
);
})}
{/* Data points for duration */}
{chartData.map((point, idx) => {
const x = xScale(idx);
const y = yScaleDuration(point.duration);
const isHovered = hoveredPoint?.index === idx;
{/* Data points for each provider */}
{providers.map((provider, providerIdx) => {
const color = getProviderColor(provider, providerIdx, colorBlindMode);
return chartData[provider].map((day, dayIdx) => {
const x = xScale(dayIdx);
const y = yScale(metricMode === 'count' ? day.count : day.duration);
const isHovered = hoveredDay?.dayIndex === dayIdx && hoveredDay?.provider === provider;
return (
<circle
key={`duration-point-${idx}`}
cx={x}
cy={y}
r={isHovered ? 5 : 3}
fill={isHovered ? durationColor : theme.colors.bgMain}
stroke={durationColor}
strokeWidth={2}
style={{
cursor: 'pointer',
transition: 'cx 0.5s, cy 0.5s, r 0.15s ease',
}}
onMouseEnter={(e) => handleMouseEnter(point, idx, e)}
onMouseLeave={handleMouseLeave}
/>
);
return (
<circle
key={`point-${provider}-${dayIdx}`}
cx={x}
cy={y}
r={isHovered ? 6 : 4}
fill={isHovered ? color : theme.colors.bgMain}
stroke={color}
strokeWidth={2}
style={{
cursor: 'pointer',
transition: 'r 0.15s ease',
}}
onMouseEnter={(e) => handleMouseEnter(dayIdx, provider, e)}
onMouseLeave={handleMouseLeave}
/>
);
});
})}
{/* Y-axis labels */}
{/* Y-axis title */}
<text
x={12}
y={chartHeight / 2}
textAnchor="middle"
dominantBaseline="middle"
fontSize={11}
fill={sessionColor}
fill={theme.colors.textDim}
transform={`rotate(-90, 12, ${chartHeight / 2})`}
>
Sessions
</text>
<text
x={chartWidth - 10}
y={chartHeight / 2}
textAnchor="middle"
dominantBaseline="middle"
fontSize={11}
fill={durationColor}
transform={`rotate(90, ${chartWidth - 10}, ${chartHeight / 2})`}
>
Time
{metricMode === 'count' ? 'Queries' : 'Time'}
</text>
</svg>
)}
{/* Tooltip */}
{hoveredPoint && tooltipPos && (
{hoveredDay && tooltipPos && allDates[hoveredDay.dayIndex] && (
<div
className="fixed z-50 px-3 py-2 rounded text-xs whitespace-nowrap pointer-events-none shadow-lg"
style={{
@@ -506,16 +453,24 @@ export function AgentUsageChart({ data, timeRange, theme, colorBlindMode = false
border: `1px solid ${theme.colors.border}`,
}}
>
<div className="font-medium mb-1">{hoveredPoint.point.formattedDate}</div>
<div className="font-medium mb-1">{allDates[hoveredDay.dayIndex].formattedDate}</div>
<div style={{ color: theme.colors.textDim }}>
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: sessionColor }} />
Sessions: <span style={{ color: theme.colors.textMain }}>{hoveredPoint.point.sessions}</span>
</div>
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: durationColor }} />
Time: <span style={{ color: theme.colors.textMain }}>{formatDuration(hoveredPoint.point.duration)}</span>
</div>
{providers.map((provider, idx) => {
const dayData = allDates[hoveredDay.dayIndex].providers[provider];
if (!dayData || (dayData.count === 0 && dayData.duration === 0)) return null;
const color = getProviderColor(provider, idx, colorBlindMode);
return (
<div key={provider} className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: color }} />
<span>{provider}:</span>
<span style={{ color: theme.colors.textMain }}>
{metricMode === 'count'
? `${dayData.count} ${dayData.count === 1 ? 'query' : 'queries'}`
: formatDuration(dayData.duration)}
</span>
</div>
);
})}
</div>
</div>
)}
@@ -523,23 +478,18 @@ export function AgentUsageChart({ data, timeRange, theme, colorBlindMode = false
{/* Legend */}
<div
className="flex items-center justify-center gap-6 mt-3 pt-3 border-t"
className="flex items-center justify-center gap-4 mt-3 pt-3 border-t flex-wrap"
style={{ borderColor: theme.colors.border }}
>
<div className="flex items-center gap-2">
<div className="w-4 h-0.5 rounded" style={{ backgroundColor: sessionColor }} />
<span className="text-xs" style={{ color: theme.colors.textDim }}>Sessions</span>
</div>
<div className="flex items-center gap-2">
<div
className="w-4 h-0.5 rounded"
style={{
backgroundColor: durationColor,
backgroundImage: `repeating-linear-gradient(90deg, ${durationColor} 0, ${durationColor} 4px, transparent 4px, transparent 6px)`,
}}
/>
<span className="text-xs" style={{ color: theme.colors.textDim }}>Time</span>
</div>
{providers.map((provider, idx) => {
const color = getProviderColor(provider, idx, colorBlindMode);
return (
<div key={provider} className="flex items-center gap-1.5">
<div className="w-3 h-0.5 rounded" style={{ backgroundColor: color }} />
<span className="text-xs" style={{ color: theme.colors.textDim }}>{provider}</span>
</div>
);
})}
</div>
</div>
);

View File

@@ -214,7 +214,7 @@ export function SessionStats({ sessions, theme, colorBlindMode = false }: Sessio
{/* Summary Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
<StatCard
label="Total Sessions"
label="Total Agents"
value={stats.total}
icon={<Monitor className="w-4 h-4" style={{ color: theme.colors.accent }} />}
theme={theme}
@@ -234,7 +234,7 @@ export function SessionStats({ sessions, theme, colorBlindMode = false }: Sessio
theme={theme}
/>
<StatCard
label="Local Sessions"
label="Local Agents"
value={stats.localSessions}
icon={<Laptop className="w-4 h-4" style={{ color: theme.colors.accent }} />}
theme={theme}

View File

@@ -34,7 +34,7 @@ interface SummaryCardsProps {
data: StatsAggregation;
/** Current theme for styling */
theme: Theme;
/** Number of columns for responsive layout (default: 6) */
/** Number of columns for responsive layout (default: 3 for 2 rows × 3 cols) */
columns?: number;
}
@@ -114,7 +114,7 @@ function MetricCard({ icon, label, value, theme, animationIndex = 0 }: MetricCar
{label}
</div>
<div
className="text-2xl font-bold truncate"
className="text-2xl font-bold"
style={{ color: theme.colors.textMain }}
title={value}
>
@@ -125,7 +125,7 @@ function MetricCard({ icon, label, value, theme, animationIndex = 0 }: MetricCar
);
}
export function SummaryCards({ data, theme, columns = 6 }: SummaryCardsProps) {
export function SummaryCards({ data, theme, columns = 3 }: SummaryCardsProps) {
// Calculate derived metrics
const { mostActiveAgent, interactiveRatio } = useMemo(() => {
// Find most active agent by query count

View File

@@ -63,6 +63,8 @@ interface StatsAggregation {
sessionsByAgent: Record<string, number>;
sessionsByDay: Array<{ date: string; count: number }>;
avgSessionDuration: number;
// Per-agent per-day breakdown for provider usage chart
byAgentByDay: Record<string, Array<{ date: string; count: number; duration: number }>>;
}
// View mode options for the dashboard
@@ -300,8 +302,8 @@ export function UsageDashboardModal({
isWide,
// Chart grid: 1 col on narrow, 2 cols on medium/wide
chartGridCols: isNarrow ? 1 : 2,
// Summary cards: 2 cols on narrow, 3 on medium, 5 on wide
summaryCardsCols: isNarrow ? 2 : isMedium ? 3 : 5,
// Summary cards: 2 cols on narrow, 3 on medium/wide (2 rows × 3 cols)
summaryCardsCols: isNarrow ? 2 : 3,
// AutoRun stats: 2 cols on narrow, 3 on medium, 6 on wide
autoRunStatsCols: isNarrow ? 2 : isMedium ? 3 : 6,
};

View File

@@ -380,21 +380,32 @@ function TypingIndicator({
}
/**
* Extract a descriptive detail string from tool input
* Looks for common properties like command, pattern, file_path, query
* Safely convert a value to a string for rendering.
* Returns the string if it's already a string, otherwise null.
* This prevents objects from being passed to React as children.
*/
function safeString(value: unknown): string | null {
return typeof value === 'string' ? value : null;
}
/**
* Extract a descriptive detail string from tool input.
* Looks for common properties like command, pattern, file_path, query.
* Only returns actual strings - objects are safely ignored to prevent React errors.
*/
function getToolDetail(input: unknown): string | null {
if (!input || typeof input !== 'object') return null;
const inputObj = input as Record<string, unknown>;
// Check common tool input properties in order of preference
const detail =
(inputObj.command as string) ||
(inputObj.pattern as string) ||
(inputObj.file_path as string) ||
(inputObj.query as string) ||
(inputObj.path as string) ||
null;
return detail;
// Use safeString to ensure we only return actual strings, not objects
return (
safeString(inputObj.command) ||
safeString(inputObj.pattern) ||
safeString(inputObj.file_path) ||
safeString(inputObj.query) ||
safeString(inputObj.path) ||
null
);
}
/**

View File

@@ -1669,6 +1669,7 @@ interface MaestroAPI {
sessionsByAgent: Record<string, number>;
sessionsByDay: Array<{ date: string; count: number }>;
avgSessionDuration: number;
byAgentByDay: Record<string, Array<{ date: string; count: number; duration: number }>>;
}>;
// Export query events to CSV
exportCsv: (range: 'day' | 'week' | 'month' | 'year' | 'all') => Promise<string>;

View File

@@ -34,6 +34,8 @@ export interface StatsAggregation {
sessionsByAgent: Record<string, number>;
sessionsByDay: Array<{ date: string; count: number }>;
avgSessionDuration: number;
// Per-agent per-day breakdown for provider usage chart
byAgentByDay: Record<string, Array<{ date: string; count: number; duration: number }>>;
}
// Return type for the useStats hook

View File

@@ -93,6 +93,8 @@ export interface StatsAggregation {
sessionsByDay: Array<{ date: string; count: number }>;
/** Average session duration in ms (for closed sessions) */
avgSessionDuration: number;
/** Queries and duration by agent per day (for provider usage chart) */
byAgentByDay: Record<string, Array<{ date: string; count: number; duration: number }>>;
}
/**