From 51aa4b9ff0da144f85e02936bacadca0a87ca36c Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Sun, 18 Jan 2026 12:44:17 -0600 Subject: [PATCH] =?UTF-8?q?##=20CHANGES=20-=20Removed=20OpenSpec=20prompt?= =?UTF-8?q?=20injection=20quick=20actions=20and=20related=20modal=20wiring?= =?UTF-8?q?=20=F0=9F=A7=B9=20-=20QuickActionsModal=20no=20longer=20fetches?= =?UTF-8?q?=20or=20exposes=20OpenSpec=20command=20helpers=20=F0=9F=94=8C?= =?UTF-8?q?=20-=20Added=20brand-new=20Agent=20Usage=20Over=20Time=20dual-a?= =?UTF-8?q?xis=20chart=20(sessions=20+=20time)=20=F0=9F=93=88=20-=20Integr?= =?UTF-8?q?ated=20Agent=20Usage=20section=20into=20Agents=20dashboard=20wi?= =?UTF-8?q?th=20keyboard=20navigation=20=F0=9F=A7=AD=20-=20Renamed=20?= =?UTF-8?q?=E2=80=9CAgent=20Comparison=E2=80=9D=20UI=20to=20=E2=80=9CProvi?= =?UTF-8?q?der=20Comparison=E2=80=9D=20for=20clarity=20=F0=9F=8F=B7?= =?UTF-8?q?=EF=B8=8F=20-=20Updated=20=E2=80=9CSession=20Statistics?= =?UTF-8?q?=E2=80=9D=20panels=20to=20=E2=80=9CAgent=20Statistics=E2=80=9D?= =?UTF-8?q?=20across=20dashboard=20=F0=9F=93=8A=20-=20Rebranded=20?= =?UTF-8?q?=E2=80=9CSource=20Distribution=E2=80=9D=20chart=20to=20?= =?UTF-8?q?=E2=80=9CSession=20Type=E2=80=9D=20for=20better=20meaning=20?= =?UTF-8?q?=F0=9F=97=82=EF=B8=8F=20-=20Improved=20accessibility=20labels?= =?UTF-8?q?=20to=20match=20new=20provider/session=20terminology=20?= =?UTF-8?q?=E2=99=BF=20-=20Fixed=20batch=20processor=20ref=20syncing=20to?= =?UTF-8?q?=20prevent=20=E2=80=9C0=20of=20N=20completed=E2=80=9D=20regress?= =?UTF-8?q?ions=20=F0=9F=9B=A0=EF=B8=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/App.tsx | 19 - src/renderer/components/AppModals.tsx | 10 - src/renderer/components/QuickActionsModal.tsx | 67 --- .../UsageDashboard/AgentComparisonChart.tsx | 4 +- .../UsageDashboard/AgentUsageChart.tsx | 548 ++++++++++++++++++ .../UsageDashboard/SessionStats.tsx | 4 +- .../SourceDistributionChart.tsx | 4 +- .../UsageDashboard/UsageDashboardModal.tsx | 42 +- src/renderer/hooks/batch/useBatchProcessor.ts | 7 +- 9 files changed, 591 insertions(+), 114 deletions(-) create mode 100644 src/renderer/components/UsageDashboard/AgentUsageChart.tsx diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index d9c0e3fb..c799bf2c 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -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) { diff --git a/src/renderer/components/AppModals.tsx b/src/renderer/components/AppModals.tsx index 485005d3..91d12800 100644 --- a/src/renderer/components/AppModals.tsx +++ b/src/renderer/components/AppModals.tsx @@ -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} diff --git a/src/renderer/components/QuickActionsModal.tsx b/src/renderer/components/QuickActionsModal.tsx index 0fb04045..8588c4d5 100644 --- a/src/renderer/components/QuickActionsModal.tsx +++ b/src/renderer/components/QuickActionsModal.tsx @@ -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 diff --git a/src/renderer/components/UsageDashboard/AgentComparisonChart.tsx b/src/renderer/components/UsageDashboard/AgentComparisonChart.tsx index 48081475..2e809bdf 100644 --- a/src/renderer/components/UsageDashboard/AgentComparisonChart.tsx +++ b/src/renderer/components/UsageDashboard/AgentComparisonChart.tsx @@ -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 */}
@@ -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
diff --git a/src/renderer/components/UsageDashboard/AgentUsageChart.tsx b/src/renderer/components/UsageDashboard/AgentUsageChart.tsx new file mode 100644 index 00000000..1d3880fc --- /dev/null +++ b/src/renderer/components/UsageDashboard/AgentUsageChart.tsx @@ -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(); + 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) => { + 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 ( +
+ {/* Header */} +
+

+ Agent Usage Over Time +

+
+ + {/* Chart container */} +
+ {chartData.length === 0 ? ( +
+ No usage data available +
+ ) : ( + + {/* Gradient definitions */} + + + + + + + + + + + + {/* Grid lines (horizontal) */} + {yTicksSessions.map((tick, idx) => ( + + ))} + + {/* Left Y-axis labels (Sessions) */} + {yTicksSessions.map((tick, idx) => ( + + {tick} + + ))} + + {/* Right Y-axis labels (Duration) */} + {yTicksDuration.map((tick, idx) => ( + + {formatYAxisDuration(tick)} + + ))} + + {/* 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 ( + + {formatXAxisDate(point.date, timeRange)} + + ); + })} + + {/* Sessions area fill */} + + + {/* Sessions line */} + + + {/* Duration line */} + + + {/* Data points for sessions */} + {chartData.map((point, idx) => { + const x = xScale(idx); + const y = yScaleSessions(point.sessions); + const isHovered = hoveredPoint?.index === idx; + + return ( + 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 ( + handleMouseEnter(point, idx, e)} + onMouseLeave={handleMouseLeave} + /> + ); + })} + + {/* Y-axis labels */} + + Sessions + + + Time + + + )} + + {/* Tooltip */} + {hoveredPoint && tooltipPos && ( +
+
{hoveredPoint.point.formattedDate}
+
+
+ + Sessions: {hoveredPoint.point.sessions} +
+
+ + Time: {formatDuration(hoveredPoint.point.duration)} +
+
+
+ )} +
+ + {/* Legend */} +
+
+
+ Sessions +
+
+
+ Time +
+
+
+ ); +} + +export default AgentUsageChart; diff --git a/src/renderer/components/UsageDashboard/SessionStats.tsx b/src/renderer/components/UsageDashboard/SessionStats.tsx index db13878b..fcc3fcfa 100644 --- a/src/renderer/components/UsageDashboard/SessionStats.tsx +++ b/src/renderer/components/UsageDashboard/SessionStats.tsx @@ -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
- Session Statistics + Agent Statistics {/* Summary Cards */} diff --git a/src/renderer/components/UsageDashboard/SourceDistributionChart.tsx b/src/renderer/components/UsageDashboard/SourceDistributionChart.tsx index b4ecb6f3..874cd930 100644 --- a/src/renderer/components/UsageDashboard/SourceDistributionChart.tsx +++ b/src/renderer/components/UsageDashboard/SourceDistributionChart.tsx @@ -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 */}
@@ -250,7 +250,7 @@ export function SourceDistributionChart({ className="text-sm font-medium" style={{ color: theme.colors.textMain }} > - Source Distribution + Session Type
{ const labels: Record = { '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 */}
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" > - +
- {/* Agent-focused view */} + {/* Provider Comparison */}
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" > - +
+ + {/* Agent Usage Over Time */} +
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" + > + + + +
)} diff --git a/src/renderer/hooks/batch/useBatchProcessor.ts b/src/renderer/hooks/batch/useBatchProcessor.ts index c38777e8..631c3236 100644 --- a/src/renderer/hooks/batch/useBatchProcessor.ts +++ b/src/renderer/hooks/batch/useBatchProcessor.ts @@ -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>(batchRunStates); // Ref to track latest updateBatchStateAndBroadcast for async callbacks (fixes HMR stale closure) const updateBatchStateAndBroadcastRef = useRef(null);