mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
## 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:
@@ -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');
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
264
src/renderer/components/UsageDashboard/PeakHoursChart.tsx
Normal file
264
src/renderer/components/UsageDashboard/PeakHoursChart.tsx
Normal 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;
|
||||
@@ -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')}
|
||||
|
||||
1
src/renderer/global.d.ts
vendored
1
src/renderer/global.d.ts
vendored
@@ -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>;
|
||||
|
||||
@@ -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
54
src/renderer/wdyr.dev.ts
Normal 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);
|
||||
});
|
||||
@@ -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 {};
|
||||
|
||||
@@ -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 }>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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'] : [],
|
||||
|
||||
Reference in New Issue
Block a user