diff --git a/src/__tests__/renderer/components/ui/EmptyStatePlaceholder.test.tsx b/src/__tests__/renderer/components/ui/EmptyStatePlaceholder.test.tsx new file mode 100644 index 0000000000..3bc6f7e0c6 --- /dev/null +++ b/src/__tests__/renderer/components/ui/EmptyStatePlaceholder.test.tsx @@ -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(); + expect(screen.getByText('No items')).toBeInTheDocument(); + }); + + it('renders icon when provided', () => { + render( + } /> + ); + expect(screen.getByTestId('icon')).toBeInTheDocument(); + }); + + it('renders description when provided', () => { + render( + + ); + expect(screen.getByText('Try adjusting your filters')).toBeInTheDocument(); + }); + + it('renders action when provided', () => { + render( + Clear} /> + ); + expect(screen.getByRole('button', { name: 'Clear' })).toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/renderer/components/ui/GhostIconButton.test.tsx b/src/__tests__/renderer/components/ui/GhostIconButton.test.tsx new file mode 100644 index 0000000000..f4b59594a1 --- /dev/null +++ b/src/__tests__/renderer/components/ui/GhostIconButton.test.tsx @@ -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( + + x + + ); + 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( + + x + + ); + fireEvent.click(screen.getByRole('button', { name: 'Do it' })); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('respects disabled prop', () => { + const onClick = vi.fn(); + render( + + x + + ); + const btn = screen.getByRole('button', { name: 'Disabled' }); + expect(btn).toBeDisabled(); + fireEvent.click(btn); + expect(onClick).not.toHaveBeenCalled(); + }); + + it('applies custom padding', () => { + render( + + x + + ); + 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( +
+ + x + +
+ ); + fireEvent.click(screen.getByRole('button', { name: 'Stop' })); + expect(onClick).toHaveBeenCalledTimes(1); + expect(parentClick).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/renderer/components/ui/Spinner.test.tsx b/src/__tests__/renderer/components/ui/Spinner.test.tsx new file mode 100644 index 0000000000..fe5fbe27b2 --- /dev/null +++ b/src/__tests__/renderer/components/ui/Spinner.test.tsx @@ -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(); + 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(); + const icon = screen.getByTestId('loader2-icon'); + expect(icon).toHaveStyle({ width: '32px', height: '32px' }); + }); + + it('applies custom color', () => { + render(); + const icon = screen.getByTestId('loader2-icon'); + expect(icon).toHaveStyle({ color: 'rgb(255, 0, 0)' }); + }); + + it('merges custom className', () => { + render(); + const icon = screen.getByTestId('loader2-icon'); + expect(icon).toHaveClass('animate-spin'); + expect(icon).toHaveClass('text-blue-500'); + }); +}); diff --git a/src/renderer/components/AboutModal.tsx b/src/renderer/components/AboutModal.tsx index 74203a58db..2b4554a6d7 100644 --- a/src/renderer/components/AboutModal.tsx +++ b/src/renderer/components/AboutModal.tsx @@ -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'; @@ -126,48 +127,36 @@ export function AboutModal({

About Maestro

- - - + - + ); @@ -227,13 +216,11 @@ export function AboutModal({ Global Statistics - {!isStatsComplete && ( - - )} + {!isStatsComplete && } {loading ? (
- + Loading stats... diff --git a/src/renderer/components/AgentCreationDialog.tsx b/src/renderer/components/AgentCreationDialog.tsx index 96b8f8a02a..d734aa09e4 100644 --- a/src/renderer/components/AgentCreationDialog.tsx +++ b/src/renderer/components/AgentCreationDialog.tsx @@ -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'; @@ -337,13 +330,9 @@ export function AgentCreationDialog({ Create Symphony Agent
- + {/* Content - scrollable */} @@ -377,7 +366,7 @@ export function AgentCreationDialog({ {ac.isDetecting ? (
- +
) : ac.detectedAgents.length === 0 ? (
@@ -446,19 +435,18 @@ export function AgentCreationDialog({ > Available - +
@@ -647,7 +635,7 @@ export function AgentCreationDialog({ > {isCreating ? ( <> - + Creating... ) : ( diff --git a/src/renderer/components/AgentPromptComposerModal.tsx b/src/renderer/components/AgentPromptComposerModal.tsx index f63018ffb2..6318405dae 100644 --- a/src/renderer/components/AgentPromptComposerModal.tsx +++ b/src/renderer/components/AgentPromptComposerModal.tsx @@ -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'; @@ -151,13 +152,9 @@ export function AgentPromptComposerModal({
- +
diff --git a/src/renderer/components/AgentSessionsBrowser.tsx b/src/renderer/components/AgentSessionsBrowser.tsx index 173a625200..9968666bf4 100644 --- a/src/renderer/components/AgentSessionsBrowser.tsx +++ b/src/renderer/components/AgentSessionsBrowser.tsx @@ -25,6 +25,9 @@ import { ArrowUpFromLine, Edit3, } from 'lucide-react'; +import { GhostIconButton } from './ui/GhostIconButton'; +import { Spinner } from './ui/Spinner'; +import { EmptyStatePlaceholder } from './ui/EmptyStatePlaceholder'; import type { Theme, Session, LogEntry, UsageStats } from '../types'; import { useLayerStack } from '../contexts/LayerStackContext'; import { MODAL_PRIORITIES } from '../constants/modalPriorities'; @@ -726,17 +729,18 @@ export function AgentSessionsBrowser({
{viewingSession ? ( <> - + {/* Star button for detail view */} - +
{/* Session name with edit button */} {renamingSessionId === viewingSession.sessionId ? ( @@ -792,18 +796,18 @@ export function AgentSessionsBrowser({ > {viewingSession.sessionName} - +
) : (
@@ -814,18 +818,18 @@ export function AgentSessionsBrowser({ > {viewingSession.sessionId.toUpperCase()} - +
)} {/* Show UUID underneath the custom name */} @@ -1181,7 +1185,7 @@ export function AgentSessionsBrowser({ {messagesLoading && messages.length === 0 && (
- +
)}
@@ -1324,12 +1328,7 @@ export function AgentSessionsBrowser({ } }} /> - {isSearching && ( - - )} + {isSearching && } {search && !isSearching && ( + + ) : ( + <> + + setSearch(e.target.value)} + onKeyDown={handleKeyDown} + /> +
+ ESC +
+ + )} + + + {/* Content */} + {viewingSession ? ( +
+ {/* Load more indicator */} + {hasMoreMessages && ( +
+ {messagesLoading ? ( + + ) : ( + + )} +
+ )} + + {/* Messages */} + {messages.map((msg, idx) => ( +
+ {/* Tool call messages - render with ToolCallCard */} + {msg.toolUse && msg.toolUse.length > 0 ? ( +
+ +
+ ) : ( + /* Regular text messages */ +
+
+ {msg.content || '[No content]'} +
+
+ {formatRelativeTime(msg.timestamp)} +
+
+ )} +
+ ))} + + {messagesLoading && messages.length === 0 && ( +
+ +
+ )} +
+ ) : ( +
+ {loading ? ( +
+ +
+ ) : filteredSessions.length === 0 ? ( + + ) : ( + <> + {filteredSessions.map((session, i) => { + const isStarred = starredSessions.has(session.sessionId); + return ( + +
+
+ {session.sessionName || + session.firstMessage || + `Session ${session.sessionId.slice(0, 8)}...`} +
+
+ + + {formatRelativeTime(session.modifiedAt)} + + + + {session.messageCount} msgs + + + + {formatSize(session.sizeBytes)} + +
+
+ + ); + })} + {/* Pagination indicator */} + {(isLoadingMoreSessions || hasMoreSessions) && !search && ( +
+ {isLoadingMoreSessions ? ( +
+ + + Loading more sessions... + +
+ ) : ( + + {sessions.length} of {totalSessionCount} sessions loaded + + )} +
+ )} + + )} +
+ )} + + + ); +} diff --git a/src/renderer/components/AutoRun/AttachmentImage.tsx b/src/renderer/components/AutoRun/AttachmentImage.tsx index a7ef038cc1..23c5eaace5 100644 --- a/src/renderer/components/AutoRun/AttachmentImage.tsx +++ b/src/renderer/components/AutoRun/AttachmentImage.tsx @@ -1,5 +1,6 @@ import { useState, useRef, useEffect, useMemo, memo } from 'react'; -import { Loader2, Image, X, Search } from 'lucide-react'; +import { Image, X, Search } from 'lucide-react'; +import { Spinner } from '../ui/Spinner'; import { imageCache } from '../../hooks'; import type { Theme } from '../../types'; @@ -213,7 +214,7 @@ export const AttachmentImage = memo(function AttachmentImage({ className="inline-flex items-center gap-2 px-3 py-2 rounded" style={{ backgroundColor: theme.colors.bgActivity }} > - + Loading image... diff --git a/src/renderer/components/AutoRun/AutoRunExpandedModal.tsx b/src/renderer/components/AutoRun/AutoRunExpandedModal.tsx index 7202a3e2f4..f21c5285c3 100644 --- a/src/renderer/components/AutoRun/AutoRunExpandedModal.tsx +++ b/src/renderer/components/AutoRun/AutoRunExpandedModal.tsx @@ -7,12 +7,13 @@ import { Edit, Play, Square, - Loader2, Save, RotateCcw, LayoutGrid, AlertTriangle, } from 'lucide-react'; +import { GhostIconButton } from '../ui/GhostIconButton'; +import { Spinner } from '../ui/Spinner'; import type { Theme, BatchRunState, SessionState, Shortcut } from '../../types'; import { useLayerStack } from '../../contexts/LayerStackContext'; import { MODAL_PRIORITIES } from '../../constants/modalPriorities'; @@ -360,11 +361,7 @@ export function AutoRunExpandedModal({ }} title={isStopping ? 'Stopping after current task...' : 'Stop auto-run'} > - {isStopping ? ( - - ) : ( - - )} + {isStopping ? : } {isStopping ? 'Stopping' : 'Stop'} ) : ( @@ -422,13 +419,9 @@ export function AutoRunExpandedModal({ Collapse - + diff --git a/src/renderer/components/AutoRun/AutoRunSearchBar.tsx b/src/renderer/components/AutoRun/AutoRunSearchBar.tsx index 83c72371cd..00929eb68c 100644 --- a/src/renderer/components/AutoRun/AutoRunSearchBar.tsx +++ b/src/renderer/components/AutoRun/AutoRunSearchBar.tsx @@ -1,5 +1,6 @@ import React, { useRef, useEffect, useCallback } from 'react'; import { Search, ChevronUp, ChevronDown, X } from 'lucide-react'; +import { GhostIconButton } from '../ui/GhostIconButton'; import type { Theme } from '../../types'; import { useLayerStack } from '../../contexts/LayerStackContext'; import { MODAL_PRIORITIES } from '../../constants/modalPriorities'; @@ -119,14 +120,9 @@ export function AutoRunSearchBar({ )} - + ); } diff --git a/src/renderer/components/AutoRun/AutoRunToolbar.tsx b/src/renderer/components/AutoRun/AutoRunToolbar.tsx index fd172b81f3..3591921765 100644 --- a/src/renderer/components/AutoRun/AutoRunToolbar.tsx +++ b/src/renderer/components/AutoRun/AutoRunToolbar.tsx @@ -1,5 +1,6 @@ import { memo } from 'react'; -import { Play, Square, HelpCircle, Loader2, LayoutGrid, Wand2 } from 'lucide-react'; +import { Play, Square, HelpCircle, LayoutGrid, Wand2 } from 'lucide-react'; +import { Spinner } from '../ui/Spinner'; import type { Theme } from '../../types'; export interface AutoRunToolbarProps { @@ -63,11 +64,7 @@ export const AutoRunToolbar = memo(function AutoRunToolbar({ }} title={isStopping ? 'Stopping after current task...' : 'Stop auto-run'} > - {isStopping ? ( - - ) : ( - - )} + {isStopping ? : } {isStopping ? 'Stopping' : 'Stop'} ) : ( diff --git a/src/renderer/components/BatchRunnerModal.tsx b/src/renderer/components/BatchRunnerModal.tsx index bae09c591b..f287323a3d 100644 --- a/src/renderer/components/BatchRunnerModal.tsx +++ b/src/renderer/components/BatchRunnerModal.tsx @@ -13,8 +13,8 @@ import { Download, Upload, LayoutGrid, - Loader2, } from 'lucide-react'; +import { Spinner } from './ui/Spinner'; import type { Theme, BatchDocumentEntry, BatchRunConfig, WorktreeRunTarget } from '../types'; import { useLayerStack } from '../contexts/LayerStackContext'; import { MODAL_PRIORITIES } from '../constants/modalPriorities'; @@ -904,11 +904,7 @@ export function BatchRunnerModal(props: BatchRunnerModalProps) { : 'Start auto-run' } > - {isPreparingWorktree ? ( - - ) : ( - - )} + {isPreparingWorktree ? : } {isPreparingWorktree ? 'Preparing Worktree...' : 'Go'} diff --git a/src/renderer/components/CreatePRModal.tsx b/src/renderer/components/CreatePRModal.tsx index 202923c9c4..9bae2ea3f1 100644 --- a/src/renderer/components/CreatePRModal.tsx +++ b/src/renderer/components/CreatePRModal.tsx @@ -1,5 +1,7 @@ import React, { useState, useEffect, useRef } from 'react'; -import { X, GitPullRequest, Loader2, AlertTriangle, ExternalLink } from 'lucide-react'; +import { X, GitPullRequest, AlertTriangle, ExternalLink } from 'lucide-react'; +import { GhostIconButton } from './ui/GhostIconButton'; +import { Spinner } from './ui/Spinner'; import type { Theme, GhCliStatus } from '../types'; import { useLayerStack } from '../contexts/LayerStackContext'; import { MODAL_PRIORITIES } from '../constants/modalPriorities'; @@ -228,9 +230,9 @@ export function CreatePRModal({ Create Pull Request - + {/* Content */} @@ -301,7 +303,7 @@ export function CreatePRModal({ className="flex items-center gap-2 text-sm" style={{ color: theme.colors.textDim }} > - + Checking GitHub CLI... )} @@ -472,7 +474,7 @@ export function CreatePRModal({ > {isCreating ? ( <> - + Creating... ) : ( diff --git a/src/renderer/components/CreateWorktreeModal.tsx b/src/renderer/components/CreateWorktreeModal.tsx index c74c31cfe8..2a62e7bb94 100644 --- a/src/renderer/components/CreateWorktreeModal.tsx +++ b/src/renderer/components/CreateWorktreeModal.tsx @@ -1,5 +1,7 @@ import { useState, useEffect, useRef } from 'react'; -import { X, GitBranch, Loader2, AlertTriangle } from 'lucide-react'; +import { X, GitBranch, AlertTriangle } from 'lucide-react'; +import { GhostIconButton } from './ui/GhostIconButton'; +import { Spinner } from './ui/Spinner'; import type { Theme, Session, GhCliStatus } from '../types'; import { useLayerStack } from '../contexts/LayerStackContext'; import { MODAL_PRIORITIES } from '../constants/modalPriorities'; @@ -138,9 +140,9 @@ export function CreateWorktreeModal({ Create New Worktree - + {/* Content */} @@ -277,7 +279,7 @@ export function CreateWorktreeModal({ > {isCreating ? ( <> - + Creating... ) : ( diff --git a/src/renderer/components/CueYamlEditor/CueAiChat.tsx b/src/renderer/components/CueYamlEditor/CueAiChat.tsx index 2f704c0897..4f761b88b8 100644 --- a/src/renderer/components/CueYamlEditor/CueAiChat.tsx +++ b/src/renderer/components/CueYamlEditor/CueAiChat.tsx @@ -4,7 +4,8 @@ * Shows message history, streaming indicator, and input field. */ -import { Loader2, Send } from 'lucide-react'; +import { Send } from 'lucide-react'; +import { Spinner } from '../ui/Spinner'; import { CUE_COLOR } from '../../../shared/cue-pipeline-types'; import type { Theme } from '../../types'; import type { ChatMessage } from '../../hooks/cue/useCueAiChat'; @@ -68,7 +69,7 @@ export function CueAiChat({ style={{ color: theme.colors.textDim }} data-testid="chat-busy-indicator" > - + Agent is working... )} diff --git a/src/renderer/components/CueYamlEditor/CueYamlEditor.tsx b/src/renderer/components/CueYamlEditor/CueYamlEditor.tsx index f2f3d7bc77..e9b4595011 100644 --- a/src/renderer/components/CueYamlEditor/CueYamlEditor.tsx +++ b/src/renderer/components/CueYamlEditor/CueYamlEditor.tsx @@ -7,6 +7,7 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { CheckCircle, XCircle, Zap, LayoutDashboard, GitFork, X } from 'lucide-react'; +import { GhostIconButton } from '../ui/GhostIconButton'; import type { CuePattern } from '../../constants/cuePatterns'; import { Modal, ModalFooter } from '../ui/Modal'; import { MODAL_PRIORITIES } from '../../constants/modalPriorities'; @@ -271,15 +272,9 @@ export function CueYamlEditor({ - + ) : undefined; diff --git a/src/renderer/components/DebugPackageModal.tsx b/src/renderer/components/DebugPackageModal.tsx index 1de8ee4110..6c412b1530 100644 --- a/src/renderer/components/DebugPackageModal.tsx +++ b/src/renderer/components/DebugPackageModal.tsx @@ -9,7 +9,8 @@ */ import { useState, useEffect, useRef, useCallback } from 'react'; -import { Package, Check, Loader2, FolderOpen, AlertCircle, Copy } from 'lucide-react'; +import { Package, Check, FolderOpen, AlertCircle, Copy } from 'lucide-react'; +import { Spinner } from './ui/Spinner'; import type { Theme } from '../types'; import { MODAL_PRIORITIES } from '../constants/modalPriorities'; import { Modal, ModalFooter } from './ui/Modal'; @@ -239,11 +240,11 @@ export function DebugPackageModal({ theme, isOpen, onClose }: DebugPackageModalP {loading ? (
- +
) : generationState === 'generating' ? (
- +

Collecting diagnostic information...

diff --git a/src/renderer/components/DeleteWorktreeModal.tsx b/src/renderer/components/DeleteWorktreeModal.tsx index df0002190b..fd2a2c4e97 100644 --- a/src/renderer/components/DeleteWorktreeModal.tsx +++ b/src/renderer/components/DeleteWorktreeModal.tsx @@ -1,8 +1,9 @@ import { useRef, useState } from 'react'; -import { AlertTriangle, Loader2, Trash2 } from 'lucide-react'; +import { AlertTriangle, Trash2 } from 'lucide-react'; import type { Theme, Session } from '../types'; import { MODAL_PRIORITIES } from '../constants/modalPriorities'; import { Modal } from './ui/Modal'; +import { Spinner } from './ui/Spinner'; interface DeleteWorktreeModalProps { theme: Theme; @@ -71,7 +72,7 @@ export function DeleteWorktreeModal({ opacity: 0.7, }} > - + Deleting... ) : ( diff --git a/src/renderer/components/DirectorNotes/AIOverviewTab.tsx b/src/renderer/components/DirectorNotes/AIOverviewTab.tsx index 0704d3bea6..cc2f3d8e04 100644 --- a/src/renderer/components/DirectorNotes/AIOverviewTab.tsx +++ b/src/renderer/components/DirectorNotes/AIOverviewTab.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useCallback, useRef } from 'react'; -import { RefreshCw, Save, Loader2, Clock, Copy, Check, Bot, History, Timer } from 'lucide-react'; +import { RefreshCw, Save, Clock, Copy, Check, Bot, History, Timer } from 'lucide-react'; +import { Spinner } from '../ui/Spinner'; import type { Theme } from '../../types'; import { MarkdownRenderer } from '../MarkdownRenderer'; import { SaveMarkdownModal } from '../SaveMarkdownModal'; @@ -259,11 +260,7 @@ export function AIOverviewTab({ theme, onSynopsisReady }: AIOverviewTabProps) { opacity: isGenerating ? 0.5 : 1, }} > - {isGenerating ? ( - - ) : ( - - )} + {isGenerating ? : } {isGenerating ? 'Regenerating…' : 'Regenerate'} @@ -366,7 +363,7 @@ export function AIOverviewTab({ theme, onSynopsisReady }: AIOverviewTabProps) { ) : isGenerating ? (
- +

Generating…

diff --git a/src/renderer/components/DirectorNotes/DirectorNotesModal.tsx b/src/renderer/components/DirectorNotes/DirectorNotesModal.tsx index d7094581c9..a863bec8fd 100644 --- a/src/renderer/components/DirectorNotes/DirectorNotesModal.tsx +++ b/src/renderer/components/DirectorNotes/DirectorNotesModal.tsx @@ -1,6 +1,8 @@ import React, { useState, useEffect, useRef, useCallback, lazy, Suspense } from 'react'; import { createPortal } from 'react-dom'; -import { X, History, Sparkles, Loader2, Clapperboard, HelpCircle } from 'lucide-react'; +import { X, History, Sparkles, Clapperboard, HelpCircle } from 'lucide-react'; +import { GhostIconButton } from '../ui/GhostIconButton'; +import { Spinner } from '../ui/Spinner'; import type { Theme } from '../../types'; import { useLayerStack } from '../../contexts/LayerStackContext'; import { MODAL_PRIORITIES } from '../../constants/modalPriorities'; @@ -210,9 +212,9 @@ export function DirectorNotesModal({
{/* Close button */} - +
{/* Tab navigation */} @@ -239,11 +241,7 @@ export function DirectorNotesModal({ cursor: isDisabled ? 'default' : 'pointer', }} > - {showGenerating ? ( - - ) : ( - - )} + {showGenerating ? : } {tab.label} {showGenerating && generating…} @@ -259,7 +257,7 @@ export function DirectorNotesModal({ - +
} > diff --git a/src/renderer/components/DirectorNotes/UnifiedHistoryTab.tsx b/src/renderer/components/DirectorNotes/UnifiedHistoryTab.tsx index e69551f2ba..c43515a2b4 100644 --- a/src/renderer/components/DirectorNotes/UnifiedHistoryTab.tsx +++ b/src/renderer/components/DirectorNotes/UnifiedHistoryTab.tsx @@ -8,7 +8,8 @@ import React, { useImperativeHandle, } from 'react'; import { useVirtualizer } from '@tanstack/react-virtual'; -import { Loader2, Search, X } from 'lucide-react'; +import { Search, X } from 'lucide-react'; +import { Spinner } from '../ui/Spinner'; import type { Theme, HistoryEntry, HistoryEntryType } from '../../types'; import type { FileNode } from '../../types/fileTree'; import { @@ -736,10 +737,7 @@ export const UnifiedHistoryTab = forwardRef - + Loading more... diff --git a/src/renderer/components/DocumentGraph/DocumentGraphView.tsx b/src/renderer/components/DocumentGraph/DocumentGraphView.tsx index 3fcd442ad3..199949be8f 100644 --- a/src/renderer/components/DocumentGraph/DocumentGraphView.tsx +++ b/src/renderer/components/DocumentGraph/DocumentGraphView.tsx @@ -20,7 +20,6 @@ import { ExternalLink, RefreshCw, Search, - Loader2, ChevronDown, Sliders, AlertCircle, @@ -32,6 +31,7 @@ import { ChevronLeft, ChevronRight, } from 'lucide-react'; +import { Spinner } from '../ui/Spinner'; import type { Theme } from '../../types'; import { useLayerStack } from '../../contexts/LayerStackContext'; import { MODAL_PRIORITIES } from '../../constants/modalPriorities'; @@ -1686,7 +1686,7 @@ export function DocumentGraphView({ > {loading ? (
- +

{progress @@ -1925,7 +1925,7 @@ export function DocumentGraphView({ className="flex items-center gap-2 text-xs" style={{ color: theme.colors.textDim }} > - + Loading preview...

) : previewError ? ( @@ -2015,11 +2015,7 @@ export function DocumentGraphView({ onMouseLeave={(e) => !loadingMore && (e.currentTarget.style.opacity = '1')} title={`Load ${Math.min(LOAD_MORE_INCREMENT, totalDocuments - loadedDocuments)} more documents`} > - {loadingMore ? ( - - ) : ( - - )} + {loadingMore ? : } {loadingMore ? 'Loading...' : `Load more (${totalDocuments - loadedDocuments} remaining)`} @@ -2035,7 +2031,7 @@ export function DocumentGraphView({ }} title="Scanning for documents that link to the current graph" > - + Scanning backlinks {backlinkProgress && ` (${backlinkProgress.scanned}/${backlinkProgress.total})`} diff --git a/src/renderer/components/DocumentsPanel.tsx b/src/renderer/components/DocumentsPanel.tsx index df3ddbfc99..3c7f708476 100644 --- a/src/renderer/components/DocumentsPanel.tsx +++ b/src/renderer/components/DocumentsPanel.tsx @@ -12,6 +12,7 @@ import { Folder, CheckSquare, } from 'lucide-react'; +import { GhostIconButton } from './ui/GhostIconButton'; import type { Theme, BatchDocumentEntry } from '../types'; import { generateId } from '../utils/ids'; import { useLayerStack } from '../contexts/LayerStackContext'; @@ -484,13 +485,9 @@ function DocumentSelectorModal({ > - +
diff --git a/src/renderer/components/EmptyStateView.tsx b/src/renderer/components/EmptyStateView.tsx index cd1b885a65..45deb96bb0 100644 --- a/src/renderer/components/EmptyStateView.tsx +++ b/src/renderer/components/EmptyStateView.tsx @@ -12,6 +12,7 @@ import { BookOpen, ExternalLink, } from 'lucide-react'; +import { GhostIconButton } from './ui/GhostIconButton'; import type { Theme, Shortcut } from '../types'; import { formatShortcutKeys } from '../utils/shortcutFormatter'; import { useClickOutside } from '../hooks'; @@ -83,14 +84,14 @@ export function EmptyStateView({ {/* Right: Hamburger Menu */}
- + {/* Menu Overlay */} {menuOpen && ( diff --git a/src/renderer/components/FeedbackChatView.tsx b/src/renderer/components/FeedbackChatView.tsx index 1d822b28ca..0e1933992d 100644 --- a/src/renderer/components/FeedbackChatView.tsx +++ b/src/renderer/components/FeedbackChatView.tsx @@ -16,7 +16,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ImagePlus, - Loader2, MessageSquareHeart, Send, X, @@ -28,6 +27,7 @@ import { Check, Copy, } from 'lucide-react'; +import { Spinner } from './ui/Spinner'; import { safeClipboardWrite } from '../utils/clipboard'; import { MarkdownRenderer } from './MarkdownRenderer'; import { generateTerminalProseStyles } from '../utils/markdownConfig'; @@ -517,7 +517,7 @@ export function FeedbackChatView({ theme, onCancel, onWidthChange }: FeedbackCha if (ghAuth.checking) { return (
- +

Checking GitHub CLI...

@@ -674,7 +674,7 @@ export function FeedbackChatView({ theme, onCancel, onWidthChange }: FeedbackCha
{searchingIssues ? (
- +

Searching for similar existing issues...

@@ -750,7 +750,7 @@ export function FeedbackChatView({ theme, onCancel, onWidthChange }: FeedbackCha title="Subscribe and add your feedback as a comment" > {subscribingTo === issue.number ? ( - + ) : ( )} @@ -825,7 +825,7 @@ export function FeedbackChatView({ theme, onCancel, onWidthChange }: FeedbackCha className="flex items-center gap-1 text-[10px]" style={{ color: theme.colors.textDim }} > - + Checking for similar issues... )} @@ -843,11 +843,7 @@ export function FeedbackChatView({ theme, onCancel, onWidthChange }: FeedbackCha className="flex items-center gap-1.5 px-3 py-1 rounded text-xs font-bold transition-colors hover:opacity-90 disabled:opacity-40 shrink-0" style={{ backgroundColor: theme.colors.success, color: '#000' }} > - {step === 'submitting' ? ( - - ) : ( - - )} + {step === 'submitting' ? : } Submit Feedback )} @@ -902,7 +898,7 @@ export function FeedbackChatView({ theme, onCancel, onWidthChange }: FeedbackCha border: `1px solid ${theme.colors.border}`, }} > - +
)} @@ -966,7 +962,7 @@ export function FeedbackChatView({ theme, onCancel, onWidthChange }: FeedbackCha title="Subscribe and add your feedback as a comment" > {subscribingTo === issue.number ? ( - + ) : ( )} @@ -1125,11 +1121,7 @@ export function FeedbackChatView({ theme, onCancel, onWidthChange }: FeedbackCha style={{ backgroundColor: theme.colors.accent, color: theme.colors.accentForeground }} title="Send message" > - {isLoading ? ( - - ) : ( - - )} + {isLoading ? : } {/* Submit button — appears when ready */} {isReady && ( @@ -1141,11 +1133,7 @@ export function FeedbackChatView({ theme, onCancel, onWidthChange }: FeedbackCha style={{ backgroundColor: theme.colors.success, color: '#000' }} title="Submit feedback as GitHub issue" > - {step === 'submitting' ? ( - - ) : ( - - )} + {step === 'submitting' ? : } Submit )} diff --git a/src/renderer/components/FileExplorerPanel.tsx b/src/renderer/components/FileExplorerPanel.tsx index f90404f4fc..8668747587 100644 --- a/src/renderer/components/FileExplorerPanel.tsx +++ b/src/renderer/components/FileExplorerPanel.tsx @@ -21,9 +21,9 @@ import { Edit2, Trash2, AlertTriangle, - Loader2, Search, } from 'lucide-react'; +import { Spinner } from './ui/Spinner'; import type { Session, Theme, FocusArea } from '../types'; import type { FileNode } from '../types/fileTree'; import type { FileTreeChanges } from '../utils/fileExplorer'; @@ -114,7 +114,7 @@ function FileTreeLoadingProgress({ return (
{/* Animated spinner */} - + {/* Status text */}
diff --git a/src/renderer/components/FilePreview/FilePreview.tsx b/src/renderer/components/FilePreview/FilePreview.tsx index 79759e8737..95e53f3edf 100644 --- a/src/renderer/components/FilePreview/FilePreview.tsx +++ b/src/renderer/components/FilePreview/FilePreview.tsx @@ -21,6 +21,7 @@ import { X, Filter, } from 'lucide-react'; +import { GhostIconButton } from '../ui/GhostIconButton'; import { captureException } from '../../utils/sentry'; import { safeClipboardWrite, safeClipboardWriteBlob } from '../../utils/clipboard'; import { useLayerStack } from '../../contexts/LayerStackContext'; @@ -998,13 +999,9 @@ export const FilePreview = React.memo( > Reload - +
)} diff --git a/src/renderer/components/FilePreview/FilePreviewHeader.tsx b/src/renderer/components/FilePreview/FilePreviewHeader.tsx index 16240f15dd..6a61d63efe 100644 --- a/src/renderer/components/FilePreview/FilePreviewHeader.tsx +++ b/src/renderer/components/FilePreview/FilePreviewHeader.tsx @@ -6,7 +6,6 @@ import { ChevronRight, Clipboard, Copy, - Loader2, Globe, Save, Edit, @@ -14,6 +13,7 @@ import { GitGraph, ExternalLink, } from 'lucide-react'; +import { Spinner } from '../ui/Spinner'; import { captureException } from '../../utils/sentry'; import { formatShortcutKeys } from '../../utils/shortcutFormatter'; import { formatFileSize, formatDateTime } from './filePreviewUtils'; @@ -145,11 +145,7 @@ export const FilePreviewHeader = React.memo(function FilePreviewHeader({ : 'No changes to save' } > - {isSaving ? ( - - ) : ( - - )} + {isSaving ? : } {isSaving ? 'Saving...' : 'Save'} )} diff --git a/src/renderer/components/FilePreview/ImageViewer.tsx b/src/renderer/components/FilePreview/ImageViewer.tsx index d3b5976299..17efebbdba 100644 --- a/src/renderer/components/FilePreview/ImageViewer.tsx +++ b/src/renderer/components/FilePreview/ImageViewer.tsx @@ -1,6 +1,7 @@ import React, { useState, useRef, useCallback, useEffect, memo } from 'react'; import { ZoomIn, ZoomOut, Maximize2 } from 'lucide-react'; +import { GhostIconButton } from '../ui/GhostIconButton'; interface ImageViewerProps { src: string; alt: string; @@ -118,36 +119,21 @@ export const ImageViewer = memo(function ImageViewer({ src, alt, theme }: ImageV className="flex items-center justify-center gap-2 py-1.5 shrink-0 border-b" style={{ borderColor: theme.colors.border }} > - + {zoomPercent}% - - + {naturalSize && ( {naturalSize.w} × {naturalSize.h} diff --git a/src/renderer/components/FilePreview/MarkdownImage.tsx b/src/renderer/components/FilePreview/MarkdownImage.tsx index ad7a5d4d2f..b3299117ca 100644 --- a/src/renderer/components/FilePreview/MarkdownImage.tsx +++ b/src/renderer/components/FilePreview/MarkdownImage.tsx @@ -1,5 +1,6 @@ import React, { useState, useMemo, useEffect, useCallback } from 'react'; -import { Loader2, Image } from 'lucide-react'; +import { Image } from 'lucide-react'; +import { Spinner } from '../ui/Spinner'; import { imageCache, resolveImagePath } from './filePreviewUtils'; /** @@ -138,7 +139,7 @@ export const MarkdownImage = React.memo(function MarkdownImage({ minWidth: '200px', }} > - + Loading image... diff --git a/src/renderer/components/GistPublishModal.tsx b/src/renderer/components/GistPublishModal.tsx index 685c92c239..61e2e5db2c 100644 --- a/src/renderer/components/GistPublishModal.tsx +++ b/src/renderer/components/GistPublishModal.tsx @@ -1,5 +1,6 @@ import { useRef, useState, useCallback } from 'react'; import { Share2, Copy, Check, ExternalLink } from 'lucide-react'; +import { GhostIconButton } from './ui/GhostIconButton'; import type { Theme } from '../types'; import { MODAL_PRIORITIES } from '../constants/modalPriorities'; import { Modal } from './ui/Modal'; @@ -180,24 +181,22 @@ export function GistPublishModal({ style={{ color: theme.colors.textMain }} onClick={(e) => (e.target as HTMLInputElement).select()} /> - - +

diff --git a/src/renderer/components/GroupChatModal.tsx b/src/renderer/components/GroupChatModal.tsx index 04c8888d7a..e44c1d0d12 100644 --- a/src/renderer/components/GroupChatModal.tsx +++ b/src/renderer/components/GroupChatModal.tsx @@ -13,6 +13,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { X, Settings, ChevronDown, Check } from 'lucide-react'; +import { GhostIconButton } from './ui/GhostIconButton'; import { isBetaAgent } from '../../shared/agentMetadata'; import type { Theme, AgentConfig, ModeratorConfig, GroupChat } from '../types'; import { MODAL_PRIORITIES } from '../constants/modalPriorities'; @@ -242,15 +243,9 @@ export function GroupChatModal(props: GroupChatModalProps): JSX.Element | null { Beta

- + ) : undefined } diff --git a/src/renderer/components/History/HistoryStatsBar.tsx b/src/renderer/components/History/HistoryStatsBar.tsx index eeaccbf922..1ea67ba0ff 100644 --- a/src/renderer/components/History/HistoryStatsBar.tsx +++ b/src/renderer/components/History/HistoryStatsBar.tsx @@ -1,5 +1,6 @@ import React, { memo } from 'react'; -import { Layers, Hash, Bot, User, BarChart3, Loader2, ListOrdered } from 'lucide-react'; +import { Layers, Hash, Bot, User, BarChart3, ListOrdered } from 'lucide-react'; +import { Spinner } from '../ui/Spinner'; import type { Theme } from '../../types'; export interface HistoryStats { @@ -113,7 +114,7 @@ export const HistoryStatsBar = memo(function HistoryStatsBar({ color: theme.colors.warning, }} > - + - + Loading... diff --git a/src/renderer/components/InlineWizard/WizardPill.tsx b/src/renderer/components/InlineWizard/WizardPill.tsx index 9c8c6edfb8..f01e47eb09 100644 --- a/src/renderer/components/InlineWizard/WizardPill.tsx +++ b/src/renderer/components/InlineWizard/WizardPill.tsx @@ -6,7 +6,8 @@ * while the wizard is active. Shows a spinner when thinking. */ -import { Wand2, Loader2 } from 'lucide-react'; +import { Wand2 } from 'lucide-react'; +import { Spinner } from '../ui/Spinner'; import type { Theme } from '../../types'; interface WizardPillProps { @@ -40,7 +41,7 @@ export function WizardPill({ theme, onClick, isThinking = false }: WizardPillPro }} title={isThinking ? 'Wizard is thinking...' : 'Wizard mode active - click to exit'} > - {isThinking ? : } + {isThinking ? : } {isThinking ? 'Thinking...' : 'Wizard'} {/* Pulse animation styles */} diff --git a/src/renderer/components/LeaderboardRegistrationModal.tsx b/src/renderer/components/LeaderboardRegistrationModal.tsx index 5b9ca0e7c2..9e7bd6f653 100644 --- a/src/renderer/components/LeaderboardRegistrationModal.tsx +++ b/src/renderer/components/LeaderboardRegistrationModal.tsx @@ -12,7 +12,6 @@ import { Trophy, Mail, User, - Loader2, Check, AlertCircle, ExternalLink, @@ -22,6 +21,8 @@ import { Send, DownloadCloud, } from 'lucide-react'; +import { GhostIconButton } from './ui/GhostIconButton'; +import { Spinner } from './ui/Spinner'; import type { Theme, AutoRunStats, LeaderboardRegistration, KeyboardMasteryStats } from '../types'; import { useLayerStack } from '../contexts/LayerStackContext'; import { MODAL_PRIORITIES } from '../constants/modalPriorities'; @@ -797,13 +798,9 @@ export function LeaderboardRegistrationModal({ : 'Register for Leaderboard'} - + {/* Content */} @@ -1148,7 +1145,7 @@ export function LeaderboardRegistrationModal({ > {isResending ? ( <> - + Sending... ) : ( @@ -1307,7 +1304,7 @@ export function LeaderboardRegistrationModal({ > {submitState === 'submitting' ? ( <> - + Pushing... ) : ( @@ -1336,7 +1333,7 @@ export function LeaderboardRegistrationModal({ > {isSyncing ? ( <> - + Pulling... ) : ( diff --git a/src/renderer/components/MainPanel/AgentErrorBanner.tsx b/src/renderer/components/MainPanel/AgentErrorBanner.tsx index 4d89a3ecdf..929f48f8b8 100644 --- a/src/renderer/components/MainPanel/AgentErrorBanner.tsx +++ b/src/renderer/components/MainPanel/AgentErrorBanner.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { AlertCircle, X } from 'lucide-react'; +import { GhostIconButton } from '../ui/GhostIconButton'; import type { AgentError, Theme } from '../../types'; interface AgentErrorBannerProps { @@ -43,13 +44,9 @@ export const AgentErrorBanner = React.memo(function AgentErrorBanner({ )} {onClear && error.recoverable && ( - + )} diff --git a/src/renderer/components/MainPanel/BrowserTabView.tsx b/src/renderer/components/MainPanel/BrowserTabView.tsx index 67109ad020..d1607c628d 100644 --- a/src/renderer/components/MainPanel/BrowserTabView.tsx +++ b/src/renderer/components/MainPanel/BrowserTabView.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { ArrowLeft, ArrowRight, ExternalLink, Globe, Loader2, RotateCw } from 'lucide-react'; +import { ArrowLeft, ArrowRight, ExternalLink, Globe, RotateCw } from 'lucide-react'; +import { Spinner } from '../ui/Spinner'; import type { BrowserTab, Theme } from '../../types'; import { DEFAULT_BROWSER_TAB_TITLE, @@ -442,11 +443,7 @@ export const BrowserTabView = React.memo(function BrowserTabView({ style={{ color: theme.colors.textMain }} title={tab.isLoading ? 'Stop' : 'Reload'} > - {tab.isLoading ? ( - - ) : ( - - )} + {tab.isLoading ? : }