Compare commits

..

2 Commits

Author SHA1 Message Date
Web Dev Cody
518c81815e Merge pull request #563 from AutoMaker-Org/v0.12.0rc
V0.12.0rc
2026-01-17 18:50:55 -05:00
webdevcody
01652d0d11 feat: add hostname configuration for web server
Introduce APP_HOST variable to allow custom hostname configuration for the web server. Default to localhost if VITE_HOSTNAME is not set. Update relevant URLs and CORS origins to use APP_HOST, enhancing flexibility for local development and deployment.

This change improves the application's adaptability to different environments.
2026-01-17 18:43:10 -05:00
143 changed files with 398 additions and 7035 deletions

View File

@@ -83,8 +83,6 @@ import { createNotificationsRoutes } from './routes/notifications/index.js';
import { getNotificationService } from './services/notification-service.js';
import { createEventHistoryRoutes } from './routes/event-history/index.js';
import { getEventHistoryService } from './services/event-history-service.js';
import { createCodeReviewRoutes } from './routes/code-review/index.js';
import { CodeReviewService } from './services/code-review-service.js';
// Load environment variables
dotenv.config();
@@ -211,7 +209,6 @@ const codexModelCacheService = new CodexModelCacheService(DATA_DIR, codexAppServ
const codexUsageService = new CodexUsageService(codexAppServerService);
const mcpTestService = new MCPTestService(settingsService);
const ideationService = new IdeationService(events, settingsService, featureLoader);
const codeReviewService = new CodeReviewService(events, settingsService);
// Initialize DevServerService with event emitter for real-time log streaming
const devServerService = getDevServerService();
@@ -303,7 +300,6 @@ app.use('/api/pipeline', createPipelineRoutes(pipelineService));
app.use('/api/ideation', createIdeationRoutes(events, ideationService, featureLoader));
app.use('/api/notifications', createNotificationsRoutes(notificationService));
app.use('/api/event-history', createEventHistoryRoutes(eventHistoryService, settingsService));
app.use('/api/code-review', createCodeReviewRoutes(codeReviewService));
// Create HTTP server
const server = createServer(app);

View File

@@ -40,7 +40,6 @@ export interface UnifiedCliDetection {
claude?: CliDetectionResult;
codex?: CliDetectionResult;
cursor?: CliDetectionResult;
coderabbit?: CliDetectionResult;
}
/**
@@ -77,16 +76,6 @@ const CLI_CONFIGS = {
win32: 'iwr https://cursor.sh/install.ps1 -UseBasicParsing | iex',
},
},
coderabbit: {
name: 'CodeRabbit CLI',
commands: ['coderabbit', 'cr'],
versionArgs: ['--version'],
installCommands: {
darwin: 'npm install -g coderabbit',
linux: 'npm install -g coderabbit',
win32: 'npm install -g coderabbit',
},
},
} as const;
/**
@@ -241,8 +230,6 @@ export async function checkCliAuth(
return await checkCodexAuth(command);
case 'cursor':
return await checkCursorAuth(command);
case 'coderabbit':
return await checkCodeRabbitAuth(command);
default:
return 'none';
}
@@ -368,64 +355,6 @@ async function checkCursorAuth(command: string): Promise<'cli' | 'api_key' | 'no
return 'none';
}
/**
* Check CodeRabbit CLI authentication
*
* Expected output when authenticated:
* ```
* CodeRabbit CLI Status
* ✅ Authentication: Logged in
* User Information:
* 👤 Name: ...
* ```
*/
async function checkCodeRabbitAuth(command: string): Promise<'cli' | 'api_key' | 'none'> {
// Check for environment variable
if (process.env.CODERABBIT_API_KEY) {
return 'api_key';
}
// Try running auth status command
return new Promise((resolve) => {
const child = spawn(command, ['auth', 'status'], {
stdio: 'pipe',
timeout: 10000, // Increased timeout for slower systems
});
let stdout = '';
let stderr = '';
child.stdout?.on('data', (data) => {
stdout += data.toString();
});
child.stderr?.on('data', (data) => {
stderr += data.toString();
});
child.on('close', (code) => {
const output = stdout + stderr;
// Check for positive authentication indicators in output
const isAuthenticated =
code === 0 &&
(output.includes('Logged in') || output.includes('logged in')) &&
!output.toLowerCase().includes('not logged in') &&
!output.toLowerCase().includes('not authenticated');
if (isAuthenticated) {
resolve('cli');
} else {
resolve('none');
}
});
child.on('error', () => {
resolve('none');
});
});
}
/**
* Get installation instructions for a provider
*/

View File

@@ -5,14 +5,18 @@
import * as secureFs from './secure-fs.js';
import * as path from 'path';
import type { PRState, WorktreePRInfo } from '@automaker/types';
// Re-export types for backwards compatibility
export type { PRState, WorktreePRInfo };
/** Maximum length for sanitized branch names in filesystem paths */
const MAX_SANITIZED_BRANCH_PATH_LENGTH = 200;
export interface WorktreePRInfo {
number: number;
url: string;
title: string;
state: string;
createdAt: string;
}
export interface WorktreeMetadata {
branch: string;
createdAt: string;

View File

@@ -1,78 +0,0 @@
/**
* Common utilities for code-review routes
*/
import { createLogger } from '@automaker/utils';
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
const logger = createLogger('CodeReview');
// Re-export shared utilities
export { getErrorMessageShared as getErrorMessage };
export const logError = createLogError(logger);
/**
* Review state interface
*/
interface ReviewState {
isRunning: boolean;
abortController: AbortController | null;
projectPath: string | null;
}
/**
* Shared state for code review operations
* Using an object to avoid mutable `let` exports which can cause issues in ES modules
*/
const reviewState: ReviewState = {
isRunning: false,
abortController: null,
projectPath: null,
};
/**
* Check if a review is currently running
*/
export function isRunning(): boolean {
return reviewState.isRunning;
}
/**
* Get the current abort controller (for stopping reviews)
*/
export function getAbortController(): AbortController | null {
return reviewState.abortController;
}
/**
* Get the current project path being reviewed
*/
export function getCurrentProjectPath(): string | null {
return reviewState.projectPath;
}
/**
* Set the running state for code review operations
*/
export function setRunningState(
running: boolean,
controller: AbortController | null = null,
projectPath: string | null = null
): void {
reviewState.isRunning = running;
reviewState.abortController = controller;
reviewState.projectPath = projectPath;
}
/**
* Get the current review status
*/
export function getReviewStatus(): {
isRunning: boolean;
projectPath: string | null;
} {
return {
isRunning: reviewState.isRunning,
projectPath: reviewState.projectPath,
};
}

View File

@@ -1,40 +0,0 @@
/**
* Code Review routes - HTTP API for triggering and managing code reviews
*
* Provides endpoints for:
* - Triggering code reviews on projects
* - Checking review status
* - Stopping in-progress reviews
*
* Uses the CodeReviewService for actual review execution with AI providers.
*/
import { Router } from 'express';
import type { CodeReviewService } from '../../services/code-review-service.js';
import { validatePathParams } from '../../middleware/validate-paths.js';
import { createTriggerHandler } from './routes/trigger.js';
import { createStatusHandler } from './routes/status.js';
import { createStopHandler } from './routes/stop.js';
import { createProvidersHandler } from './routes/providers.js';
export function createCodeReviewRoutes(codeReviewService: CodeReviewService): Router {
const router = Router();
// POST /trigger - Start a new code review
router.post(
'/trigger',
validatePathParams('projectPath'),
createTriggerHandler(codeReviewService)
);
// GET /status - Get current review status
router.get('/status', createStatusHandler());
// POST /stop - Stop current review
router.post('/stop', createStopHandler());
// GET /providers - Get available providers and their status
router.get('/providers', createProvidersHandler(codeReviewService));
return router;
}

View File

@@ -1,38 +0,0 @@
/**
* GET /providers endpoint - Get available code review providers
*
* Returns the status of all available AI providers that can be used for code reviews.
*/
import type { Request, Response } from 'express';
import type { CodeReviewService } from '../../../services/code-review-service.js';
import { createLogger } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
const logger = createLogger('CodeReview');
export function createProvidersHandler(codeReviewService: CodeReviewService) {
return async (req: Request, res: Response): Promise<void> => {
logger.debug('========== /providers endpoint called ==========');
try {
// Check if refresh is requested
const forceRefresh = req.query.refresh === 'true';
const providers = await codeReviewService.getProviderStatus(forceRefresh);
const bestProvider = await codeReviewService.getBestProvider();
res.json({
success: true,
providers,
recommended: bestProvider,
});
} catch (error) {
logError(error, 'Providers handler exception');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}

View File

@@ -1,32 +0,0 @@
/**
* GET /status endpoint - Get current code review status
*
* Returns whether a code review is currently running and which project.
*/
import type { Request, Response } from 'express';
import { createLogger } from '@automaker/utils';
import { getReviewStatus, getErrorMessage, logError } from '../common.js';
const logger = createLogger('CodeReview');
export function createStatusHandler() {
return async (_req: Request, res: Response): Promise<void> => {
logger.debug('========== /status endpoint called ==========');
try {
const status = getReviewStatus();
res.json({
success: true,
...status,
});
} catch (error) {
logError(error, 'Status handler exception');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}

View File

@@ -1,54 +0,0 @@
/**
* POST /stop endpoint - Stop the current code review
*
* Aborts any running code review operation.
*/
import type { Request, Response } from 'express';
import { createLogger } from '@automaker/utils';
import {
isRunning,
getAbortController,
setRunningState,
getErrorMessage,
logError,
} from '../common.js';
const logger = createLogger('CodeReview');
export function createStopHandler() {
return async (_req: Request, res: Response): Promise<void> => {
logger.info('========== /stop endpoint called ==========');
try {
if (!isRunning()) {
res.json({
success: true,
message: 'No code review is currently running',
});
return;
}
// Abort the current operation
const abortController = getAbortController();
if (abortController) {
abortController.abort();
logger.info('Code review aborted');
}
// Reset state
setRunningState(false, null, null);
res.json({
success: true,
message: 'Code review stopped',
});
} catch (error) {
logError(error, 'Stop handler exception');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}

View File

@@ -1,188 +0,0 @@
/**
* POST /trigger endpoint - Trigger a code review
*
* Starts an asynchronous code review on the specified project.
* Progress updates are streamed via WebSocket events.
*/
import type { Request, Response } from 'express';
import type { CodeReviewService } from '../../../services/code-review-service.js';
import type { CodeReviewCategory, ThinkingLevel, ModelId } from '@automaker/types';
import { createLogger } from '@automaker/utils';
import { isRunning, setRunningState, getErrorMessage, logError } from '../common.js';
const logger = createLogger('CodeReview');
/**
* Maximum number of files allowed per review request
*/
const MAX_FILES_PER_REQUEST = 100;
/**
* Maximum length for baseRef parameter
*/
const MAX_BASE_REF_LENGTH = 256;
/**
* Valid categories for code review
*/
const VALID_CATEGORIES: CodeReviewCategory[] = [
'tech_stack',
'security',
'code_quality',
'implementation',
'architecture',
'performance',
'testing',
'documentation',
];
/**
* Valid thinking levels
*/
const VALID_THINKING_LEVELS: ThinkingLevel[] = ['low', 'medium', 'high'];
interface TriggerRequestBody {
projectPath: string;
files?: string[];
baseRef?: string;
categories?: CodeReviewCategory[];
autoFix?: boolean;
model?: ModelId;
thinkingLevel?: ThinkingLevel;
}
/**
* Validate and sanitize the request body
*/
function validateRequestBody(body: TriggerRequestBody): { valid: boolean; error?: string } {
const { files, baseRef, categories, autoFix, thinkingLevel } = body;
// Validate files array
if (files !== undefined) {
if (!Array.isArray(files)) {
return { valid: false, error: 'files must be an array' };
}
if (files.length > MAX_FILES_PER_REQUEST) {
return { valid: false, error: `Maximum ${MAX_FILES_PER_REQUEST} files allowed per request` };
}
for (const file of files) {
if (typeof file !== 'string') {
return { valid: false, error: 'Each file must be a string' };
}
if (file.length > 500) {
return { valid: false, error: 'File path too long' };
}
}
}
// Validate baseRef
if (baseRef !== undefined) {
if (typeof baseRef !== 'string') {
return { valid: false, error: 'baseRef must be a string' };
}
if (baseRef.length > MAX_BASE_REF_LENGTH) {
return { valid: false, error: 'baseRef is too long' };
}
}
// Validate categories
if (categories !== undefined) {
if (!Array.isArray(categories)) {
return { valid: false, error: 'categories must be an array' };
}
for (const category of categories) {
if (!VALID_CATEGORIES.includes(category)) {
return { valid: false, error: `Invalid category: ${category}` };
}
}
}
// Validate autoFix
if (autoFix !== undefined && typeof autoFix !== 'boolean') {
return { valid: false, error: 'autoFix must be a boolean' };
}
// Validate thinkingLevel
if (thinkingLevel !== undefined) {
if (!VALID_THINKING_LEVELS.includes(thinkingLevel)) {
return { valid: false, error: `Invalid thinkingLevel: ${thinkingLevel}` };
}
}
return { valid: true };
}
export function createTriggerHandler(codeReviewService: CodeReviewService) {
return async (req: Request, res: Response): Promise<void> => {
logger.info('========== /trigger endpoint called ==========');
try {
const body = req.body as TriggerRequestBody;
const { projectPath, files, baseRef, categories, autoFix, model, thinkingLevel } = body;
// Validate required parameters
if (!projectPath) {
res.status(400).json({
success: false,
error: 'projectPath is required',
});
return;
}
// SECURITY: Validate all input parameters
const validation = validateRequestBody(body);
if (!validation.valid) {
res.status(400).json({
success: false,
error: validation.error,
});
return;
}
// Check if a review is already running
if (isRunning()) {
res.status(409).json({
success: false,
error: 'A code review is already in progress',
});
return;
}
// Set up abort controller for cancellation
const abortController = new AbortController();
setRunningState(true, abortController, projectPath);
// Start the review in the background
codeReviewService
.executeReview({
projectPath,
files,
baseRef,
categories,
autoFix,
model,
thinkingLevel,
abortController,
})
.catch((error) => {
logError(error, 'Code review failed');
})
.finally(() => {
setRunningState(false, null, null);
});
// Return immediate response
res.json({
success: true,
message: 'Code review started',
});
} catch (error) {
logError(error, 'Trigger handler exception');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}

View File

@@ -3,7 +3,6 @@
*/
import { Router } from 'express';
import { createStatusHandler } from './routes/status.js';
import { createClaudeStatusHandler } from './routes/claude-status.js';
import { createInstallClaudeHandler } from './routes/install-claude.js';
import { createAuthClaudeHandler } from './routes/auth-claude.js';
@@ -13,10 +12,6 @@ import { createApiKeysHandler } from './routes/api-keys.js';
import { createPlatformHandler } from './routes/platform.js';
import { createVerifyClaudeAuthHandler } from './routes/verify-claude-auth.js';
import { createVerifyCodexAuthHandler } from './routes/verify-codex-auth.js';
import { createVerifyCodeRabbitAuthHandler } from './routes/verify-coderabbit-auth.js';
import { createCodeRabbitStatusHandler } from './routes/coderabbit-status.js';
import { createAuthCodeRabbitHandler } from './routes/auth-coderabbit.js';
import { createDeauthCodeRabbitHandler } from './routes/deauth-coderabbit.js';
import { createGhStatusHandler } from './routes/gh-status.js';
import { createCursorStatusHandler } from './routes/cursor-status.js';
import { createCodexStatusHandler } from './routes/codex-status.js';
@@ -49,9 +44,6 @@ import {
export function createSetupRoutes(): Router {
const router = Router();
// Unified CLI status endpoint
router.get('/status', createStatusHandler());
router.get('/claude-status', createClaudeStatusHandler());
router.post('/install-claude', createInstallClaudeHandler());
router.post('/auth-claude', createAuthClaudeHandler());
@@ -62,7 +54,6 @@ export function createSetupRoutes(): Router {
router.get('/platform', createPlatformHandler());
router.post('/verify-claude-auth', createVerifyClaudeAuthHandler());
router.post('/verify-codex-auth', createVerifyCodexAuthHandler());
router.post('/verify-coderabbit-auth', createVerifyCodeRabbitAuthHandler());
router.get('/gh-status', createGhStatusHandler());
// Cursor CLI routes
@@ -81,11 +72,6 @@ export function createSetupRoutes(): Router {
router.post('/auth-opencode', createAuthOpencodeHandler());
router.post('/deauth-opencode', createDeauthOpencodeHandler());
// CodeRabbit CLI routes
router.get('/coderabbit-status', createCodeRabbitStatusHandler());
router.post('/auth-coderabbit', createAuthCodeRabbitHandler());
router.post('/deauth-coderabbit', createDeauthCodeRabbitHandler());
// OpenCode Dynamic Model Discovery routes
router.get('/opencode/models', createGetOpencodeModelsHandler());
router.post('/opencode/models/refresh', createRefreshOpencodeModelsHandler());

View File

@@ -1,80 +0,0 @@
/**
* POST /auth-coderabbit endpoint - Authenticate CodeRabbit CLI via OAuth
*
* CodeRabbit CLI requires interactive authentication:
* 1. Run `cr auth login`
* 2. Browser opens with OAuth flow
* 3. After browser auth, CLI shows a token
* 4. User must press Enter to confirm
*
* Since step 4 requires interactive input, we can't fully automate this.
* Instead, we provide the command for the user to run manually.
*/
import type { Request, Response } from 'express';
import { execSync } from 'child_process';
import { logError, getErrorMessage } from '../common.js';
import * as fs from 'fs';
import * as path from 'path';
/**
* Find the CodeRabbit CLI command (coderabbit or cr)
*/
function findCodeRabbitCommand(): string | null {
const commands = ['coderabbit', 'cr'];
for (const command of commands) {
try {
const whichCommand = process.platform === 'win32' ? 'where' : 'which';
const result = execSync(`${whichCommand} ${command}`, {
encoding: 'utf8',
timeout: 2000,
}).trim();
if (result) {
return result.split('\n')[0];
}
} catch {
// Command not found, try next
}
}
return null;
}
export function createAuthCodeRabbitHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
// Remove the disconnected marker file to reconnect the app to the CLI
const markerPath = path.join(process.cwd(), '.automaker', '.coderabbit-disconnected');
if (fs.existsSync(markerPath)) {
fs.unlinkSync(markerPath);
}
// Find CodeRabbit CLI
const cliPath = findCodeRabbitCommand();
if (!cliPath) {
res.status(400).json({
success: false,
error: 'CodeRabbit CLI is not installed. Please install it first.',
});
return;
}
// CodeRabbit CLI requires interactive input (pressing Enter after OAuth)
// We can't automate this, so we return the command for the user to run
const command = cliPath.includes('coderabbit') ? 'coderabbit auth login' : 'cr auth login';
res.json({
success: true,
requiresManualAuth: true,
command,
message: `Please run "${command}" in your terminal to authenticate. After completing OAuth in your browser, press Enter in the terminal to confirm.`,
});
} catch (error) {
logError(error, 'Auth CodeRabbit failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
message: 'Failed to initiate CodeRabbit authentication',
});
}
};
}

View File

@@ -1,240 +0,0 @@
/**
* GET /coderabbit-status endpoint - Get CodeRabbit CLI installation and auth status
*/
import type { Request, Response } from 'express';
import { spawn, execSync } from 'child_process';
import { getErrorMessage, logError } from '../common.js';
import * as fs from 'fs';
import * as path from 'path';
const DISCONNECTED_MARKER_FILE = '.coderabbit-disconnected';
function isCodeRabbitDisconnectedFromApp(): boolean {
try {
const projectRoot = process.cwd();
const markerPath = path.join(projectRoot, '.automaker', DISCONNECTED_MARKER_FILE);
return fs.existsSync(markerPath);
} catch {
return false;
}
}
/**
* Find the CodeRabbit CLI command (coderabbit or cr)
*/
function findCodeRabbitCommand(): string | null {
const commands = ['coderabbit', 'cr'];
for (const command of commands) {
try {
const whichCommand = process.platform === 'win32' ? 'where' : 'which';
const result = execSync(`${whichCommand} ${command}`, {
encoding: 'utf8',
timeout: 2000,
}).trim();
if (result) {
return result.split('\n')[0];
}
} catch {
// Command not found, try next
}
}
return null;
}
/**
* Get CodeRabbit CLI version
*/
async function getCodeRabbitVersion(command: string): Promise<string | null> {
return new Promise((resolve) => {
const child = spawn(command, ['--version'], {
stdio: 'pipe',
timeout: 5000,
});
let stdout = '';
child.stdout?.on('data', (data) => {
stdout += data.toString();
});
child.on('close', (code) => {
if (code === 0 && stdout) {
resolve(stdout.trim());
} else {
resolve(null);
}
});
child.on('error', () => {
resolve(null);
});
});
}
interface CodeRabbitAuthInfo {
authenticated: boolean;
method: 'oauth' | 'none';
username?: string;
email?: string;
organization?: string;
}
/**
* Check CodeRabbit CLI authentication status
* Parses output like:
* ```
* CodeRabbit CLI Status
* ✅ Authentication: Logged in
* User Information:
* 👤 Name: Kacper
* 📧 Email: kacperlachowiczwp.pl@wp.pl
* 🔧 Username: Shironex
* Organization Information:
* 🏢 Name: Anime-World-SPZOO
* ```
*/
async function getCodeRabbitAuthStatus(command: string): Promise<CodeRabbitAuthInfo> {
return new Promise((resolve) => {
const child = spawn(command, ['auth', 'status'], {
stdio: 'pipe',
timeout: 10000,
});
let stdout = '';
let stderr = '';
child.stdout?.on('data', (data) => {
stdout += data.toString();
});
child.stderr?.on('data', (data) => {
stderr += data.toString();
});
child.on('close', (code) => {
const output = stdout + stderr;
// Check for "Logged in" in Authentication line
const isAuthenticated =
code === 0 &&
(output.includes('Logged in') || output.includes('logged in')) &&
!output.toLowerCase().includes('not logged in');
if (isAuthenticated) {
// Parse the structured output format
// Username: look for "Username: <value>" line
const usernameMatch = output.match(/Username:\s*(\S+)/i);
// Email: look for "Email: <value>" line
const emailMatch = output.match(/Email:\s*(\S+@\S+)/i);
// Organization: look for "Name: <value>" under Organization Information
// The org name appears after "Organization Information:" section
const orgSection = output.split(/Organization Information:/i)[1];
const orgMatch = orgSection?.match(/Name:\s*(.+?)(?:\n|$)/i);
resolve({
authenticated: true,
method: 'oauth',
username: usernameMatch?.[1]?.trim(),
email: emailMatch?.[1]?.trim(),
organization: orgMatch?.[1]?.trim(),
});
} else {
resolve({
authenticated: false,
method: 'none',
});
}
});
child.on('error', () => {
resolve({
authenticated: false,
method: 'none',
});
});
});
}
/**
* Creates handler for GET /api/setup/coderabbit-status
* Returns CodeRabbit CLI installation and authentication status
*/
export function createCodeRabbitStatusHandler() {
const installCommand = 'npm install -g coderabbit';
const loginCommand = 'coderabbit auth login';
return async (_req: Request, res: Response): Promise<void> => {
try {
// Check if user has manually disconnected from the app
if (isCodeRabbitDisconnectedFromApp()) {
res.json({
success: true,
installed: true,
version: null,
path: null,
auth: {
authenticated: false,
method: 'none',
},
recommendation: 'CodeRabbit CLI is disconnected. Click Sign In to reconnect.',
installCommand,
loginCommand,
});
return;
}
// Find CodeRabbit CLI
const cliPath = findCodeRabbitCommand();
if (!cliPath) {
res.json({
success: true,
installed: false,
version: null,
path: null,
auth: {
authenticated: false,
method: 'none',
},
recommendation: 'Install CodeRabbit CLI to enable AI-powered code reviews.',
installCommand,
loginCommand,
installCommands: {
macos: 'curl -fsSL https://coderabbit.ai/install | bash',
npm: installCommand,
},
});
return;
}
// Get version
const version = await getCodeRabbitVersion(cliPath);
// Get auth status
const authStatus = await getCodeRabbitAuthStatus(cliPath);
res.json({
success: true,
installed: true,
version,
path: cliPath,
auth: authStatus,
recommendation: authStatus.authenticated
? undefined
: 'Sign in to CodeRabbit to enable AI-powered code reviews.',
installCommand,
loginCommand,
installCommands: {
macos: 'curl -fsSL https://coderabbit.ai/install | bash',
npm: installCommand,
},
});
} catch (error) {
logError(error, 'Get CodeRabbit status failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}

View File

@@ -1,113 +0,0 @@
/**
* POST /deauth-coderabbit endpoint - Sign out from CodeRabbit CLI
*/
import type { Request, Response } from 'express';
import { spawn, execSync } from 'child_process';
import { logError, getErrorMessage } from '../common.js';
import * as fs from 'fs';
import * as path from 'path';
/**
* Find the CodeRabbit CLI command (coderabbit or cr)
*/
function findCodeRabbitCommand(): string | null {
const commands = ['coderabbit', 'cr'];
for (const command of commands) {
try {
const whichCommand = process.platform === 'win32' ? 'where' : 'which';
const result = execSync(`${whichCommand} ${command}`, {
encoding: 'utf8',
timeout: 2000,
}).trim();
if (result) {
return result.split('\n')[0];
}
} catch {
// Command not found, try next
}
}
return null;
}
export function createDeauthCodeRabbitHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
// Find CodeRabbit CLI
const cliPath = findCodeRabbitCommand();
if (cliPath) {
// Try to run the CLI logout command
const logoutResult = await new Promise<{ success: boolean; error?: string }>((resolve) => {
const child = spawn(cliPath, ['auth', 'logout'], {
stdio: 'pipe',
timeout: 10000,
});
let stderr = '';
child.stderr?.on('data', (data) => {
stderr += data.toString();
});
child.on('close', (code) => {
if (code === 0) {
resolve({ success: true });
} else {
resolve({ success: false, error: stderr || 'Logout command failed' });
}
});
child.on('error', (err) => {
resolve({ success: false, error: err.message });
});
});
if (!logoutResult.success) {
// CLI logout failed, create marker file as fallback
const automakerDir = path.join(process.cwd(), '.automaker');
const markerPath = path.join(automakerDir, '.coderabbit-disconnected');
if (!fs.existsSync(automakerDir)) {
fs.mkdirSync(automakerDir, { recursive: true });
}
fs.writeFileSync(
markerPath,
JSON.stringify({
disconnectedAt: new Date().toISOString(),
message: 'CodeRabbit CLI is disconnected from the app',
})
);
}
} else {
// CLI not installed, just create marker file
const automakerDir = path.join(process.cwd(), '.automaker');
const markerPath = path.join(automakerDir, '.coderabbit-disconnected');
if (!fs.existsSync(automakerDir)) {
fs.mkdirSync(automakerDir, { recursive: true });
}
fs.writeFileSync(
markerPath,
JSON.stringify({
disconnectedAt: new Date().toISOString(),
message: 'CodeRabbit CLI is disconnected from the app',
})
);
}
res.json({
success: true,
message: 'Successfully signed out from CodeRabbit CLI',
});
} catch (error) {
logError(error, 'Deauth CodeRabbit failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
message: 'Failed to sign out from CodeRabbit CLI',
});
}
};
}

View File

@@ -1,249 +0,0 @@
/**
* GET /status endpoint - Get unified CLI availability status
*
* Returns the installation and authentication status of all supported CLIs
* in a single response. This is useful for quickly determining which
* providers are available without making multiple API calls.
*/
import type { Request, Response } from 'express';
import { getClaudeStatus } from '../get-claude-status.js';
import { getErrorMessage, logError } from '../common.js';
import { CursorProvider } from '../../../providers/cursor-provider.js';
import { CodexProvider } from '../../../providers/codex-provider.js';
import { OpencodeProvider } from '../../../providers/opencode-provider.js';
import * as fs from 'fs';
import * as path from 'path';
/**
* Check if a CLI has been manually disconnected from the app
*/
function isCliDisconnected(cliName: string): boolean {
try {
const projectRoot = process.cwd();
const markerPath = path.join(projectRoot, '.automaker', `.${cliName}-disconnected`);
return fs.existsSync(markerPath);
} catch {
return false;
}
}
/**
* CLI status response for a single provider
*/
interface CliStatusResponse {
installed: boolean;
version: string | null;
path: string | null;
auth: {
authenticated: boolean;
method: string;
};
disconnected: boolean;
}
/**
* Unified status response for all CLIs
*/
interface UnifiedStatusResponse {
success: boolean;
timestamp: string;
clis: {
claude: CliStatusResponse | null;
cursor: CliStatusResponse | null;
codex: CliStatusResponse | null;
opencode: CliStatusResponse | null;
};
availableProviders: string[];
hasAnyAuthenticated: boolean;
}
/**
* Get detailed Claude CLI status
*/
async function getClaudeCliStatus(): Promise<CliStatusResponse> {
const disconnected = isCliDisconnected('claude');
try {
const status = await getClaudeStatus();
return {
installed: status.installed,
version: status.version || null,
path: status.path || null,
auth: {
authenticated: disconnected ? false : status.auth.authenticated,
method: disconnected ? 'none' : status.auth.method,
},
disconnected,
};
} catch {
return {
installed: false,
version: null,
path: null,
auth: { authenticated: false, method: 'none' },
disconnected,
};
}
}
/**
* Get detailed Cursor CLI status
*/
async function getCursorCliStatus(): Promise<CliStatusResponse> {
const disconnected = isCliDisconnected('cursor');
try {
const provider = new CursorProvider();
const [installed, version, auth] = await Promise.all([
provider.isInstalled(),
provider.getVersion(),
provider.checkAuth(),
]);
const cliPath = installed ? provider.getCliPath() : null;
return {
installed,
version: version || null,
path: cliPath,
auth: {
authenticated: disconnected ? false : auth.authenticated,
method: disconnected ? 'none' : auth.method,
},
disconnected,
};
} catch {
return {
installed: false,
version: null,
path: null,
auth: { authenticated: false, method: 'none' },
disconnected,
};
}
}
/**
* Get detailed Codex CLI status
*/
async function getCodexCliStatus(): Promise<CliStatusResponse> {
const disconnected = isCliDisconnected('codex');
try {
const provider = new CodexProvider();
const status = await provider.detectInstallation();
let authMethod = 'none';
if (!disconnected && status.authenticated) {
authMethod = status.hasApiKey ? 'api_key_env' : 'cli_authenticated';
}
return {
installed: status.installed,
version: status.version || null,
path: status.path || null,
auth: {
authenticated: disconnected ? false : status.authenticated || false,
method: authMethod,
},
disconnected,
};
} catch {
return {
installed: false,
version: null,
path: null,
auth: { authenticated: false, method: 'none' },
disconnected,
};
}
}
/**
* Get detailed OpenCode CLI status
*/
async function getOpencodeCliStatus(): Promise<CliStatusResponse> {
try {
const provider = new OpencodeProvider();
const status = await provider.detectInstallation();
let authMethod = 'none';
if (status.authenticated) {
authMethod = status.hasApiKey ? 'api_key_env' : 'cli_authenticated';
}
return {
installed: status.installed,
version: status.version || null,
path: status.path || null,
auth: {
authenticated: status.authenticated || false,
method: authMethod,
},
disconnected: false, // OpenCode doesn't have disconnect feature
};
} catch {
return {
installed: false,
version: null,
path: null,
auth: { authenticated: false, method: 'none' },
disconnected: false,
};
}
}
/**
* Creates handler for GET /api/setup/status
* Returns unified CLI availability status for all providers
*/
export function createStatusHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
// Fetch all CLI statuses in parallel for performance
const [claude, cursor, codex, opencode] = await Promise.all([
getClaudeCliStatus(),
getCursorCliStatus(),
getCodexCliStatus(),
getOpencodeCliStatus(),
]);
// Determine which providers are available (installed and authenticated)
const availableProviders: string[] = [];
if (claude.installed && claude.auth.authenticated) {
availableProviders.push('claude');
}
if (cursor.installed && cursor.auth.authenticated) {
availableProviders.push('cursor');
}
if (codex.installed && codex.auth.authenticated) {
availableProviders.push('codex');
}
if (opencode.installed && opencode.auth.authenticated) {
availableProviders.push('opencode');
}
const response: UnifiedStatusResponse = {
success: true,
timestamp: new Date().toISOString(),
clis: {
claude,
cursor,
codex,
opencode,
},
availableProviders,
hasAnyAuthenticated: availableProviders.length > 0,
};
res.json(response);
} catch (error) {
logError(error, 'Get unified CLI status failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}

View File

@@ -1,163 +0,0 @@
/**
* POST /verify-coderabbit-auth endpoint - Verify CodeRabbit authentication
* Validates API key format and optionally tests the connection
*/
import type { Request, Response } from 'express';
import { spawn } from 'child_process';
import { createLogger } from '@automaker/utils';
import { AuthRateLimiter, validateApiKey } from '../../../lib/auth-utils.js';
const logger = createLogger('Setup');
const rateLimiter = new AuthRateLimiter();
/**
* Test CodeRabbit CLI authentication by running a simple command
*/
async function testCodeRabbitCli(
apiKey?: string
): Promise<{ authenticated: boolean; error?: string }> {
return new Promise((resolve) => {
// Set up environment with API key if provided
const env = { ...process.env };
if (apiKey) {
env.CODERABBIT_API_KEY = apiKey;
}
// Try to run coderabbit auth status to verify auth
const child = spawn('coderabbit', ['auth', 'status'], {
stdio: ['pipe', 'pipe', 'pipe'],
env,
timeout: 10000,
});
let stdout = '';
let stderr = '';
child.stdout?.on('data', (data) => {
stdout += data.toString();
});
child.stderr?.on('data', (data) => {
stderr += data.toString();
});
child.on('close', (code) => {
if (code === 0) {
// Check output for authentication status
const output = stdout.toLowerCase() + stderr.toLowerCase();
if (
output.includes('authenticated') ||
output.includes('logged in') ||
output.includes('valid')
) {
resolve({ authenticated: true });
} else if (output.includes('not authenticated') || output.includes('not logged in')) {
resolve({ authenticated: false, error: 'CodeRabbit CLI is not authenticated.' });
} else {
// Command succeeded, assume authenticated
resolve({ authenticated: true });
}
} else {
// Command failed
const errorMsg = stderr || stdout || 'CodeRabbit CLI authentication check failed.';
resolve({ authenticated: false, error: errorMsg.trim() });
}
});
child.on('error', (err) => {
// CodeRabbit CLI not installed or other error
resolve({ authenticated: false, error: `CodeRabbit CLI error: ${err.message}` });
});
});
}
/**
* Validate CodeRabbit API key format
* CodeRabbit API keys typically start with 'cr-'
*/
function validateCodeRabbitKey(apiKey: string): { isValid: boolean; error?: string } {
if (!apiKey || apiKey.trim().length === 0) {
return { isValid: false, error: 'API key cannot be empty.' };
}
// CodeRabbit API keys typically start with 'cr-'
if (!apiKey.startsWith('cr-')) {
return {
isValid: false,
error: 'Invalid CodeRabbit API key format. Keys should start with "cr-".',
};
}
if (apiKey.length < 10) {
return { isValid: false, error: 'API key is too short.' };
}
return { isValid: true };
}
export function createVerifyCodeRabbitAuthHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { authMethod, apiKey } = req.body as {
authMethod?: 'cli' | 'api_key';
apiKey?: string;
};
// Rate limiting to prevent abuse
const clientIp = req.ip || req.socket.remoteAddress || 'unknown';
if (!rateLimiter.canAttempt(clientIp)) {
const resetTime = rateLimiter.getResetTime(clientIp);
res.status(429).json({
success: false,
authenticated: false,
error: 'Too many authentication attempts. Please try again later.',
resetTime,
});
return;
}
logger.info(
`[Setup] Verifying CodeRabbit authentication using method: ${authMethod || 'auto'}${apiKey ? ' (with provided key)' : ''}`
);
// For API key verification
if (authMethod === 'api_key' && apiKey) {
// Validate key format
const validation = validateCodeRabbitKey(apiKey);
if (!validation.isValid) {
res.json({
success: true,
authenticated: false,
error: validation.error,
});
return;
}
// Test the CLI with the provided API key
const result = await testCodeRabbitCli(apiKey);
res.json({
success: true,
authenticated: result.authenticated,
error: result.error,
});
return;
}
// For CLI auth or auto detection
const result = await testCodeRabbitCli();
res.json({
success: true,
authenticated: result.authenticated,
error: result.error,
});
} catch (error) {
logger.error('[Setup] Verify CodeRabbit auth endpoint error:', error);
res.status(500).json({
success: false,
authenticated: false,
error: error instanceof Error ? error.message : 'Verification failed',
});
}
};
}

View File

@@ -13,7 +13,6 @@ import {
} from '../common.js';
import { updateWorktreePRInfo } from '../../../lib/worktree-metadata.js';
import { createLogger } from '@automaker/utils';
import { validatePRState } from '@automaker/types';
const logger = createLogger('CreatePR');
@@ -269,12 +268,11 @@ export function createCreatePRHandler() {
prAlreadyExisted = true;
// Store the existing PR info in metadata
// GitHub CLI returns uppercase states: OPEN, MERGED, CLOSED
await updateWorktreePRInfo(effectiveProjectPath, branchName, {
number: existingPr.number,
url: existingPr.url,
title: existingPr.title || title,
state: validatePRState(existingPr.state),
state: existingPr.state || 'open',
createdAt: new Date().toISOString(),
});
logger.debug(
@@ -321,12 +319,11 @@ export function createCreatePRHandler() {
if (prNumber) {
try {
// Note: GitHub doesn't have a 'DRAFT' state - drafts still show as 'OPEN'
await updateWorktreePRInfo(effectiveProjectPath, branchName, {
number: prNumber,
url: prUrl,
title,
state: 'OPEN',
state: draft ? 'draft' : 'open',
createdAt: new Date().toISOString(),
});
logger.debug(`Stored PR info for branch ${branchName}: PR #${prNumber}`);
@@ -355,12 +352,11 @@ export function createCreatePRHandler() {
prNumber = existingPr.number;
prAlreadyExisted = true;
// GitHub CLI returns uppercase states: OPEN, MERGED, CLOSED
await updateWorktreePRInfo(effectiveProjectPath, branchName, {
number: existingPr.number,
url: existingPr.url,
title: existingPr.title || title,
state: validatePRState(existingPr.state),
state: existingPr.state || 'open',
createdAt: new Date().toISOString(),
});
logger.debug(`Fetched and stored existing PR: #${existingPr.number}`);

View File

@@ -14,13 +14,8 @@ import path from 'path';
import * as secureFs from '../../../lib/secure-fs.js';
import { isGitRepo } from '@automaker/git-utils';
import { getErrorMessage, logError, normalizePath, execEnv, isGhCliAvailable } from '../common.js';
import {
readAllWorktreeMetadata,
updateWorktreePRInfo,
type WorktreePRInfo,
} from '../../../lib/worktree-metadata.js';
import { readAllWorktreeMetadata, type WorktreePRInfo } from '../../../lib/worktree-metadata.js';
import { createLogger } from '@automaker/utils';
import { validatePRState } from '@automaker/types';
import {
checkGitHubRemote,
type GitHubRemoteStatus,
@@ -173,11 +168,8 @@ async function getGitHubRemoteStatus(projectPath: string): Promise<GitHubRemoteS
}
/**
* Fetch all PRs from GitHub and create a map of branch name to PR info.
* Uses --state all to include merged/closed PRs, allowing detection of
* state changes (e.g., when a PR is merged on GitHub).
*
* This also allows detecting PRs that were created outside the app.
* Fetch open PRs from GitHub and create a map of branch name to PR info.
* This allows detecting PRs that were created outside the app.
*
* Uses cached GitHub remote status to avoid repeated warnings when the
* project doesn't have a GitHub remote configured.
@@ -200,9 +192,9 @@ async function fetchGitHubPRs(projectPath: string): Promise<Map<string, Worktree
? `-R ${remoteStatus.owner}/${remoteStatus.repo}`
: '';
// Fetch all PRs from GitHub (including merged/closed to detect state changes)
// Fetch open PRs from GitHub
const { stdout } = await execAsync(
`gh pr list ${repoFlag} --state all --json number,title,url,state,headRefName,createdAt --limit 1000`,
`gh pr list ${repoFlag} --state open --json number,title,url,state,headRefName,createdAt --limit 1000`,
{ cwd: projectPath, env: execEnv, timeout: 15000 }
);
@@ -220,8 +212,7 @@ async function fetchGitHubPRs(projectPath: string): Promise<Map<string, Worktree
number: pr.number,
url: pr.url,
title: pr.title,
// GitHub CLI returns state as uppercase: OPEN, MERGED, CLOSED
state: validatePRState(pr.state),
state: pr.state,
createdAt: pr.createdAt,
});
}
@@ -360,36 +351,23 @@ export function createListHandler() {
}
}
// Assign PR info to each worktree, preferring fresh GitHub data over cached metadata.
// Only fetch GitHub PRs if includeDetails is requested (performance optimization).
// Uses --state all to detect merged/closed PRs, limited to 1000 recent PRs.
// Add PR info from metadata or GitHub for each worktree
// Only fetch GitHub PRs if includeDetails is requested (performance optimization)
const githubPRs = includeDetails
? await fetchGitHubPRs(projectPath)
: new Map<string, WorktreePRInfo>();
for (const worktree of worktrees) {
const metadata = allMetadata.get(worktree.branch);
const githubPR = githubPRs.get(worktree.branch);
if (githubPR) {
// Prefer fresh GitHub data (it has the current state)
worktree.pr = githubPR;
// Sync metadata with GitHub state when:
// 1. No metadata exists for this PR (PR created externally)
// 2. State has changed (e.g., merged/closed on GitHub)
const needsSync = !metadata?.pr || metadata.pr.state !== githubPR.state;
if (needsSync) {
// Fire and forget - don't block the response
updateWorktreePRInfo(projectPath, worktree.branch, githubPR).catch((err) => {
logger.warn(
`Failed to update PR info for ${worktree.branch}: ${getErrorMessage(err)}`
);
});
}
} else if (metadata?.pr) {
// Fall back to stored metadata (for PRs not in recent GitHub response)
if (metadata?.pr) {
// Use stored metadata (more complete info)
worktree.pr = metadata.pr;
} else if (includeDetails) {
// Fall back to GitHub PR detection only when includeDetails is requested
const githubPR = githubPRs.get(worktree.branch);
if (githubPR) {
worktree.pr = githubPR;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -420,7 +420,6 @@ export class SettingsService {
anthropic: { configured: boolean; masked: string };
google: { configured: boolean; masked: string };
openai: { configured: boolean; masked: string };
coderabbit: { configured: boolean; masked: string };
}> {
const credentials = await this.getCredentials();
@@ -442,10 +441,6 @@ export class SettingsService {
configured: !!credentials.apiKeys.openai,
masked: maskKey(credentials.apiKeys.openai),
},
coderabbit: {
configured: !!credentials.apiKeys.coderabbit,
masked: maskKey(credentials.apiKeys.coderabbit),
},
};
}
@@ -663,14 +658,12 @@ export class SettingsService {
anthropic?: string;
google?: string;
openai?: string;
coderabbit?: string;
};
await this.updateCredentials({
apiKeys: {
anthropic: apiKeys.anthropic || '',
google: apiKeys.google || '',
openai: apiKeys.openai || '',
coderabbit: apiKeys.coderabbit || '',
},
});
migratedCredentials = true;

View File

@@ -121,7 +121,7 @@ describe('worktree-metadata.ts', () => {
number: 123,
url: 'https://github.com/owner/repo/pull/123',
title: 'Test PR',
state: 'OPEN',
state: 'open',
createdAt: new Date().toISOString(),
},
};
@@ -158,7 +158,7 @@ describe('worktree-metadata.ts', () => {
number: 456,
url: 'https://github.com/owner/repo/pull/456',
title: 'Updated PR',
state: 'CLOSED',
state: 'closed',
createdAt: new Date().toISOString(),
},
};
@@ -177,7 +177,7 @@ describe('worktree-metadata.ts', () => {
number: 789,
url: 'https://github.com/owner/repo/pull/789',
title: 'New PR',
state: 'OPEN',
state: 'open',
createdAt: new Date().toISOString(),
};
@@ -201,7 +201,7 @@ describe('worktree-metadata.ts', () => {
number: 999,
url: 'https://github.com/owner/repo/pull/999',
title: 'Updated PR',
state: 'MERGED',
state: 'merged',
createdAt: new Date().toISOString(),
};
@@ -224,7 +224,7 @@ describe('worktree-metadata.ts', () => {
number: 111,
url: 'https://github.com/owner/repo/pull/111',
title: 'PR',
state: 'OPEN',
state: 'open',
createdAt: new Date().toISOString(),
};
@@ -259,7 +259,7 @@ describe('worktree-metadata.ts', () => {
number: 222,
url: 'https://github.com/owner/repo/pull/222',
title: 'Has PR',
state: 'OPEN',
state: 'open',
createdAt: new Date().toISOString(),
};
@@ -297,7 +297,7 @@ describe('worktree-metadata.ts', () => {
number: 333,
url: 'https://github.com/owner/repo/pull/333',
title: 'PR 3',
state: 'OPEN',
state: 'open',
createdAt: new Date().toISOString(),
},
};

View File

@@ -1,196 +0,0 @@
/**
* Unit tests for code-review providers route handler
*
* Tests:
* - Returns provider status list
* - Returns recommended provider
* - Force refresh functionality
* - Error handling
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Request, Response } from 'express';
import { createProvidersHandler } from '@/routes/code-review/routes/providers.js';
import type { CodeReviewService } from '@/services/code-review-service.js';
import { createMockExpressContext } from '../../../utils/mocks.js';
// Mock logger
vi.mock('@automaker/utils', async () => {
const actual = await vi.importActual<typeof import('@automaker/utils')>('@automaker/utils');
return {
...actual,
createLogger: vi.fn(() => ({
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
})),
};
});
describe('code-review/providers route', () => {
let mockCodeReviewService: CodeReviewService;
let req: Request;
let res: Response;
const mockProviderStatuses = [
{
provider: 'claude' as const,
available: true,
authenticated: true,
version: '1.0.0',
issues: [],
},
{
provider: 'codex' as const,
available: true,
authenticated: false,
version: '0.5.0',
issues: ['Not authenticated'],
},
];
beforeEach(() => {
vi.clearAllMocks();
mockCodeReviewService = {
getProviderStatus: vi.fn().mockResolvedValue(mockProviderStatuses),
getBestProvider: vi.fn().mockResolvedValue('claude'),
executeReview: vi.fn(),
refreshProviderStatus: vi.fn(),
initialize: vi.fn(),
} as any;
const context = createMockExpressContext();
req = context.req;
res = context.res;
req.query = {};
});
describe('successful responses', () => {
it('should return provider status and recommended provider', async () => {
const handler = createProvidersHandler(mockCodeReviewService);
await handler(req, res);
expect(res.json).toHaveBeenCalledWith({
success: true,
providers: mockProviderStatuses,
recommended: 'claude',
});
});
it('should use cached status by default', async () => {
const handler = createProvidersHandler(mockCodeReviewService);
await handler(req, res);
expect(mockCodeReviewService.getProviderStatus).toHaveBeenCalledWith(false);
});
it('should force refresh when refresh=true query param is set', async () => {
req.query = { refresh: 'true' };
const handler = createProvidersHandler(mockCodeReviewService);
await handler(req, res);
expect(mockCodeReviewService.getProviderStatus).toHaveBeenCalledWith(true);
});
it('should handle no recommended provider', async () => {
mockCodeReviewService.getBestProvider = vi.fn().mockResolvedValue(null);
const handler = createProvidersHandler(mockCodeReviewService);
await handler(req, res);
expect(res.json).toHaveBeenCalledWith({
success: true,
providers: mockProviderStatuses,
recommended: null,
});
});
it('should handle empty provider list', async () => {
mockCodeReviewService.getProviderStatus = vi.fn().mockResolvedValue([]);
mockCodeReviewService.getBestProvider = vi.fn().mockResolvedValue(null);
const handler = createProvidersHandler(mockCodeReviewService);
await handler(req, res);
expect(res.json).toHaveBeenCalledWith({
success: true,
providers: [],
recommended: null,
});
});
});
describe('error handling', () => {
it('should handle getProviderStatus errors', async () => {
mockCodeReviewService.getProviderStatus = vi
.fn()
.mockRejectedValue(new Error('Failed to detect CLIs'));
const handler = createProvidersHandler(mockCodeReviewService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'Failed to detect CLIs',
});
});
it('should handle getBestProvider errors gracefully', async () => {
mockCodeReviewService.getBestProvider = vi
.fn()
.mockRejectedValue(new Error('Detection failed'));
const handler = createProvidersHandler(mockCodeReviewService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'Detection failed',
});
});
});
describe('provider priority', () => {
it('should recommend claude when available and authenticated', async () => {
const handler = createProvidersHandler(mockCodeReviewService);
await handler(req, res);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
recommended: 'claude',
})
);
});
it('should recommend codex when claude is not available', async () => {
mockCodeReviewService.getBestProvider = vi.fn().mockResolvedValue('codex');
const handler = createProvidersHandler(mockCodeReviewService);
await handler(req, res);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
recommended: 'codex',
})
);
});
it('should recommend cursor as fallback', async () => {
mockCodeReviewService.getBestProvider = vi.fn().mockResolvedValue('cursor');
const handler = createProvidersHandler(mockCodeReviewService);
await handler(req, res);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
recommended: 'cursor',
})
);
});
});
});

View File

@@ -1,109 +0,0 @@
/**
* Unit tests for code-review status route handler
*
* Tests:
* - Returns correct running status
* - Returns correct project path
* - Handles errors gracefully
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Request, Response } from 'express';
import { createStatusHandler } from '@/routes/code-review/routes/status.js';
import { createMockExpressContext } from '../../../utils/mocks.js';
// Mock the common module to control running state
vi.mock('@/routes/code-review/common.js', () => {
return {
isRunning: vi.fn(),
getReviewStatus: vi.fn(),
getCurrentProjectPath: vi.fn(),
setRunningState: vi.fn(),
getAbortController: vi.fn(),
getErrorMessage: (e: unknown) => (e instanceof Error ? e.message : String(e)),
logError: vi.fn(),
};
});
// Mock logger
vi.mock('@automaker/utils', async () => {
const actual = await vi.importActual<typeof import('@automaker/utils')>('@automaker/utils');
return {
...actual,
createLogger: vi.fn(() => ({
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
})),
};
});
describe('code-review/status route', () => {
let req: Request;
let res: Response;
beforeEach(() => {
vi.clearAllMocks();
const context = createMockExpressContext();
req = context.req;
res = context.res;
});
describe('when no review is running', () => {
it('should return isRunning: false with null projectPath', async () => {
const { getReviewStatus } = await import('@/routes/code-review/common.js');
vi.mocked(getReviewStatus).mockReturnValue({
isRunning: false,
projectPath: null,
});
const handler = createStatusHandler();
await handler(req, res);
expect(res.json).toHaveBeenCalledWith({
success: true,
isRunning: false,
projectPath: null,
});
});
});
describe('when a review is running', () => {
it('should return isRunning: true with the current project path', async () => {
const { getReviewStatus } = await import('@/routes/code-review/common.js');
vi.mocked(getReviewStatus).mockReturnValue({
isRunning: true,
projectPath: '/test/project',
});
const handler = createStatusHandler();
await handler(req, res);
expect(res.json).toHaveBeenCalledWith({
success: true,
isRunning: true,
projectPath: '/test/project',
});
});
});
describe('error handling', () => {
it('should handle errors gracefully', async () => {
const { getReviewStatus } = await import('@/routes/code-review/common.js');
vi.mocked(getReviewStatus).mockImplementation(() => {
throw new Error('Unexpected error');
});
const handler = createStatusHandler();
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'Unexpected error',
});
});
});
});

View File

@@ -1,129 +0,0 @@
/**
* Unit tests for code-review stop route handler
*
* Tests:
* - Stopping when no review is running
* - Stopping a running review
* - Abort controller behavior
* - Error handling
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Request, Response } from 'express';
import { createStopHandler } from '@/routes/code-review/routes/stop.js';
import { createMockExpressContext } from '../../../utils/mocks.js';
// Mock the common module
vi.mock('@/routes/code-review/common.js', () => {
return {
isRunning: vi.fn(),
getAbortController: vi.fn(),
setRunningState: vi.fn(),
getReviewStatus: vi.fn(),
getCurrentProjectPath: vi.fn(),
getErrorMessage: (e: unknown) => (e instanceof Error ? e.message : String(e)),
logError: vi.fn(),
};
});
// Mock logger
vi.mock('@automaker/utils', async () => {
const actual = await vi.importActual<typeof import('@automaker/utils')>('@automaker/utils');
return {
...actual,
createLogger: vi.fn(() => ({
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
})),
};
});
describe('code-review/stop route', () => {
let req: Request;
let res: Response;
beforeEach(() => {
vi.clearAllMocks();
const context = createMockExpressContext();
req = context.req;
res = context.res;
});
describe('when no review is running', () => {
it('should return success with message that nothing is running', async () => {
const { isRunning } = await import('@/routes/code-review/common.js');
vi.mocked(isRunning).mockReturnValue(false);
const handler = createStopHandler();
await handler(req, res);
expect(res.json).toHaveBeenCalledWith({
success: true,
message: 'No code review is currently running',
});
});
});
describe('when a review is running', () => {
it('should abort the review and reset running state', async () => {
const { isRunning, getAbortController, setRunningState } =
await import('@/routes/code-review/common.js');
const mockAbortController = {
abort: vi.fn(),
signal: { aborted: false },
};
vi.mocked(isRunning).mockReturnValue(true);
vi.mocked(getAbortController).mockReturnValue(mockAbortController as any);
const handler = createStopHandler();
await handler(req, res);
expect(mockAbortController.abort).toHaveBeenCalled();
expect(setRunningState).toHaveBeenCalledWith(false, null, null);
expect(res.json).toHaveBeenCalledWith({
success: true,
message: 'Code review stopped',
});
});
it('should handle case when abort controller is null', async () => {
const { isRunning, getAbortController, setRunningState } =
await import('@/routes/code-review/common.js');
vi.mocked(isRunning).mockReturnValue(true);
vi.mocked(getAbortController).mockReturnValue(null);
const handler = createStopHandler();
await handler(req, res);
expect(setRunningState).toHaveBeenCalledWith(false, null, null);
expect(res.json).toHaveBeenCalledWith({
success: true,
message: 'Code review stopped',
});
});
});
describe('error handling', () => {
it('should handle errors gracefully', async () => {
const { isRunning } = await import('@/routes/code-review/common.js');
vi.mocked(isRunning).mockImplementation(() => {
throw new Error('Unexpected error');
});
const handler = createStopHandler();
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'Unexpected error',
});
});
});
});

View File

@@ -1,384 +0,0 @@
/**
* Unit tests for code-review trigger route handler
*
* Tests:
* - Parameter validation
* - Request body validation (security)
* - Concurrent review prevention
* - Review execution
* - Error handling
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Request, Response } from 'express';
import { createTriggerHandler } from '@/routes/code-review/routes/trigger.js';
import type { CodeReviewService } from '@/services/code-review-service.js';
import { createMockExpressContext } from '../../../utils/mocks.js';
// Mock the common module to control running state
vi.mock('@/routes/code-review/common.js', () => {
let running = false;
return {
isRunning: vi.fn(() => running),
setRunningState: vi.fn((state: boolean) => {
running = state;
}),
getErrorMessage: (e: unknown) => (e instanceof Error ? e.message : String(e)),
logError: vi.fn(),
getAbortController: vi.fn(() => null),
getCurrentProjectPath: vi.fn(() => null),
};
});
// Mock logger
vi.mock('@automaker/utils', async () => {
const actual = await vi.importActual<typeof import('@automaker/utils')>('@automaker/utils');
return {
...actual,
createLogger: vi.fn(() => ({
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
})),
};
});
describe('code-review/trigger route', () => {
let mockCodeReviewService: CodeReviewService;
let req: Request;
let res: Response;
beforeEach(async () => {
vi.clearAllMocks();
// Reset running state
const { setRunningState, isRunning } = await import('@/routes/code-review/common.js');
vi.mocked(setRunningState)(false);
vi.mocked(isRunning).mockReturnValue(false);
mockCodeReviewService = {
executeReview: vi.fn().mockResolvedValue({
id: 'review-123',
verdict: 'approved',
summary: 'No issues found',
comments: [],
stats: {
totalComments: 0,
bySeverity: { critical: 0, high: 0, medium: 0, low: 0, info: 0 },
byCategory: {},
autoFixedCount: 0,
},
filesReviewed: ['src/index.ts'],
model: 'claude-sonnet-4-20250514',
reviewedAt: new Date().toISOString(),
durationMs: 1000,
}),
getProviderStatus: vi.fn(),
getBestProvider: vi.fn(),
refreshProviderStatus: vi.fn(),
initialize: vi.fn(),
} as any;
const context = createMockExpressContext();
req = context.req;
res = context.res;
});
describe('parameter validation', () => {
it('should return 400 if projectPath is missing', async () => {
req.body = {};
const handler = createTriggerHandler(mockCodeReviewService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'projectPath is required',
});
expect(mockCodeReviewService.executeReview).not.toHaveBeenCalled();
});
it('should return 400 if files is not an array', async () => {
req.body = {
projectPath: '/test/project',
files: 'not-an-array',
};
const handler = createTriggerHandler(mockCodeReviewService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'files must be an array',
});
});
it('should return 400 if too many files', async () => {
req.body = {
projectPath: '/test/project',
files: Array.from({ length: 150 }, (_, i) => `file${i}.ts`),
};
const handler = createTriggerHandler(mockCodeReviewService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'Maximum 100 files allowed per request',
});
});
it('should return 400 if file path is too long', async () => {
req.body = {
projectPath: '/test/project',
files: ['a'.repeat(600)],
};
const handler = createTriggerHandler(mockCodeReviewService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'File path too long',
});
});
it('should return 400 if baseRef is not a string', async () => {
req.body = {
projectPath: '/test/project',
baseRef: 123,
};
const handler = createTriggerHandler(mockCodeReviewService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'baseRef must be a string',
});
});
it('should return 400 if baseRef is too long', async () => {
req.body = {
projectPath: '/test/project',
baseRef: 'a'.repeat(300),
};
const handler = createTriggerHandler(mockCodeReviewService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'baseRef is too long',
});
});
it('should return 400 if categories is not an array', async () => {
req.body = {
projectPath: '/test/project',
categories: 'security',
};
const handler = createTriggerHandler(mockCodeReviewService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'categories must be an array',
});
});
it('should return 400 if category is invalid', async () => {
req.body = {
projectPath: '/test/project',
categories: ['security', 'invalid_category'],
};
const handler = createTriggerHandler(mockCodeReviewService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'Invalid category: invalid_category',
});
});
it('should return 400 if autoFix is not a boolean', async () => {
req.body = {
projectPath: '/test/project',
autoFix: 'true',
};
const handler = createTriggerHandler(mockCodeReviewService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'autoFix must be a boolean',
});
});
it('should return 400 if thinkingLevel is invalid', async () => {
req.body = {
projectPath: '/test/project',
thinkingLevel: 'invalid',
};
const handler = createTriggerHandler(mockCodeReviewService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'Invalid thinkingLevel: invalid',
});
});
});
describe('concurrent review prevention', () => {
it('should return 409 if a review is already in progress', async () => {
const { isRunning } = await import('@/routes/code-review/common.js');
vi.mocked(isRunning).mockReturnValue(true);
req.body = { projectPath: '/test/project' };
const handler = createTriggerHandler(mockCodeReviewService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(409);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'A code review is already in progress',
});
expect(mockCodeReviewService.executeReview).not.toHaveBeenCalled();
});
});
describe('successful review execution', () => {
it('should trigger review and return success immediately', async () => {
req.body = {
projectPath: '/test/project',
};
const handler = createTriggerHandler(mockCodeReviewService);
await handler(req, res);
expect(res.json).toHaveBeenCalledWith({
success: true,
message: 'Code review started',
});
});
it('should pass all options to executeReview', async () => {
req.body = {
projectPath: '/test/project',
files: ['src/index.ts', 'src/utils.ts'],
baseRef: 'main',
categories: ['security', 'performance'],
autoFix: true,
model: 'claude-opus-4-5-20251101',
thinkingLevel: 'high',
};
const handler = createTriggerHandler(mockCodeReviewService);
await handler(req, res);
// Wait for async execution
await new Promise((resolve) => setTimeout(resolve, 10));
expect(mockCodeReviewService.executeReview).toHaveBeenCalledWith(
expect.objectContaining({
projectPath: '/test/project',
files: ['src/index.ts', 'src/utils.ts'],
baseRef: 'main',
categories: ['security', 'performance'],
autoFix: true,
model: 'claude-opus-4-5-20251101',
thinkingLevel: 'high',
abortController: expect.any(AbortController),
})
);
});
it('should accept valid categories', async () => {
const validCategories = [
'tech_stack',
'security',
'code_quality',
'implementation',
'architecture',
'performance',
'testing',
'documentation',
];
req.body = {
projectPath: '/test/project',
categories: validCategories,
};
const handler = createTriggerHandler(mockCodeReviewService);
await handler(req, res);
expect(res.json).toHaveBeenCalledWith({
success: true,
message: 'Code review started',
});
});
it('should accept valid thinking levels', async () => {
for (const level of ['low', 'medium', 'high']) {
req.body = {
projectPath: '/test/project',
thinkingLevel: level,
};
const handler = createTriggerHandler(mockCodeReviewService);
await handler(req, res);
expect(res.json).toHaveBeenCalledWith({
success: true,
message: 'Code review started',
});
vi.clearAllMocks();
}
});
});
describe('error handling', () => {
it('should handle service errors gracefully', async () => {
mockCodeReviewService.executeReview = vi.fn().mockRejectedValue(new Error('Service error'));
req.body = {
projectPath: '/test/project',
};
const handler = createTriggerHandler(mockCodeReviewService);
await handler(req, res);
// Response is sent immediately (async execution)
expect(res.json).toHaveBeenCalledWith({
success: true,
message: 'Code review started',
});
// Wait for async error handling
await new Promise((resolve) => setTimeout(resolve, 50));
// Running state should be reset
const { setRunningState } = await import('@/routes/code-review/common.js');
expect(setRunningState).toHaveBeenCalledWith(false);
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,6 @@ import { useState, useEffect, useMemo, useCallback } from 'react';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Button } from '@/components/ui/button';
import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
@@ -280,7 +279,7 @@ export function ClaudeUsagePopover() {
) : !claudeUsage ? (
// Loading state
<div className="flex flex-col items-center justify-center py-8 space-y-2">
<Spinner size="lg" />
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground/50" />
<p className="text-xs text-muted-foreground">Loading usage data...</p>
</div>
) : (

View File

@@ -2,7 +2,6 @@ import { useState, useEffect, useMemo, useCallback } from 'react';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Button } from '@/components/ui/button';
import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
@@ -334,7 +333,7 @@ export function CodexUsagePopover() {
) : !codexUsage ? (
// Loading state
<div className="flex flex-col items-center justify-center py-8 space-y-2">
<Spinner size="lg" />
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground/50" />
<p className="text-xs text-muted-foreground">Loading usage data...</p>
</div>
) : codexUsage.rateLimits ? (

View File

@@ -1,7 +1,6 @@
import { useState, useRef, useCallback, useEffect } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { ImageIcon, Upload, Trash2 } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { ImageIcon, Upload, Loader2, Trash2 } from 'lucide-react';
const logger = createLogger('BoardBackgroundModal');
import {
@@ -314,7 +313,7 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
/>
{isProcessing && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80">
<Spinner size="lg" />
<Loader2 className="w-6 h-6 animate-spin text-brand-500" />
</div>
)}
</div>
@@ -354,7 +353,7 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
)}
>
{isProcessing ? (
<Spinner size="lg" />
<Upload className="h-6 w-6 animate-spin text-muted-foreground" />
) : (
<ImageIcon className="h-6 w-6 text-muted-foreground" />
)}

View File

@@ -1,755 +0,0 @@
/**
* CodeReviewDialog Component
*
* A dialog for displaying code review results from automated code analysis.
* Shows the review verdict, summary, and detailed comments organized by severity.
*/
import { useMemo, useState } from 'react';
import {
CheckCircle2,
AlertTriangle,
MessageSquare,
FileCode,
Copy,
Check,
AlertCircle,
Info,
ChevronDown,
ChevronRight,
Sparkles,
Clock,
Wrench,
RotateCcw,
} from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import { cn } from '@/lib/utils';
import type {
CodeReviewResult,
CodeReviewComment,
CodeReviewSeverity,
CodeReviewCategory,
CodeReviewVerdict,
} from '@automaker/types';
// ============================================================================
// Types
// ============================================================================
export interface CodeReviewDialogProps {
/** Whether the dialog is open */
open: boolean;
/** Callback when open state changes */
onOpenChange: (open: boolean) => void;
/** The code review result to display */
review: CodeReviewResult | null;
/** Optional loading state */
loading?: boolean;
/** Optional error message */
error?: string | null;
/** Optional callback when user wants to retry */
onRetry?: () => void;
}
// ============================================================================
// Constants & Helpers
// ============================================================================
const SEVERITY_CONFIG: Record<
CodeReviewSeverity,
{ label: string; variant: 'error' | 'warning' | 'info' | 'muted'; icon: typeof AlertCircle }
> = {
critical: { label: 'Critical', variant: 'error', icon: AlertCircle },
high: { label: 'High', variant: 'error', icon: AlertTriangle },
medium: { label: 'Medium', variant: 'warning', icon: AlertTriangle },
low: { label: 'Low', variant: 'info', icon: Info },
info: { label: 'Info', variant: 'muted', icon: Info },
};
const SEVERITY_ORDER: CodeReviewSeverity[] = ['critical', 'high', 'medium', 'low', 'info'];
const CATEGORY_LABELS: Record<CodeReviewCategory, string> = {
tech_stack: 'Tech Stack',
security: 'Security',
code_quality: 'Code Quality',
implementation: 'Implementation',
architecture: 'Architecture',
performance: 'Performance',
testing: 'Testing',
documentation: 'Documentation',
};
const VERDICT_CONFIG: Record<
CodeReviewVerdict,
{ label: string; variant: 'success' | 'warning' | 'info'; icon: typeof CheckCircle2 }
> = {
approved: { label: 'Approved', variant: 'success', icon: CheckCircle2 },
changes_requested: { label: 'Changes Requested', variant: 'warning', icon: AlertTriangle },
needs_discussion: { label: 'Needs Discussion', variant: 'info', icon: MessageSquare },
};
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
const seconds = Math.floor(ms / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}m ${remainingSeconds}s`;
}
// ============================================================================
// Sub-components
// ============================================================================
interface VerdictBadgeProps {
verdict: CodeReviewVerdict;
className?: string;
}
function VerdictBadge({ verdict, className }: VerdictBadgeProps) {
const config = VERDICT_CONFIG[verdict];
const Icon = config.icon;
return (
<Badge variant={config.variant} size="lg" className={cn('gap-1.5', className)}>
<Icon className="w-3.5 h-3.5" />
{config.label}
</Badge>
);
}
interface SeverityBadgeProps {
severity: CodeReviewSeverity;
count?: number;
className?: string;
}
function SeverityBadge({ severity, count, className }: SeverityBadgeProps) {
const config = SEVERITY_CONFIG[severity];
const Icon = config.icon;
return (
<Badge variant={config.variant} size="sm" className={cn('gap-1', className)}>
<Icon className="w-3 h-3" />
{config.label}
{count !== undefined && count > 0 && <span className="ml-0.5">({count})</span>}
</Badge>
);
}
interface CategoryBadgeProps {
category: CodeReviewCategory;
className?: string;
}
function CategoryBadge({ category, className }: CategoryBadgeProps) {
return (
<Badge variant="outline" size="sm" className={className}>
{CATEGORY_LABELS[category]}
</Badge>
);
}
interface CommentCardProps {
comment: CodeReviewComment;
defaultExpanded?: boolean;
}
function CommentCard({ comment, defaultExpanded = false }: CommentCardProps) {
const [expanded, setExpanded] = useState(defaultExpanded);
const [copied, setCopied] = useState(false);
const commentId = `comment-${comment.id}`;
const lineRange =
comment.startLine === comment.endLine
? `Line ${comment.startLine}`
: `Lines ${comment.startLine}-${comment.endLine}`;
const handleCopyCode = async () => {
if (comment.suggestedCode) {
await navigator.clipboard.writeText(comment.suggestedCode);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
return (
<div
className={cn(
'rounded-lg border border-border bg-card/50 overflow-hidden transition-all duration-200',
'hover:border-border/80',
'focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2'
)}
>
{/* Header - accessible expand/collapse button */}
<button
type="button"
onClick={() => setExpanded(!expanded)}
className="w-full flex items-start gap-3 p-3 text-left hover:bg-accent/30 transition-colors focus:outline-none focus-visible:bg-accent/30"
aria-expanded={expanded}
aria-controls={commentId}
aria-label={`${expanded ? 'Collapse' : 'Expand'} comment for ${comment.filePath} at ${lineRange}`}
>
<div className="flex-shrink-0 mt-0.5" aria-hidden="true">
{expanded ? (
<ChevronDown className="w-4 h-4 text-muted-foreground" />
) : (
<ChevronRight className="w-4 h-4 text-muted-foreground" />
)}
</div>
<div className="flex-1 min-w-0">
{/* File and line info */}
<div className="flex items-center gap-2 flex-wrap mb-1.5">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<FileCode className="w-3.5 h-3.5" aria-hidden="true" />
<span className="font-mono truncate max-w-[200px]" title={comment.filePath}>
{comment.filePath}
</span>
<span className="text-muted-foreground/60" aria-hidden="true">
:
</span>
<span>{lineRange}</span>
</div>
</div>
{/* Comment preview */}
<p className={cn('text-sm text-foreground', !expanded && 'line-clamp-2')}>
{comment.body}
</p>
{/* Visual indicator for truncated content */}
{!expanded && comment.body.length > 150 && (
<span className="text-xs text-muted-foreground/60 mt-1 inline-block">
Click to expand...
</span>
)}
</div>
{/* Badges */}
<div className="flex items-center gap-1.5 flex-shrink-0">
<SeverityBadge severity={comment.severity} />
<CategoryBadge category={comment.category} />
{comment.autoFixed && (
<Badge variant="success" size="sm" className="gap-1">
<Wrench className="w-3 h-3" aria-hidden="true" />
<span>Fixed</span>
</Badge>
)}
</div>
</button>
{/* Expanded content */}
{expanded && (
<div
id={commentId}
className="px-3 pb-3 pt-0 space-y-3 border-t border-border/50"
role="region"
aria-label={`Details for comment on ${comment.filePath}`}
>
{/* Full body */}
<div className="pl-7 pt-3">
<p className="text-sm text-foreground whitespace-pre-wrap">{comment.body}</p>
</div>
{/* Suggested fix */}
{comment.suggestedFix && (
<div className="pl-7">
<div className="rounded-md bg-muted/50 p-3">
<div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground mb-2">
<Sparkles className="w-3.5 h-3.5" aria-hidden="true" />
<span>Suggested Fix</span>
</div>
<p className="text-sm text-foreground">{comment.suggestedFix}</p>
</div>
</div>
)}
{/* Suggested code */}
{comment.suggestedCode && (
<div className="pl-7">
<div className="rounded-md bg-muted/80 overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 bg-muted border-b border-border/50">
<span className="text-xs font-medium text-muted-foreground">Suggested Code</span>
<Button
variant="ghost"
size="sm"
onClick={handleCopyCode}
className="h-6 px-2 text-xs focus-visible:ring-2 focus-visible:ring-ring"
aria-label={copied ? 'Code copied to clipboard' : 'Copy code to clipboard'}
>
{copied ? (
<>
<Check className="w-3 h-3 mr-1" aria-hidden="true" />
<span>Copied</span>
</>
) : (
<>
<Copy className="w-3 h-3 mr-1" aria-hidden="true" />
<span>Copy</span>
</>
)}
</Button>
</div>
<pre className="p-3 overflow-x-auto text-xs font-mono text-foreground" tabIndex={0}>
<code>{comment.suggestedCode}</code>
</pre>
</div>
</div>
)}
</div>
)}
</div>
);
}
interface StatsOverviewProps {
review: CodeReviewResult;
}
function StatsOverview({ review }: StatsOverviewProps) {
const { stats } = review;
return (
<div className="flex flex-wrap gap-2">
{SEVERITY_ORDER.map((severity) => {
const count = stats.bySeverity[severity] || 0;
if (count === 0) return null;
return <SeverityBadge key={severity} severity={severity} count={count} />;
})}
{stats.autoFixedCount > 0 && (
<Badge variant="success" size="sm" className="gap-1">
<Wrench className="w-3 h-3" />
{stats.autoFixedCount} auto-fixed
</Badge>
)}
</div>
);
}
// ============================================================================
// Main Component
// ============================================================================
export function CodeReviewDialog({
open,
onOpenChange,
review,
loading = false,
error = null,
onRetry,
}: CodeReviewDialogProps) {
const [activeTab, setActiveTab] = useState<'severity' | 'file'>('severity');
// Group comments by severity
const commentsBySeverity = useMemo(() => {
if (!review) return {};
const grouped: Partial<Record<CodeReviewSeverity, CodeReviewComment[]>> = {};
for (const comment of review.comments) {
if (!grouped[comment.severity]) {
grouped[comment.severity] = [];
}
grouped[comment.severity]!.push(comment);
}
return grouped;
}, [review]);
// Group comments by file
const commentsByFile = useMemo(() => {
if (!review) return {};
const grouped: Record<string, CodeReviewComment[]> = {};
for (const comment of review.comments) {
if (!grouped[comment.filePath]) {
grouped[comment.filePath] = [];
}
grouped[comment.filePath].push(comment);
}
// Sort comments within each file by line number
Object.values(grouped).forEach((comments) => {
comments.sort((a, b) => a.startLine - b.startLine);
});
return grouped;
}, [review]);
// Render loading state with improved skeleton and progress
if (loading) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="max-w-3xl"
data-testid="code-review-dialog"
aria-busy="true"
aria-describedby="loading-description"
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileCode className="w-5 h-5 text-brand-500" aria-hidden="true" />
Code Review
</DialogTitle>
<DialogDescription id="loading-description">
Analyzing your code for best practices, security, and performance issues...
</DialogDescription>
</DialogHeader>
{/* Loading skeleton with spinner and placeholders */}
<div className="space-y-4 py-4">
{/* Spinner and status */}
<div className="flex items-center justify-center">
<div className="flex flex-col items-center gap-3">
<div
className="animate-spin rounded-full h-10 w-10 border-3 border-primary border-t-transparent"
role="progressbar"
aria-label="Code review in progress"
/>
<p className="text-sm text-muted-foreground font-medium">Running code review...</p>
</div>
</div>
{/* Skeleton placeholders for expected content */}
<div className="space-y-3 animate-pulse">
{/* Verdict skeleton */}
<div className="flex items-center justify-between">
<div className="h-4 w-32 bg-muted rounded" />
<div className="h-6 w-24 bg-muted rounded-full" />
</div>
{/* Summary skeleton */}
<div className="space-y-2 py-3 border-y border-border/50">
<div className="h-4 w-full bg-muted rounded" />
<div className="h-4 w-3/4 bg-muted rounded" />
<div className="flex gap-2 mt-2">
<div className="h-5 w-16 bg-muted rounded-full" />
<div className="h-5 w-16 bg-muted rounded-full" />
<div className="h-5 w-16 bg-muted rounded-full" />
</div>
</div>
{/* Comments skeleton */}
<div className="space-y-2">
<div className="h-16 w-full bg-muted/50 rounded-lg" />
<div className="h-16 w-full bg-muted/50 rounded-lg" />
</div>
</div>
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
data-testid="code-review-loading-close"
>
Cancel
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// Render error state with improved accessibility
if (error) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="max-w-md"
data-testid="code-review-dialog"
role="alertdialog"
aria-describedby="error-description"
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-destructive">
<AlertCircle className="w-5 h-5" aria-hidden="true" />
Review Failed
</DialogTitle>
<DialogDescription id="error-description">
Something went wrong during the code review.
</DialogDescription>
</DialogHeader>
<div className="py-4" role="alert" aria-live="polite">
<p className="text-sm text-destructive bg-destructive/10 rounded-md p-3 border border-destructive/20">
{error}
</p>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
data-testid="code-review-error-close"
>
Close
</Button>
{onRetry && (
<Button
onClick={onRetry}
data-testid="code-review-retry"
aria-label="Retry code review"
>
<RotateCcw className="w-4 h-4 mr-2" aria-hidden="true" />
Retry
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// Render empty state with helpful guidance
if (!review) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md" data-testid="code-review-dialog">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileCode className="w-5 h-5 text-brand-500" aria-hidden="true" />
Code Review
</DialogTitle>
<DialogDescription>No review results available yet.</DialogDescription>
</DialogHeader>
<div className="py-6 text-center">
<Info className="w-10 h-10 text-muted-foreground/50 mx-auto mb-3" aria-hidden="true" />
<p className="text-sm text-muted-foreground">
Start a code review to analyze your changes for best practices, security
vulnerabilities, and performance issues.
</p>
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
data-testid="code-review-empty-close"
>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="max-w-4xl max-h-[85vh] flex flex-col overflow-hidden"
data-testid="code-review-dialog"
aria-describedby="review-summary"
>
{/* Header */}
<DialogHeader className="flex-shrink-0">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<DialogTitle className="flex items-center gap-2 text-lg">
<FileCode className="w-5 h-5 text-brand-500" aria-hidden="true" />
Code Review Results
</DialogTitle>
<DialogDescription className="mt-1">
Reviewed {review.filesReviewed.length} file
{review.filesReviewed.length !== 1 ? 's' : ''}
{review.gitRef && (
<span
className="ml-1 font-mono text-xs"
aria-label={`Git reference: ${review.gitRef.slice(0, 7)}`}
>
({review.gitRef.slice(0, 7)})
</span>
)}
</DialogDescription>
</div>
<VerdictBadge verdict={review.verdict} />
</div>
</DialogHeader>
{/* Summary section */}
<div className="flex-shrink-0 space-y-3 py-3 border-y border-border/50" id="review-summary">
{/* Summary text */}
<p className="text-sm text-foreground leading-relaxed">{review.summary}</p>
{/* Stats and metadata */}
<div className="flex items-center justify-between gap-4 flex-wrap">
<StatsOverview review={review} />
{review.durationMs && (
<div
className="flex items-center gap-1.5 text-xs text-muted-foreground"
aria-label={`Review completed in ${formatDuration(review.durationMs)}`}
>
<Clock className="w-3.5 h-3.5" aria-hidden="true" />
<span>{formatDuration(review.durationMs)}</span>
</div>
)}
</div>
</div>
{/* Comments section */}
{review.comments.length > 0 ? (
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as 'severity' | 'file')}
className="flex-1 flex flex-col min-h-0"
>
<TabsList className="flex-shrink-0">
<TabsTrigger value="severity">By Severity</TabsTrigger>
<TabsTrigger value="file">By File</TabsTrigger>
</TabsList>
<TabsContent value="severity" className="flex-1 min-h-0 mt-3 overflow-hidden">
<ScrollArea className="h-[350px]">
<Accordion
type="multiple"
defaultValue={['critical', 'high']}
className="space-y-2 pr-4"
>
{SEVERITY_ORDER.map((severity) => {
const comments = commentsBySeverity[severity];
if (!comments || comments.length === 0) return null;
const config = SEVERITY_CONFIG[severity];
const Icon = config.icon;
return (
<AccordionItem
key={severity}
value={severity}
className="border rounded-lg bg-card/30 overflow-hidden"
>
<AccordionTrigger className="px-4 py-3 hover:no-underline hover:bg-accent/30">
<div className="flex items-center gap-2">
<Icon
className={cn(
'w-4 h-4',
severity === 'critical' || severity === 'high'
? 'text-[var(--status-error)]'
: severity === 'medium'
? 'text-[var(--status-warning)]'
: 'text-[var(--status-info)]'
)}
/>
<span className="font-medium">{config.label}</span>
<Badge variant="muted" size="sm">
{comments.length}
</Badge>
</div>
</AccordionTrigger>
<AccordionContent className="px-4 pb-3">
<div className="space-y-2">
{comments.map((comment) => (
<CommentCard
key={comment.id}
comment={comment}
defaultExpanded={severity === 'critical'}
/>
))}
</div>
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>
</ScrollArea>
</TabsContent>
<TabsContent value="file" className="flex-1 min-h-0 mt-3 overflow-hidden">
<ScrollArea className="h-[350px]">
<Accordion type="multiple" className="space-y-2 pr-4">
{Object.entries(commentsByFile).map(([filePath, comments]) => {
// Determine the highest severity in this file
const highestSeverity = comments.reduce((highest, comment) => {
const currentIndex = SEVERITY_ORDER.indexOf(comment.severity);
const highestIndex = SEVERITY_ORDER.indexOf(highest);
return currentIndex < highestIndex ? comment.severity : highest;
}, 'info' as CodeReviewSeverity);
const severityConfig = SEVERITY_CONFIG[highestSeverity];
const Icon = severityConfig.icon;
return (
<AccordionItem
key={filePath}
value={filePath}
className="border rounded-lg bg-card/30 overflow-hidden"
>
<AccordionTrigger className="px-4 py-3 hover:no-underline hover:bg-accent/30">
<div className="flex items-center gap-2 min-w-0">
<FileCode className="w-4 h-4 text-brand-500 flex-shrink-0" />
<span className="font-mono text-sm truncate" title={filePath}>
{filePath}
</span>
<Badge
variant={severityConfig.variant}
size="sm"
className="flex-shrink-0"
>
<Icon className="w-3 h-3 mr-1" />
{comments.length}
</Badge>
</div>
</AccordionTrigger>
<AccordionContent className="px-4 pb-3">
<div className="space-y-2">
{comments.map((comment) => (
<CommentCard key={comment.id} comment={comment} />
))}
</div>
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>
</ScrollArea>
</TabsContent>
</Tabs>
</div>
) : (
<div
className="flex-1 flex items-center justify-center py-8"
role="status"
aria-live="polite"
>
<div className="text-center">
<div className="w-16 h-16 rounded-full bg-[var(--status-success)]/10 flex items-center justify-center mx-auto mb-4">
<CheckCircle2
className="w-10 h-10 text-[var(--status-success)]"
aria-hidden="true"
/>
</div>
<h3 className="text-base font-semibold text-foreground mb-1">No issues found!</h3>
<p className="text-sm text-muted-foreground max-w-xs mx-auto">
Your code looks great. The review found no issues, suggestions, or improvements
needed.
</p>
</div>
</div>
)}
{/* Footer */}
<DialogFooter className="flex-shrink-0 border-t border-border/50 pt-4 mt-2">
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
data-testid="code-review-close"
>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,6 +1,4 @@
export { BoardBackgroundModal } from './board-background-modal';
export { CodeReviewDialog } from './code-review-dialog';
export type { CodeReviewDialogProps } from './code-review-dialog';
export { DeleteAllArchivedSessionsDialog } from './delete-all-archived-sessions-dialog';
export { DeleteSessionDialog } from './delete-session-dialog';
export { FileBrowserDialog } from './file-browser-dialog';

View File

@@ -14,8 +14,16 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Badge } from '@/components/ui/badge';
import { FolderPlus, FolderOpen, Rocket, ExternalLink, Check, Link, Folder } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import {
FolderPlus,
FolderOpen,
Rocket,
ExternalLink,
Check,
Loader2,
Link,
Folder,
} from 'lucide-react';
import { starterTemplates, type StarterTemplate } from '@/lib/templates';
import { getElectronAPI } from '@/lib/electron';
import { cn } from '@/lib/utils';
@@ -443,7 +451,7 @@ export function NewProjectModal({
>
{isCreating ? (
<>
<Spinner size="sm" className="mr-2" />
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
{activeTab === 'template' ? 'Cloning...' : 'Creating...'}
</>
) : (

View File

@@ -8,8 +8,7 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Folder, FolderOpen, AlertCircle } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { Folder, Loader2, FolderOpen, AlertCircle } from 'lucide-react';
import { getHttpApiClient } from '@/lib/http-api-client';
interface WorkspaceDirectory {
@@ -75,7 +74,7 @@ export function WorkspacePickerModal({ open, onOpenChange, onSelect }: Workspace
<div className="flex-1 overflow-y-auto py-4 min-h-[200px]">
{isLoading && (
<div className="flex flex-col items-center justify-center h-full gap-3">
<Spinner size="xl" />
<Loader2 className="w-8 h-8 text-brand-500 animate-spin" />
<p className="text-sm text-muted-foreground">Loading projects...</p>
</div>
)}

View File

@@ -1,9 +1,9 @@
import type { NavigateOptions } from '@tanstack/react-router';
import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { formatShortcut } from '@/store/app-store';
import type { NavSection } from '../types';
import type { Project } from '@/lib/electron';
import { Spinner } from '@/components/ui/spinner';
interface SidebarNavigationProps {
currentProject: Project | null;
@@ -93,10 +93,9 @@ export function SidebarNavigation({
>
<div className="relative">
{item.isLoading ? (
<Spinner
size="md"
<Loader2
className={cn(
'shrink-0',
'w-[18px] h-[18px] shrink-0 animate-spin',
isActive ? 'text-brand-500' : 'text-muted-foreground'
)}
/>

View File

@@ -16,8 +16,8 @@ import {
Check,
X,
ArchiveRestore,
Loader2,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import type { SessionListItem } from '@/types/electron';
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
@@ -466,7 +466,7 @@ export function SessionManager({
{/* Show loading indicator if this session is running (either current session thinking or any session in runningSessions) */}
{(currentSessionId === session.id && isCurrentSessionThinking) ||
runningSessions.has(session.id) ? (
<Spinner size="sm" className="shrink-0" />
<Loader2 className="w-4 h-4 text-primary animate-spin shrink-0" />
) : (
<MessageSquare className="w-4 h-4 text-muted-foreground shrink-0" />
)}

View File

@@ -1,9 +1,9 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Spinner } from '@/components/ui/spinner';
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all duration-200 cursor-pointer disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive active:scale-[0.98]",
@@ -39,7 +39,7 @@ const buttonVariants = cva(
// Loading spinner component
function ButtonSpinner({ className }: { className?: string }) {
return <Spinner size="sm" className={className} />;
return <Loader2 className={cn('size-4 animate-spin', className)} aria-hidden="true" />;
}
function Button({

View File

@@ -3,8 +3,7 @@ import { createLogger } from '@automaker/utils/logger';
import { cn } from '@/lib/utils';
const logger = createLogger('DescriptionImageDropZone');
import { ImageIcon, X, FileText } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { ImageIcon, X, Loader2, FileText } from 'lucide-react';
import { Textarea } from '@/components/ui/textarea';
import { getElectronAPI } from '@/lib/electron';
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
@@ -432,7 +431,7 @@ export function DescriptionImageDropZone({
{/* Processing indicator */}
{isProcessing && (
<div className="flex items-center gap-2 mt-2 text-sm text-muted-foreground">
<Spinner size="sm" />
<Loader2 className="w-4 h-4 animate-spin" />
<span>Processing files...</span>
</div>
)}

View File

@@ -3,8 +3,7 @@ import { createLogger } from '@automaker/utils/logger';
import { cn } from '@/lib/utils';
const logger = createLogger('FeatureImageUpload');
import { ImageIcon, X } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { ImageIcon, X, Upload } from 'lucide-react';
import {
fileToBase64,
generateImageId,
@@ -197,7 +196,7 @@ export function FeatureImageUpload({
)}
>
{isProcessing ? (
<Spinner size="md" />
<Upload className="h-5 w-5 animate-spin text-muted-foreground" />
) : (
<ImageIcon className="h-5 w-5 text-muted-foreground" />
)}

View File

@@ -9,11 +9,11 @@ import {
FilePen,
ChevronDown,
ChevronRight,
Loader2,
RefreshCw,
GitBranch,
AlertCircle,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { Button } from './button';
import type { FileStatus } from '@/types/electron';
@@ -484,7 +484,7 @@ export function GitDiffPanel({
<div className="border-t border-border">
{isLoading ? (
<div className="flex items-center justify-center gap-2 py-8 text-muted-foreground">
<Spinner size="md" />
<Loader2 className="w-5 h-5 animate-spin" />
<span className="text-sm">Loading changes...</span>
</div>
) : error ? (

View File

@@ -3,8 +3,7 @@ import { createLogger } from '@automaker/utils/logger';
import { cn } from '@/lib/utils';
const logger = createLogger('ImageDropZone');
import { ImageIcon, X } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { ImageIcon, X, Upload } from 'lucide-react';
import type { ImageAttachment } from '@/store/app-store';
import {
fileToBase64,
@@ -205,7 +204,7 @@ export function ImageDropZone({
)}
>
{isProcessing ? (
<Spinner size="lg" />
<Upload className="h-6 w-6 animate-spin text-muted-foreground" />
) : (
<ImageIcon className="h-6 w-6 text-muted-foreground" />
)}

View File

@@ -1,15 +1,17 @@
import { Spinner } from '@/components/ui/spinner';
import { Loader2 } from 'lucide-react';
interface LoadingStateProps {
/** Optional custom message to display below the spinner */
message?: string;
/** Optional custom size class for the spinner (default: h-8 w-8) */
size?: string;
}
export function LoadingState({ message }: LoadingStateProps) {
export function LoadingState({ message, size = 'h-8 w-8' }: LoadingStateProps) {
return (
<div className="flex-1 flex flex-col items-center justify-center">
<Spinner size="xl" />
{message && <p className="mt-4 text-sm font-medium text-primary">{message}</p>}
<Loader2 className={`${size} animate-spin text-muted-foreground`} />
{message && <p className="mt-4 text-sm text-muted-foreground">{message}</p>}
</div>
);
}

View File

@@ -22,8 +22,8 @@ import {
Filter,
Circle,
Play,
Loader2,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import {
parseLogOutput,
@@ -148,7 +148,7 @@ function TodoListRenderer({ todos }: { todos: TodoItem[] }) {
case 'completed':
return <CheckCircle2 className="w-4 h-4 text-emerald-400" />;
case 'in_progress':
return <Spinner size="sm" />;
return <Loader2 className="w-4 h-4 text-amber-400 animate-spin" />;
case 'pending':
return <Circle className="w-4 h-4 text-muted-foreground/70" />;
default:

View File

@@ -19,7 +19,6 @@ const PROVIDER_ICON_KEYS = {
minimax: 'minimax',
glm: 'glm',
bigpickle: 'bigpickle',
coderabbit: 'coderabbit',
} as const;
type ProviderIconKey = keyof typeof PROVIDER_ICON_KEYS;
@@ -114,12 +113,6 @@ const PROVIDER_ICON_DEFINITIONS: Record<ProviderIconKey, ProviderIconDefinition>
path: 'M8 4c-2.21 0-4 1.79-4 4v8c0 2.21 1.79 4 4 4h8c2.21 0 4-1.79 4-4V8c0-2.21-1.79-4-4-4H8zm0 2h8c1.103 0 2 .897 2 2v8c0 1.103-.897 2-2 2H8c-1.103 0-2-.897-2-2V8c0-1.103.897-2 2-2zm2 3a1 1 0 100 2 1 1 0 000-2zm4 0a1 1 0 100 2 1 1 0 000-2zm-4 4a1 1 0 100 2 1 1 0 000-2zm4 0a1 1 0 100 2 1 1 0 000-2z',
fill: '#4ADE80',
},
coderabbit: {
viewBox: '0 0 24 24',
// CodeRabbit logo - rabbit/bunny icon
path: 'M18 4a2 2 0 0 0-2-2c-.9 0-1.7.6-1.9 1.5l-.3 1.1c-.2.6-.7 1-1.3 1.2L12 6l-.5-.2c-.6-.2-1.1-.6-1.3-1.2l-.3-1.1C9.7 2.6 8.9 2 8 2a2 2 0 0 0-2 2c0 .7.4 1.4 1 1.7V7c0 1.1.9 2 2 2h1v2H8.5C6 11 4 13 4 15.5V18c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2v-2.5c0-2.5-2-4.5-4.5-4.5H14V9h1c1.1 0 2-.9 2-2V5.7c.6-.3 1-1 1-1.7zm-8 9h4c1.1 0 2 .9 2 2v1H8v-1c0-1.1.9-2 2-2z',
fill: '#FF6B35',
},
};
export interface ProviderIconProps extends Omit<SVGProps<SVGSVGElement>, 'viewBox'> {
@@ -185,10 +178,6 @@ export function OpenCodeIcon(props: Omit<ProviderIconProps, 'provider'>) {
return <ProviderIcon provider={PROVIDER_ICON_KEYS.opencode} {...props} />;
}
export function CodeRabbitIcon(props: Omit<ProviderIconProps, 'provider'>) {
return <ProviderIcon provider={PROVIDER_ICON_KEYS.coderabbit} {...props} />;
}
export function DeepSeekIcon({
className,
title,
@@ -580,7 +569,6 @@ export function getProviderIconForModel(
minimax: MiniMaxIcon,
glm: GlmIcon,
bigpickle: BigPickleIcon,
coderabbit: CodeRabbitIcon,
};
return iconMap[iconKey] || AnthropicIcon;

View File

@@ -1,32 +0,0 @@
import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
type SpinnerSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
const sizeClasses: Record<SpinnerSize, string> = {
xs: 'h-3 w-3',
sm: 'h-4 w-4',
md: 'h-5 w-5',
lg: 'h-6 w-6',
xl: 'h-8 w-8',
};
interface SpinnerProps {
/** Size of the spinner */
size?: SpinnerSize;
/** Additional class names */
className?: string;
}
/**
* Themed spinner component using the primary brand color.
* Use this for all loading indicators throughout the app for consistency.
*/
export function Spinner({ size = 'md', className }: SpinnerProps) {
return (
<Loader2
className={cn(sizeClasses[size], 'animate-spin text-primary', className)}
aria-hidden="true"
/>
);
}

View File

@@ -5,8 +5,7 @@ import { createLogger } from '@automaker/utils/logger';
import { cn } from '@/lib/utils';
const logger = createLogger('TaskProgressPanel');
import { Check, Circle, ChevronDown, ChevronRight, FileCode } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { Check, Loader2, Circle, ChevronDown, ChevronRight, FileCode } from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
import type { AutoModeEvent } from '@/types/electron';
import { Badge } from '@/components/ui/badge';
@@ -261,7 +260,7 @@ export function TaskProgressPanel({
)}
>
{isCompleted && <Check className="h-3.5 w-3.5" />}
{isActive && <Spinner size="xs" />}
{isActive && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
{isPending && <Circle className="h-2 w-2 fill-current opacity-50" />}
</div>

View File

@@ -3,7 +3,6 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
@@ -450,7 +449,7 @@ export function UsagePopover() {
</div>
) : !claudeUsage ? (
<div className="flex flex-col items-center justify-center py-8 space-y-2">
<Spinner size="lg" />
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground/50" />
<p className="text-xs text-muted-foreground">Loading usage data...</p>
</div>
) : (
@@ -569,7 +568,7 @@ export function UsagePopover() {
</div>
) : !codexUsage ? (
<div className="flex flex-col items-center justify-center py-8 space-y-2">
<Spinner size="lg" />
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground/50" />
<p className="text-xs text-muted-foreground">Loading usage data...</p>
</div>
) : codexUsage.rateLimits ? (

View File

@@ -11,12 +11,12 @@ import {
Terminal,
CheckCircle,
XCircle,
Loader2,
Play,
File,
Pencil,
Wrench,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import { getElectronAPI } from '@/lib/electron';
@@ -236,7 +236,7 @@ export function AgentToolsView() {
>
{isReadingFile ? (
<>
<Spinner size="sm" className="mr-2" />
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Reading...
</>
) : (
@@ -315,7 +315,7 @@ export function AgentToolsView() {
>
{isWritingFile ? (
<>
<Spinner size="sm" className="mr-2" />
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Writing...
</>
) : (
@@ -383,7 +383,7 @@ export function AgentToolsView() {
>
{isRunningCommand ? (
<>
<Spinner size="sm" className="mr-2" />
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Running...
</>
) : (

View File

@@ -1,5 +1,4 @@
import { Bot } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
export function ThinkingIndicator() {
return (
@@ -9,7 +8,20 @@ export function ThinkingIndicator() {
</div>
<div className="bg-card border border-border rounded-2xl px-4 py-3 shadow-sm">
<div className="flex items-center gap-3">
<Spinner size="sm" />
<div className="flex items-center gap-1">
<span
className="w-2 h-2 rounded-full bg-primary animate-pulse"
style={{ animationDelay: '0ms' }}
/>
<span
className="w-2 h-2 rounded-full bg-primary animate-pulse"
style={{ animationDelay: '150ms' }}
/>
<span
className="w-2 h-2 rounded-full bg-primary animate-pulse"
style={{ animationDelay: '300ms' }}
/>
</div>
<span className="text-sm text-muted-foreground">Thinking...</span>
</div>
</div>

View File

@@ -14,12 +14,12 @@ import {
RefreshCw,
BarChart3,
FileCode,
Loader2,
FileText,
CheckCircle,
AlertCircle,
ListChecks,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn, generateUUID } from '@/lib/utils';
const logger = createLogger('AnalysisView');
@@ -742,7 +742,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
<Button onClick={runAnalysis} disabled={isAnalyzing} data-testid="analyze-project-button">
{isAnalyzing ? (
<>
<Spinner size="sm" className="mr-2" />
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Analyzing...
</>
) : (
@@ -771,7 +771,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
</div>
) : isAnalyzing ? (
<div className="flex flex-col items-center justify-center h-full">
<Spinner size="xl" className="mb-4" />
<Loader2 className="w-12 h-12 animate-spin text-primary mb-4" />
<p className="text-muted-foreground">Scanning project files...</p>
</div>
) : projectAnalysis ? (
@@ -850,7 +850,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
>
{isGeneratingSpec ? (
<>
<Spinner size="sm" className="mr-2" />
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Generating...
</>
) : (
@@ -903,7 +903,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
>
{isGeneratingFeatureList ? (
<>
<Spinner size="sm" className="mr-2" />
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Generating...
</>
) : (

View File

@@ -34,7 +34,7 @@ import { pathsEqual } from '@/lib/utils';
import { toast } from 'sonner';
import { getBlockingDependencies } from '@automaker/dependency-resolver';
import { BoardBackgroundModal } from '@/components/dialogs/board-background-modal';
import { Spinner } from '@/components/ui/spinner';
import { RefreshCw } from 'lucide-react';
import { useAutoMode } from '@/hooks/use-auto-mode';
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
import { useWindowState } from '@/hooks/use-window-state';
@@ -52,8 +52,6 @@ import {
FollowUpDialog,
PlanApprovalDialog,
} from './board-view/dialogs';
import { CodeReviewDialog } from '@/components/dialogs/code-review-dialog';
import { useCodeReview } from '@/hooks/use-code-review';
import { PipelineSettingsDialog } from './board-view/dialogs/pipeline-settings-dialog';
import { CreateWorktreeDialog } from './board-view/dialogs/create-worktree-dialog';
import { DeleteWorktreeDialog } from './board-view/dialogs/delete-worktree-dialog';
@@ -169,11 +167,6 @@ export function BoardView() {
// Pipeline settings dialog state
const [showPipelineSettings, setShowPipelineSettings] = useState(false);
// Code review state
const [showCodeReviewDialog, setShowCodeReviewDialog] = useState(false);
const [codeReviewFeature, setCodeReviewFeature] = useState<Feature | null>(null);
const codeReview = useCodeReview();
// Follow-up state hook
const {
showFollowUpDialog,
@@ -1380,44 +1373,6 @@ export function BoardView() {
[currentProject, setPendingPlanApproval]
);
// Handle opening code review for a feature
const handleCodeReview = useCallback(
async (feature: Feature) => {
if (!feature.branchName) {
toast.error('Cannot review code', {
description: 'Feature has no associated branch',
});
return;
}
// Find the worktree for this feature's branch
const featureWorktree = worktrees.find((w) => w.branch === feature.branchName);
const worktreePath = featureWorktree?.path;
if (!worktreePath) {
toast.error('Cannot review code', {
description: 'No worktree found for this feature. Create a worktree first.',
});
return;
}
setCodeReviewFeature(feature);
setShowCodeReviewDialog(true);
// Trigger the code review for the feature's worktree
// Don't pass baseRef - let the backend auto-detect the base branch for worktrees
try {
await codeReview.triggerReview({
projectPath: worktreePath,
// baseRef is omitted - backend will detect main/master for worktrees
});
} catch (error) {
logger.error('Failed to trigger code review:', error);
}
},
[codeReview, worktrees]
);
if (!currentProject) {
return (
<div className="flex-1 flex items-center justify-center" data-testid="board-view-no-project">
@@ -1429,7 +1384,7 @@ export function BoardView() {
if (isLoading) {
return (
<div className="flex-1 flex items-center justify-center" data-testid="board-view-loading">
<Spinner size="lg" />
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
);
}
@@ -1530,7 +1485,6 @@ export function BoardView() {
setSpawnParentFeature(feature);
setShowAddDialog(true);
},
onCodeReview: handleCodeReview,
}}
runningAutoTasks={runningAutoTasks}
pipelineConfig={pipelineConfig}
@@ -1574,7 +1528,6 @@ export function BoardView() {
setSpawnParentFeature(feature);
setShowAddDialog(true);
}}
onCodeReview={handleCodeReview}
featuresWithContext={featuresWithContext}
runningAutoTasks={runningAutoTasks}
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
@@ -1796,26 +1749,6 @@ export function BoardView() {
/>
)}
{/* Code Review Dialog */}
<CodeReviewDialog
open={showCodeReviewDialog}
onOpenChange={(open) => {
setShowCodeReviewDialog(open);
if (!open) {
setCodeReviewFeature(null);
codeReview.clearReview();
}
}}
review={codeReview.review}
loading={codeReview.reviewing}
error={codeReview.error}
onRetry={() => {
if (codeReviewFeature) {
handleCodeReview(codeReviewFeature);
}
}}
/>
{/* Create Worktree Dialog */}
<CreateWorktreeDialog
open={showCreateWorktreeDialog}

View File

@@ -1,7 +1,6 @@
import { useRef, useEffect } from 'react';
import { Input } from '@/components/ui/input';
import { Search, X } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { Search, X, Loader2 } from 'lucide-react';
interface BoardSearchBarProps {
searchQuery: string;
@@ -76,7 +75,7 @@ export function BoardSearchBar({
title="Creating App Specification"
data-testid="spec-creation-badge"
>
<Spinner size="xs" className="shrink-0" />
<Loader2 className="w-3 h-3 animate-spin text-brand-500 shrink-0" />
<span className="text-xs font-medium text-brand-500 whitespace-nowrap">
Creating spec
</span>

View File

@@ -11,8 +11,16 @@ import {
} from '@/lib/agent-context-parser';
import { cn } from '@/lib/utils';
import type { AutoModeEvent } from '@/types/electron';
import { Brain, ListTodo, Sparkles, Expand, CheckCircle2, Circle, Wrench } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import {
Brain,
ListTodo,
Sparkles,
Expand,
CheckCircle2,
Circle,
Loader2,
Wrench,
} from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
import { SummaryDialog } from './summary-dialog';
import { getProviderIconForModel } from '@/components/ui/provider-icon';
@@ -330,7 +338,7 @@ export function AgentInfoPanel({
{todo.status === 'completed' ? (
<CheckCircle2 className="w-2.5 h-2.5 text-[var(--status-success)] shrink-0" />
) : todo.status === 'in_progress' ? (
<Spinner size="xs" className="w-2.5 h-2.5 shrink-0" />
<Loader2 className="w-2.5 h-2.5 text-[var(--status-warning)] animate-spin shrink-0" />
) : (
<Circle className="w-2.5 h-2.5 text-muted-foreground/50 shrink-0" />
)}

View File

@@ -1,4 +1,5 @@
import type { Feature } from '@/store/app-store';
// @ts-nocheck
import { Feature } from '@/store/app-store';
import { Button } from '@/components/ui/button';
import {
Edit,
@@ -10,7 +11,6 @@ import {
Eye,
Wand2,
Archive,
FileSearch,
} from 'lucide-react';
interface CardActionsProps {
@@ -30,13 +30,12 @@ interface CardActionsProps {
onComplete?: () => void;
onViewPlan?: () => void;
onApprovePlan?: () => void;
onCodeReview?: () => void;
}
export function CardActions({
feature,
isCurrentAutoTask,
hasContext: _hasContext,
hasContext,
shortcutKey,
isSelectionMode = false,
onEdit,
@@ -50,7 +49,6 @@ export function CardActions({
onComplete,
onViewPlan,
onApprovePlan,
onCodeReview,
}: CardActionsProps) {
// Hide all actions when in selection mode
if (isSelectionMode) {
@@ -260,24 +258,6 @@ export function CardActions({
<span className="truncate">Refine</span>
</Button>
)}
{/* Code Review button - analyzes code for best practices */}
{onCodeReview && (
<Button
variant="outline"
size="sm"
className="h-7 text-[11px] px-2.5 min-w-[44px] gap-1.5 hover:bg-blue-500/10 hover:text-blue-600 hover:border-blue-500/30 dark:hover:text-blue-400 transition-colors"
onClick={(e) => {
e.stopPropagation();
onCodeReview();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`code-review-${feature.id}`}
aria-label="Start code review for this feature"
>
<FileSearch className="w-3.5 h-3.5" aria-hidden="true" />
<span className="sr-only sm:not-sr-only">Review</span>
</Button>
)}
{/* Show Verify button if PR was created (changes are committed), otherwise show Mark as Verified button */}
{feature.prUrl && onManualVerify ? (
<Button

View File

@@ -13,6 +13,7 @@ import {
import {
GripVertical,
Edit,
Loader2,
Trash2,
FileText,
MoreVertical,
@@ -20,7 +21,6 @@ import {
ChevronUp,
GitFork,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { CountUpTimer } from '@/components/ui/count-up-timer';
import { formatModelName, DEFAULT_MODEL } from '@/lib/agent-context-parser';
import { DeleteConfirmDialog } from '@/components/ui/delete-confirm-dialog';
@@ -65,7 +65,7 @@ export function CardHeaderSection({
{isCurrentAutoTask && !isSelectionMode && (
<div className="absolute top-2 right-2 flex items-center gap-1">
<div className="flex items-center justify-center gap-2 bg-[var(--status-in-progress)]/15 border border-[var(--status-in-progress)]/50 rounded-md px-2 py-0.5">
<Spinner size="xs" />
<Loader2 className="w-3.5 h-3.5 text-[var(--status-in-progress)] animate-spin" />
{feature.startedAt && (
<CountUpTimer
startedAt={feature.startedAt}
@@ -324,7 +324,7 @@ export function CardHeaderSection({
<div className="flex-1 min-w-0 overflow-hidden">
{feature.titleGenerating ? (
<div className="flex items-center gap-1.5 mb-1">
<Spinner size="xs" />
<Loader2 className="w-3 h-3 animate-spin text-muted-foreground" />
<span className="text-xs text-muted-foreground italic">Generating title...</span>
</div>
) : feature.title ? (

View File

@@ -51,7 +51,6 @@ interface KanbanCardProps {
onViewPlan?: () => void;
onApprovePlan?: () => void;
onSpawnTask?: () => void;
onCodeReview?: () => void;
hasContext?: boolean;
isCurrentAutoTask?: boolean;
shortcutKey?: string;
@@ -85,7 +84,6 @@ export const KanbanCard = memo(function KanbanCard({
onViewPlan,
onApprovePlan,
onSpawnTask,
onCodeReview,
hasContext,
isCurrentAutoTask,
shortcutKey,
@@ -240,7 +238,6 @@ export const KanbanCard = memo(function KanbanCard({
onComplete={onComplete}
onViewPlan={onViewPlan}
onApprovePlan={onApprovePlan}
onCodeReview={onCodeReview}
/>
</CardContent>
</Card>

View File

@@ -42,7 +42,6 @@ export interface ListViewActionHandlers {
onViewPlan?: (feature: Feature) => void;
onApprovePlan?: (feature: Feature) => void;
onSpawnTask?: (feature: Feature) => void;
onCodeReview?: (feature: Feature) => void;
}
export interface ListViewProps {

View File

@@ -14,7 +14,6 @@ import {
GitBranch,
GitFork,
ExternalLink,
FileSearch,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
@@ -44,7 +43,6 @@ export interface RowActionHandlers {
onViewPlan?: () => void;
onApprovePlan?: () => void;
onSpawnTask?: () => void;
onCodeReview?: () => void;
}
export interface RowActionsProps {
@@ -481,14 +479,6 @@ export const RowActions = memo(function RowActions({
{handlers.onFollowUp && (
<MenuItem icon={Wand2} label="Refine" onClick={withClose(handlers.onFollowUp)} />
)}
{handlers.onCodeReview && (
<MenuItem
icon={FileSearch}
label="Code Review"
onClick={withClose(handlers.onCodeReview)}
variant="primary"
/>
)}
{feature.prUrl && (
<MenuItem
icon={ExternalLink}
@@ -625,7 +615,6 @@ export function createRowActionHandlers(
viewPlan?: (id: string) => void;
approvePlan?: (id: string) => void;
spawnTask?: (id: string) => void;
codeReview?: (id: string) => void;
}
): RowActionHandlers {
return {
@@ -642,6 +631,5 @@ export function createRowActionHandlers(
onViewPlan: actions.viewPlan ? () => actions.viewPlan!(featureId) : undefined,
onApprovePlan: actions.approvePlan ? () => actions.approvePlan!(featureId) : undefined,
onSpawnTask: actions.spawnTask ? () => actions.spawnTask!(featureId) : undefined,
onCodeReview: actions.codeReview ? () => actions.codeReview!(featureId) : undefined,
};
}

View File

@@ -6,8 +6,7 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { List, FileText, GitBranch, ClipboardList } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { Loader2, List, FileText, GitBranch, ClipboardList } from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
import { LogViewer } from '@/components/ui/log-viewer';
import { GitDiffPanel } from '@/components/ui/git-diff-panel';
@@ -354,7 +353,7 @@ export function AgentOutputModal({
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 pr-8">
<DialogTitle className="flex items-center gap-2">
{featureStatus !== 'verified' && featureStatus !== 'waiting_approval' && (
<Spinner size="md" />
<Loader2 className="w-5 h-5 text-primary animate-spin" />
)}
Agent Output
</DialogTitle>
@@ -440,7 +439,7 @@ export function AgentOutputModal({
/>
) : (
<div className="flex items-center justify-center h-full text-muted-foreground">
<Spinner size="lg" className="mr-2" />
<Loader2 className="w-6 h-6 animate-spin mr-2" />
Loading...
</div>
)}
@@ -458,7 +457,7 @@ export function AgentOutputModal({
>
{isLoading && !output ? (
<div className="flex items-center justify-center h-full text-muted-foreground">
<Spinner size="lg" className="mr-2" />
<Loader2 className="w-6 h-6 animate-spin mr-2" />
Loading output...
</div>
) : !output ? (

View File

@@ -11,8 +11,16 @@ import {
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Checkbox } from '@/components/ui/checkbox';
import { Wand2, Check, Plus, Pencil, Trash2, ChevronDown, ChevronRight } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import {
Loader2,
Wand2,
Check,
Plus,
Pencil,
Trash2,
ChevronDown,
ChevronRight,
} from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
@@ -279,7 +287,8 @@ export function BacklogPlanDialog({
</div>
{isGeneratingPlan && (
<div className="flex items-center gap-2 text-sm text-muted-foreground bg-muted/50 rounded-lg p-3">
<Spinner size="sm" />A plan is currently being generated in the background...
<Loader2 className="w-4 h-4 animate-spin" />A plan is currently being generated in
the background...
</div>
)}
</div>
@@ -396,7 +405,7 @@ export function BacklogPlanDialog({
case 'applying':
return (
<div className="flex flex-col items-center justify-center py-12">
<Spinner size="xl" className="mb-4" />
<Loader2 className="w-8 h-8 animate-spin text-primary mb-4" />
<p className="text-muted-foreground">Applying changes...</p>
</div>
);
@@ -443,7 +452,7 @@ export function BacklogPlanDialog({
<Button onClick={handleGenerate} disabled={!prompt.trim() || isGeneratingPlan}>
{isGeneratingPlan ? (
<>
<Spinner size="sm" className="mr-2" />
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Generating...
</>
) : (

View File

@@ -10,8 +10,7 @@ import {
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { GitCommit, Sparkles } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { GitCommit, Loader2, Sparkles } from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import { useAppStore } from '@/store/app-store';
@@ -210,7 +209,7 @@ export function CommitWorktreeDialog({
<Button onClick={handleCommit} disabled={isLoading || isGenerating || !message.trim()}>
{isLoading ? (
<>
<Spinner size="sm" className="mr-2" />
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Committing...
</>
) : (

View File

@@ -13,8 +13,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import { GitBranchPlus } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { GitBranchPlus, Loader2 } from 'lucide-react';
interface WorktreeInfo {
path: string;
@@ -134,7 +133,7 @@ export function CreateBranchDialog({
<Button onClick={handleCreate} disabled={!branchName.trim() || isCreating}>
{isCreating ? (
<>
<Spinner size="sm" className="mr-2" />
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Creating...
</>
) : (

View File

@@ -13,8 +13,7 @@ import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { BranchAutocomplete } from '@/components/ui/branch-autocomplete';
import { GitPullRequest, ExternalLink } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { GitPullRequest, Loader2, ExternalLink } from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
@@ -406,7 +405,7 @@ export function CreatePRDialog({
<Button onClick={handleCreate} disabled={isLoading}>
{isLoading ? (
<>
<Spinner size="sm" className="mr-2" />
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Creating...
</>
) : (

View File

@@ -10,8 +10,7 @@ import {
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { GitBranch, AlertCircle } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { GitBranch, Loader2, AlertCircle } from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
@@ -217,7 +216,7 @@ export function CreateWorktreeDialog({
<Button onClick={handleCreate} disabled={isLoading || !branchName.trim()}>
{isLoading ? (
<>
<Spinner size="sm" className="mr-2" />
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Creating...
</>
) : (

View File

@@ -10,8 +10,7 @@ import {
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { Trash2, AlertTriangle, FileWarning } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { Loader2, Trash2, AlertTriangle, FileWarning } from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
@@ -148,7 +147,7 @@ export function DeleteWorktreeDialog({
<Button variant="destructive" onClick={handleDelete} disabled={isLoading}>
{isLoading ? (
<>
<Spinner size="sm" className="mr-2" />
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Deleting...
</>
) : (

View File

@@ -10,8 +10,7 @@ import {
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { GitMerge, AlertTriangle, CheckCircle2 } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { Loader2, GitMerge, AlertTriangle, CheckCircle2 } from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
@@ -218,7 +217,7 @@ export function MergeWorktreeDialog({
>
{isLoading ? (
<>
<Spinner size="sm" className="mr-2" />
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Merging...
</>
) : (

View File

@@ -14,8 +14,7 @@ import { Textarea } from '@/components/ui/textarea';
import { Markdown } from '@/components/ui/markdown';
import { Label } from '@/components/ui/label';
import { Feature } from '@/store/app-store';
import { Check, RefreshCw, Edit2, Eye } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { Check, RefreshCw, Edit2, Eye, Loader2 } from 'lucide-react';
interface PlanApprovalDialogProps {
open: boolean;
@@ -172,7 +171,7 @@ export function PlanApprovalDialog({
</Button>
<Button variant="secondary" onClick={handleReject} disabled={isLoading}>
{isLoading ? (
<Spinner size="sm" className="mr-2" />
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<RefreshCw className="w-4 h-4 mr-2" />
)}
@@ -191,7 +190,7 @@ export function PlanApprovalDialog({
className="bg-green-600 hover:bg-green-700 text-white"
>
{isLoading ? (
<Spinner size="sm" className="mr-2" />
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Check className="w-4 h-4 mr-2" />
)}

View File

@@ -1,6 +1,5 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { Terminal, Check, X, ChevronDown, ChevronUp } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { Terminal, Check, X, Loader2, ChevronDown, ChevronUp } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAppStore, type InitScriptState } from '@/store/app-store';
import { AnsiOutput } from '@/components/ui/ansi-output';
@@ -66,7 +65,7 @@ function SingleIndicator({
{/* Header */}
<div className="flex items-center justify-between p-3 border-b border-border/50">
<div className="flex items-center gap-2">
{status === 'running' && <Spinner size="sm" />}
{status === 'running' && <Loader2 className="w-4 h-4 animate-spin text-blue-500" />}
{status === 'success' && <Check className="w-4 h-4 text-green-500" />}
{status === 'failed' && <X className="w-4 h-4 text-red-500" />}
<span className="font-medium text-sm">

View File

@@ -40,7 +40,6 @@ interface KanbanBoardProps {
onViewPlan: (feature: Feature) => void;
onApprovePlan: (feature: Feature) => void;
onSpawnTask?: (feature: Feature) => void;
onCodeReview?: (feature: Feature) => void;
featuresWithContext: Set<string>;
runningAutoTasks: string[];
onArchiveAllVerified: () => void;
@@ -88,7 +87,6 @@ export function KanbanBoard({
onViewPlan,
onApprovePlan,
onSpawnTask,
onCodeReview,
featuresWithContext,
runningAutoTasks,
onArchiveAllVerified,
@@ -328,7 +326,6 @@ export function KanbanBoard({
onViewPlan={() => onViewPlan(feature)}
onApprovePlan={() => onApprovePlan(feature)}
onSpawnTask={() => onSpawnTask?.(feature)}
onCodeReview={onCodeReview ? () => onCodeReview(feature) : undefined}
hasContext={featuresWithContext.has(feature.id)}
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
shortcutKey={shortcutKey}

View File

@@ -1,7 +1,6 @@
import { useEffect, useCallback, useState, type ComponentType, type ReactNode } from 'react';
import { RefreshCw } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { AnthropicIcon, OpenAIIcon } from '@/components/ui/provider-icon';
@@ -91,11 +90,9 @@ function UsageItem({
className="p-1 rounded hover:bg-accent/50 transition-colors"
title="Refresh usage"
>
{isLoading ? (
<Spinner size="xs" />
) : (
<RefreshCw className="w-3.5 h-3.5 text-muted-foreground" />
)}
<RefreshCw
className={cn('w-3.5 h-3.5 text-muted-foreground', isLoading && 'animate-spin')}
/>
</button>
</div>
<div className="pl-6 space-y-2">{children}</div>

View File

@@ -11,7 +11,7 @@ import { getModelProvider, PROVIDER_PREFIXES, stripProviderPrefix } from '@autom
import type { ModelProvider } from '@automaker/types';
import { CLAUDE_MODELS, CURSOR_MODELS, ModelOption } from './model-constants';
import { useEffect } from 'react';
import { Spinner } from '@/components/ui/spinner';
import { RefreshCw } from 'lucide-react';
interface ModelSelectorProps {
selectedModel: string; // Can be ModelAlias or "cursor-{id}"
@@ -294,7 +294,7 @@ export function ModelSelector({
{/* Loading state */}
{codexModelsLoading && dynamicCodexModels.length === 0 && (
<div className="flex items-center justify-center gap-2 p-6 text-sm text-muted-foreground">
<Spinner size="sm" />
<RefreshCw className="w-4 h-4 animate-spin" />
Loading models...
</div>
)}

View File

@@ -6,12 +6,12 @@ import {
ClipboardList,
FileText,
ScrollText,
Loader2,
Check,
Eye,
RefreshCw,
Sparkles,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
@@ -236,7 +236,7 @@ export function PlanningModeSelector({
<div className="flex items-center gap-2">
{isGenerating ? (
<>
<Spinner size="sm" />
<Loader2 className="h-4 w-4 animate-spin text-primary" />
<span className="text-sm text-muted-foreground">
Generating {mode === 'full' ? 'comprehensive spec' : 'spec'}...
</span>

View File

@@ -8,8 +8,7 @@ import {
DropdownMenuTrigger,
DropdownMenuLabel,
} from '@/components/ui/dropdown-menu';
import { GitBranch, GitBranchPlus, Check, Search } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { GitBranch, RefreshCw, GitBranchPlus, Check, Search } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { WorktreeInfo, BranchInfo } from '../types';
@@ -82,7 +81,7 @@ export function BranchSwitchDropdown({
<div className="max-h-[250px] overflow-y-auto">
{isLoadingBranches ? (
<DropdownMenuItem disabled className="text-xs">
<Spinner size="xs" className="mr-2" />
<RefreshCw className="w-3.5 h-3.5 mr-2 animate-spin" />
Loading branches...
</DropdownMenuItem>
) : filteredBranches.length === 0 ? (

View File

@@ -2,6 +2,7 @@ import { useEffect, useRef, useCallback, useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import {
Loader2,
Terminal,
ArrowDown,
ExternalLink,
@@ -11,7 +12,6 @@ import {
Clock,
GitBranch,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import { XtermLogViewer, type XtermLogViewerRef } from '@/components/ui/xterm-log-viewer';
import { useDevServerLogs } from '../hooks/use-dev-server-logs';
@@ -183,7 +183,7 @@ export function DevServerLogsPanel({
onClick={() => fetchLogs()}
title="Refresh logs"
>
{isLoading ? <Spinner size="xs" /> : <RefreshCw className="w-3.5 h-3.5" />}
<RefreshCw className={cn('w-3.5 h-3.5', isLoading && 'animate-spin')} />
</Button>
</div>
</div>
@@ -234,7 +234,7 @@ export function DevServerLogsPanel({
>
{isLoading && !logs ? (
<div className="flex items-center justify-center h-full min-h-[300px] text-muted-foreground">
<Spinner size="md" className="mr-2" />
<Loader2 className="w-5 h-5 animate-spin mr-2" />
<span className="text-sm">Loading logs...</span>
</div>
) : !logs && !isRunning ? (
@@ -245,7 +245,7 @@ export function DevServerLogsPanel({
</div>
) : !logs ? (
<div className="flex flex-col items-center justify-center h-full min-h-[300px] text-muted-foreground p-8">
<Spinner size="xl" className="mb-3" />
<div className="w-8 h-8 mb-3 rounded-full border-2 border-muted-foreground/20 border-t-muted-foreground/60 animate-spin" />
<p className="text-sm">Waiting for output...</p>
<p className="text-xs mt-1 opacity-60">
Logs will appear as the server generates output

View File

@@ -7,8 +7,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { GitBranch, ChevronDown, CircleDot, Check } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { GitBranch, ChevronDown, Loader2, CircleDot, Check } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { WorktreeInfo } from '../types';
@@ -45,7 +44,7 @@ export function WorktreeMobileDropdown({
<GitBranch className="w-3.5 h-3.5 shrink-0" />
<span className="truncate">{displayBranch}</span>
{isActivating ? (
<Spinner size="xs" className="shrink-0" />
<Loader2 className="w-3 h-3 animate-spin shrink-0" />
) : (
<ChevronDown className="w-3 h-3 shrink-0 ml-auto" />
)}
@@ -75,7 +74,7 @@ export function WorktreeMobileDropdown({
) : (
<div className="w-3.5 h-3.5 shrink-0" />
)}
{isRunning && <Spinner size="xs" className="shrink-0" />}
{isRunning && <Loader2 className="w-3 h-3 animate-spin shrink-0" />}
<span className={cn('font-mono text-xs truncate', isSelected && 'font-medium')}>
{worktree.branch}
</span>

View File

@@ -1,7 +1,6 @@
import type { JSX } from 'react';
import { Button } from '@/components/ui/button';
import { Globe, CircleDot, GitPullRequest } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { RefreshCw, Globe, Loader2, CircleDot, GitPullRequest } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import type { WorktreeInfo, BranchInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types';
@@ -198,8 +197,8 @@ export function WorktreeTab({
aria-label={worktree.branch}
data-testid={`worktree-branch-${worktree.branch}`}
>
{isRunning && <Spinner size="xs" />}
{isActivating && !isRunning && <Spinner size="xs" />}
{isRunning && <Loader2 className="w-3 h-3 animate-spin" />}
{isActivating && !isRunning && <RefreshCw className="w-3 h-3 animate-spin" />}
{worktree.branch}
{cardCount !== undefined && cardCount > 0 && (
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
@@ -265,8 +264,8 @@ export function WorktreeTab({
: 'Click to switch to this branch'
}
>
{isRunning && <Spinner size="xs" />}
{isActivating && !isRunning && <Spinner size="xs" />}
{isRunning && <Loader2 className="w-3 h-3 animate-spin" />}
{isActivating && !isRunning && <RefreshCw className="w-3 h-3 animate-spin" />}
{worktree.branch}
{cardCount !== undefined && cardCount > 0 && (
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">

View File

@@ -1,6 +1,10 @@
// Re-export shared types from @automaker/types
export type { PRState, WorktreePRInfo } from '@automaker/types';
import type { PRState, WorktreePRInfo } from '@automaker/types';
export interface WorktreePRInfo {
number: number;
url: string;
title: string;
state: string;
createdAt: string;
}
export interface WorktreeInfo {
path: string;
@@ -39,8 +43,7 @@ export interface PRInfo {
number: number;
title: string;
url: string;
/** PR state: OPEN, MERGED, or CLOSED */
state: PRState;
state: string;
author: string;
body: string;
comments: Array<{

View File

@@ -1,7 +1,6 @@
import { useEffect, useRef, useCallback, useState } from 'react';
import { Button } from '@/components/ui/button';
import { GitBranch, Plus, RefreshCw } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn, pathsEqual } from '@/lib/utils';
import { toast } from 'sonner';
import { getHttpApiClient } from '@/lib/http-api-client';
@@ -286,7 +285,7 @@ export function WorktreePanel({
disabled={isLoading}
title="Refresh worktrees"
>
{isLoading ? <Spinner size="xs" /> : <RefreshCw className="w-3.5 h-3.5" />}
<RefreshCw className={cn('w-3.5 h-3.5', isLoading && 'animate-spin')} />
</Button>
</>
)}
@@ -430,7 +429,7 @@ export function WorktreePanel({
disabled={isLoading}
title="Refresh worktrees"
>
{isLoading ? <Spinner size="xs" /> : <RefreshCw className="w-3.5 h-3.5" />}
<RefreshCw className={cn('w-3.5 h-3.5', isLoading && 'animate-spin')} />
</Button>
</div>
</>

View File

@@ -4,8 +4,7 @@ import { useAppStore } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { File, Folder, FolderOpen, ChevronRight, ChevronDown, Code } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { File, Folder, FolderOpen, ChevronRight, ChevronDown, RefreshCw, Code } from 'lucide-react';
import { cn } from '@/lib/utils';
const logger = createLogger('CodeView');
@@ -207,7 +206,7 @@ export function CodeView() {
if (isLoading) {
return (
<div className="flex-1 flex items-center justify-center" data-testid="code-view-loading">
<Spinner size="lg" />
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
);
}

View File

@@ -12,6 +12,7 @@ import {
HeaderActionsPanelTrigger,
} from '@/components/ui/header-actions-panel';
import {
RefreshCw,
FileText,
Image as ImageIcon,
Trash2,
@@ -23,9 +24,9 @@ import {
Pencil,
FilePlus,
FileUp,
Loader2,
MoreVertical,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import {
useKeyboardShortcuts,
useKeyboardShortcutsConfig,
@@ -669,7 +670,7 @@ export function ContextView() {
if (isLoading) {
return (
<div className="flex-1 flex items-center justify-center" data-testid="context-view-loading">
<Spinner size="lg" />
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
);
}
@@ -789,7 +790,7 @@ export function ContextView() {
{isUploading && (
<div className="absolute inset-0 bg-background/80 z-50 flex items-center justify-center">
<div className="flex flex-col items-center">
<Spinner size="xl" className="mb-2" />
<Loader2 className="w-8 h-8 animate-spin text-primary mb-2" />
<span className="text-sm font-medium">Uploading {uploadingFileName}...</span>
</div>
</div>
@@ -837,7 +838,7 @@ export function ContextView() {
<span className="truncate text-sm block">{file.name}</span>
{isGenerating ? (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Spinner size="xs" />
<Loader2 className="w-3 h-3 animate-spin" />
Generating description...
</span>
) : file.description ? (
@@ -954,7 +955,7 @@ export function ContextView() {
</span>
{generatingDescriptions.has(selectedFile.name) ? (
<div className="flex items-center gap-2 mt-1 text-sm text-muted-foreground">
<Spinner size="sm" />
<Loader2 className="w-4 h-4 animate-spin" />
<span>Generating description with AI...</span>
</div>
) : selectedFile.description ? (

View File

@@ -18,6 +18,7 @@ import {
Folder,
Star,
Clock,
Loader2,
ChevronDown,
MessageSquare,
MoreVertical,
@@ -27,7 +28,6 @@ import {
type LucideIcon,
} from 'lucide-react';
import * as LucideIcons from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { Input } from '@/components/ui/input';
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
import {
@@ -992,7 +992,7 @@ export function DashboardView() {
data-testid="project-opening-overlay"
>
<div className="flex flex-col items-center gap-4 p-8 rounded-2xl bg-card border border-border shadow-2xl">
<Spinner size="xl" />
<Loader2 className="w-10 h-10 text-brand-500 animate-spin" />
<p className="text-foreground font-medium">Opening project...</p>
</div>
</div>

View File

@@ -4,6 +4,7 @@ import {
X,
Wand2,
ExternalLink,
Loader2,
CheckCircle,
Clock,
GitPullRequest,
@@ -13,7 +14,6 @@ import {
ChevronDown,
ChevronUp,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
@@ -87,7 +87,7 @@ export function IssueDetailPanel({
if (isValidating) {
return (
<Button variant="default" size="sm" disabled>
<Spinner size="sm" className="mr-1" />
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
Validating...
</Button>
);
@@ -297,7 +297,9 @@ export function IssueDetailPanel({
<span className="text-sm font-medium">
Comments {totalCount > 0 && `(${totalCount})`}
</span>
{commentsLoading && <Spinner size="xs" />}
{commentsLoading && (
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
)}
{commentsExpanded ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
) : (
@@ -338,7 +340,7 @@ export function IssueDetailPanel({
>
{loadingMore ? (
<>
<Spinner size="sm" className="mr-2" />
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Loading...
</>
) : (

View File

@@ -2,12 +2,12 @@ import {
Circle,
CheckCircle2,
ExternalLink,
Loader2,
CheckCircle,
Sparkles,
GitPullRequest,
User,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type { IssueRowProps } from '../types';
@@ -97,7 +97,7 @@ export function IssueRow({
{/* Validating indicator */}
{isValidating && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full bg-primary/10 text-primary border border-primary/20 animate-in fade-in duration-200">
<Spinner size="xs" />
<Loader2 className="h-3 w-3 animate-spin" />
Analyzing...
</span>
)}

View File

@@ -1,6 +1,5 @@
import { CircleDot, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import type { IssuesStateFilter } from '../types';
import { IssuesFilterControls } from './issues-filter-controls';
@@ -78,7 +77,7 @@ export function IssuesListHeader({
</div>
</div>
<Button variant="outline" size="sm" onClick={onRefresh} disabled={refreshing}>
{refreshing ? <Spinner size="sm" /> : <RefreshCw className="h-4 w-4" />}
<RefreshCw className={cn('h-4 w-4', refreshing && 'animate-spin')} />
</Button>
</div>

View File

@@ -1,7 +1,6 @@
import { useState, useEffect, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { GitPullRequest, RefreshCw, ExternalLink, GitMerge, X } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { GitPullRequest, Loader2, RefreshCw, ExternalLink, GitMerge, X } from 'lucide-react';
import { getElectronAPI, GitHubPR } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { Button } from '@/components/ui/button';
@@ -87,7 +86,7 @@ export function GitHubPRsView() {
if (loading) {
return (
<div className="flex-1 flex items-center justify-center">
<Spinner size="xl" />
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
@@ -135,7 +134,7 @@ export function GitHubPRsView() {
</div>
</div>
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={refreshing}>
{refreshing ? <Spinner size="sm" /> : <RefreshCw className="h-4 w-4" />}
<RefreshCw className={cn('h-4 w-4', refreshing && 'animate-spin')} />
</Button>
</div>

View File

@@ -17,7 +17,7 @@ import {
import { useWorktrees } from './board-view/worktree-panel/hooks';
import { useAutoMode } from '@/hooks/use-auto-mode';
import { pathsEqual } from '@/lib/utils';
import { Spinner } from '@/components/ui/spinner';
import { RefreshCw } from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
import { createLogger } from '@automaker/utils/logger';
import { toast } from 'sonner';
@@ -330,7 +330,7 @@ export function GraphViewPage() {
if (isLoading) {
return (
<div className="flex-1 flex items-center justify-center" data-testid="graph-view-loading">
<Spinner size="lg" />
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
);
}

View File

@@ -4,8 +4,7 @@
*/
import { useState, useMemo, useEffect, useCallback } from 'react';
import { AlertCircle, Plus, X, Sparkles, Lightbulb, Trash2 } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { Loader2, AlertCircle, Plus, X, Sparkles, Lightbulb, Trash2 } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@@ -110,7 +109,7 @@ function SuggestionCard({
)}
>
{isAdding ? (
<Spinner size="sm" />
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<>
<Plus className="w-4 h-4" />
@@ -154,7 +153,11 @@ function GeneratingCard({ job }: { job: GenerationJob }) {
isError ? 'bg-destructive/10 text-destructive' : 'bg-blue-500/10 text-blue-500'
)}
>
{isError ? <AlertCircle className="w-5 h-5" /> : <Spinner size="md" />}
{isError ? (
<AlertCircle className="w-5 h-5" />
) : (
<Loader2 className="w-5 h-5 animate-spin" />
)}
</div>
<div>
<p className="font-medium">{job.prompt.title}</p>

View File

@@ -13,8 +13,8 @@ import {
Gauge,
Accessibility,
BarChart3,
Loader2,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { Card, CardContent } from '@/components/ui/card';
import { useGuidedPrompts } from '@/hooks/use-guided-prompts';
import type { IdeaCategory } from '@automaker/types';
@@ -53,7 +53,7 @@ export function PromptCategoryGrid({ onSelect, onBack }: PromptCategoryGridProps
{isLoading && (
<div className="flex items-center justify-center py-12">
<Spinner size="lg" />
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground">Loading categories...</span>
</div>
)}

View File

@@ -3,8 +3,7 @@
*/
import { useState, useMemo } from 'react';
import { ArrowLeft, Lightbulb, CheckCircle2 } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { ArrowLeft, Lightbulb, Loader2, CheckCircle2 } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { useGuidedPrompts } from '@/hooks/use-guided-prompts';
import { useIdeationStore } from '@/store/ideation-store';
@@ -122,7 +121,7 @@ export function PromptList({ category, onBack }: PromptListProps) {
<div className="space-y-3">
{isLoadingPrompts && (
<div className="flex items-center justify-center py-8">
<Spinner size="lg" />
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground">Loading prompts...</span>
</div>
)}
@@ -163,7 +162,7 @@ export function PromptList({ category, onBack }: PromptListProps) {
}`}
>
{isLoading || isGenerating ? (
<Spinner size="md" />
<Loader2 className="w-5 h-5 animate-spin" />
) : isStarted ? (
<CheckCircle2 className="w-5 h-5" />
) : (

View File

@@ -11,8 +11,7 @@ import { PromptList } from './components/prompt-list';
import { IdeationDashboard } from './components/ideation-dashboard';
import { useGuidedPrompts } from '@/hooks/use-guided-prompts';
import { Button } from '@/components/ui/button';
import { ArrowLeft, ChevronRight, Lightbulb, CheckCheck, Trash2 } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { ArrowLeft, ChevronRight, Lightbulb, CheckCheck, Loader2, Trash2 } from 'lucide-react';
import type { IdeaCategory } from '@automaker/types';
import type { IdeationMode } from '@/store/ideation-store';
@@ -153,7 +152,11 @@ function IdeationHeader({
className="gap-2"
disabled={isAcceptingAll}
>
{isAcceptingAll ? <Spinner size="sm" /> : <CheckCheck className="w-4 h-4" />}
{isAcceptingAll ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<CheckCheck className="w-4 h-4" />
)}
Accept All ({acceptAllCount})
</Button>
)}

View File

@@ -5,8 +5,7 @@ import { useAppStore, Feature } from '@/store/app-store';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Bot, Send, User, Sparkles, FileText, ArrowLeft, CheckCircle } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { Bot, Send, User, Loader2, Sparkles, FileText, ArrowLeft, CheckCircle } from 'lucide-react';
import { cn, generateUUID } from '@/lib/utils';
import { getElectronAPI } from '@/lib/electron';
import { Markdown } from '@/components/ui/markdown';
@@ -492,7 +491,7 @@ export function InterviewView() {
<Card className="border border-primary/30 bg-card">
<CardContent className="p-3">
<div className="flex items-center gap-2">
<Spinner size="sm" />
<Loader2 className="w-4 h-4 animate-spin text-primary" />
<span className="text-sm text-primary">Generating specification...</span>
</div>
</CardContent>
@@ -572,7 +571,7 @@ export function InterviewView() {
>
{isGenerating ? (
<>
<Spinner size="sm" className="mr-2" />
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Creating...
</>
) : (

View File

@@ -24,8 +24,7 @@ import {
} from '@/lib/http-api-client';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { KeyRound, AlertCircle, RefreshCw, ServerCrash } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { KeyRound, AlertCircle, Loader2, RefreshCw, ServerCrash } from 'lucide-react';
import { useAuthStore } from '@/store/auth-store';
import { useSetupStore } from '@/store/setup-store';
@@ -350,7 +349,7 @@ export function LoginView() {
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="text-center space-y-4">
<Spinner size="xl" className="mx-auto" />
<Loader2 className="h-8 w-8 animate-spin mx-auto text-primary" />
<p className="text-sm text-muted-foreground">
Connecting to server
{state.attempt > 1 ? ` (attempt ${state.attempt}/${MAX_RETRIES})` : '...'}
@@ -386,7 +385,7 @@ export function LoginView() {
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="text-center space-y-4">
<Spinner size="xl" className="mx-auto" />
<Loader2 className="h-8 w-8 animate-spin mx-auto text-primary" />
<p className="text-sm text-muted-foreground">
{state.phase === 'checking_setup' ? 'Loading settings...' : 'Redirecting...'}
</p>
@@ -448,7 +447,7 @@ export function LoginView() {
>
{isLoggingIn ? (
<>
<Spinner size="sm" className="mr-2" />
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Authenticating...
</>
) : (

View File

@@ -19,7 +19,6 @@ import {
FilePlus,
MoreVertical,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import {
Dialog,
DialogContent,
@@ -300,7 +299,7 @@ export function MemoryView() {
if (isLoading) {
return (
<div className="flex-1 flex items-center justify-center" data-testid="memory-view-loading">
<Spinner size="lg" />
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
);
}

View File

@@ -9,8 +9,7 @@ import { useLoadNotifications, useNotificationEvents } from '@/hooks/use-notific
import { getHttpApiClient } from '@/lib/http-api-client';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Bell, Check, CheckCheck, Trash2, ExternalLink } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { Bell, Check, CheckCheck, Trash2, ExternalLink, Loader2 } from 'lucide-react';
import { useNavigate } from '@tanstack/react-router';
import type { Notification } from '@automaker/types';
@@ -147,7 +146,7 @@ export function NotificationsView() {
if (isLoading) {
return (
<div className="flex flex-1 flex-col items-center justify-center p-8">
<Spinner size="xl" />
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="text-muted-foreground mt-4">Loading notifications...</p>
</div>
);

View File

@@ -10,9 +10,9 @@ import {
Save,
RotateCcw,
Trash2,
Loader2,
PanelBottomClose,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import { apiGet, apiPut, apiDelete } from '@/lib/api-fetch';
import { toast } from 'sonner';
@@ -409,7 +409,7 @@ export function WorktreePreferencesSection({ project }: WorktreePreferencesSecti
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Spinner size="md" />
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
</div>
) : (
<>
@@ -448,7 +448,11 @@ npm install
disabled={!scriptExists || isSaving || isDeleting}
className="gap-1.5 text-destructive hover:text-destructive hover:bg-destructive/10"
>
{isDeleting ? <Spinner size="xs" /> : <Trash2 className="w-3.5 h-3.5" />}
{isDeleting ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Trash2 className="w-3.5 h-3.5" />
)}
Delete
</Button>
<Button
@@ -457,7 +461,11 @@ npm install
disabled={!hasChanges || isSaving || isDeleting}
className="gap-1.5"
>
{isSaving ? <Spinner size="xs" /> : <Save className="w-3.5 h-3.5" />}
{isSaving ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Save className="w-3.5 h-3.5" />
)}
Save
</Button>
</div>

View File

@@ -1,7 +1,6 @@
import { useState, useEffect, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { Bot, Folder, RefreshCw, Square, Activity, FileText } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { Bot, Folder, Loader2, RefreshCw, Square, Activity, FileText } from 'lucide-react';
import { getElectronAPI, RunningAgent } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { Button } from '@/components/ui/button';
@@ -147,7 +146,7 @@ export function RunningAgentsView() {
if (loading) {
return (
<div className="flex-1 flex items-center justify-center">
<Spinner size="xl" />
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
@@ -170,11 +169,7 @@ export function RunningAgentsView() {
</div>
</div>
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={refreshing}>
{refreshing ? (
<Spinner size="sm" className="mr-2" />
) : (
<RefreshCw className="h-4 w-4 mr-2" />
)}
<RefreshCw className={cn('h-4 w-4 mr-2', refreshing && 'animate-spin')} />
Refresh
</Button>
</div>

View File

@@ -23,7 +23,6 @@ import {
CursorSettingsTab,
CodexSettingsTab,
OpencodeSettingsTab,
CodeRabbitSettingsTab,
} from './settings-view/providers';
import { MCPServersSection } from './settings-view/mcp-servers';
import { PromptCustomizationSection } from './settings-view/prompts';
@@ -124,8 +123,6 @@ export function SettingsView() {
return <CodexSettingsTab />;
case 'opencode-provider':
return <OpencodeSettingsTab />;
case 'coderabbit-provider':
return <CodeRabbitSettingsTab />;
case 'providers':
case 'claude': // Backwards compatibility - redirect to claude-provider
return <ClaudeSettingsTab />;

View File

@@ -11,7 +11,6 @@ import {
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { toast } from 'sonner';
import { LogOut, User, Code2, RefreshCw } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import { logout } from '@/lib/http-api-client';
import { useAuthStore } from '@/store/auth-store';
@@ -144,7 +143,7 @@ export function AccountSection() {
disabled={isRefreshing || isLoadingEditors}
className="shrink-0 h-9 w-9"
>
{isRefreshing ? <Spinner size="sm" /> : <RefreshCw className="w-4 h-4" />}
<RefreshCw className={cn('w-4 h-4', isRefreshing && 'animate-spin')} />
</Button>
</TooltipTrigger>
<TooltipContent>

View File

@@ -1,8 +1,7 @@
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { AlertCircle, CheckCircle2, Eye, EyeOff, Zap } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { AlertCircle, CheckCircle2, Eye, EyeOff, Loader2, Zap } from 'lucide-react';
import type { ProviderConfig } from '@/config/api-providers';
interface ApiKeyFieldProps {
@@ -71,7 +70,7 @@ export function ApiKeyField({ config }: ApiKeyFieldProps) {
>
{testButton.loading ? (
<>
<Spinner size="sm" className="mr-2" />
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Testing...
</>
) : (

View File

@@ -1,8 +1,7 @@
import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
import { Button } from '@/components/ui/button';
import { Key, CheckCircle2, Trash2 } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { Key, CheckCircle2, Trash2, Loader2 } from 'lucide-react';
import { ApiKeyField } from './api-key-field';
import { buildProviderConfigs } from '@/config/api-providers';
import { SecurityNotice } from './security-notice';
@@ -143,7 +142,7 @@ export function ApiKeysSection() {
data-testid="delete-anthropic-key"
>
{isDeletingAnthropicKey ? (
<Spinner size="sm" className="mr-2" />
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Trash2 className="w-4 h-4 mr-2" />
)}
@@ -160,7 +159,7 @@ export function ApiKeysSection() {
data-testid="delete-openai-key"
>
{isDeletingOpenaiKey ? (
<Spinner size="sm" className="mr-2" />
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Trash2 className="w-4 h-4 mr-2" />
)}

View File

@@ -4,7 +4,6 @@ import { getElectronAPI } from '@/lib/electron';
import { useSetupStore } from '@/store/setup-store';
import { useAppStore } from '@/store/app-store';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import { RefreshCw, AlertCircle } from 'lucide-react';
const ERROR_NO_API = 'Claude usage API not available';
@@ -179,7 +178,7 @@ export function ClaudeUsageSection() {
data-testid="refresh-claude-usage"
title={CLAUDE_REFRESH_LABEL}
>
{isLoading ? <Spinner size="sm" /> : <RefreshCw className="w-4 h-4" />}
<RefreshCw className={cn('w-4 h-4', isLoading && 'animate-spin')} />
</Button>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">{CLAUDE_USAGE_SUBTITLE}</p>

View File

@@ -1,6 +1,5 @@
import { useState, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import { CheckCircle2, AlertCircle, RefreshCw, XCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { CliStatus } from '../shared/types';
@@ -173,7 +172,7 @@ export function ClaudeCliStatus({ status, authStatus, isChecking, onRefresh }: C
'transition-all duration-200'
)}
>
{isChecking ? <Spinner size="sm" /> : <RefreshCw className="w-4 h-4" />}
<RefreshCw className={cn('w-4 h-4', isChecking && 'animate-spin')} />
</Button>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">

Some files were not shown because too many files have changed in this diff Show More