diff --git a/src/__tests__/main/utils/ssh-command-builder.test.ts b/src/__tests__/main/utils/ssh-command-builder.test.ts index bf24fc4d..d30c00a1 100644 --- a/src/__tests__/main/utils/ssh-command-builder.test.ts +++ b/src/__tests__/main/utils/ssh-command-builder.test.ts @@ -166,6 +166,75 @@ describe('ssh-command-builder', () => { expect(result.args).toContain('testuser@dev.example.com'); }); + describe('TTY allocation (CRITICAL for Claude Code)', () => { + /** + * IMPORTANT: These tests document a critical requirement for SSH remote execution. + * + * Claude Code's `--print` mode (batch/non-interactive) REQUIRES a TTY to produce output. + * Without forced TTY allocation (-tt), the SSH process hangs indefinitely with no stdout. + * + * This was discovered when SSH commands appeared to run (process status: Running) + * but produced no output, causing Maestro to get stuck in "Thinking..." state forever. + * + * The fix requires BOTH: + * 1. The `-tt` flag (force pseudo-TTY allocation even when stdin isn't a terminal) + * 2. The `RequestTTY=force` option (explicit option for the same purpose) + * + * DO NOT CHANGE THESE TO `-T` or `RequestTTY=no` - it will break SSH agent execution! + * + * Test commands that verified this behavior: + * - HANGS: ssh -T user@host 'zsh -lc "claude --print -- hi"' + * - WORKS: ssh -tt user@host 'zsh -lc "claude --print -- hi"' + */ + + it('uses -tt flag for forced TTY allocation (first argument)', () => { + const result = buildSshCommand(baseConfig, { + command: 'claude', + args: ['--print', '--verbose'], + }); + + // -tt MUST be the first argument for reliable TTY allocation + expect(result.args[0]).toBe('-tt'); + }); + + it('includes RequestTTY=force in SSH options', () => { + const result = buildSshCommand(baseConfig, { + command: 'claude', + args: ['--print'], + }); + + // Find the RequestTTY option + const requestTtyIndex = result.args.findIndex( + (arg, i) => result.args[i - 1] === '-o' && arg.startsWith('RequestTTY=') + ); + expect(requestTtyIndex).toBeGreaterThan(-1); + expect(result.args[requestTtyIndex]).toBe('RequestTTY=force'); + }); + + it('never uses -T (disable TTY) which breaks Claude Code', () => { + const result = buildSshCommand(baseConfig, { + command: 'claude', + args: ['--print'], + }); + + // Ensure -T is never present - it causes Claude Code to hang + expect(result.args).not.toContain('-T'); + }); + + it('never uses RequestTTY=no which breaks Claude Code', () => { + const result = buildSshCommand(baseConfig, { + command: 'claude', + args: ['--print'], + }); + + // Check no option says RequestTTY=no + const hasNoTty = result.args.some( + (arg, i) => result.args[i - 1] === '-o' && arg === 'RequestTTY=no' + ); + expect(hasNoTty).toBe(false); + }); + }); + it('includes default SSH options', () => { const result = buildSshCommand(baseConfig, { command: 'claude', @@ -278,8 +347,8 @@ describe('ssh-command-builder', () => { expect(result.command).toBe('ssh'); // Verify the arguments form a valid SSH command - // First argument is -T (disable TTY), then -i for identity file - expect(result.args[0]).toBe('-T'); + // First argument is -tt (force TTY for Claude Code's --print mode), then -i for identity file + expect(result.args[0]).toBe('-tt'); expect(result.args[1]).toBe('-i'); expect(result.args[2]).toBe('/Users/testuser/.ssh/id_ed25519'); diff --git a/src/__tests__/renderer/components/ExecutionQueueBrowser.test.tsx b/src/__tests__/renderer/components/ExecutionQueueBrowser.test.tsx index d2a67571..6ea7ecc6 100644 --- a/src/__tests__/renderer/components/ExecutionQueueBrowser.test.tsx +++ b/src/__tests__/renderer/components/ExecutionQueueBrowser.test.tsx @@ -2,7 +2,7 @@ * Tests for ExecutionQueueBrowser component * * This component displays a modal for browsing and managing the execution queue - * across all sessions. Supports filtering by current project vs global view. + * across all sessions. Supports filtering by current agent vs global view. */ import React from 'react'; @@ -331,7 +331,7 @@ describe('ExecutionQueueBrowser', () => { }); describe('view mode toggle', () => { - it('should default to current project view', () => { + it('should default to current agent view', () => { render( { /> ); - const currentButton = screen.getByText('Current Project'); + const currentButton = screen.getByText('Current Agent'); expect(currentButton.closest('button')).toHaveStyle({ backgroundColor: theme.colors.accent }); }); - it('should switch to global view when All Projects is clicked', () => { + it('should switch to global view when All Agents is clicked', () => { const session = createSession({ executionQueue: [createQueuedItem()] }); @@ -366,12 +366,12 @@ describe('ExecutionQueueBrowser', () => { /> ); - const allProjectsButton = screen.getByText('All Projects').closest('button'); - expect(allProjectsButton).not.toBeNull(); - fireEvent.click(allProjectsButton!); + const allAgentsButton = screen.getByText('All Agents').closest('button'); + expect(allAgentsButton).not.toBeNull(); + fireEvent.click(allAgentsButton!); - // After switching, All Projects should be active - expect(allProjectsButton).toHaveStyle({ + // After switching, All Agents should be active + expect(allAgentsButton).toHaveStyle({ backgroundColor: theme.colors.accent }); }); @@ -396,11 +396,11 @@ describe('ExecutionQueueBrowser', () => { /> ); - const currentButton = screen.getByText('Current Project').closest('button'); + const currentButton = screen.getByText('Current Agent').closest('button'); expect(currentButton).toHaveTextContent('(2)'); }); - it('should show total item count in All Projects button', () => { + it('should show total item count in All Agents button', () => { const session1 = createSession({ id: 'session-1', executionQueue: [createQueuedItem()] @@ -421,11 +421,11 @@ describe('ExecutionQueueBrowser', () => { /> ); - const allButton = screen.getByText('All Projects').closest('button'); + const allButton = screen.getByText('All Agents').closest('button'); expect(allButton).toHaveTextContent('(3)'); }); - it('should not show count for current project when 0 items', () => { + it('should not show count for current agent when 0 items', () => { const session = createSession({ id: 'active-session', executionQueue: [] @@ -442,7 +442,7 @@ describe('ExecutionQueueBrowser', () => { /> ); - const currentButton = screen.getByText('Current Project').closest('button'); + const currentButton = screen.getByText('Current Agent').closest('button'); expect(currentButton?.textContent).not.toContain('(0)'); }); }); @@ -465,7 +465,7 @@ describe('ExecutionQueueBrowser', () => { /> ); - expect(screen.getByText('No items queued for this project')).toBeInTheDocument(); + expect(screen.getByText('No items queued for this agent')).toBeInTheDocument(); }); it('should show empty message in global view when no items', () => { @@ -482,7 +482,7 @@ describe('ExecutionQueueBrowser', () => { ); // Switch to global view - const allButton = screen.getByText('All Projects').closest('button'); + const allButton = screen.getByText('All Agents').closest('button'); fireEvent.click(allButton!); expect(screen.getByText('No items queued')).toBeInTheDocument(); @@ -509,7 +509,7 @@ describe('ExecutionQueueBrowser', () => { /> ); - expect(screen.getByText('No items queued for this project')).toBeInTheDocument(); + expect(screen.getByText('No items queued for this agent')).toBeInTheDocument(); }); }); @@ -565,7 +565,7 @@ describe('ExecutionQueueBrowser', () => { ); // Switch to global view - const allButton = screen.getByText('All Projects').closest('button'); + const allButton = screen.getByText('All Agents').closest('button'); fireEvent.click(allButton!); expect(screen.getByText('Item one')).toBeInTheDocument(); @@ -596,7 +596,7 @@ describe('ExecutionQueueBrowser', () => { ); // Switch to global view - const allButton = screen.getByText('All Projects').closest('button'); + const allButton = screen.getByText('All Agents').closest('button'); fireEvent.click(allButton!); expect(screen.getByText('Has Items')).toBeInTheDocument(); @@ -624,7 +624,7 @@ describe('ExecutionQueueBrowser', () => { ); // Switch to global view - const allButton = screen.getByText('All Projects').closest('button'); + const allButton = screen.getByText('All Agents').closest('button'); fireEvent.click(allButton!); expect(screen.getByText('Test Project')).toBeInTheDocument(); @@ -680,7 +680,7 @@ describe('ExecutionQueueBrowser', () => { ); // Switch to global view - const allButton = screen.getByText('All Projects').closest('button'); + const allButton = screen.getByText('All Agents').closest('button'); fireEvent.click(allButton!); // Find the session header and check for count @@ -707,7 +707,7 @@ describe('ExecutionQueueBrowser', () => { ); // Switch to global view - const allButton = screen.getByText('All Projects').closest('button'); + const allButton = screen.getByText('All Agents').closest('button'); fireEvent.click(allButton!); // Click session header @@ -1082,7 +1082,7 @@ describe('ExecutionQueueBrowser', () => { /> ); - expect(screen.getByText('Drag and drop to reorder. Items are processed sequentially per project to prevent file conflicts.')).toBeInTheDocument(); + expect(screen.getByText('Drag and drop to reorder. Items are processed sequentially per agent to prevent file conflicts.')).toBeInTheDocument(); }); }); @@ -1251,7 +1251,7 @@ describe('ExecutionQueueBrowser', () => { ); // Should show empty state - expect(screen.getByText('No items queued for this project')).toBeInTheDocument(); + expect(screen.getByText('No items queued for this agent')).toBeInTheDocument(); }); it('should handle items without tabName', () => { @@ -1313,7 +1313,7 @@ describe('ExecutionQueueBrowser', () => { ); // Current view should show empty (no active session) - expect(screen.getByText('No items queued for this project')).toBeInTheDocument(); + expect(screen.getByText('No items queued for this agent')).toBeInTheDocument(); }); it('should handle multiple sessions with same name', () => { @@ -1340,7 +1340,7 @@ describe('ExecutionQueueBrowser', () => { ); // Switch to global view - const allButton = screen.getByText('All Projects').closest('button'); + const allButton = screen.getByText('All Agents').closest('button'); fireEvent.click(allButton!); // Both sessions should be rendered (both with "Same Name") @@ -1368,7 +1368,7 @@ describe('ExecutionQueueBrowser', () => { ); // Switch to global view - const allButton = screen.getByText('All Projects').closest('button'); + const allButton = screen.getByText('All Agents').closest('button'); fireEvent.click(allButton!); expect(screen.getByText(longName)).toBeInTheDocument(); @@ -1774,7 +1774,7 @@ describe('ExecutionQueueBrowser', () => { ); // Switch to global view - const allButton = screen.getByText('All Projects').closest('button'); + const allButton = screen.getByText('All Agents').closest('button'); fireEvent.click(allButton!); // All items should have grab cursor @@ -1816,7 +1816,7 @@ describe('ExecutionQueueBrowser', () => { ); // Switch to global view - const allButton = screen.getByText('All Projects').closest('button'); + const allButton = screen.getByText('All Agents').closest('button'); fireEvent.click(allButton!); // Should have drop zones for each session: 3 for session1 (2 items + 1 after) + 3 for session2 diff --git a/src/main/stats-db.ts b/src/main/stats-db.ts index 898c4986..3362890f 100644 --- a/src/main/stats-db.ts +++ b/src/main/stats-db.ts @@ -1228,6 +1228,24 @@ export class StatsDB { }>; perfMetrics.end(byDayStart, 'getAggregatedStats:byDay', { range, dayCount: byDayRows.length }); + // By hour (for peak hours chart) + const byHourStart = perfMetrics.start(); + const byHourStmt = this.db.prepare(` + SELECT CAST(strftime('%H', start_time / 1000, 'unixepoch', 'localtime') AS INTEGER) as hour, + COUNT(*) as count, + SUM(duration) as duration + FROM query_events + WHERE start_time >= ? + GROUP BY hour + ORDER BY hour ASC + `); + const byHourRows = byHourStmt.all(startTime) as Array<{ + hour: number; + count: number; + duration: number; + }>; + perfMetrics.end(byHourStart, 'getAggregatedStats:byHour', { range }); + const totalDuration = perfMetrics.end(perfStart, 'getAggregatedStats:total', { range, totalQueries: totals.count, @@ -1250,6 +1268,7 @@ export class StatsDB { bySource, byDay: byDayRows, byLocation, + byHour: byHourRows, }; } diff --git a/src/main/utils/ssh-command-builder.ts b/src/main/utils/ssh-command-builder.ts index d3765153..6c165a60 100644 --- a/src/main/utils/ssh-command-builder.ts +++ b/src/main/utils/ssh-command-builder.ts @@ -46,7 +46,7 @@ const DEFAULT_SSH_OPTIONS: Record = { StrictHostKeyChecking: 'accept-new', // Auto-accept new host keys ConnectTimeout: '10', // Connection timeout in seconds ClearAllForwardings: 'yes', // Disable port forwarding from SSH config (avoids "Address already in use" errors) - RequestTTY: 'no', // Don't request a TTY for command execution (avoids shell rc issues) + RequestTTY: 'force', // Force TTY allocation - required for Claude Code's --print mode to produce output }; /** @@ -170,8 +170,9 @@ export function buildSshCommand( ): SshCommandResult { const args: string[] = []; - // Force disable TTY allocation - this helps prevent shell rc files from being sourced - args.push('-T'); + // Force TTY allocation - required for Claude Code's --print mode to produce output + // Without a TTY, Claude Code with --print hangs indefinitely + args.push('-tt'); // When using SSH config, we let SSH handle authentication settings // Only add explicit overrides if provided diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index d013cd6f..66aa8b0e 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -2396,6 +2396,8 @@ function MaestroConsoleInner() { // Handle SSH remote status events - tracks when sessions are executing on remote hosts // Also populates session-wide SSH context (sshRemoteId, remoteCwd) for file explorer, git, auto run, etc. + // IMPORTANT: When SSH connection is established, we also recheck isGitRepo since the initial + // check may have failed or been done before SSH was ready. const unsubscribeSshRemote = window.maestro.process.onSshRemote?.((sessionId: string, sshRemote: { id: string; name: string; host: string; remoteWorkingDir?: string } | null) => { // Parse sessionId to get actual session ID (format: {id}-ai-{tabId} or {id}-terminal) let actualSessionId: string; @@ -2423,6 +2425,46 @@ function MaestroConsoleInner() { remoteCwd: sshRemote?.remoteWorkingDir, }; })); + + // When SSH connection is established, check isGitRepo with the SSH context + // For SSH sessions, this is the FIRST git check (deferred from session creation) + // since we can't check until SSH is connected + if (sshRemote?.id) { + const session = sessionsRef.current.find(s => s.id === actualSessionId); + // Only check if session hasn't been detected as git repo yet + // (avoids redundant checks if SSH reconnects) + if (session && !session.isGitRepo) { + const remoteCwd = sshRemote.remoteWorkingDir || session.sessionSshRemoteConfig?.workingDirOverride || session.cwd; + (async () => { + try { + const isGitRepo = await gitService.isRepo(remoteCwd, sshRemote.id); + if (isGitRepo) { + // Fetch git branches and tags now that we know it's a git repo + const [gitBranches, gitTags] = await Promise.all([ + gitService.getBranches(remoteCwd, sshRemote.id), + gitService.getTags(remoteCwd, sshRemote.id) + ]); + const gitRefsCacheTime = Date.now(); + + setSessions(prev => prev.map(s => { + if (s.id !== actualSessionId) return s; + // Only update if still not detected as git repo + if (s.isGitRepo) return s; + return { + ...s, + isGitRepo: true, + gitBranches, + gitTags, + gitRefsCacheTime, + }; + })); + } + } catch (err) { + console.error(`[SSH] Failed to check git repo status for ${actualSessionId}:`, err); + } + })(); + } + } }); // Handle tool execution events from AI agents @@ -5611,23 +5653,27 @@ function MaestroConsoleInner() { const newId = generateId(); const aiPid = 0; - // Get SSH remote ID for remote git operations (from session config) - const sshRemoteId = sessionSshRemoteConfig?.enabled ? sessionSshRemoteConfig.remoteId || undefined : undefined; - - // Check if the working directory is a Git repository (via SSH for remote sessions) - const isGitRepo = await gitService.isRepo(workingDir, sshRemoteId); - - // Fetch git branches and tags if it's a git repo + // For SSH sessions, defer git check until onSshRemote fires (SSH connection established) + // For local sessions, check git repo status immediately + const isRemoteSession = sessionSshRemoteConfig?.enabled && sessionSshRemoteConfig.remoteId; + let isGitRepo = false; let gitBranches: string[] | undefined; let gitTags: string[] | undefined; let gitRefsCacheTime: number | undefined; - if (isGitRepo) { - [gitBranches, gitTags] = await Promise.all([ - gitService.getBranches(workingDir, sshRemoteId), - gitService.getTags(workingDir, sshRemoteId) - ]); - gitRefsCacheTime = Date.now(); + + if (!isRemoteSession) { + // Local session - check git repo status now + isGitRepo = await gitService.isRepo(workingDir); + if (isGitRepo) { + [gitBranches, gitTags] = await Promise.all([ + gitService.getBranches(workingDir), + gitService.getTags(workingDir) + ]); + gitRefsCacheTime = Date.now(); + } } + // For SSH sessions: isGitRepo stays false until onSshRemote callback fires + // and rechecks with the established SSH connection // Create initial fresh tab for new sessions const initialTabId = generateId(); diff --git a/src/renderer/components/ExecutionQueueBrowser.tsx b/src/renderer/components/ExecutionQueueBrowser.tsx index 5c9a8d19..964284fa 100644 --- a/src/renderer/components/ExecutionQueueBrowser.tsx +++ b/src/renderer/components/ExecutionQueueBrowser.tsx @@ -178,7 +178,7 @@ export function ExecutionQueueBrowser({ }} > - Current Project + Current Agent {currentSessionItems > 0 && ( ({currentSessionItems}) )} @@ -194,7 +194,7 @@ export function ExecutionQueueBrowser({ }} > - All Projects + All Agents ({totalQueuedItems}) @@ -206,7 +206,7 @@ export function ExecutionQueueBrowser({ className="text-center py-12 text-sm" style={{ color: theme.colors.textDim }} > - No items queued{viewMode === 'current' ? ' for this project' : ''} + No items queued{viewMode === 'current' ? ' for this agent' : ''} ) : ( filteredSessions.map(session => ( @@ -283,7 +283,7 @@ export function ExecutionQueueBrowser({ className="px-4 py-3 border-t text-xs" style={{ borderColor: theme.colors.border, color: theme.colors.textDim }} > - Drag and drop to reorder. Items are processed sequentially per project to prevent file conflicts. + Drag and drop to reorder. Items are processed sequentially per agent to prevent file conflicts. diff --git a/src/renderer/components/SessionList.tsx b/src/renderer/components/SessionList.tsx index c932b80e..ee2cf569 100644 --- a/src/renderer/components/SessionList.tsx +++ b/src/renderer/components/SessionList.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useRef, memo } from 'react'; import { Wand2, Plus, Settings, ChevronRight, ChevronDown, ChevronUp, X, Keyboard, - Radio, Copy, ExternalLink, PanelLeftClose, PanelLeftOpen, Folder, Info, GitBranch, Bot, Clock, + Radio, Copy, ExternalLink, PanelLeftClose, PanelLeftOpen, Folder, FolderPlus, Info, GitBranch, Bot, Clock, ScrollText, Cpu, Menu, Bookmark, Trophy, Trash2, Edit3, FolderInput, Download, Compass, Globe, GitPullRequest, BookOpen, BarChart3 } from 'lucide-react'; @@ -37,6 +37,7 @@ interface SessionContextMenuProps { onQuickCreateWorktree?: () => void; // Opens small modal for quick worktree creation onConfigureWorktrees?: () => void; // Opens full worktree config modal onDeleteWorktree?: () => void; // For worktree child sessions to delete + onCreateGroup?: () => void; // Creates a new group from the Move to Group submenu } function SessionContextMenu({ @@ -57,6 +58,7 @@ function SessionContextMenu({ onQuickCreateWorktree, onConfigureWorktrees, onDeleteWorktree, + onCreateGroup, }: SessionContextMenuProps) { const menuRef = useRef(null); const moveToGroupRef = useRef(null); @@ -257,6 +259,26 @@ function SessionContextMenu({ {session.groupId === group.id && (current)} ))} + + {/* Divider before Create New Group */} + {onCreateGroup && ( +
+ )} + + {/* Create New Group option */} + {onCreateGroup && ( + + )}
)} @@ -2210,6 +2232,7 @@ function SessionListInner(props: SessionListProps) { onQuickCreateWorktree={onQuickCreateWorktree && !contextMenuSession.parentSessionId ? () => onQuickCreateWorktree(contextMenuSession) : undefined} onConfigureWorktrees={onOpenWorktreeConfig && !contextMenuSession.parentSessionId ? () => onOpenWorktreeConfig(contextMenuSession) : undefined} onDeleteWorktree={onDeleteWorktree && contextMenuSession.parentSessionId ? () => onDeleteWorktree(contextMenuSession) : undefined} + onCreateGroup={createNewGroup} /> )} diff --git a/src/renderer/components/UsageDashboard/PeakHoursChart.tsx b/src/renderer/components/UsageDashboard/PeakHoursChart.tsx new file mode 100644 index 00000000..cebce3ea --- /dev/null +++ b/src/renderer/components/UsageDashboard/PeakHoursChart.tsx @@ -0,0 +1,264 @@ +/** + * PeakHoursChart + * + * Bar chart showing activity distribution across hours of the day. + * Helps users understand their work patterns and peak productivity times. + * + * Features: + * - 24-hour bar chart (0-23) + * - Toggle between count and duration views + * - Highlights the peak hour + * - Theme-aware colors + * - Hover tooltips + */ + +import React, { useState, useMemo } from 'react'; +import type { Theme } from '../../types'; +import type { StatsAggregation } from '../../hooks/useStats'; + +type MetricMode = 'count' | 'duration'; + +interface PeakHoursChartProps { + /** Aggregated stats data from the API */ + data: StatsAggregation; + /** Current theme for styling */ + theme: Theme; + /** Enable colorblind-friendly colors */ + colorBlindMode?: boolean; +} + +/** + * Format hour to 12-hour format with AM/PM + */ +function formatHour(hour: number): string { + if (hour === 0) return '12am'; + if (hour === 12) return '12pm'; + if (hour < 12) return `${hour}am`; + return `${hour - 12}pm`; +} + +/** + * Format duration in milliseconds to human-readable string + */ +function formatDuration(ms: number): string { + const totalSeconds = Math.floor(ms / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + + if (hours > 0) { + return `${hours}h ${minutes}m`; + } + if (minutes > 0) { + return `${minutes}m`; + } + return `${totalSeconds}s`; +} + +export function PeakHoursChart({ + data, + theme, + colorBlindMode: _colorBlindMode = false, +}: PeakHoursChartProps) { + const [metricMode, setMetricMode] = useState('count'); + const [hoveredHour, setHoveredHour] = useState(null); + + // Build complete 24-hour data with zeros for missing hours + const hourlyData = useMemo(() => { + const byHourMap = new Map(); + + // Initialize all hours with zeros + for (let h = 0; h < 24; h++) { + byHourMap.set(h, { count: 0, duration: 0 }); + } + + // Fill in actual data + for (const entry of (data.byHour ?? [])) { + byHourMap.set(entry.hour, { count: entry.count, duration: entry.duration }); + } + + return Array.from(byHourMap.entries()) + .sort((a, b) => a[0] - b[0]) + .map(([hour, values]) => ({ + hour, + ...values, + })); + }, [data.byHour]); + + // Calculate max value for scaling + const maxValue = useMemo(() => { + const values = hourlyData.map(h => metricMode === 'count' ? h.count : h.duration); + return Math.max(...values, 1); + }, [hourlyData, metricMode]); + + // Find peak hour + const peakHour = useMemo(() => { + let peak = { hour: 0, value: 0 }; + for (const h of hourlyData) { + const value = metricMode === 'count' ? h.count : h.duration; + if (value > peak.value) { + peak = { hour: h.hour, value }; + } + } + return peak.hour; + }, [hourlyData, metricMode]); + + // Check if there's any data + const hasData = hourlyData.some(h => h.count > 0); + + // Chart dimensions + const chartHeight = 120; + const barWidth = 100 / 24; // percentage width per bar + + return ( +
+ {/* Header */} +
+

+ Peak Hours +

+
+ + Show: + +
+ + +
+
+
+ + {/* Chart */} + {!hasData ? ( +
+ No hourly data available +
+ ) : ( +
+ {/* Bars */} +
+ {hourlyData.map((h) => { + const value = metricMode === 'count' ? h.count : h.duration; + const height = maxValue > 0 ? (value / maxValue) * 100 : 0; + const isPeak = h.hour === peakHour && value > 0; + const isHovered = hoveredHour === h.hour; + + return ( +
setHoveredHour(h.hour)} + onMouseLeave={() => setHoveredHour(null)} + > + {/* Bar */} +
0 ? 2 : 0)}%`, + backgroundColor: isPeak + ? theme.colors.accent + : isHovered + ? `${theme.colors.accent}90` + : `${theme.colors.accent}50`, + transform: isHovered ? 'scaleY(1.05)' : 'scaleY(1)', + transformOrigin: 'bottom', + }} + /> + + {/* Tooltip on hover */} + {isHovered && value > 0 && ( +
+
{formatHour(h.hour)}
+
+ {metricMode === 'count' + ? `${h.count} queries` + : formatDuration(h.duration)} +
+
+ )} +
+ ); + })} +
+ + {/* X-axis labels (show every 4 hours) */} +
+ {[0, 4, 8, 12, 16, 20].map((hour) => ( +
+ {formatHour(hour)} +
+ ))} +
+ + {/* Peak indicator */} + {hasData && ( +
+ Peak: + + {formatHour(peakHour)} + +
+ )} +
+ )} +
+ ); +} + +export default PeakHoursChart; diff --git a/src/renderer/components/UsageDashboard/UsageDashboardModal.tsx b/src/renderer/components/UsageDashboard/UsageDashboardModal.tsx index 0d6f4157..8f6a5efe 100644 --- a/src/renderer/components/UsageDashboard/UsageDashboardModal.tsx +++ b/src/renderer/components/UsageDashboard/UsageDashboardModal.tsx @@ -20,6 +20,7 @@ import { ActivityHeatmap } from './ActivityHeatmap'; import { AgentComparisonChart } from './AgentComparisonChart'; import { SourceDistributionChart } from './SourceDistributionChart'; import { LocationDistributionChart } from './LocationDistributionChart'; +import { PeakHoursChart } from './PeakHoursChart'; import { DurationTrendsChart } from './DurationTrendsChart'; import { AutoRunStats } from './AutoRunStats'; import { SessionStats } from './SessionStats'; @@ -33,7 +34,7 @@ import { getRendererPerfMetrics } from '../../utils/logger'; import { PERFORMANCE_THRESHOLDS } from '../../../shared/performance-metrics'; // Section IDs for keyboard navigation -const OVERVIEW_SECTIONS = ['summary-cards', 'agent-comparison', 'source-distribution', 'location-distribution', 'activity-heatmap', 'duration-trends'] as const; +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 ACTIVITY_SECTIONS = ['activity-heatmap', 'duration-trends'] as const; const AUTORUN_SECTIONS = ['autorun-stats'] as const; @@ -55,6 +56,7 @@ interface StatsAggregation { bySource: { user: number; auto: number }; byLocation: { local: number; remote: number }; byDay: Array<{ date: string; count: number; duration: number }>; + byHour: Array<{ hour: number; count: number; duration: number }>; } // View mode options for the dashboard @@ -323,6 +325,7 @@ export function UsageDashboardModal({ 'agent-comparison': 'Agent Comparison Chart', 'source-distribution': 'Source Distribution Chart', 'location-distribution': 'Location Distribution Chart', + 'peak-hours': 'Peak Hours Chart', 'activity-heatmap': 'Activity Heatmap', 'duration-trends': 'Duration Trends Chart', 'autorun-stats': 'Auto Run Statistics', @@ -673,33 +676,34 @@ export function UsageDashboardModal({
- {/* Charts Grid - 2 columns on wide, 1 on narrow */} + {/* Agent Comparison Chart - Full width bar chart */} +
handleSectionKeyDown(e, 'agent-comparison')} + className="outline-none rounded-lg transition-shadow dashboard-section-enter" + style={{ + minHeight: '180px', + boxShadow: focusedSection === 'agent-comparison' ? `0 0 0 2px ${theme.colors.accent}` : 'none', + animationDelay: '100ms', + }} + data-testid="section-agent-comparison" + > + + + +
+ + {/* Distribution Charts Grid - 2 columns for donut charts */}
- {/* Agent Comparison Chart */} -
handleSectionKeyDown(e, 'agent-comparison')} - className="outline-none rounded-lg transition-shadow" - style={{ - minHeight: '300px', - boxShadow: focusedSection === 'agent-comparison' ? `0 0 0 2px ${theme.colors.accent}` : 'none', - }} - data-testid="section-agent-comparison" - > - - - -
- {/* Source Distribution Chart */}
handleSectionKeyDown(e, 'source-distribution')} className="outline-none rounded-lg transition-shadow" style={{ - minHeight: '300px', + minHeight: '240px', boxShadow: focusedSection === 'source-distribution' ? `0 0 0 2px ${theme.colors.accent}` : 'none', }} data-testid="section-source-distribution" @@ -728,7 +732,7 @@ export function UsageDashboardModal({ onKeyDown={(e) => handleSectionKeyDown(e, 'location-distribution')} className="outline-none rounded-lg transition-shadow" style={{ - minHeight: '300px', + minHeight: '240px', boxShadow: focusedSection === 'location-distribution' ? `0 0 0 2px ${theme.colors.accent}` : 'none', }} data-testid="section-location-distribution" @@ -739,6 +743,26 @@ export function UsageDashboardModal({
+ {/* Peak Hours Chart - Full width compact bar chart */} +
handleSectionKeyDown(e, 'peak-hours')} + className="outline-none rounded-lg transition-shadow dashboard-section-enter" + style={{ + minHeight: '180px', + boxShadow: focusedSection === 'peak-hours' ? `0 0 0 2px ${theme.colors.accent}` : 'none', + animationDelay: '175ms', + }} + data-testid="section-peak-hours" + > + + + +
+ {/* Activity Heatmap - Full width */}
; + byHour: Array<{ hour: number; count: number; duration: number }>; }>; // Export query events to CSV exportCsv: (range: 'day' | 'week' | 'month' | 'year' | 'all') => Promise; diff --git a/src/renderer/hooks/useStats.ts b/src/renderer/hooks/useStats.ts index 2335bad0..d4721aaf 100644 --- a/src/renderer/hooks/useStats.ts +++ b/src/renderer/hooks/useStats.ts @@ -28,6 +28,7 @@ export interface StatsAggregation { bySource: { user: number; auto: number }; byLocation: { local: number; remote: number }; byDay: Array<{ date: string; count: number; duration: number }>; + byHour: Array<{ hour: number; count: number; duration: number }>; } // Return type for the useStats hook diff --git a/src/renderer/wdyr.dev.ts b/src/renderer/wdyr.dev.ts new file mode 100644 index 00000000..50505ef7 --- /dev/null +++ b/src/renderer/wdyr.dev.ts @@ -0,0 +1,54 @@ +/** + * why-did-you-render setup for development performance profiling + * + * This file is only loaded in development mode via Vite's alias configuration. + * In production, the empty wdyr.ts is used instead. + * + * To track a specific component, add this to the component file: + * MyComponent.whyDidYouRender = true; + * + * Or track all pure components by setting trackAllPureComponents: true below. + * + * Output appears in the browser DevTools console showing: + * - Which components re-rendered + * - What props/state changes triggered the re-render + * - Whether the re-render was necessary + */ +import React from 'react'; + +// Use dynamic import for ESM compatibility with Vite +import('@welldone-software/why-did-you-render').then((whyDidYouRenderModule) => { + const whyDidYouRender = whyDidYouRenderModule.default; + + whyDidYouRender(React, { + // Track all pure components (React.memo, PureComponent) + // Set to true to see ALL unnecessary re-renders + trackAllPureComponents: true, + + // Track React hooks like useMemo, useCallback + trackHooks: true, + + // Log to console (can also use custom notifier) + logOnDifferentValues: true, + + // Collapse logs by default (expand to see details) + collapseGroups: true, + + // Include component stack traces + include: [ + // Add specific components to always track, e.g.: + // /^RightPanel/, + // /^AutoRun/, + // /^FilePreview/, + ], + + // Exclude noisy components you don't care about + exclude: [ + /^BrowserRouter/, + /^Link/, + /^Route/, + ], + }); +}).catch((err) => { + console.warn('[wdyr] Failed to load why-did-you-render:', err); +}); diff --git a/src/renderer/wdyr.ts b/src/renderer/wdyr.ts index b59c99c1..476e71bf 100644 --- a/src/renderer/wdyr.ts +++ b/src/renderer/wdyr.ts @@ -14,43 +14,7 @@ * - What props/state changes triggered the re-render * - Whether the re-render was necessary */ -import React from 'react'; -if (process.env.NODE_ENV === 'development') { - // Use dynamic import for ESM compatibility with Vite - import('@welldone-software/why-did-you-render').then((whyDidYouRenderModule) => { - const whyDidYouRender = whyDidYouRenderModule.default; - - whyDidYouRender(React, { - // Track all pure components (React.memo, PureComponent) - // Set to true to see ALL unnecessary re-renders - trackAllPureComponents: true, - - // Track React hooks like useMemo, useCallback - trackHooks: true, - - // Log to console (can also use custom notifier) - logOnDifferentValues: true, - - // Collapse logs by default (expand to see details) - collapseGroups: true, - - // Include component stack traces - include: [ - // Add specific components to always track, e.g.: - // /^RightPanel/, - // /^AutoRun/, - // /^FilePreview/, - ], - - // Exclude noisy components you don't care about - exclude: [ - /^BrowserRouter/, - /^Link/, - /^Route/, - ], - }); - }).catch((err) => { - console.warn('[wdyr] Failed to load why-did-you-render:', err); - }); -} +// Empty file in production - all wdyr code is in wdyr.dev.ts +// This prevents the library from being bundled in production +export {}; diff --git a/src/shared/stats-types.ts b/src/shared/stats-types.ts index ae4ee3d1..3d6c07bf 100644 --- a/src/shared/stats-types.ts +++ b/src/shared/stats-types.ts @@ -67,6 +67,8 @@ export interface StatsAggregation { byDay: Array<{ date: string; count: number; duration: number }>; /** Breakdown by session location (local vs SSH remote) */ byLocation: { local: number; remote: number }; + /** Breakdown by hour of day (0-23) for peak hours chart */ + byHour: Array<{ hour: number; count: number; duration: number }>; } /** diff --git a/vite.config.mts b/vite.config.mts index e38cd740..b168232d 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -17,6 +17,15 @@ export default defineConfig(({ mode }) => ({ define: { __APP_VERSION__: JSON.stringify(appVersion), }, + resolve: { + alias: { + // In development, use wdyr.dev.ts which loads why-did-you-render + // In production, use wdyr.ts which is empty (prevents bundling the library) + './wdyr': mode === 'development' + ? path.join(__dirname, 'src/renderer/wdyr.dev.ts') + : path.join(__dirname, 'src/renderer/wdyr.ts'), + }, + }, esbuild: { // Strip console.log and console.debug in production builds drop: mode === 'production' ? ['console', 'debugger'] : [],