Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* Tests for EmptyStatePlaceholder component
*/

import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { EmptyStatePlaceholder } from '../../../../renderer/components/ui/EmptyStatePlaceholder';
import type { Theme } from '../../../../renderer/types';

const mockTheme: Theme = {
id: 'test-theme',
name: 'Test Theme',
mode: 'dark',
colors: {
bgMain: '#1a1a1a',
bgSidebar: '#242424',
bgActivity: '#2a2a2a',
textMain: '#ffffff',
textDim: '#888888',
accent: '#3b82f6',
accentForeground: '#ffffff',
border: '#333333',
error: '#ef4444',
success: '#22c55e',
warning: '#f59e0b',
cursor: '#ffffff',
terminalBg: '#1a1a1a',
},
};

describe('EmptyStatePlaceholder', () => {
it('renders title only', () => {
render(<EmptyStatePlaceholder theme={mockTheme} title="No items" />);
expect(screen.getByText('No items')).toBeInTheDocument();
});

it('renders icon when provided', () => {
render(
<EmptyStatePlaceholder theme={mockTheme} title="No items" icon={<svg data-testid="icon" />} />
);
expect(screen.getByTestId('icon')).toBeInTheDocument();
});

it('renders description when provided', () => {
render(
<EmptyStatePlaceholder
theme={mockTheme}
title="Empty"
description="Try adjusting your filters"
/>
);
expect(screen.getByText('Try adjusting your filters')).toBeInTheDocument();
});

it('renders action when provided', () => {
render(
<EmptyStatePlaceholder theme={mockTheme} title="Empty" action={<button>Clear</button>} />
);
expect(screen.getByRole('button', { name: 'Clear' })).toBeInTheDocument();
});
});
71 changes: 71 additions & 0 deletions src/__tests__/renderer/components/ui/GhostIconButton.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* Tests for GhostIconButton component
*/

import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { GhostIconButton } from '../../../../renderer/components/ui/GhostIconButton';

describe('GhostIconButton', () => {
it('renders children and default classes', () => {
render(
<GhostIconButton ariaLabel="Close">
<span data-testid="icon">x</span>
</GhostIconButton>
);
const btn = screen.getByRole('button', { name: 'Close' });
expect(btn).toBeInTheDocument();
expect(btn).toHaveClass('rounded');
expect(btn).toHaveClass('hover:bg-white/10');
expect(btn).toHaveClass('p-1');
expect(screen.getByTestId('icon')).toBeInTheDocument();
});

it('calls onClick when clicked', () => {
const onClick = vi.fn();
render(
<GhostIconButton onClick={onClick} ariaLabel="Do it">
<span>x</span>
</GhostIconButton>
);
fireEvent.click(screen.getByRole('button', { name: 'Do it' }));
expect(onClick).toHaveBeenCalledTimes(1);
});

it('respects disabled prop', () => {
const onClick = vi.fn();
render(
<GhostIconButton onClick={onClick} disabled ariaLabel="Disabled">
<span>x</span>
</GhostIconButton>
);
const btn = screen.getByRole('button', { name: 'Disabled' });
expect(btn).toBeDisabled();
fireEvent.click(btn);
expect(onClick).not.toHaveBeenCalled();
});

it('applies custom padding', () => {
render(
<GhostIconButton padding="p-2" ariaLabel="Pad">
<span>x</span>
</GhostIconButton>
);
expect(screen.getByRole('button', { name: 'Pad' })).toHaveClass('p-2');
});

it('stops propagation when stopPropagation is true', () => {
const parentClick = vi.fn();
const onClick = vi.fn();
render(
<div onClick={parentClick}>
<GhostIconButton onClick={onClick} stopPropagation ariaLabel="Stop">
<span>x</span>
</GhostIconButton>
</div>
);
fireEvent.click(screen.getByRole('button', { name: 'Stop' }));
expect(onClick).toHaveBeenCalledTimes(1);
expect(parentClick).not.toHaveBeenCalled();
});
});
36 changes: 36 additions & 0 deletions src/__tests__/renderer/components/ui/Spinner.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* Tests for Spinner component
*/

import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { Spinner } from '../../../../renderer/components/ui/Spinner';

describe('Spinner', () => {
it('renders with default size', () => {
render(<Spinner />);
const icon = screen.getByTestId('loader2-icon');
expect(icon).toBeInTheDocument();
expect(icon).toHaveClass('animate-spin');
expect(icon).toHaveStyle({ width: '16px', height: '16px' });
});

it('applies custom size', () => {
render(<Spinner size={32} />);
const icon = screen.getByTestId('loader2-icon');
expect(icon).toHaveStyle({ width: '32px', height: '32px' });
});

it('applies custom color', () => {
render(<Spinner color="rgb(255, 0, 0)" />);
const icon = screen.getByTestId('loader2-icon');
expect(icon).toHaveStyle({ color: 'rgb(255, 0, 0)' });
});

it('merges custom className', () => {
render(<Spinner className="text-blue-500" />);
const icon = screen.getByTestId('loader2-icon');
Comment thread
coderabbitai[bot] marked this conversation as resolved.
expect(icon).toHaveClass('animate-spin');
expect(icon).toHaveClass('text-blue-500');
});
});
49 changes: 18 additions & 31 deletions src/renderer/components/AboutModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@ import {
ExternalLink,
FileCode,
BarChart3,
Loader2,
Trophy,
Globe,
Check,
BookOpen,
} from 'lucide-react';
import { Spinner } from './ui/Spinner';
import { GhostIconButton } from './ui/GhostIconButton';
import type { Theme, AutoRunStats, MaestroUsageStats, LeaderboardRegistration } from '../types';
import type { GlobalAgentStats } from '../../shared/types';
import { MODAL_PRIORITIES } from '../constants/modalPriorities';
Expand Down Expand Up @@ -126,48 +127,36 @@ export function AboutModal({
<h2 className="text-sm font-bold" style={{ color: theme.colors.textMain }}>
About Maestro
</h2>
<button
type="button"
<GhostIconButton
onClick={() => openUrl(buildMaestroUrl('https://runmaestro.ai'))}
className="p-1 rounded hover:bg-white/10 transition-colors"
title="Visit runmaestro.ai"
aria-label="Visit runmaestro.ai"
style={{ color: theme.colors.accent }}
ariaLabel="Visit runmaestro.ai"
color={theme.colors.accent}
>
<Globe className="w-4 h-4" />
</button>
<button
type="button"
</GhostIconButton>
<GhostIconButton
onClick={() => openUrl(buildMaestroUrl('https://runmaestro.ai/discord'))}
className="p-1 rounded hover:bg-white/10 transition-colors"
title="Join our Discord"
aria-label="Join our Discord"
style={{ color: theme.colors.accent }}
ariaLabel="Join our Discord"
color={theme.colors.accent}
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
</svg>
</button>
<button
type="button"
</GhostIconButton>
<GhostIconButton
onClick={() => openUrl(buildMaestroUrl('https://docs.runmaestro.ai/'))}
className="p-1 rounded hover:bg-white/10 transition-colors"
title="Documentation"
aria-label="Documentation"
style={{ color: theme.colors.accent }}
ariaLabel="Documentation"
color={theme.colors.accent}
>
<BookOpen className="w-4 h-4" />
</button>
</GhostIconButton>
</div>
<button
type="button"
onClick={onClose}
className="p-1 rounded hover:bg-white/10 transition-colors"
style={{ color: theme.colors.textDim }}
aria-label="Close modal"
>
<GhostIconButton onClick={onClose} color={theme.colors.textDim} ariaLabel="Close modal">
<X className="w-4 h-4" />
</button>
</GhostIconButton>
</div>
);

Expand Down Expand Up @@ -227,13 +216,11 @@ export function AboutModal({
<span className="text-sm font-bold" style={{ color: theme.colors.textMain }}>
Global Statistics
</span>
{!isStatsComplete && (
<Loader2 className="w-3 h-3 animate-spin" style={{ color: theme.colors.textDim }} />
)}
{!isStatsComplete && <Spinner size={12} color={theme.colors.textDim} />}
</div>
{loading ? (
<div className="flex items-center justify-center py-4 gap-2">
<Loader2 className="w-4 h-4 animate-spin" style={{ color: theme.colors.textDim }} />
<Spinner size={16} color={theme.colors.textDim} />
<span className="text-xs" style={{ color: theme.colors.textDim }}>
Loading stats...
</span>
Expand Down
32 changes: 10 additions & 22 deletions src/renderer/components/AgentCreationDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,9 @@

import { useState, useEffect, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
import {
Music,
X,
Loader2,
Bot,
Settings,
FolderOpen,
ChevronRight,
RefreshCw,
} from 'lucide-react';
import { Music, X, Bot, Settings, FolderOpen, ChevronRight, RefreshCw } from 'lucide-react';
import { GhostIconButton } from './ui/GhostIconButton';
import { Spinner } from './ui/Spinner';
import type { Theme, AgentConfig } from '../types';
import type { RegisteredRepository, SymphonyIssue } from '../../shared/symphony-types';
import { useLayerStack } from '../contexts/LayerStackContext';
Expand Down Expand Up @@ -337,13 +330,9 @@ export function AgentCreationDialog({
Create Symphony Agent
</h2>
</div>
<button
onClick={onClose}
className="p-1.5 rounded hover:bg-white/10 transition-colors"
title="Close (Esc)"
>
<GhostIconButton onClick={onClose} padding="p-1.5" title="Close (Esc)">
<X className="w-4 h-4" style={{ color: theme.colors.textDim }} />
</button>
</GhostIconButton>
</div>

{/* Content - scrollable */}
Expand Down Expand Up @@ -377,7 +366,7 @@ export function AgentCreationDialog({

{ac.isDetecting ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin" style={{ color: theme.colors.accent }} />
<Spinner size={24} color={theme.colors.accent} />
</div>
) : ac.detectedAgents.length === 0 ? (
<div className="text-center py-4" style={{ color: theme.colors.textDim }}>
Expand Down Expand Up @@ -446,19 +435,18 @@ export function AgentCreationDialog({
>
Available
</span>
<button
<GhostIconButton
onClick={(e) => {
e.stopPropagation();
handleRefreshAgent(agent.id);
}}
className="p-1 rounded hover:bg-white/10 transition-colors"
title="Refresh detection"
style={{ color: theme.colors.textDim }}
color={theme.colors.textDim}
>
<RefreshCw
className={`w-3 h-3 ${refreshingAgent === agent.id ? 'animate-spin' : ''}`}
/>
</button>
</GhostIconButton>
</div>
</div>

Expand Down Expand Up @@ -647,7 +635,7 @@ export function AgentCreationDialog({
>
{isCreating ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
<Spinner size={16} />
Creating...
</>
) : (
Expand Down
9 changes: 3 additions & 6 deletions src/renderer/components/AgentPromptComposerModal.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { useEffect, useRef, useState } from 'react';
import { X, FileText, Variable, ChevronDown, ChevronRight } from 'lucide-react';
import { GhostIconButton } from './ui/GhostIconButton';
import type { Theme } from '../types';
import { useLayerStack } from '../contexts/LayerStackContext';
import { MODAL_PRIORITIES } from '../constants/modalPriorities';
Expand Down Expand Up @@ -151,13 +152,9 @@ export function AgentPromptComposerModal({
</span>
</div>
<div className="flex items-center gap-3">
<button
onClick={handleDone}
className="p-1.5 rounded hover:bg-white/10 transition-colors"
title="Close (Escape)"
>
<GhostIconButton onClick={handleDone} padding="p-1.5" title="Close (Escape)">
<X className="w-5 h-5" style={{ color: theme.colors.textDim }} />
</button>
</GhostIconButton>
</div>
</div>

Expand Down
Loading