mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
## 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:
@@ -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 }),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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} />);
|
||||
|
||||
@@ -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))' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -143,6 +143,7 @@ const createSampleData = () => ({
|
||||
{ date: '2024-01-18', count: 7 },
|
||||
],
|
||||
avgSessionDuration: 144000,
|
||||
byAgentByDay: {},
|
||||
});
|
||||
|
||||
describe('UsageDashboardModal', () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
1
src/renderer/global.d.ts
vendored
1
src/renderer/global.d.ts
vendored
@@ -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>;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }>>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user