mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-05 09:33:07 +00:00
Compare commits
2 Commits
feat/coder
...
v0.12.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
518c81815e | ||
|
|
01652d0d11 |
@@ -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);
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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" />
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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...'}
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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'
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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" />
|
||||
)}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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" />
|
||||
)}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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" />
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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" />
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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" />
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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" />
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user