## CHANGES

- Forced SSH TTY allocation (`-tt` + `RequestTTY=force`) to stop Claude hangs 🧠
- Added dedicated tests guarding critical SSH TTY behavior forever 🛡️
- Deferred remote Git-repo detection until SSH connects, then fetch refs 🔌
- Introduced hourly stats aggregation (`byHour`) for richer analytics 📈
- Shipped new Peak Hours dashboard chart with count/duration toggle 
- Reworked Usage Dashboard layout, adding Peak Hours section navigation 🧭
- Updated Execution Queue UI to “Current Agent / All Agents” wording 🤖
- Clarified queue messaging and processing semantics “per agent” for safety 🔒
- Added “Create New Group” action inside Session context menu submenu 📁
- Enabled dev-only why-did-you-render via Vite alias, excluded from prod 🚀
This commit is contained in:
Pedram Amini
2025-12-31 19:28:51 -06:00
parent 333b47b82a
commit ec9db781f2
15 changed files with 592 additions and 115 deletions

View File

@@ -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');

View File

@@ -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(
<ExecutionQueueBrowser
isOpen={true}
@@ -344,13 +344,13 @@ describe('ExecutionQueueBrowser', () => {
/>
);
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

View File

@@ -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,
};
}

View File

@@ -46,7 +46,7 @@ const DEFAULT_SSH_OPTIONS: Record<string, string> = {
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

View File

@@ -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();

View File

@@ -178,7 +178,7 @@ export function ExecutionQueueBrowser({
}}
>
<Folder className="w-3.5 h-3.5" />
Current Project
Current Agent
{currentSessionItems > 0 && (
<span className="ml-1 text-xs opacity-80">({currentSessionItems})</span>
)}
@@ -194,7 +194,7 @@ export function ExecutionQueueBrowser({
}}
>
<FolderOpen className="w-3.5 h-3.5" />
All Projects
All Agents
<span className="ml-1 text-xs opacity-80">({totalQueuedItems})</span>
</button>
</div>
@@ -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' : ''}
</div>
) : (
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.
</div>
</div>
</div>

View File

@@ -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<HTMLDivElement>(null);
const moveToGroupRef = useRef<HTMLDivElement>(null);
@@ -257,6 +259,26 @@ function SessionContextMenu({
{session.groupId === group.id && <span className="text-[10px] opacity-50">(current)</span>}
</button>
))}
{/* Divider before Create New Group */}
{onCreateGroup && (
<div className="my-1 border-t" style={{ borderColor: theme.colors.border }} />
)}
{/* Create New Group option */}
{onCreateGroup && (
<button
onClick={() => {
onCreateGroup();
onDismiss();
}}
className="w-full text-left px-3 py-1.5 text-xs hover:bg-white/5 transition-colors flex items-center gap-2"
style={{ color: theme.colors.accent }}
>
<FolderPlus className="w-3.5 h-3.5" />
Create New Group
</button>
)}
</div>
)}
</div>
@@ -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}
/>
)}
</div>

View File

@@ -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<MetricMode>('count');
const [hoveredHour, setHoveredHour] = useState<number | null>(null);
// Build complete 24-hour data with zeros for missing hours
const hourlyData = useMemo(() => {
const byHourMap = new Map<number, { count: number; duration: number }>();
// 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 (
<div
className="p-4 rounded-lg"
style={{ backgroundColor: theme.colors.bgMain }}
role="figure"
aria-label="Peak hours chart showing activity distribution across hours of the day"
>
{/* Header */}
<div className="flex items-center justify-between mb-3">
<h3
className="text-sm font-medium"
style={{ color: theme.colors.textMain }}
>
Peak Hours
</h3>
<div className="flex items-center gap-2">
<span className="text-xs" style={{ color: theme.colors.textDim }}>
Show:
</span>
<div
className="flex rounded overflow-hidden border"
style={{ borderColor: theme.colors.border }}
>
<button
onClick={() => setMetricMode('count')}
className="px-2 py-1 text-xs transition-colors"
style={{
backgroundColor: metricMode === 'count' ? `${theme.colors.accent}20` : 'transparent',
color: metricMode === 'count' ? theme.colors.accent : theme.colors.textDim,
}}
aria-pressed={metricMode === 'count'}
>
Count
</button>
<button
onClick={() => setMetricMode('duration')}
className="px-2 py-1 text-xs transition-colors"
style={{
backgroundColor: metricMode === 'duration' ? `${theme.colors.accent}20` : 'transparent',
color: metricMode === 'duration' ? theme.colors.accent : theme.colors.textDim,
borderLeft: `1px solid ${theme.colors.border}`,
}}
aria-pressed={metricMode === 'duration'}
>
Duration
</button>
</div>
</div>
</div>
{/* Chart */}
{!hasData ? (
<div
className="flex items-center justify-center"
style={{ height: chartHeight, color: theme.colors.textDim }}
>
<span className="text-sm">No hourly data available</span>
</div>
) : (
<div className="relative">
{/* Bars */}
<div
className="flex items-end gap-px"
style={{ height: chartHeight }}
>
{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 (
<div
key={h.hour}
className="relative flex-1 flex flex-col justify-end cursor-default"
style={{ minWidth: 0 }}
onMouseEnter={() => setHoveredHour(h.hour)}
onMouseLeave={() => setHoveredHour(null)}
>
{/* Bar */}
<div
className="w-full rounded-t transition-all duration-200"
style={{
height: `${Math.max(height, value > 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 && (
<div
className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 rounded text-xs whitespace-nowrap z-10"
style={{
backgroundColor: theme.colors.bgActivity,
color: theme.colors.textMain,
border: `1px solid ${theme.colors.border}`,
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
}}
>
<div className="font-medium">{formatHour(h.hour)}</div>
<div style={{ color: theme.colors.textDim }}>
{metricMode === 'count'
? `${h.count} queries`
: formatDuration(h.duration)}
</div>
</div>
)}
</div>
);
})}
</div>
{/* X-axis labels (show every 4 hours) */}
<div className="flex mt-1">
{[0, 4, 8, 12, 16, 20].map((hour) => (
<div
key={hour}
className="text-xs"
style={{
width: `${barWidth * 4}%`,
color: theme.colors.textDim,
}}
>
{formatHour(hour)}
</div>
))}
</div>
{/* Peak indicator */}
{hasData && (
<div
className="mt-2 text-xs flex items-center gap-1"
style={{ color: theme.colors.textDim }}
>
<span>Peak:</span>
<span style={{ color: theme.colors.accent, fontWeight: 500 }}>
{formatHour(peakHour)}
</span>
</div>
)}
</div>
)}
</div>
);
}
export default PeakHoursChart;

View File

@@ -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({
</ChartErrorBoundary>
</div>
{/* Charts Grid - 2 columns on wide, 1 on narrow */}
{/* Agent Comparison Chart - Full width bar chart */}
<div
ref={setSectionRef('agent-comparison')}
tabIndex={0}
role="region"
aria-label={getSectionLabel('agent-comparison')}
onKeyDown={(e) => 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"
>
<ChartErrorBoundary theme={theme} chartName="Agent Comparison">
<AgentComparisonChart data={data} theme={theme} colorBlindMode={colorBlindMode} />
</ChartErrorBoundary>
</div>
{/* Distribution Charts Grid - 2 columns for donut charts */}
<div
className="grid gap-6 dashboard-section-enter"
style={{
gridTemplateColumns: `repeat(${layout.chartGridCols}, minmax(0, 1fr))`,
animationDelay: '100ms',
animationDelay: '150ms',
}}
>
{/* Agent Comparison Chart */}
<div
ref={setSectionRef('agent-comparison')}
tabIndex={0}
role="region"
aria-label={getSectionLabel('agent-comparison')}
onKeyDown={(e) => 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"
>
<ChartErrorBoundary theme={theme} chartName="Agent Comparison">
<AgentComparisonChart data={data} theme={theme} colorBlindMode={colorBlindMode} />
</ChartErrorBoundary>
</div>
{/* Source Distribution Chart */}
<div
ref={setSectionRef('source-distribution')}
@@ -709,7 +713,7 @@ export function UsageDashboardModal({
onKeyDown={(e) => 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({
</div>
</div>
{/* Peak Hours Chart - Full width compact bar chart */}
<div
ref={setSectionRef('peak-hours')}
tabIndex={0}
role="region"
aria-label={getSectionLabel('peak-hours')}
onKeyDown={(e) => 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"
>
<ChartErrorBoundary theme={theme} chartName="Peak Hours">
<PeakHoursChart data={data} theme={theme} colorBlindMode={colorBlindMode} />
</ChartErrorBoundary>
</div>
{/* Activity Heatmap - Full width */}
<div
ref={setSectionRef('activity-heatmap')}

View File

@@ -1644,6 +1644,7 @@ interface MaestroAPI {
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 }>;
}>;
// Export query events to CSV
exportCsv: (range: 'day' | 'week' | 'month' | 'year' | 'all') => Promise<string>;

View File

@@ -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

54
src/renderer/wdyr.dev.ts Normal file
View File

@@ -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);
});

View File

@@ -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 {};

View File

@@ -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 }>;
}
/**

View File

@@ -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'] : [],