mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
## CHANGES
- Removed OpenSpec prompt injection quick actions and related modal wiring 🧹 - QuickActionsModal no longer fetches or exposes OpenSpec command helpers 🔌 - Added brand-new Agent Usage Over Time dual-axis chart (sessions + time) 📈 - Integrated Agent Usage section into Agents dashboard with keyboard navigation 🧭 - Renamed “Agent Comparison” UI to “Provider Comparison” for clarity 🏷️ - Updated “Session Statistics” panels to “Agent Statistics” across dashboard 📊 - Rebranded “Source Distribution” chart to “Session Type” for better meaning 🗂️ - Improved accessibility labels to match new provider/session terminology ♿ - Fixed batch processor ref syncing to prevent “0 of N completed” regressions 🛠️
This commit is contained in:
@@ -11951,24 +11951,6 @@ You are taking over this conversation. Based on the context above, provide a bri
|
||||
() => setEnterToSendAI(!enterToSendAI),
|
||||
[enterToSendAI]
|
||||
);
|
||||
// OpenSpec command injection - sets prompt content into input field
|
||||
const handleInjectOpenSpecPrompt = useCallback(
|
||||
(prompt: string) => {
|
||||
if (activeGroupChatId) {
|
||||
// Update group chat draft
|
||||
setGroupChats(prev =>
|
||||
prev.map(c =>
|
||||
c.id === activeGroupChatId ? { ...c, draftMessage: prompt } : c
|
||||
)
|
||||
);
|
||||
} else {
|
||||
setInputValue(prompt);
|
||||
}
|
||||
// Focus the input so user can edit/send the injected prompt
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
},
|
||||
[activeGroupChatId, setInputValue]
|
||||
);
|
||||
|
||||
// QuickActionsModal stable callbacks
|
||||
const handleQuickActionsRenameTab = useCallback(() => {
|
||||
@@ -12816,7 +12798,6 @@ You are taking over this conversation. Based on the context above, provide a bri
|
||||
isFilePreviewOpen={previewFile !== null}
|
||||
ghCliAvailable={ghCliAvailable}
|
||||
onPublishGist={() => setGistPublishModalOpen(true)}
|
||||
onInjectOpenSpecPrompt={handleInjectOpenSpecPrompt}
|
||||
lastGraphFocusFile={lastGraphFocusFilePath}
|
||||
onOpenLastDocumentGraph={() => {
|
||||
if (lastGraphFocusFilePath) {
|
||||
|
||||
@@ -791,8 +791,6 @@ export interface AppUtilityModalsProps {
|
||||
autoRunSelectedDocument: string | null;
|
||||
autoRunCompletedTaskCount: number;
|
||||
onAutoRunResetTasks: () => void;
|
||||
// OpenSpec commands
|
||||
onInjectOpenSpecPrompt?: (prompt: string) => void;
|
||||
|
||||
// Gist publishing (for QuickActionsModal)
|
||||
isFilePreviewOpen: boolean;
|
||||
@@ -975,8 +973,6 @@ export function AppUtilityModals({
|
||||
isFilePreviewOpen,
|
||||
ghCliAvailable,
|
||||
onPublishGist,
|
||||
// OpenSpec commands
|
||||
onInjectOpenSpecPrompt,
|
||||
// Document Graph - quick re-open last graph
|
||||
lastGraphFocusFile,
|
||||
onOpenLastDocumentGraph,
|
||||
@@ -1127,7 +1123,6 @@ export function AppUtilityModals({
|
||||
isFilePreviewOpen={isFilePreviewOpen}
|
||||
ghCliAvailable={ghCliAvailable}
|
||||
onPublishGist={onPublishGist}
|
||||
onInjectOpenSpecPrompt={onInjectOpenSpecPrompt}
|
||||
onOpenPlaybookExchange={onOpenMarketplace}
|
||||
lastGraphFocusFile={lastGraphFocusFile}
|
||||
onOpenLastDocumentGraph={onOpenLastDocumentGraph}
|
||||
@@ -1839,8 +1834,6 @@ export interface AppModalsProps {
|
||||
isFilePreviewOpen: boolean;
|
||||
ghCliAvailable: boolean;
|
||||
onPublishGist?: () => void;
|
||||
// OpenSpec commands
|
||||
onInjectOpenSpecPrompt?: (prompt: string) => void;
|
||||
// Document Graph - quick re-open last graph
|
||||
lastGraphFocusFile?: string;
|
||||
onOpenLastDocumentGraph?: () => void;
|
||||
@@ -2131,8 +2124,6 @@ export function AppModals(props: AppModalsProps) {
|
||||
isFilePreviewOpen,
|
||||
ghCliAvailable,
|
||||
onPublishGist,
|
||||
// OpenSpec commands
|
||||
onInjectOpenSpecPrompt,
|
||||
// Document Graph - quick re-open last graph
|
||||
lastGraphFocusFile,
|
||||
onOpenLastDocumentGraph,
|
||||
@@ -2433,7 +2424,6 @@ export function AppModals(props: AppModalsProps) {
|
||||
isFilePreviewOpen={isFilePreviewOpen}
|
||||
ghCliAvailable={ghCliAvailable}
|
||||
onPublishGist={onPublishGist}
|
||||
onInjectOpenSpecPrompt={onInjectOpenSpecPrompt}
|
||||
lastGraphFocusFile={lastGraphFocusFile}
|
||||
onOpenLastDocumentGraph={onOpenLastDocumentGraph}
|
||||
lightboxImage={lightboxImage}
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useLayerStack } from '../contexts/LayerStackContext';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { MODAL_PRIORITIES } from '../constants/modalPriorities';
|
||||
import { gitService } from '../services/git';
|
||||
import { getOpenSpecCommand } from '../services/openspec';
|
||||
import { formatShortcutKeys } from '../utils/shortcutFormatter';
|
||||
import type { WizardStep } from './Wizard/WizardContext';
|
||||
import { useListNavigation } from '../hooks';
|
||||
@@ -105,8 +104,6 @@ interface QuickActionsModalProps {
|
||||
isFilePreviewOpen?: boolean;
|
||||
ghCliAvailable?: boolean;
|
||||
onPublishGist?: () => void;
|
||||
// OpenSpec commands
|
||||
onInjectOpenSpecPrompt?: (prompt: string) => void;
|
||||
// Playbook Exchange
|
||||
onOpenPlaybookExchange?: () => void;
|
||||
// Document Graph - quick re-open last graph
|
||||
@@ -133,7 +130,6 @@ export function QuickActionsModal(props: QuickActionsModalProps) {
|
||||
autoRunSelectedDocument, autoRunCompletedTaskCount, onAutoRunResetTasks,
|
||||
onCloseAllTabs, onCloseOtherTabs, onCloseTabsLeft, onCloseTabsRight,
|
||||
isFilePreviewOpen, ghCliAvailable, onPublishGist,
|
||||
onInjectOpenSpecPrompt,
|
||||
onOpenPlaybookExchange,
|
||||
lastGraphFocusFile, onOpenLastDocumentGraph
|
||||
} = props;
|
||||
@@ -448,69 +444,6 @@ export function QuickActionsModal(props: QuickActionsModalProps) {
|
||||
...(onNewGroupChat && sessions.filter(s => s.toolType !== 'terminal').length >= 2 ? [{ id: 'newGroupChat', label: 'New Group Chat', action: () => { onNewGroupChat(); setQuickActionOpen(false); } }] : []),
|
||||
...(activeGroupChatId && onCloseGroupChat ? [{ id: 'closeGroupChat', label: 'Close Group Chat', action: () => { onCloseGroupChat(); setQuickActionOpen(false); } }] : []),
|
||||
...(activeGroupChatId && onDeleteGroupChat && groupChats ? [{ id: 'deleteGroupChat', label: `Remove Group Chat: ${groupChats.find(c => c.id === activeGroupChatId)?.name || 'Group Chat'}`, shortcut: shortcuts.killInstance, action: () => { onDeleteGroupChat(activeGroupChatId); setQuickActionOpen(false); } }] : []),
|
||||
// OpenSpec commands - inject prompt when selected
|
||||
...(onInjectOpenSpecPrompt ? [
|
||||
{
|
||||
id: 'openspec-proposal',
|
||||
label: 'OpenSpec: Create Proposal',
|
||||
subtext: 'Create a new change proposal',
|
||||
action: async () => {
|
||||
const cmd = await getOpenSpecCommand('/openspec.proposal');
|
||||
if (cmd) {
|
||||
onInjectOpenSpecPrompt(cmd.prompt);
|
||||
}
|
||||
setQuickActionOpen(false);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'openspec-apply',
|
||||
label: 'OpenSpec: Apply Changes',
|
||||
subtext: 'Apply an approved change proposal',
|
||||
action: async () => {
|
||||
const cmd = await getOpenSpecCommand('/openspec.apply');
|
||||
if (cmd) {
|
||||
onInjectOpenSpecPrompt(cmd.prompt);
|
||||
}
|
||||
setQuickActionOpen(false);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'openspec-archive',
|
||||
label: 'OpenSpec: Archive Change',
|
||||
subtext: 'Archive a completed change',
|
||||
action: async () => {
|
||||
const cmd = await getOpenSpecCommand('/openspec.archive');
|
||||
if (cmd) {
|
||||
onInjectOpenSpecPrompt(cmd.prompt);
|
||||
}
|
||||
setQuickActionOpen(false);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'openspec-implement',
|
||||
label: 'OpenSpec: Generate Auto Run',
|
||||
subtext: 'Generate Auto Run document from proposal',
|
||||
action: async () => {
|
||||
const cmd = await getOpenSpecCommand('/openspec.implement');
|
||||
if (cmd) {
|
||||
onInjectOpenSpecPrompt(cmd.prompt);
|
||||
}
|
||||
setQuickActionOpen(false);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'openspec-help',
|
||||
label: 'OpenSpec: Help',
|
||||
subtext: 'Show OpenSpec workflow help',
|
||||
action: async () => {
|
||||
const cmd = await getOpenSpecCommand('/openspec.help');
|
||||
if (cmd) {
|
||||
onInjectOpenSpecPrompt(cmd.prompt);
|
||||
}
|
||||
setQuickActionOpen(false);
|
||||
}
|
||||
}
|
||||
] : []),
|
||||
// Debug commands - only visible when user types "debug"
|
||||
{ id: 'debugResetBusy', label: 'Debug: Reset Busy State', subtext: 'Clear stuck thinking/busy state for all sessions', action: () => {
|
||||
// Reset all sessions and tabs to idle state
|
||||
|
||||
@@ -157,7 +157,7 @@ export function AgentComparisonChart({ data, theme, colorBlindMode = false }: Ag
|
||||
className="p-4 rounded-lg"
|
||||
style={{ backgroundColor: theme.colors.bgMain }}
|
||||
role="figure"
|
||||
aria-label={`Agent comparison chart showing query counts and duration by agent type. ${agentData.length} agents displayed.`}
|
||||
aria-label={`Provider comparison chart showing query counts and duration by provider type. ${agentData.length} providers displayed.`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
@@ -165,7 +165,7 @@ export function AgentComparisonChart({ data, theme, colorBlindMode = false }: Ag
|
||||
className="text-sm font-medium"
|
||||
style={{ color: theme.colors.textMain }}
|
||||
>
|
||||
Agent Comparison
|
||||
Provider Comparison
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
|
||||
548
src/renderer/components/UsageDashboard/AgentUsageChart.tsx
Normal file
548
src/renderer/components/UsageDashboard/AgentUsageChart.tsx
Normal file
@@ -0,0 +1,548 @@
|
||||
/**
|
||||
* AgentUsageChart
|
||||
*
|
||||
* Line chart showing agent usage over time with dual metrics.
|
||||
* Displays both session count and total time on the same chart.
|
||||
*
|
||||
* Features:
|
||||
* - Dual Y-axes: sessions (left) and time (right)
|
||||
* - Color-coded lines for each metric
|
||||
* - Hover tooltips with exact values
|
||||
* - Responsive SVG rendering
|
||||
* - Theme-aware styling
|
||||
*/
|
||||
|
||||
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';
|
||||
|
||||
// Data point for the chart
|
||||
interface DataPoint {
|
||||
date: string;
|
||||
formattedDate: string;
|
||||
sessions: number;
|
||||
duration: number; // Total duration in ms
|
||||
}
|
||||
|
||||
interface AgentUsageChartProps {
|
||||
/** Aggregated stats data from the API */
|
||||
data: StatsAggregation;
|
||||
/** Current time range selection */
|
||||
timeRange: StatsTimeRange;
|
||||
/** Current theme for styling */
|
||||
theme: Theme;
|
||||
/** Enable colorblind-friendly colors */
|
||||
colorBlindMode?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration in milliseconds to human-readable string
|
||||
*/
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms === 0) return '0s';
|
||||
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m ${seconds}s`;
|
||||
}
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration for Y-axis labels (shorter format)
|
||||
*/
|
||||
function formatYAxisDuration(ms: number): string {
|
||||
if (ms === 0) return '0';
|
||||
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m`;
|
||||
}
|
||||
return `${totalSeconds}s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date for X-axis based on time range
|
||||
*/
|
||||
function formatXAxisDate(dateStr: string, timeRange: StatsTimeRange): string {
|
||||
const date = parseISO(dateStr);
|
||||
|
||||
switch (timeRange) {
|
||||
case 'day':
|
||||
return format(date, 'HH:mm');
|
||||
case 'week':
|
||||
return format(date, 'EEE');
|
||||
case 'month':
|
||||
return format(date, 'MMM d');
|
||||
case 'year':
|
||||
return format(date, 'MMM');
|
||||
case 'all':
|
||||
return format(date, 'MMM yyyy');
|
||||
default:
|
||||
return format(date, 'MMM d');
|
||||
}
|
||||
}
|
||||
|
||||
export function AgentUsageChart({ data, timeRange, theme, colorBlindMode = false }: AgentUsageChartProps) {
|
||||
const [hoveredPoint, setHoveredPoint] = useState<{ point: DataPoint; index: number } | null>(null);
|
||||
const [tooltipPos, setTooltipPos] = useState<{ x: number; y: number } | null>(null);
|
||||
|
||||
// Chart dimensions
|
||||
const chartWidth = 600;
|
||||
const chartHeight = 220;
|
||||
const padding = { top: 20, right: 60, 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]);
|
||||
|
||||
const durationColor = useMemo(() => {
|
||||
return colorBlindMode ? COLORBLIND_LINE_COLORS.secondary : '#10b981'; // emerald
|
||||
}, [colorBlindMode]);
|
||||
|
||||
// Combine byDay data with sessionsByDay to get both metrics per day
|
||||
const chartData = useMemo((): DataPoint[] => {
|
||||
if (data.byDay.length === 0) return [];
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
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]);
|
||||
|
||||
// Calculate scales for both Y-axes
|
||||
const { xScale, yScaleSessions, yScaleDuration, yTicksSessions, yTicksDuration } = useMemo(() => {
|
||||
if (chartData.length === 0) {
|
||||
return {
|
||||
xScale: (_: number) => padding.left,
|
||||
yScaleSessions: (_: number) => chartHeight - padding.bottom,
|
||||
yScaleDuration: (_: number) => chartHeight - padding.bottom,
|
||||
yTicksSessions: [0],
|
||||
yTicksDuration: [0],
|
||||
};
|
||||
}
|
||||
|
||||
const maxSessions = Math.max(...chartData.map((d) => d.sessions), 1);
|
||||
const maxDuration = Math.max(...chartData.map((d) => d.duration), 1);
|
||||
|
||||
// Add 10% padding to max values
|
||||
const sessionMax = Math.ceil(maxSessions * 1.1);
|
||||
const durationMax = maxDuration * 1.1;
|
||||
|
||||
// X scale - linear across data points
|
||||
const xScaleFn = (index: number) =>
|
||||
padding.left + (index / Math.max(chartData.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 for duration (right axis)
|
||||
const yScaleDurationFn = (value: number) =>
|
||||
chartHeight - padding.bottom - (value / durationMax) * innerHeight;
|
||||
|
||||
// Generate nice Y-axis 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
|
||||
);
|
||||
|
||||
return {
|
||||
xScale: xScaleFn,
|
||||
yScaleSessions: yScaleSessionsFn,
|
||||
yScaleDuration: yScaleDurationFn,
|
||||
yTicksSessions: yTicksSessionsArr,
|
||||
yTicksDuration: yTicksDurationArr,
|
||||
};
|
||||
}, [chartData, 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]);
|
||||
|
||||
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]);
|
||||
|
||||
// Handle mouse events for tooltip
|
||||
const handleMouseEnter = useCallback(
|
||||
(point: DataPoint, index: number, event: React.MouseEvent<SVGCircleElement>) => {
|
||||
setHoveredPoint({ point, index });
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
setTooltipPos({
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top,
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
setHoveredPoint(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.`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3
|
||||
className="text-sm font-medium"
|
||||
style={{ color: theme.colors.textMain }}
|
||||
>
|
||||
Agent Usage Over Time
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Chart container */}
|
||||
<div className="relative">
|
||||
{chartData.length === 0 ? (
|
||||
<div
|
||||
className="flex items-center justify-center"
|
||||
style={{ height: chartHeight, color: theme.colors.textDim }}
|
||||
>
|
||||
<span className="text-sm">No usage data available</span>
|
||||
</div>
|
||||
) : (
|
||||
<svg
|
||||
width="100%"
|
||||
viewBox={`0 0 ${chartWidth} ${chartHeight}`}
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
role="img"
|
||||
aria-label={`Dual-axis chart showing sessions and time usage 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) => (
|
||||
<line
|
||||
key={`grid-${idx}`}
|
||||
x1={padding.left}
|
||||
y1={yScaleSessions(tick)}
|
||||
x2={chartWidth - padding.right}
|
||||
y2={yScaleSessions(tick)}
|
||||
stroke={theme.colors.border}
|
||||
strokeOpacity={0.3}
|
||||
strokeDasharray="4,4"
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Left Y-axis labels (Sessions) */}
|
||||
{yTicksSessions.map((tick, idx) => (
|
||||
<text
|
||||
key={`y-left-${idx}`}
|
||||
x={padding.left - 8}
|
||||
y={yScaleSessions(tick)}
|
||||
textAnchor="end"
|
||||
dominantBaseline="middle"
|
||||
fontSize={10}
|
||||
fill={sessionColor}
|
||||
>
|
||||
{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)}
|
||||
</text>
|
||||
))}
|
||||
|
||||
{/* X-axis labels */}
|
||||
{chartData.map((point, idx) => {
|
||||
const labelInterval = chartData.length > 14
|
||||
? Math.ceil(chartData.length / 7)
|
||||
: chartData.length > 7 ? 2 : 1;
|
||||
|
||||
if (idx % labelInterval !== 0 && idx !== chartData.length - 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<text
|
||||
key={`x-label-${idx}`}
|
||||
x={xScale(idx)}
|
||||
y={chartHeight - padding.bottom + 20}
|
||||
textAnchor="middle"
|
||||
fontSize={10}
|
||||
fill={theme.colors.textDim}
|
||||
>
|
||||
{formatXAxisDate(point.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;
|
||||
|
||||
return (
|
||||
<circle
|
||||
key={`session-point-${idx}`}
|
||||
cx={x}
|
||||
cy={y}
|
||||
r={isHovered ? 6 : 4}
|
||||
fill={isHovered ? sessionColor : theme.colors.bgMain}
|
||||
stroke={sessionColor}
|
||||
strokeWidth={2}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
transition: 'cx 0.5s, cy 0.5s, r 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => handleMouseEnter(point, idx, e)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Data points for duration */}
|
||||
{chartData.map((point, idx) => {
|
||||
const x = xScale(idx);
|
||||
const y = yScaleDuration(point.duration);
|
||||
const isHovered = hoveredPoint?.index === idx;
|
||||
|
||||
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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Y-axis labels */}
|
||||
<text
|
||||
x={12}
|
||||
y={chartHeight / 2}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize={11}
|
||||
fill={sessionColor}
|
||||
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
|
||||
</text>
|
||||
</svg>
|
||||
)}
|
||||
|
||||
{/* Tooltip */}
|
||||
{hoveredPoint && tooltipPos && (
|
||||
<div
|
||||
className="fixed z-50 px-3 py-2 rounded text-xs whitespace-nowrap pointer-events-none shadow-lg"
|
||||
style={{
|
||||
left: tooltipPos.x,
|
||||
top: tooltipPos.y - 8,
|
||||
transform: 'translate(-50%, -100%)',
|
||||
backgroundColor: theme.colors.bgActivity,
|
||||
color: theme.colors.textMain,
|
||||
border: `1px solid ${theme.colors.border}`,
|
||||
}}
|
||||
>
|
||||
<div className="font-medium mb-1">{hoveredPoint.point.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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div
|
||||
className="flex items-center justify-center gap-6 mt-3 pt-3 border-t"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AgentUsageChart;
|
||||
@@ -187,7 +187,7 @@ export function SessionStats({ sessions, theme, colorBlindMode = false }: Sessio
|
||||
className="text-sm font-medium mb-4"
|
||||
style={{ color: theme.colors.textMain }}
|
||||
>
|
||||
Session Statistics
|
||||
Agent Statistics
|
||||
</h3>
|
||||
<div
|
||||
className="flex items-center justify-center h-24"
|
||||
@@ -208,7 +208,7 @@ export function SessionStats({ sessions, theme, colorBlindMode = false }: Sessio
|
||||
className="text-sm font-medium mb-4"
|
||||
style={{ color: theme.colors.textMain }}
|
||||
>
|
||||
Session Statistics
|
||||
Agent Statistics
|
||||
</h3>
|
||||
|
||||
{/* Summary Cards */}
|
||||
|
||||
@@ -242,7 +242,7 @@ export function SourceDistributionChart({
|
||||
className="p-4 rounded-lg"
|
||||
style={{ backgroundColor: theme.colors.bgMain }}
|
||||
role="figure"
|
||||
aria-label={`Source distribution chart showing ${metricMode === 'count' ? 'query counts' : 'duration'} breakdown between Interactive and Auto Run sources.`}
|
||||
aria-label={`Session type chart showing ${metricMode === 'count' ? 'query counts' : 'duration'} breakdown between Interactive and Auto Run sessions.`}
|
||||
>
|
||||
{/* Header with title and metric toggle */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
@@ -250,7 +250,7 @@ export function SourceDistributionChart({
|
||||
className="text-sm font-medium"
|
||||
style={{ color: theme.colors.textMain }}
|
||||
>
|
||||
Source Distribution
|
||||
Session Type
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
|
||||
@@ -22,6 +22,7 @@ import { SourceDistributionChart } from './SourceDistributionChart';
|
||||
import { LocationDistributionChart } from './LocationDistributionChart';
|
||||
import { PeakHoursChart } from './PeakHoursChart';
|
||||
import { DurationTrendsChart } from './DurationTrendsChart';
|
||||
import { AgentUsageChart } from './AgentUsageChart';
|
||||
import { AutoRunStats } from './AutoRunStats';
|
||||
import { SessionStats } from './SessionStats';
|
||||
import { EmptyState } from './EmptyState';
|
||||
@@ -35,7 +36,7 @@ import { PERFORMANCE_THRESHOLDS } from '../../../shared/performance-metrics';
|
||||
|
||||
// Section IDs for keyboard navigation
|
||||
const OVERVIEW_SECTIONS = ['summary-cards', 'agent-comparison', 'source-distribution', 'location-distribution', 'peak-hours', 'activity-heatmap', 'duration-trends'] as const;
|
||||
const AGENTS_SECTIONS = ['session-stats', 'agent-comparison'] as const;
|
||||
const AGENTS_SECTIONS = ['session-stats', 'agent-comparison', 'agent-usage'] as const;
|
||||
const ACTIVITY_SECTIONS = ['activity-heatmap', 'duration-trends'] as const;
|
||||
const AUTORUN_SECTIONS = ['autorun-stats'] as const;
|
||||
|
||||
@@ -326,9 +327,10 @@ export function UsageDashboardModal({
|
||||
const getSectionLabel = useCallback((sectionId: SectionId): string => {
|
||||
const labels: Record<SectionId, string> = {
|
||||
'summary-cards': 'Summary Cards',
|
||||
'session-stats': 'Session Statistics',
|
||||
'agent-comparison': 'Agent Comparison Chart',
|
||||
'source-distribution': 'Source Distribution Chart',
|
||||
'session-stats': 'Agent Statistics',
|
||||
'agent-comparison': 'Provider Comparison Chart',
|
||||
'agent-usage': 'Agent Usage Chart',
|
||||
'source-distribution': 'Session Type Chart',
|
||||
'location-distribution': 'Location Distribution Chart',
|
||||
'peak-hours': 'Peak Hours Chart',
|
||||
'activity-heatmap': 'Activity Heatmap',
|
||||
@@ -822,12 +824,12 @@ export function UsageDashboardModal({
|
||||
|
||||
{viewMode === 'agents' && (
|
||||
<>
|
||||
{/* Session Statistics */}
|
||||
{/* Agent Statistics */}
|
||||
<div
|
||||
ref={setSectionRef('session-stats')}
|
||||
tabIndex={0}
|
||||
role="region"
|
||||
aria-label="Session Statistics"
|
||||
aria-label={getSectionLabel('session-stats')}
|
||||
onKeyDown={(e) => handleSectionKeyDown(e, 'session-stats')}
|
||||
className="outline-none rounded-lg transition-shadow dashboard-section-enter"
|
||||
style={{
|
||||
@@ -836,12 +838,12 @@ export function UsageDashboardModal({
|
||||
}}
|
||||
data-testid="section-session-stats"
|
||||
>
|
||||
<ChartErrorBoundary theme={theme} chartName="Session Statistics">
|
||||
<ChartErrorBoundary theme={theme} chartName="Agent Statistics">
|
||||
<SessionStats sessions={sessions} theme={theme} colorBlindMode={colorBlindMode} />
|
||||
</ChartErrorBoundary>
|
||||
</div>
|
||||
|
||||
{/* Agent-focused view */}
|
||||
{/* Provider Comparison */}
|
||||
<div
|
||||
ref={setSectionRef('agent-comparison')}
|
||||
tabIndex={0}
|
||||
@@ -850,16 +852,36 @@ export function UsageDashboardModal({
|
||||
onKeyDown={(e) => handleSectionKeyDown(e, 'agent-comparison')}
|
||||
className="outline-none rounded-lg transition-shadow dashboard-section-enter"
|
||||
style={{
|
||||
minHeight: '400px',
|
||||
minHeight: '180px',
|
||||
boxShadow: focusedSection === 'agent-comparison' ? `0 0 0 2px ${theme.colors.accent}` : 'none',
|
||||
animationDelay: '100ms',
|
||||
}}
|
||||
data-testid="section-agent-comparison"
|
||||
>
|
||||
<ChartErrorBoundary theme={theme} chartName="Agent Comparison">
|
||||
<ChartErrorBoundary theme={theme} chartName="Provider Comparison">
|
||||
<AgentComparisonChart data={data} theme={theme} colorBlindMode={colorBlindMode} />
|
||||
</ChartErrorBoundary>
|
||||
</div>
|
||||
|
||||
{/* Agent Usage Over Time */}
|
||||
<div
|
||||
ref={setSectionRef('agent-usage')}
|
||||
tabIndex={0}
|
||||
role="region"
|
||||
aria-label={getSectionLabel('agent-usage')}
|
||||
onKeyDown={(e) => handleSectionKeyDown(e, 'agent-usage')}
|
||||
className="outline-none rounded-lg transition-shadow dashboard-section-enter"
|
||||
style={{
|
||||
minHeight: '280px',
|
||||
boxShadow: focusedSection === 'agent-usage' ? `0 0 0 2px ${theme.colors.accent}` : 'none',
|
||||
animationDelay: '200ms',
|
||||
}}
|
||||
data-testid="section-agent-usage"
|
||||
>
|
||||
<ChartErrorBoundary theme={theme} chartName="Agent Usage">
|
||||
<AgentUsageChart data={data} timeRange={timeRange} theme={theme} colorBlindMode={colorBlindMode} />
|
||||
</ChartErrorBoundary>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -219,8 +219,11 @@ export function useBatchProcessor({
|
||||
sessionsRef.current = sessions;
|
||||
|
||||
// Ref to track latest batchRunStates for time tracking callback
|
||||
const batchRunStatesRef = useRef(batchRunStates);
|
||||
batchRunStatesRef.current = batchRunStates;
|
||||
// NOTE: Do NOT auto-sync with batchRunStates on every render!
|
||||
// The dispatch wrapper updates this ref synchronously after each action.
|
||||
// Auto-syncing can reset the ref to stale React state when batches are pending,
|
||||
// causing the "0 of N tasks completed" regression. See commit 01eca193.
|
||||
const batchRunStatesRef = useRef<Record<string, BatchRunState>>(batchRunStates);
|
||||
|
||||
// Ref to track latest updateBatchStateAndBroadcast for async callbacks (fixes HMR stale closure)
|
||||
const updateBatchStateAndBroadcastRef = useRef<typeof updateBatchStateAndBroadcast | null>(null);
|
||||
|
||||
Reference in New Issue
Block a user