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 { getNotificationService } from './services/notification-service.js';
|
||||||
import { createEventHistoryRoutes } from './routes/event-history/index.js';
|
import { createEventHistoryRoutes } from './routes/event-history/index.js';
|
||||||
import { getEventHistoryService } from './services/event-history-service.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
|
// Load environment variables
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
@@ -211,7 +209,6 @@ const codexModelCacheService = new CodexModelCacheService(DATA_DIR, codexAppServ
|
|||||||
const codexUsageService = new CodexUsageService(codexAppServerService);
|
const codexUsageService = new CodexUsageService(codexAppServerService);
|
||||||
const mcpTestService = new MCPTestService(settingsService);
|
const mcpTestService = new MCPTestService(settingsService);
|
||||||
const ideationService = new IdeationService(events, settingsService, featureLoader);
|
const ideationService = new IdeationService(events, settingsService, featureLoader);
|
||||||
const codeReviewService = new CodeReviewService(events, settingsService);
|
|
||||||
|
|
||||||
// Initialize DevServerService with event emitter for real-time log streaming
|
// Initialize DevServerService with event emitter for real-time log streaming
|
||||||
const devServerService = getDevServerService();
|
const devServerService = getDevServerService();
|
||||||
@@ -303,7 +300,6 @@ app.use('/api/pipeline', createPipelineRoutes(pipelineService));
|
|||||||
app.use('/api/ideation', createIdeationRoutes(events, ideationService, featureLoader));
|
app.use('/api/ideation', createIdeationRoutes(events, ideationService, featureLoader));
|
||||||
app.use('/api/notifications', createNotificationsRoutes(notificationService));
|
app.use('/api/notifications', createNotificationsRoutes(notificationService));
|
||||||
app.use('/api/event-history', createEventHistoryRoutes(eventHistoryService, settingsService));
|
app.use('/api/event-history', createEventHistoryRoutes(eventHistoryService, settingsService));
|
||||||
app.use('/api/code-review', createCodeReviewRoutes(codeReviewService));
|
|
||||||
|
|
||||||
// Create HTTP server
|
// Create HTTP server
|
||||||
const server = createServer(app);
|
const server = createServer(app);
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ export interface UnifiedCliDetection {
|
|||||||
claude?: CliDetectionResult;
|
claude?: CliDetectionResult;
|
||||||
codex?: CliDetectionResult;
|
codex?: CliDetectionResult;
|
||||||
cursor?: CliDetectionResult;
|
cursor?: CliDetectionResult;
|
||||||
coderabbit?: CliDetectionResult;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -77,16 +76,6 @@ const CLI_CONFIGS = {
|
|||||||
win32: 'iwr https://cursor.sh/install.ps1 -UseBasicParsing | iex',
|
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;
|
} as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -241,8 +230,6 @@ export async function checkCliAuth(
|
|||||||
return await checkCodexAuth(command);
|
return await checkCodexAuth(command);
|
||||||
case 'cursor':
|
case 'cursor':
|
||||||
return await checkCursorAuth(command);
|
return await checkCursorAuth(command);
|
||||||
case 'coderabbit':
|
|
||||||
return await checkCodeRabbitAuth(command);
|
|
||||||
default:
|
default:
|
||||||
return 'none';
|
return 'none';
|
||||||
}
|
}
|
||||||
@@ -368,64 +355,6 @@ async function checkCursorAuth(command: string): Promise<'cli' | 'api_key' | 'no
|
|||||||
return 'none';
|
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
|
* Get installation instructions for a provider
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -5,14 +5,18 @@
|
|||||||
|
|
||||||
import * as secureFs from './secure-fs.js';
|
import * as secureFs from './secure-fs.js';
|
||||||
import * as path from 'path';
|
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 */
|
/** Maximum length for sanitized branch names in filesystem paths */
|
||||||
const MAX_SANITIZED_BRANCH_PATH_LENGTH = 200;
|
const MAX_SANITIZED_BRANCH_PATH_LENGTH = 200;
|
||||||
|
|
||||||
|
export interface WorktreePRInfo {
|
||||||
|
number: number;
|
||||||
|
url: string;
|
||||||
|
title: string;
|
||||||
|
state: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface WorktreeMetadata {
|
export interface WorktreeMetadata {
|
||||||
branch: string;
|
branch: string;
|
||||||
createdAt: 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 { Router } from 'express';
|
||||||
import { createStatusHandler } from './routes/status.js';
|
|
||||||
import { createClaudeStatusHandler } from './routes/claude-status.js';
|
import { createClaudeStatusHandler } from './routes/claude-status.js';
|
||||||
import { createInstallClaudeHandler } from './routes/install-claude.js';
|
import { createInstallClaudeHandler } from './routes/install-claude.js';
|
||||||
import { createAuthClaudeHandler } from './routes/auth-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 { createPlatformHandler } from './routes/platform.js';
|
||||||
import { createVerifyClaudeAuthHandler } from './routes/verify-claude-auth.js';
|
import { createVerifyClaudeAuthHandler } from './routes/verify-claude-auth.js';
|
||||||
import { createVerifyCodexAuthHandler } from './routes/verify-codex-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 { createGhStatusHandler } from './routes/gh-status.js';
|
||||||
import { createCursorStatusHandler } from './routes/cursor-status.js';
|
import { createCursorStatusHandler } from './routes/cursor-status.js';
|
||||||
import { createCodexStatusHandler } from './routes/codex-status.js';
|
import { createCodexStatusHandler } from './routes/codex-status.js';
|
||||||
@@ -49,9 +44,6 @@ import {
|
|||||||
export function createSetupRoutes(): Router {
|
export function createSetupRoutes(): Router {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
// Unified CLI status endpoint
|
|
||||||
router.get('/status', createStatusHandler());
|
|
||||||
|
|
||||||
router.get('/claude-status', createClaudeStatusHandler());
|
router.get('/claude-status', createClaudeStatusHandler());
|
||||||
router.post('/install-claude', createInstallClaudeHandler());
|
router.post('/install-claude', createInstallClaudeHandler());
|
||||||
router.post('/auth-claude', createAuthClaudeHandler());
|
router.post('/auth-claude', createAuthClaudeHandler());
|
||||||
@@ -62,7 +54,6 @@ export function createSetupRoutes(): Router {
|
|||||||
router.get('/platform', createPlatformHandler());
|
router.get('/platform', createPlatformHandler());
|
||||||
router.post('/verify-claude-auth', createVerifyClaudeAuthHandler());
|
router.post('/verify-claude-auth', createVerifyClaudeAuthHandler());
|
||||||
router.post('/verify-codex-auth', createVerifyCodexAuthHandler());
|
router.post('/verify-codex-auth', createVerifyCodexAuthHandler());
|
||||||
router.post('/verify-coderabbit-auth', createVerifyCodeRabbitAuthHandler());
|
|
||||||
router.get('/gh-status', createGhStatusHandler());
|
router.get('/gh-status', createGhStatusHandler());
|
||||||
|
|
||||||
// Cursor CLI routes
|
// Cursor CLI routes
|
||||||
@@ -81,11 +72,6 @@ export function createSetupRoutes(): Router {
|
|||||||
router.post('/auth-opencode', createAuthOpencodeHandler());
|
router.post('/auth-opencode', createAuthOpencodeHandler());
|
||||||
router.post('/deauth-opencode', createDeauthOpencodeHandler());
|
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
|
// OpenCode Dynamic Model Discovery routes
|
||||||
router.get('/opencode/models', createGetOpencodeModelsHandler());
|
router.get('/opencode/models', createGetOpencodeModelsHandler());
|
||||||
router.post('/opencode/models/refresh', createRefreshOpencodeModelsHandler());
|
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';
|
} from '../common.js';
|
||||||
import { updateWorktreePRInfo } from '../../../lib/worktree-metadata.js';
|
import { updateWorktreePRInfo } from '../../../lib/worktree-metadata.js';
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
import { validatePRState } from '@automaker/types';
|
|
||||||
|
|
||||||
const logger = createLogger('CreatePR');
|
const logger = createLogger('CreatePR');
|
||||||
|
|
||||||
@@ -269,12 +268,11 @@ export function createCreatePRHandler() {
|
|||||||
prAlreadyExisted = true;
|
prAlreadyExisted = true;
|
||||||
|
|
||||||
// Store the existing PR info in metadata
|
// Store the existing PR info in metadata
|
||||||
// GitHub CLI returns uppercase states: OPEN, MERGED, CLOSED
|
|
||||||
await updateWorktreePRInfo(effectiveProjectPath, branchName, {
|
await updateWorktreePRInfo(effectiveProjectPath, branchName, {
|
||||||
number: existingPr.number,
|
number: existingPr.number,
|
||||||
url: existingPr.url,
|
url: existingPr.url,
|
||||||
title: existingPr.title || title,
|
title: existingPr.title || title,
|
||||||
state: validatePRState(existingPr.state),
|
state: existingPr.state || 'open',
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -321,12 +319,11 @@ export function createCreatePRHandler() {
|
|||||||
|
|
||||||
if (prNumber) {
|
if (prNumber) {
|
||||||
try {
|
try {
|
||||||
// Note: GitHub doesn't have a 'DRAFT' state - drafts still show as 'OPEN'
|
|
||||||
await updateWorktreePRInfo(effectiveProjectPath, branchName, {
|
await updateWorktreePRInfo(effectiveProjectPath, branchName, {
|
||||||
number: prNumber,
|
number: prNumber,
|
||||||
url: prUrl,
|
url: prUrl,
|
||||||
title,
|
title,
|
||||||
state: 'OPEN',
|
state: draft ? 'draft' : 'open',
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
logger.debug(`Stored PR info for branch ${branchName}: PR #${prNumber}`);
|
logger.debug(`Stored PR info for branch ${branchName}: PR #${prNumber}`);
|
||||||
@@ -355,12 +352,11 @@ export function createCreatePRHandler() {
|
|||||||
prNumber = existingPr.number;
|
prNumber = existingPr.number;
|
||||||
prAlreadyExisted = true;
|
prAlreadyExisted = true;
|
||||||
|
|
||||||
// GitHub CLI returns uppercase states: OPEN, MERGED, CLOSED
|
|
||||||
await updateWorktreePRInfo(effectiveProjectPath, branchName, {
|
await updateWorktreePRInfo(effectiveProjectPath, branchName, {
|
||||||
number: existingPr.number,
|
number: existingPr.number,
|
||||||
url: existingPr.url,
|
url: existingPr.url,
|
||||||
title: existingPr.title || title,
|
title: existingPr.title || title,
|
||||||
state: validatePRState(existingPr.state),
|
state: existingPr.state || 'open',
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
logger.debug(`Fetched and stored existing PR: #${existingPr.number}`);
|
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 * as secureFs from '../../../lib/secure-fs.js';
|
||||||
import { isGitRepo } from '@automaker/git-utils';
|
import { isGitRepo } from '@automaker/git-utils';
|
||||||
import { getErrorMessage, logError, normalizePath, execEnv, isGhCliAvailable } from '../common.js';
|
import { getErrorMessage, logError, normalizePath, execEnv, isGhCliAvailable } from '../common.js';
|
||||||
import {
|
import { readAllWorktreeMetadata, type WorktreePRInfo } from '../../../lib/worktree-metadata.js';
|
||||||
readAllWorktreeMetadata,
|
|
||||||
updateWorktreePRInfo,
|
|
||||||
type WorktreePRInfo,
|
|
||||||
} from '../../../lib/worktree-metadata.js';
|
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
import { validatePRState } from '@automaker/types';
|
|
||||||
import {
|
import {
|
||||||
checkGitHubRemote,
|
checkGitHubRemote,
|
||||||
type GitHubRemoteStatus,
|
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.
|
* Fetch open PRs from GitHub and create a map of branch name to PR info.
|
||||||
* Uses --state all to include merged/closed PRs, allowing detection of
|
* This allows detecting PRs that were created outside the app.
|
||||||
* state changes (e.g., when a PR is merged on GitHub).
|
|
||||||
*
|
|
||||||
* This also allows detecting PRs that were created outside the app.
|
|
||||||
*
|
*
|
||||||
* Uses cached GitHub remote status to avoid repeated warnings when the
|
* Uses cached GitHub remote status to avoid repeated warnings when the
|
||||||
* project doesn't have a GitHub remote configured.
|
* 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}`
|
? `-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(
|
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 }
|
{ cwd: projectPath, env: execEnv, timeout: 15000 }
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -220,8 +212,7 @@ async function fetchGitHubPRs(projectPath: string): Promise<Map<string, Worktree
|
|||||||
number: pr.number,
|
number: pr.number,
|
||||||
url: pr.url,
|
url: pr.url,
|
||||||
title: pr.title,
|
title: pr.title,
|
||||||
// GitHub CLI returns state as uppercase: OPEN, MERGED, CLOSED
|
state: pr.state,
|
||||||
state: validatePRState(pr.state),
|
|
||||||
createdAt: pr.createdAt,
|
createdAt: pr.createdAt,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -360,36 +351,23 @@ export function createListHandler() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Assign PR info to each worktree, preferring fresh GitHub data over cached metadata.
|
// Add PR info from metadata or GitHub for each worktree
|
||||||
// Only fetch GitHub PRs if includeDetails is requested (performance optimization).
|
// Only fetch GitHub PRs if includeDetails is requested (performance optimization)
|
||||||
// Uses --state all to detect merged/closed PRs, limited to 1000 recent PRs.
|
|
||||||
const githubPRs = includeDetails
|
const githubPRs = includeDetails
|
||||||
? await fetchGitHubPRs(projectPath)
|
? await fetchGitHubPRs(projectPath)
|
||||||
: new Map<string, WorktreePRInfo>();
|
: new Map<string, WorktreePRInfo>();
|
||||||
|
|
||||||
for (const worktree of worktrees) {
|
for (const worktree of worktrees) {
|
||||||
const metadata = allMetadata.get(worktree.branch);
|
const metadata = allMetadata.get(worktree.branch);
|
||||||
const githubPR = githubPRs.get(worktree.branch);
|
if (metadata?.pr) {
|
||||||
|
// Use stored metadata (more complete info)
|
||||||
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)
|
|
||||||
worktree.pr = metadata.pr;
|
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 };
|
anthropic: { configured: boolean; masked: string };
|
||||||
google: { configured: boolean; masked: string };
|
google: { configured: boolean; masked: string };
|
||||||
openai: { configured: boolean; masked: string };
|
openai: { configured: boolean; masked: string };
|
||||||
coderabbit: { configured: boolean; masked: string };
|
|
||||||
}> {
|
}> {
|
||||||
const credentials = await this.getCredentials();
|
const credentials = await this.getCredentials();
|
||||||
|
|
||||||
@@ -442,10 +441,6 @@ export class SettingsService {
|
|||||||
configured: !!credentials.apiKeys.openai,
|
configured: !!credentials.apiKeys.openai,
|
||||||
masked: maskKey(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;
|
anthropic?: string;
|
||||||
google?: string;
|
google?: string;
|
||||||
openai?: string;
|
openai?: string;
|
||||||
coderabbit?: string;
|
|
||||||
};
|
};
|
||||||
await this.updateCredentials({
|
await this.updateCredentials({
|
||||||
apiKeys: {
|
apiKeys: {
|
||||||
anthropic: apiKeys.anthropic || '',
|
anthropic: apiKeys.anthropic || '',
|
||||||
google: apiKeys.google || '',
|
google: apiKeys.google || '',
|
||||||
openai: apiKeys.openai || '',
|
openai: apiKeys.openai || '',
|
||||||
coderabbit: apiKeys.coderabbit || '',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
migratedCredentials = true;
|
migratedCredentials = true;
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ describe('worktree-metadata.ts', () => {
|
|||||||
number: 123,
|
number: 123,
|
||||||
url: 'https://github.com/owner/repo/pull/123',
|
url: 'https://github.com/owner/repo/pull/123',
|
||||||
title: 'Test PR',
|
title: 'Test PR',
|
||||||
state: 'OPEN',
|
state: 'open',
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -158,7 +158,7 @@ describe('worktree-metadata.ts', () => {
|
|||||||
number: 456,
|
number: 456,
|
||||||
url: 'https://github.com/owner/repo/pull/456',
|
url: 'https://github.com/owner/repo/pull/456',
|
||||||
title: 'Updated PR',
|
title: 'Updated PR',
|
||||||
state: 'CLOSED',
|
state: 'closed',
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -177,7 +177,7 @@ describe('worktree-metadata.ts', () => {
|
|||||||
number: 789,
|
number: 789,
|
||||||
url: 'https://github.com/owner/repo/pull/789',
|
url: 'https://github.com/owner/repo/pull/789',
|
||||||
title: 'New PR',
|
title: 'New PR',
|
||||||
state: 'OPEN',
|
state: 'open',
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -201,7 +201,7 @@ describe('worktree-metadata.ts', () => {
|
|||||||
number: 999,
|
number: 999,
|
||||||
url: 'https://github.com/owner/repo/pull/999',
|
url: 'https://github.com/owner/repo/pull/999',
|
||||||
title: 'Updated PR',
|
title: 'Updated PR',
|
||||||
state: 'MERGED',
|
state: 'merged',
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -224,7 +224,7 @@ describe('worktree-metadata.ts', () => {
|
|||||||
number: 111,
|
number: 111,
|
||||||
url: 'https://github.com/owner/repo/pull/111',
|
url: 'https://github.com/owner/repo/pull/111',
|
||||||
title: 'PR',
|
title: 'PR',
|
||||||
state: 'OPEN',
|
state: 'open',
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -259,7 +259,7 @@ describe('worktree-metadata.ts', () => {
|
|||||||
number: 222,
|
number: 222,
|
||||||
url: 'https://github.com/owner/repo/pull/222',
|
url: 'https://github.com/owner/repo/pull/222',
|
||||||
title: 'Has PR',
|
title: 'Has PR',
|
||||||
state: 'OPEN',
|
state: 'open',
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -297,7 +297,7 @@ describe('worktree-metadata.ts', () => {
|
|||||||
number: 333,
|
number: 333,
|
||||||
url: 'https://github.com/owner/repo/pull/333',
|
url: 'https://github.com/owner/repo/pull/333',
|
||||||
title: 'PR 3',
|
title: 'PR 3',
|
||||||
state: 'OPEN',
|
state: 'open',
|
||||||
createdAt: new Date().toISOString(),
|
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 { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } from 'lucide-react';
|
import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
@@ -280,7 +279,7 @@ export function ClaudeUsagePopover() {
|
|||||||
) : !claudeUsage ? (
|
) : !claudeUsage ? (
|
||||||
// Loading state
|
// Loading state
|
||||||
<div className="flex flex-col items-center justify-center py-8 space-y-2">
|
<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>
|
<p className="text-xs text-muted-foreground">Loading usage data...</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useState, useEffect, useMemo, useCallback } from 'react';
|
|||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } from 'lucide-react';
|
import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
@@ -334,7 +333,7 @@ export function CodexUsagePopover() {
|
|||||||
) : !codexUsage ? (
|
) : !codexUsage ? (
|
||||||
// Loading state
|
// Loading state
|
||||||
<div className="flex flex-col items-center justify-center py-8 space-y-2">
|
<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>
|
<p className="text-xs text-muted-foreground">Loading usage data...</p>
|
||||||
</div>
|
</div>
|
||||||
) : codexUsage.rateLimits ? (
|
) : codexUsage.rateLimits ? (
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
import { ImageIcon, Upload, Trash2 } from 'lucide-react';
|
import { ImageIcon, Upload, Loader2, Trash2 } from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
|
||||||
|
|
||||||
const logger = createLogger('BoardBackgroundModal');
|
const logger = createLogger('BoardBackgroundModal');
|
||||||
import {
|
import {
|
||||||
@@ -314,7 +313,7 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
|||||||
/>
|
/>
|
||||||
{isProcessing && (
|
{isProcessing && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-background/80">
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -354,7 +353,7 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isProcessing ? (
|
{isProcessing ? (
|
||||||
<Spinner size="lg" />
|
<Upload className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
) : (
|
) : (
|
||||||
<ImageIcon className="h-6 w-6 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 { 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 { DeleteAllArchivedSessionsDialog } from './delete-all-archived-sessions-dialog';
|
||||||
export { DeleteSessionDialog } from './delete-session-dialog';
|
export { DeleteSessionDialog } from './delete-session-dialog';
|
||||||
export { FileBrowserDialog } from './file-browser-dialog';
|
export { FileBrowserDialog } from './file-browser-dialog';
|
||||||
|
|||||||
@@ -14,8 +14,16 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { FolderPlus, FolderOpen, Rocket, ExternalLink, Check, Link, Folder } from 'lucide-react';
|
import {
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
FolderPlus,
|
||||||
|
FolderOpen,
|
||||||
|
Rocket,
|
||||||
|
ExternalLink,
|
||||||
|
Check,
|
||||||
|
Loader2,
|
||||||
|
Link,
|
||||||
|
Folder,
|
||||||
|
} from 'lucide-react';
|
||||||
import { starterTemplates, type StarterTemplate } from '@/lib/templates';
|
import { starterTemplates, type StarterTemplate } from '@/lib/templates';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -443,7 +451,7 @@ export function NewProjectModal({
|
|||||||
>
|
>
|
||||||
{isCreating ? (
|
{isCreating ? (
|
||||||
<>
|
<>
|
||||||
<Spinner size="sm" className="mr-2" />
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
{activeTab === 'template' ? 'Cloning...' : 'Creating...'}
|
{activeTab === 'template' ? 'Cloning...' : 'Creating...'}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Folder, FolderOpen, AlertCircle } from 'lucide-react';
|
import { Folder, Loader2, FolderOpen, AlertCircle } from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
|
||||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||||
|
|
||||||
interface WorkspaceDirectory {
|
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]">
|
<div className="flex-1 overflow-y-auto py-4 min-h-[200px]">
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="flex flex-col items-center justify-center h-full gap-3">
|
<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>
|
<p className="text-sm text-muted-foreground">Loading projects...</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { NavigateOptions } from '@tanstack/react-router';
|
import type { NavigateOptions } from '@tanstack/react-router';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { formatShortcut } from '@/store/app-store';
|
import { formatShortcut } from '@/store/app-store';
|
||||||
import type { NavSection } from '../types';
|
import type { NavSection } from '../types';
|
||||||
import type { Project } from '@/lib/electron';
|
import type { Project } from '@/lib/electron';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
|
||||||
|
|
||||||
interface SidebarNavigationProps {
|
interface SidebarNavigationProps {
|
||||||
currentProject: Project | null;
|
currentProject: Project | null;
|
||||||
@@ -93,10 +93,9 @@ export function SidebarNavigation({
|
|||||||
>
|
>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{item.isLoading ? (
|
{item.isLoading ? (
|
||||||
<Spinner
|
<Loader2
|
||||||
size="md"
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'shrink-0',
|
'w-[18px] h-[18px] shrink-0 animate-spin',
|
||||||
isActive ? 'text-brand-500' : 'text-muted-foreground'
|
isActive ? 'text-brand-500' : 'text-muted-foreground'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
X,
|
X,
|
||||||
ArchiveRestore,
|
ArchiveRestore,
|
||||||
|
Loader2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { SessionListItem } from '@/types/electron';
|
import type { SessionListItem } from '@/types/electron';
|
||||||
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
|
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) */}
|
{/* Show loading indicator if this session is running (either current session thinking or any session in runningSessions) */}
|
||||||
{(currentSessionId === session.id && isCurrentSessionThinking) ||
|
{(currentSessionId === session.id && isCurrentSessionThinking) ||
|
||||||
runningSessions.has(session.id) ? (
|
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" />
|
<MessageSquare className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { Slot } from '@radix-ui/react-slot';
|
import { Slot } from '@radix-ui/react-slot';
|
||||||
import { cva, type VariantProps } from 'class-variance-authority';
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
|
||||||
|
|
||||||
const buttonVariants = cva(
|
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]",
|
"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
|
// Loading spinner component
|
||||||
function ButtonSpinner({ className }: { className?: string }) {
|
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({
|
function Button({
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ import { createLogger } from '@automaker/utils/logger';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const logger = createLogger('DescriptionImageDropZone');
|
const logger = createLogger('DescriptionImageDropZone');
|
||||||
import { ImageIcon, X, FileText } from 'lucide-react';
|
import { ImageIcon, X, Loader2, FileText } from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
|
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
|
||||||
@@ -432,7 +431,7 @@ export function DescriptionImageDropZone({
|
|||||||
{/* Processing indicator */}
|
{/* Processing indicator */}
|
||||||
{isProcessing && (
|
{isProcessing && (
|
||||||
<div className="flex items-center gap-2 mt-2 text-sm text-muted-foreground">
|
<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>
|
<span>Processing files...</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ import { createLogger } from '@automaker/utils/logger';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const logger = createLogger('FeatureImageUpload');
|
const logger = createLogger('FeatureImageUpload');
|
||||||
import { ImageIcon, X } from 'lucide-react';
|
import { ImageIcon, X, Upload } from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
|
||||||
import {
|
import {
|
||||||
fileToBase64,
|
fileToBase64,
|
||||||
generateImageId,
|
generateImageId,
|
||||||
@@ -197,7 +196,7 @@ export function FeatureImageUpload({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isProcessing ? (
|
{isProcessing ? (
|
||||||
<Spinner size="md" />
|
<Upload className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||||
) : (
|
) : (
|
||||||
<ImageIcon className="h-5 w-5 text-muted-foreground" />
|
<ImageIcon className="h-5 w-5 text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ import {
|
|||||||
FilePen,
|
FilePen,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
Loader2,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
|
||||||
import { Button } from './button';
|
import { Button } from './button';
|
||||||
import type { FileStatus } from '@/types/electron';
|
import type { FileStatus } from '@/types/electron';
|
||||||
|
|
||||||
@@ -484,7 +484,7 @@ export function GitDiffPanel({
|
|||||||
<div className="border-t border-border">
|
<div className="border-t border-border">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center gap-2 py-8 text-muted-foreground">
|
<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>
|
<span className="text-sm">Loading changes...</span>
|
||||||
</div>
|
</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ import { createLogger } from '@automaker/utils/logger';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const logger = createLogger('ImageDropZone');
|
const logger = createLogger('ImageDropZone');
|
||||||
import { ImageIcon, X } from 'lucide-react';
|
import { ImageIcon, X, Upload } from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
|
||||||
import type { ImageAttachment } from '@/store/app-store';
|
import type { ImageAttachment } from '@/store/app-store';
|
||||||
import {
|
import {
|
||||||
fileToBase64,
|
fileToBase64,
|
||||||
@@ -205,7 +204,7 @@ export function ImageDropZone({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isProcessing ? (
|
{isProcessing ? (
|
||||||
<Spinner size="lg" />
|
<Upload className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
) : (
|
) : (
|
||||||
<ImageIcon className="h-6 w-6 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 {
|
interface LoadingStateProps {
|
||||||
/** Optional custom message to display below the spinner */
|
/** Optional custom message to display below the spinner */
|
||||||
message?: string;
|
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 (
|
return (
|
||||||
<div className="flex-1 flex flex-col items-center justify-center">
|
<div className="flex-1 flex flex-col items-center justify-center">
|
||||||
<Spinner size="xl" />
|
<Loader2 className={`${size} animate-spin text-muted-foreground`} />
|
||||||
{message && <p className="mt-4 text-sm font-medium text-primary">{message}</p>}
|
{message && <p className="mt-4 text-sm text-muted-foreground">{message}</p>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ import {
|
|||||||
Filter,
|
Filter,
|
||||||
Circle,
|
Circle,
|
||||||
Play,
|
Play,
|
||||||
|
Loader2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
parseLogOutput,
|
parseLogOutput,
|
||||||
@@ -148,7 +148,7 @@ function TodoListRenderer({ todos }: { todos: TodoItem[] }) {
|
|||||||
case 'completed':
|
case 'completed':
|
||||||
return <CheckCircle2 className="w-4 h-4 text-emerald-400" />;
|
return <CheckCircle2 className="w-4 h-4 text-emerald-400" />;
|
||||||
case 'in_progress':
|
case 'in_progress':
|
||||||
return <Spinner size="sm" />;
|
return <Loader2 className="w-4 h-4 text-amber-400 animate-spin" />;
|
||||||
case 'pending':
|
case 'pending':
|
||||||
return <Circle className="w-4 h-4 text-muted-foreground/70" />;
|
return <Circle className="w-4 h-4 text-muted-foreground/70" />;
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ const PROVIDER_ICON_KEYS = {
|
|||||||
minimax: 'minimax',
|
minimax: 'minimax',
|
||||||
glm: 'glm',
|
glm: 'glm',
|
||||||
bigpickle: 'bigpickle',
|
bigpickle: 'bigpickle',
|
||||||
coderabbit: 'coderabbit',
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
type ProviderIconKey = keyof typeof PROVIDER_ICON_KEYS;
|
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',
|
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',
|
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'> {
|
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} />;
|
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({
|
export function DeepSeekIcon({
|
||||||
className,
|
className,
|
||||||
title,
|
title,
|
||||||
@@ -580,7 +569,6 @@ export function getProviderIconForModel(
|
|||||||
minimax: MiniMaxIcon,
|
minimax: MiniMaxIcon,
|
||||||
glm: GlmIcon,
|
glm: GlmIcon,
|
||||||
bigpickle: BigPickleIcon,
|
bigpickle: BigPickleIcon,
|
||||||
coderabbit: CodeRabbitIcon,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return iconMap[iconKey] || AnthropicIcon;
|
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';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const logger = createLogger('TaskProgressPanel');
|
const logger = createLogger('TaskProgressPanel');
|
||||||
import { Check, Circle, ChevronDown, ChevronRight, FileCode } from 'lucide-react';
|
import { Check, Loader2, Circle, ChevronDown, ChevronRight, FileCode } from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import type { AutoModeEvent } from '@/types/electron';
|
import type { AutoModeEvent } from '@/types/electron';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -261,7 +260,7 @@ export function TaskProgressPanel({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isCompleted && <Check className="h-3.5 w-3.5" />}
|
{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" />}
|
{isPending && <Circle className="h-2 w-2 fill-current opacity-50" />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } from 'lucide-react';
|
import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
@@ -450,7 +449,7 @@ export function UsagePopover() {
|
|||||||
</div>
|
</div>
|
||||||
) : !claudeUsage ? (
|
) : !claudeUsage ? (
|
||||||
<div className="flex flex-col items-center justify-center py-8 space-y-2">
|
<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>
|
<p className="text-xs text-muted-foreground">Loading usage data...</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -569,7 +568,7 @@ export function UsagePopover() {
|
|||||||
</div>
|
</div>
|
||||||
) : !codexUsage ? (
|
) : !codexUsage ? (
|
||||||
<div className="flex flex-col items-center justify-center py-8 space-y-2">
|
<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>
|
<p className="text-xs text-muted-foreground">Loading usage data...</p>
|
||||||
</div>
|
</div>
|
||||||
) : codexUsage.rateLimits ? (
|
) : codexUsage.rateLimits ? (
|
||||||
|
|||||||
@@ -11,12 +11,12 @@ import {
|
|||||||
Terminal,
|
Terminal,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
XCircle,
|
XCircle,
|
||||||
|
Loader2,
|
||||||
Play,
|
Play,
|
||||||
File,
|
File,
|
||||||
Pencil,
|
Pencil,
|
||||||
Wrench,
|
Wrench,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
|
|
||||||
@@ -236,7 +236,7 @@ export function AgentToolsView() {
|
|||||||
>
|
>
|
||||||
{isReadingFile ? (
|
{isReadingFile ? (
|
||||||
<>
|
<>
|
||||||
<Spinner size="sm" className="mr-2" />
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
Reading...
|
Reading...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -315,7 +315,7 @@ export function AgentToolsView() {
|
|||||||
>
|
>
|
||||||
{isWritingFile ? (
|
{isWritingFile ? (
|
||||||
<>
|
<>
|
||||||
<Spinner size="sm" className="mr-2" />
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
Writing...
|
Writing...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -383,7 +383,7 @@ export function AgentToolsView() {
|
|||||||
>
|
>
|
||||||
{isRunningCommand ? (
|
{isRunningCommand ? (
|
||||||
<>
|
<>
|
||||||
<Spinner size="sm" className="mr-2" />
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
Running...
|
Running...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Bot } from 'lucide-react';
|
import { Bot } from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
|
||||||
|
|
||||||
export function ThinkingIndicator() {
|
export function ThinkingIndicator() {
|
||||||
return (
|
return (
|
||||||
@@ -9,7 +8,20 @@ export function ThinkingIndicator() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="bg-card border border-border rounded-2xl px-4 py-3 shadow-sm">
|
<div className="bg-card border border-border rounded-2xl px-4 py-3 shadow-sm">
|
||||||
<div className="flex items-center gap-3">
|
<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>
|
<span className="text-sm text-muted-foreground">Thinking...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,12 +14,12 @@ import {
|
|||||||
RefreshCw,
|
RefreshCw,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
FileCode,
|
FileCode,
|
||||||
|
Loader2,
|
||||||
FileText,
|
FileText,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
ListChecks,
|
ListChecks,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
|
||||||
import { cn, generateUUID } from '@/lib/utils';
|
import { cn, generateUUID } from '@/lib/utils';
|
||||||
|
|
||||||
const logger = createLogger('AnalysisView');
|
const logger = createLogger('AnalysisView');
|
||||||
@@ -742,7 +742,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
|||||||
<Button onClick={runAnalysis} disabled={isAnalyzing} data-testid="analyze-project-button">
|
<Button onClick={runAnalysis} disabled={isAnalyzing} data-testid="analyze-project-button">
|
||||||
{isAnalyzing ? (
|
{isAnalyzing ? (
|
||||||
<>
|
<>
|
||||||
<Spinner size="sm" className="mr-2" />
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
Analyzing...
|
Analyzing...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -771,7 +771,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
|||||||
</div>
|
</div>
|
||||||
) : isAnalyzing ? (
|
) : isAnalyzing ? (
|
||||||
<div className="flex flex-col items-center justify-center h-full">
|
<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>
|
<p className="text-muted-foreground">Scanning project files...</p>
|
||||||
</div>
|
</div>
|
||||||
) : projectAnalysis ? (
|
) : projectAnalysis ? (
|
||||||
@@ -850,7 +850,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
|||||||
>
|
>
|
||||||
{isGeneratingSpec ? (
|
{isGeneratingSpec ? (
|
||||||
<>
|
<>
|
||||||
<Spinner size="sm" className="mr-2" />
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
Generating...
|
Generating...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -903,7 +903,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
|||||||
>
|
>
|
||||||
{isGeneratingFeatureList ? (
|
{isGeneratingFeatureList ? (
|
||||||
<>
|
<>
|
||||||
<Spinner size="sm" className="mr-2" />
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
Generating...
|
Generating...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import { pathsEqual } from '@/lib/utils';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { getBlockingDependencies } from '@automaker/dependency-resolver';
|
import { getBlockingDependencies } from '@automaker/dependency-resolver';
|
||||||
import { BoardBackgroundModal } from '@/components/dialogs/board-background-modal';
|
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 { useAutoMode } from '@/hooks/use-auto-mode';
|
||||||
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
|
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
|
||||||
import { useWindowState } from '@/hooks/use-window-state';
|
import { useWindowState } from '@/hooks/use-window-state';
|
||||||
@@ -52,8 +52,6 @@ import {
|
|||||||
FollowUpDialog,
|
FollowUpDialog,
|
||||||
PlanApprovalDialog,
|
PlanApprovalDialog,
|
||||||
} from './board-view/dialogs';
|
} 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 { PipelineSettingsDialog } from './board-view/dialogs/pipeline-settings-dialog';
|
||||||
import { CreateWorktreeDialog } from './board-view/dialogs/create-worktree-dialog';
|
import { CreateWorktreeDialog } from './board-view/dialogs/create-worktree-dialog';
|
||||||
import { DeleteWorktreeDialog } from './board-view/dialogs/delete-worktree-dialog';
|
import { DeleteWorktreeDialog } from './board-view/dialogs/delete-worktree-dialog';
|
||||||
@@ -169,11 +167,6 @@ export function BoardView() {
|
|||||||
// Pipeline settings dialog state
|
// Pipeline settings dialog state
|
||||||
const [showPipelineSettings, setShowPipelineSettings] = useState(false);
|
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
|
// Follow-up state hook
|
||||||
const {
|
const {
|
||||||
showFollowUpDialog,
|
showFollowUpDialog,
|
||||||
@@ -1380,44 +1373,6 @@ export function BoardView() {
|
|||||||
[currentProject, setPendingPlanApproval]
|
[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) {
|
if (!currentProject) {
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex items-center justify-center" data-testid="board-view-no-project">
|
<div className="flex-1 flex items-center justify-center" data-testid="board-view-no-project">
|
||||||
@@ -1429,7 +1384,7 @@ export function BoardView() {
|
|||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex items-center justify-center" data-testid="board-view-loading">
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1530,7 +1485,6 @@ export function BoardView() {
|
|||||||
setSpawnParentFeature(feature);
|
setSpawnParentFeature(feature);
|
||||||
setShowAddDialog(true);
|
setShowAddDialog(true);
|
||||||
},
|
},
|
||||||
onCodeReview: handleCodeReview,
|
|
||||||
}}
|
}}
|
||||||
runningAutoTasks={runningAutoTasks}
|
runningAutoTasks={runningAutoTasks}
|
||||||
pipelineConfig={pipelineConfig}
|
pipelineConfig={pipelineConfig}
|
||||||
@@ -1574,7 +1528,6 @@ export function BoardView() {
|
|||||||
setSpawnParentFeature(feature);
|
setSpawnParentFeature(feature);
|
||||||
setShowAddDialog(true);
|
setShowAddDialog(true);
|
||||||
}}
|
}}
|
||||||
onCodeReview={handleCodeReview}
|
|
||||||
featuresWithContext={featuresWithContext}
|
featuresWithContext={featuresWithContext}
|
||||||
runningAutoTasks={runningAutoTasks}
|
runningAutoTasks={runningAutoTasks}
|
||||||
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
|
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 */}
|
{/* Create Worktree Dialog */}
|
||||||
<CreateWorktreeDialog
|
<CreateWorktreeDialog
|
||||||
open={showCreateWorktreeDialog}
|
open={showCreateWorktreeDialog}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useRef, useEffect } from 'react';
|
import { useRef, useEffect } from 'react';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Search, X } from 'lucide-react';
|
import { Search, X, Loader2 } from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
|
||||||
|
|
||||||
interface BoardSearchBarProps {
|
interface BoardSearchBarProps {
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
@@ -76,7 +75,7 @@ export function BoardSearchBar({
|
|||||||
title="Creating App Specification"
|
title="Creating App Specification"
|
||||||
data-testid="spec-creation-badge"
|
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">
|
<span className="text-xs font-medium text-brand-500 whitespace-nowrap">
|
||||||
Creating spec
|
Creating spec
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -11,8 +11,16 @@ import {
|
|||||||
} from '@/lib/agent-context-parser';
|
} from '@/lib/agent-context-parser';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { AutoModeEvent } from '@/types/electron';
|
import type { AutoModeEvent } from '@/types/electron';
|
||||||
import { Brain, ListTodo, Sparkles, Expand, CheckCircle2, Circle, Wrench } from 'lucide-react';
|
import {
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
Brain,
|
||||||
|
ListTodo,
|
||||||
|
Sparkles,
|
||||||
|
Expand,
|
||||||
|
CheckCircle2,
|
||||||
|
Circle,
|
||||||
|
Loader2,
|
||||||
|
Wrench,
|
||||||
|
} from 'lucide-react';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { SummaryDialog } from './summary-dialog';
|
import { SummaryDialog } from './summary-dialog';
|
||||||
import { getProviderIconForModel } from '@/components/ui/provider-icon';
|
import { getProviderIconForModel } from '@/components/ui/provider-icon';
|
||||||
@@ -330,7 +338,7 @@ export function AgentInfoPanel({
|
|||||||
{todo.status === 'completed' ? (
|
{todo.status === 'completed' ? (
|
||||||
<CheckCircle2 className="w-2.5 h-2.5 text-[var(--status-success)] shrink-0" />
|
<CheckCircle2 className="w-2.5 h-2.5 text-[var(--status-success)] shrink-0" />
|
||||||
) : todo.status === 'in_progress' ? (
|
) : 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" />
|
<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 { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Edit,
|
Edit,
|
||||||
@@ -10,7 +11,6 @@ import {
|
|||||||
Eye,
|
Eye,
|
||||||
Wand2,
|
Wand2,
|
||||||
Archive,
|
Archive,
|
||||||
FileSearch,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface CardActionsProps {
|
interface CardActionsProps {
|
||||||
@@ -30,13 +30,12 @@ interface CardActionsProps {
|
|||||||
onComplete?: () => void;
|
onComplete?: () => void;
|
||||||
onViewPlan?: () => void;
|
onViewPlan?: () => void;
|
||||||
onApprovePlan?: () => void;
|
onApprovePlan?: () => void;
|
||||||
onCodeReview?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CardActions({
|
export function CardActions({
|
||||||
feature,
|
feature,
|
||||||
isCurrentAutoTask,
|
isCurrentAutoTask,
|
||||||
hasContext: _hasContext,
|
hasContext,
|
||||||
shortcutKey,
|
shortcutKey,
|
||||||
isSelectionMode = false,
|
isSelectionMode = false,
|
||||||
onEdit,
|
onEdit,
|
||||||
@@ -50,7 +49,6 @@ export function CardActions({
|
|||||||
onComplete,
|
onComplete,
|
||||||
onViewPlan,
|
onViewPlan,
|
||||||
onApprovePlan,
|
onApprovePlan,
|
||||||
onCodeReview,
|
|
||||||
}: CardActionsProps) {
|
}: CardActionsProps) {
|
||||||
// Hide all actions when in selection mode
|
// Hide all actions when in selection mode
|
||||||
if (isSelectionMode) {
|
if (isSelectionMode) {
|
||||||
@@ -260,24 +258,6 @@ export function CardActions({
|
|||||||
<span className="truncate">Refine</span>
|
<span className="truncate">Refine</span>
|
||||||
</Button>
|
</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 */}
|
{/* Show Verify button if PR was created (changes are committed), otherwise show Mark as Verified button */}
|
||||||
{feature.prUrl && onManualVerify ? (
|
{feature.prUrl && onManualVerify ? (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
GripVertical,
|
GripVertical,
|
||||||
Edit,
|
Edit,
|
||||||
|
Loader2,
|
||||||
Trash2,
|
Trash2,
|
||||||
FileText,
|
FileText,
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
@@ -20,7 +21,6 @@ import {
|
|||||||
ChevronUp,
|
ChevronUp,
|
||||||
GitFork,
|
GitFork,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
|
||||||
import { CountUpTimer } from '@/components/ui/count-up-timer';
|
import { CountUpTimer } from '@/components/ui/count-up-timer';
|
||||||
import { formatModelName, DEFAULT_MODEL } from '@/lib/agent-context-parser';
|
import { formatModelName, DEFAULT_MODEL } from '@/lib/agent-context-parser';
|
||||||
import { DeleteConfirmDialog } from '@/components/ui/delete-confirm-dialog';
|
import { DeleteConfirmDialog } from '@/components/ui/delete-confirm-dialog';
|
||||||
@@ -65,7 +65,7 @@ export function CardHeaderSection({
|
|||||||
{isCurrentAutoTask && !isSelectionMode && (
|
{isCurrentAutoTask && !isSelectionMode && (
|
||||||
<div className="absolute top-2 right-2 flex items-center gap-1">
|
<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">
|
<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 && (
|
{feature.startedAt && (
|
||||||
<CountUpTimer
|
<CountUpTimer
|
||||||
startedAt={feature.startedAt}
|
startedAt={feature.startedAt}
|
||||||
@@ -324,7 +324,7 @@ export function CardHeaderSection({
|
|||||||
<div className="flex-1 min-w-0 overflow-hidden">
|
<div className="flex-1 min-w-0 overflow-hidden">
|
||||||
{feature.titleGenerating ? (
|
{feature.titleGenerating ? (
|
||||||
<div className="flex items-center gap-1.5 mb-1">
|
<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>
|
<span className="text-xs text-muted-foreground italic">Generating title...</span>
|
||||||
</div>
|
</div>
|
||||||
) : feature.title ? (
|
) : feature.title ? (
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ interface KanbanCardProps {
|
|||||||
onViewPlan?: () => void;
|
onViewPlan?: () => void;
|
||||||
onApprovePlan?: () => void;
|
onApprovePlan?: () => void;
|
||||||
onSpawnTask?: () => void;
|
onSpawnTask?: () => void;
|
||||||
onCodeReview?: () => void;
|
|
||||||
hasContext?: boolean;
|
hasContext?: boolean;
|
||||||
isCurrentAutoTask?: boolean;
|
isCurrentAutoTask?: boolean;
|
||||||
shortcutKey?: string;
|
shortcutKey?: string;
|
||||||
@@ -85,7 +84,6 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
onViewPlan,
|
onViewPlan,
|
||||||
onApprovePlan,
|
onApprovePlan,
|
||||||
onSpawnTask,
|
onSpawnTask,
|
||||||
onCodeReview,
|
|
||||||
hasContext,
|
hasContext,
|
||||||
isCurrentAutoTask,
|
isCurrentAutoTask,
|
||||||
shortcutKey,
|
shortcutKey,
|
||||||
@@ -240,7 +238,6 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
onComplete={onComplete}
|
onComplete={onComplete}
|
||||||
onViewPlan={onViewPlan}
|
onViewPlan={onViewPlan}
|
||||||
onApprovePlan={onApprovePlan}
|
onApprovePlan={onApprovePlan}
|
||||||
onCodeReview={onCodeReview}
|
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ export interface ListViewActionHandlers {
|
|||||||
onViewPlan?: (feature: Feature) => void;
|
onViewPlan?: (feature: Feature) => void;
|
||||||
onApprovePlan?: (feature: Feature) => void;
|
onApprovePlan?: (feature: Feature) => void;
|
||||||
onSpawnTask?: (feature: Feature) => void;
|
onSpawnTask?: (feature: Feature) => void;
|
||||||
onCodeReview?: (feature: Feature) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListViewProps {
|
export interface ListViewProps {
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
GitBranch,
|
GitBranch,
|
||||||
GitFork,
|
GitFork,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
FileSearch,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -44,7 +43,6 @@ export interface RowActionHandlers {
|
|||||||
onViewPlan?: () => void;
|
onViewPlan?: () => void;
|
||||||
onApprovePlan?: () => void;
|
onApprovePlan?: () => void;
|
||||||
onSpawnTask?: () => void;
|
onSpawnTask?: () => void;
|
||||||
onCodeReview?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RowActionsProps {
|
export interface RowActionsProps {
|
||||||
@@ -481,14 +479,6 @@ export const RowActions = memo(function RowActions({
|
|||||||
{handlers.onFollowUp && (
|
{handlers.onFollowUp && (
|
||||||
<MenuItem icon={Wand2} label="Refine" onClick={withClose(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 && (
|
{feature.prUrl && (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
icon={ExternalLink}
|
icon={ExternalLink}
|
||||||
@@ -625,7 +615,6 @@ export function createRowActionHandlers(
|
|||||||
viewPlan?: (id: string) => void;
|
viewPlan?: (id: string) => void;
|
||||||
approvePlan?: (id: string) => void;
|
approvePlan?: (id: string) => void;
|
||||||
spawnTask?: (id: string) => void;
|
spawnTask?: (id: string) => void;
|
||||||
codeReview?: (id: string) => void;
|
|
||||||
}
|
}
|
||||||
): RowActionHandlers {
|
): RowActionHandlers {
|
||||||
return {
|
return {
|
||||||
@@ -642,6 +631,5 @@ export function createRowActionHandlers(
|
|||||||
onViewPlan: actions.viewPlan ? () => actions.viewPlan!(featureId) : undefined,
|
onViewPlan: actions.viewPlan ? () => actions.viewPlan!(featureId) : undefined,
|
||||||
onApprovePlan: actions.approvePlan ? () => actions.approvePlan!(featureId) : undefined,
|
onApprovePlan: actions.approvePlan ? () => actions.approvePlan!(featureId) : undefined,
|
||||||
onSpawnTask: actions.spawnTask ? () => actions.spawnTask!(featureId) : undefined,
|
onSpawnTask: actions.spawnTask ? () => actions.spawnTask!(featureId) : undefined,
|
||||||
onCodeReview: actions.codeReview ? () => actions.codeReview!(featureId) : undefined,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { List, FileText, GitBranch, ClipboardList } from 'lucide-react';
|
import { Loader2, List, FileText, GitBranch, ClipboardList } from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { LogViewer } from '@/components/ui/log-viewer';
|
import { LogViewer } from '@/components/ui/log-viewer';
|
||||||
import { GitDiffPanel } from '@/components/ui/git-diff-panel';
|
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">
|
<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">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
{featureStatus !== 'verified' && featureStatus !== 'waiting_approval' && (
|
{featureStatus !== 'verified' && featureStatus !== 'waiting_approval' && (
|
||||||
<Spinner size="md" />
|
<Loader2 className="w-5 h-5 text-primary animate-spin" />
|
||||||
)}
|
)}
|
||||||
Agent Output
|
Agent Output
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
@@ -440,7 +439,7 @@ export function AgentOutputModal({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
<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...
|
Loading...
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -458,7 +457,7 @@ export function AgentOutputModal({
|
|||||||
>
|
>
|
||||||
{isLoading && !output ? (
|
{isLoading && !output ? (
|
||||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
<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...
|
Loading output...
|
||||||
</div>
|
</div>
|
||||||
) : !output ? (
|
) : !output ? (
|
||||||
|
|||||||
@@ -11,8 +11,16 @@ import {
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Wand2, Check, Plus, Pencil, Trash2, ChevronDown, ChevronRight } from 'lucide-react';
|
import {
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
Loader2,
|
||||||
|
Wand2,
|
||||||
|
Check,
|
||||||
|
Plus,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
} from 'lucide-react';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -279,7 +287,8 @@ export function BacklogPlanDialog({
|
|||||||
</div>
|
</div>
|
||||||
{isGeneratingPlan && (
|
{isGeneratingPlan && (
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground bg-muted/50 rounded-lg p-3">
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -396,7 +405,7 @@ export function BacklogPlanDialog({
|
|||||||
case 'applying':
|
case 'applying':
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-12">
|
<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>
|
<p className="text-muted-foreground">Applying changes...</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -443,7 +452,7 @@ export function BacklogPlanDialog({
|
|||||||
<Button onClick={handleGenerate} disabled={!prompt.trim() || isGeneratingPlan}>
|
<Button onClick={handleGenerate} disabled={!prompt.trim() || isGeneratingPlan}>
|
||||||
{isGeneratingPlan ? (
|
{isGeneratingPlan ? (
|
||||||
<>
|
<>
|
||||||
<Spinner size="sm" className="mr-2" />
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
Generating...
|
Generating...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ import {
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { GitCommit, Sparkles } from 'lucide-react';
|
import { GitCommit, Loader2, Sparkles } from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
@@ -210,7 +209,7 @@ export function CommitWorktreeDialog({
|
|||||||
<Button onClick={handleCommit} disabled={isLoading || isGenerating || !message.trim()}>
|
<Button onClick={handleCommit} disabled={isLoading || isGenerating || !message.trim()}>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<>
|
<>
|
||||||
<Spinner size="sm" className="mr-2" />
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
Committing...
|
Committing...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -13,8 +13,7 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { GitBranchPlus } from 'lucide-react';
|
import { GitBranchPlus, Loader2 } from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
|
||||||
|
|
||||||
interface WorktreeInfo {
|
interface WorktreeInfo {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -134,7 +133,7 @@ export function CreateBranchDialog({
|
|||||||
<Button onClick={handleCreate} disabled={!branchName.trim() || isCreating}>
|
<Button onClick={handleCreate} disabled={!branchName.trim() || isCreating}>
|
||||||
{isCreating ? (
|
{isCreating ? (
|
||||||
<>
|
<>
|
||||||
<Spinner size="sm" className="mr-2" />
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
Creating...
|
Creating...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -13,8 +13,7 @@ import { Textarea } from '@/components/ui/textarea';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { BranchAutocomplete } from '@/components/ui/branch-autocomplete';
|
import { BranchAutocomplete } from '@/components/ui/branch-autocomplete';
|
||||||
import { GitPullRequest, ExternalLink } from 'lucide-react';
|
import { GitPullRequest, Loader2, ExternalLink } from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
@@ -406,7 +405,7 @@ export function CreatePRDialog({
|
|||||||
<Button onClick={handleCreate} disabled={isLoading}>
|
<Button onClick={handleCreate} disabled={isLoading}>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<>
|
<>
|
||||||
<Spinner size="sm" className="mr-2" />
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
Creating...
|
Creating...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ import {
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { GitBranch, AlertCircle } from 'lucide-react';
|
import { GitBranch, Loader2, AlertCircle } from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
@@ -217,7 +216,7 @@ export function CreateWorktreeDialog({
|
|||||||
<Button onClick={handleCreate} disabled={isLoading || !branchName.trim()}>
|
<Button onClick={handleCreate} disabled={isLoading || !branchName.trim()}>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<>
|
<>
|
||||||
<Spinner size="sm" className="mr-2" />
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
Creating...
|
Creating...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ import {
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Trash2, AlertTriangle, FileWarning } from 'lucide-react';
|
import { Loader2, Trash2, AlertTriangle, FileWarning } from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
@@ -148,7 +147,7 @@ export function DeleteWorktreeDialog({
|
|||||||
<Button variant="destructive" onClick={handleDelete} disabled={isLoading}>
|
<Button variant="destructive" onClick={handleDelete} disabled={isLoading}>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<>
|
<>
|
||||||
<Spinner size="sm" className="mr-2" />
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
Deleting...
|
Deleting...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ import {
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { GitMerge, AlertTriangle, CheckCircle2 } from 'lucide-react';
|
import { Loader2, GitMerge, AlertTriangle, CheckCircle2 } from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
@@ -218,7 +217,7 @@ export function MergeWorktreeDialog({
|
|||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<>
|
<>
|
||||||
<Spinner size="sm" className="mr-2" />
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
Merging...
|
Merging...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -14,8 +14,7 @@ import { Textarea } from '@/components/ui/textarea';
|
|||||||
import { Markdown } from '@/components/ui/markdown';
|
import { Markdown } from '@/components/ui/markdown';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Feature } from '@/store/app-store';
|
import { Feature } from '@/store/app-store';
|
||||||
import { Check, RefreshCw, Edit2, Eye } from 'lucide-react';
|
import { Check, RefreshCw, Edit2, Eye, Loader2 } from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
|
||||||
|
|
||||||
interface PlanApprovalDialogProps {
|
interface PlanApprovalDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -172,7 +171,7 @@ export function PlanApprovalDialog({
|
|||||||
</Button>
|
</Button>
|
||||||
<Button variant="secondary" onClick={handleReject} disabled={isLoading}>
|
<Button variant="secondary" onClick={handleReject} disabled={isLoading}>
|
||||||
{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" />
|
<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"
|
className="bg-green-600 hover:bg-green-700 text-white"
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{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" />
|
<Check className="w-4 h-4 mr-2" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
import { Terminal, Check, X, ChevronDown, ChevronUp } from 'lucide-react';
|
import { Terminal, Check, X, Loader2, ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useAppStore, type InitScriptState } from '@/store/app-store';
|
import { useAppStore, type InitScriptState } from '@/store/app-store';
|
||||||
import { AnsiOutput } from '@/components/ui/ansi-output';
|
import { AnsiOutput } from '@/components/ui/ansi-output';
|
||||||
@@ -66,7 +65,7 @@ function SingleIndicator({
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-3 border-b border-border/50">
|
<div className="flex items-center justify-between p-3 border-b border-border/50">
|
||||||
<div className="flex items-center gap-2">
|
<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 === 'success' && <Check className="w-4 h-4 text-green-500" />}
|
||||||
{status === 'failed' && <X className="w-4 h-4 text-red-500" />}
|
{status === 'failed' && <X className="w-4 h-4 text-red-500" />}
|
||||||
<span className="font-medium text-sm">
|
<span className="font-medium text-sm">
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ interface KanbanBoardProps {
|
|||||||
onViewPlan: (feature: Feature) => void;
|
onViewPlan: (feature: Feature) => void;
|
||||||
onApprovePlan: (feature: Feature) => void;
|
onApprovePlan: (feature: Feature) => void;
|
||||||
onSpawnTask?: (feature: Feature) => void;
|
onSpawnTask?: (feature: Feature) => void;
|
||||||
onCodeReview?: (feature: Feature) => void;
|
|
||||||
featuresWithContext: Set<string>;
|
featuresWithContext: Set<string>;
|
||||||
runningAutoTasks: string[];
|
runningAutoTasks: string[];
|
||||||
onArchiveAllVerified: () => void;
|
onArchiveAllVerified: () => void;
|
||||||
@@ -88,7 +87,6 @@ export function KanbanBoard({
|
|||||||
onViewPlan,
|
onViewPlan,
|
||||||
onApprovePlan,
|
onApprovePlan,
|
||||||
onSpawnTask,
|
onSpawnTask,
|
||||||
onCodeReview,
|
|
||||||
featuresWithContext,
|
featuresWithContext,
|
||||||
runningAutoTasks,
|
runningAutoTasks,
|
||||||
onArchiveAllVerified,
|
onArchiveAllVerified,
|
||||||
@@ -328,7 +326,6 @@ export function KanbanBoard({
|
|||||||
onViewPlan={() => onViewPlan(feature)}
|
onViewPlan={() => onViewPlan(feature)}
|
||||||
onApprovePlan={() => onApprovePlan(feature)}
|
onApprovePlan={() => onApprovePlan(feature)}
|
||||||
onSpawnTask={() => onSpawnTask?.(feature)}
|
onSpawnTask={() => onSpawnTask?.(feature)}
|
||||||
onCodeReview={onCodeReview ? () => onCodeReview(feature) : undefined}
|
|
||||||
hasContext={featuresWithContext.has(feature.id)}
|
hasContext={featuresWithContext.has(feature.id)}
|
||||||
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
|
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
|
||||||
shortcutKey={shortcutKey}
|
shortcutKey={shortcutKey}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useEffect, useCallback, useState, type ComponentType, type ReactNode } from 'react';
|
import { useEffect, useCallback, useState, type ComponentType, type ReactNode } from 'react';
|
||||||
import { RefreshCw } from 'lucide-react';
|
import { RefreshCw } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { AnthropicIcon, OpenAIIcon } from '@/components/ui/provider-icon';
|
import { AnthropicIcon, OpenAIIcon } from '@/components/ui/provider-icon';
|
||||||
@@ -91,11 +90,9 @@ function UsageItem({
|
|||||||
className="p-1 rounded hover:bg-accent/50 transition-colors"
|
className="p-1 rounded hover:bg-accent/50 transition-colors"
|
||||||
title="Refresh usage"
|
title="Refresh usage"
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
<RefreshCw
|
||||||
<Spinner size="xs" />
|
className={cn('w-3.5 h-3.5 text-muted-foreground', isLoading && 'animate-spin')}
|
||||||
) : (
|
/>
|
||||||
<RefreshCw className="w-3.5 h-3.5 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="pl-6 space-y-2">{children}</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 type { ModelProvider } from '@automaker/types';
|
||||||
import { CLAUDE_MODELS, CURSOR_MODELS, ModelOption } from './model-constants';
|
import { CLAUDE_MODELS, CURSOR_MODELS, ModelOption } from './model-constants';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { RefreshCw } from 'lucide-react';
|
||||||
|
|
||||||
interface ModelSelectorProps {
|
interface ModelSelectorProps {
|
||||||
selectedModel: string; // Can be ModelAlias or "cursor-{id}"
|
selectedModel: string; // Can be ModelAlias or "cursor-{id}"
|
||||||
@@ -294,7 +294,7 @@ export function ModelSelector({
|
|||||||
{/* Loading state */}
|
{/* Loading state */}
|
||||||
{codexModelsLoading && dynamicCodexModels.length === 0 && (
|
{codexModelsLoading && dynamicCodexModels.length === 0 && (
|
||||||
<div className="flex items-center justify-center gap-2 p-6 text-sm text-muted-foreground">
|
<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...
|
Loading models...
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ import {
|
|||||||
ClipboardList,
|
ClipboardList,
|
||||||
FileText,
|
FileText,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
|
Loader2,
|
||||||
Check,
|
Check,
|
||||||
Eye,
|
Eye,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
@@ -236,7 +236,7 @@ export function PlanningModeSelector({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{isGenerating ? (
|
{isGenerating ? (
|
||||||
<>
|
<>
|
||||||
<Spinner size="sm" />
|
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
Generating {mode === 'full' ? 'comprehensive spec' : 'spec'}...
|
Generating {mode === 'full' ? 'comprehensive spec' : 'spec'}...
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { GitBranch, GitBranchPlus, Check, Search } from 'lucide-react';
|
import { GitBranch, RefreshCw, GitBranchPlus, Check, Search } from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { WorktreeInfo, BranchInfo } from '../types';
|
import type { WorktreeInfo, BranchInfo } from '../types';
|
||||||
|
|
||||||
@@ -82,7 +81,7 @@ export function BranchSwitchDropdown({
|
|||||||
<div className="max-h-[250px] overflow-y-auto">
|
<div className="max-h-[250px] overflow-y-auto">
|
||||||
{isLoadingBranches ? (
|
{isLoadingBranches ? (
|
||||||
<DropdownMenuItem disabled className="text-xs">
|
<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...
|
Loading branches...
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
) : filteredBranches.length === 0 ? (
|
) : filteredBranches.length === 0 ? (
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useCallback, useState } from 'react';
|
|||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
|
Loader2,
|
||||||
Terminal,
|
Terminal,
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
@@ -11,7 +12,6 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { XtermLogViewer, type XtermLogViewerRef } from '@/components/ui/xterm-log-viewer';
|
import { XtermLogViewer, type XtermLogViewerRef } from '@/components/ui/xterm-log-viewer';
|
||||||
import { useDevServerLogs } from '../hooks/use-dev-server-logs';
|
import { useDevServerLogs } from '../hooks/use-dev-server-logs';
|
||||||
@@ -183,7 +183,7 @@ export function DevServerLogsPanel({
|
|||||||
onClick={() => fetchLogs()}
|
onClick={() => fetchLogs()}
|
||||||
title="Refresh logs"
|
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -234,7 +234,7 @@ export function DevServerLogsPanel({
|
|||||||
>
|
>
|
||||||
{isLoading && !logs ? (
|
{isLoading && !logs ? (
|
||||||
<div className="flex items-center justify-center h-full min-h-[300px] text-muted-foreground">
|
<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>
|
<span className="text-sm">Loading logs...</span>
|
||||||
</div>
|
</div>
|
||||||
) : !logs && !isRunning ? (
|
) : !logs && !isRunning ? (
|
||||||
@@ -245,7 +245,7 @@ export function DevServerLogsPanel({
|
|||||||
</div>
|
</div>
|
||||||
) : !logs ? (
|
) : !logs ? (
|
||||||
<div className="flex flex-col items-center justify-center h-full min-h-[300px] text-muted-foreground p-8">
|
<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-sm">Waiting for output...</p>
|
||||||
<p className="text-xs mt-1 opacity-60">
|
<p className="text-xs mt-1 opacity-60">
|
||||||
Logs will appear as the server generates output
|
Logs will appear as the server generates output
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { GitBranch, ChevronDown, CircleDot, Check } from 'lucide-react';
|
import { GitBranch, ChevronDown, Loader2, CircleDot, Check } from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { WorktreeInfo } from '../types';
|
import type { WorktreeInfo } from '../types';
|
||||||
|
|
||||||
@@ -45,7 +44,7 @@ export function WorktreeMobileDropdown({
|
|||||||
<GitBranch className="w-3.5 h-3.5 shrink-0" />
|
<GitBranch className="w-3.5 h-3.5 shrink-0" />
|
||||||
<span className="truncate">{displayBranch}</span>
|
<span className="truncate">{displayBranch}</span>
|
||||||
{isActivating ? (
|
{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" />
|
<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" />
|
<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')}>
|
<span className={cn('font-mono text-xs truncate', isSelected && 'font-medium')}>
|
||||||
{worktree.branch}
|
{worktree.branch}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { JSX } from 'react';
|
import type { JSX } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Globe, CircleDot, GitPullRequest } from 'lucide-react';
|
import { RefreshCw, Globe, Loader2, CircleDot, GitPullRequest } from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import type { WorktreeInfo, BranchInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types';
|
import type { WorktreeInfo, BranchInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types';
|
||||||
@@ -198,8 +197,8 @@ export function WorktreeTab({
|
|||||||
aria-label={worktree.branch}
|
aria-label={worktree.branch}
|
||||||
data-testid={`worktree-branch-${worktree.branch}`}
|
data-testid={`worktree-branch-${worktree.branch}`}
|
||||||
>
|
>
|
||||||
{isRunning && <Spinner size="xs" />}
|
{isRunning && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||||
{isActivating && !isRunning && <Spinner size="xs" />}
|
{isActivating && !isRunning && <RefreshCw className="w-3 h-3 animate-spin" />}
|
||||||
{worktree.branch}
|
{worktree.branch}
|
||||||
{cardCount !== undefined && cardCount > 0 && (
|
{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">
|
<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'
|
: 'Click to switch to this branch'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{isRunning && <Spinner size="xs" />}
|
{isRunning && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||||
{isActivating && !isRunning && <Spinner size="xs" />}
|
{isActivating && !isRunning && <RefreshCw className="w-3 h-3 animate-spin" />}
|
||||||
{worktree.branch}
|
{worktree.branch}
|
||||||
{cardCount !== undefined && cardCount > 0 && (
|
{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">
|
<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 interface WorktreePRInfo {
|
||||||
export type { PRState, WorktreePRInfo } from '@automaker/types';
|
number: number;
|
||||||
import type { PRState, WorktreePRInfo } from '@automaker/types';
|
url: string;
|
||||||
|
title: string;
|
||||||
|
state: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface WorktreeInfo {
|
export interface WorktreeInfo {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -39,8 +43,7 @@ export interface PRInfo {
|
|||||||
number: number;
|
number: number;
|
||||||
title: string;
|
title: string;
|
||||||
url: string;
|
url: string;
|
||||||
/** PR state: OPEN, MERGED, or CLOSED */
|
state: string;
|
||||||
state: PRState;
|
|
||||||
author: string;
|
author: string;
|
||||||
body: string;
|
body: string;
|
||||||
comments: Array<{
|
comments: Array<{
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { GitBranch, Plus, RefreshCw } from 'lucide-react';
|
import { GitBranch, Plus, RefreshCw } from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
|
||||||
import { cn, pathsEqual } from '@/lib/utils';
|
import { cn, pathsEqual } from '@/lib/utils';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||||
@@ -286,7 +285,7 @@ export function WorktreePanel({
|
|||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
title="Refresh worktrees"
|
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>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -430,7 +429,7 @@ export function WorktreePanel({
|
|||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
title="Refresh worktrees"
|
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ import { useAppStore } from '@/store/app-store';
|
|||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { File, Folder, FolderOpen, ChevronRight, ChevronDown, Code } from 'lucide-react';
|
import { File, Folder, FolderOpen, ChevronRight, ChevronDown, RefreshCw, Code } from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const logger = createLogger('CodeView');
|
const logger = createLogger('CodeView');
|
||||||
@@ -207,7 +206,7 @@ export function CodeView() {
|
|||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex items-center justify-center" data-testid="code-view-loading">
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
HeaderActionsPanelTrigger,
|
HeaderActionsPanelTrigger,
|
||||||
} from '@/components/ui/header-actions-panel';
|
} from '@/components/ui/header-actions-panel';
|
||||||
import {
|
import {
|
||||||
|
RefreshCw,
|
||||||
FileText,
|
FileText,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -23,9 +24,9 @@ import {
|
|||||||
Pencil,
|
Pencil,
|
||||||
FilePlus,
|
FilePlus,
|
||||||
FileUp,
|
FileUp,
|
||||||
|
Loader2,
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
|
||||||
import {
|
import {
|
||||||
useKeyboardShortcuts,
|
useKeyboardShortcuts,
|
||||||
useKeyboardShortcutsConfig,
|
useKeyboardShortcutsConfig,
|
||||||
@@ -669,7 +670,7 @@ export function ContextView() {
|
|||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex items-center justify-center" data-testid="context-view-loading">
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -789,7 +790,7 @@ export function ContextView() {
|
|||||||
{isUploading && (
|
{isUploading && (
|
||||||
<div className="absolute inset-0 bg-background/80 z-50 flex items-center justify-center">
|
<div className="absolute inset-0 bg-background/80 z-50 flex items-center justify-center">
|
||||||
<div className="flex flex-col items-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>
|
<span className="text-sm font-medium">Uploading {uploadingFileName}...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -837,7 +838,7 @@ export function ContextView() {
|
|||||||
<span className="truncate text-sm block">{file.name}</span>
|
<span className="truncate text-sm block">{file.name}</span>
|
||||||
{isGenerating ? (
|
{isGenerating ? (
|
||||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
<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...
|
Generating description...
|
||||||
</span>
|
</span>
|
||||||
) : file.description ? (
|
) : file.description ? (
|
||||||
@@ -954,7 +955,7 @@ export function ContextView() {
|
|||||||
</span>
|
</span>
|
||||||
{generatingDescriptions.has(selectedFile.name) ? (
|
{generatingDescriptions.has(selectedFile.name) ? (
|
||||||
<div className="flex items-center gap-2 mt-1 text-sm text-muted-foreground">
|
<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>
|
<span>Generating description with AI...</span>
|
||||||
</div>
|
</div>
|
||||||
) : selectedFile.description ? (
|
) : selectedFile.description ? (
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
Folder,
|
Folder,
|
||||||
Star,
|
Star,
|
||||||
Clock,
|
Clock,
|
||||||
|
Loader2,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
@@ -27,7 +28,6 @@ import {
|
|||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import * as LucideIcons from 'lucide-react';
|
import * as LucideIcons from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
|
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
|
||||||
import {
|
import {
|
||||||
@@ -992,7 +992,7 @@ export function DashboardView() {
|
|||||||
data-testid="project-opening-overlay"
|
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">
|
<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>
|
<p className="text-foreground font-medium">Opening project...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
X,
|
X,
|
||||||
Wand2,
|
Wand2,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
|
Loader2,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
Clock,
|
Clock,
|
||||||
GitPullRequest,
|
GitPullRequest,
|
||||||
@@ -13,7 +14,6 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
@@ -87,7 +87,7 @@ export function IssueDetailPanel({
|
|||||||
if (isValidating) {
|
if (isValidating) {
|
||||||
return (
|
return (
|
||||||
<Button variant="default" size="sm" disabled>
|
<Button variant="default" size="sm" disabled>
|
||||||
<Spinner size="sm" className="mr-1" />
|
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||||
Validating...
|
Validating...
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
@@ -297,7 +297,9 @@ export function IssueDetailPanel({
|
|||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
Comments {totalCount > 0 && `(${totalCount})`}
|
Comments {totalCount > 0 && `(${totalCount})`}
|
||||||
</span>
|
</span>
|
||||||
{commentsLoading && <Spinner size="xs" />}
|
{commentsLoading && (
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
|
||||||
|
)}
|
||||||
{commentsExpanded ? (
|
{commentsExpanded ? (
|
||||||
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||||
) : (
|
) : (
|
||||||
@@ -338,7 +340,7 @@ export function IssueDetailPanel({
|
|||||||
>
|
>
|
||||||
{loadingMore ? (
|
{loadingMore ? (
|
||||||
<>
|
<>
|
||||||
<Spinner size="sm" className="mr-2" />
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
Loading...
|
Loading...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ import {
|
|||||||
Circle,
|
Circle,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
|
Loader2,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
GitPullRequest,
|
GitPullRequest,
|
||||||
User,
|
User,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { IssueRowProps } from '../types';
|
import type { IssueRowProps } from '../types';
|
||||||
@@ -97,7 +97,7 @@ export function IssueRow({
|
|||||||
{/* Validating indicator */}
|
{/* Validating indicator */}
|
||||||
{isValidating && (
|
{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">
|
<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...
|
Analyzing...
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { CircleDot, RefreshCw } from 'lucide-react';
|
import { CircleDot, RefreshCw } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { IssuesStateFilter } from '../types';
|
import type { IssuesStateFilter } from '../types';
|
||||||
import { IssuesFilterControls } from './issues-filter-controls';
|
import { IssuesFilterControls } from './issues-filter-controls';
|
||||||
@@ -78,7 +77,7 @@ export function IssuesListHeader({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" onClick={onRefresh} disabled={refreshing}>
|
<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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
import { GitPullRequest, RefreshCw, ExternalLink, GitMerge, X } from 'lucide-react';
|
import { GitPullRequest, Loader2, RefreshCw, ExternalLink, GitMerge, X } from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
|
||||||
import { getElectronAPI, GitHubPR } from '@/lib/electron';
|
import { getElectronAPI, GitHubPR } from '@/lib/electron';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -87,7 +86,7 @@ export function GitHubPRsView() {
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex items-center justify-center">
|
<div className="flex-1 flex items-center justify-center">
|
||||||
<Spinner size="xl" />
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -135,7 +134,7 @@ export function GitHubPRsView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={refreshing}>
|
<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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
import { useWorktrees } from './board-view/worktree-panel/hooks';
|
import { useWorktrees } from './board-view/worktree-panel/hooks';
|
||||||
import { useAutoMode } from '@/hooks/use-auto-mode';
|
import { useAutoMode } from '@/hooks/use-auto-mode';
|
||||||
import { pathsEqual } from '@/lib/utils';
|
import { pathsEqual } from '@/lib/utils';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { RefreshCw } from 'lucide-react';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -330,7 +330,7 @@ export function GraphViewPage() {
|
|||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex items-center justify-center" data-testid="graph-view-loading">
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useMemo, useEffect, useCallback } from 'react';
|
import { useState, useMemo, useEffect, useCallback } from 'react';
|
||||||
import { AlertCircle, Plus, X, Sparkles, Lightbulb, Trash2 } from 'lucide-react';
|
import { Loader2, AlertCircle, Plus, X, Sparkles, Lightbulb, Trash2 } from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -110,7 +109,7 @@ function SuggestionCard({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isAdding ? (
|
{isAdding ? (
|
||||||
<Spinner size="sm" />
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Plus className="w-4 h-4" />
|
<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 ? '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>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{job.prompt.title}</p>
|
<p className="font-medium">{job.prompt.title}</p>
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ import {
|
|||||||
Gauge,
|
Gauge,
|
||||||
Accessibility,
|
Accessibility,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
|
Loader2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { useGuidedPrompts } from '@/hooks/use-guided-prompts';
|
import { useGuidedPrompts } from '@/hooks/use-guided-prompts';
|
||||||
import type { IdeaCategory } from '@automaker/types';
|
import type { IdeaCategory } from '@automaker/types';
|
||||||
@@ -53,7 +53,7 @@ export function PromptCategoryGrid({ onSelect, onBack }: PromptCategoryGridProps
|
|||||||
|
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="flex items-center justify-center py-12">
|
<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>
|
<span className="ml-2 text-muted-foreground">Loading categories...</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,8 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import { ArrowLeft, Lightbulb, CheckCircle2 } from 'lucide-react';
|
import { ArrowLeft, Lightbulb, Loader2, CheckCircle2 } from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { useGuidedPrompts } from '@/hooks/use-guided-prompts';
|
import { useGuidedPrompts } from '@/hooks/use-guided-prompts';
|
||||||
import { useIdeationStore } from '@/store/ideation-store';
|
import { useIdeationStore } from '@/store/ideation-store';
|
||||||
@@ -122,7 +121,7 @@ export function PromptList({ category, onBack }: PromptListProps) {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{isLoadingPrompts && (
|
{isLoadingPrompts && (
|
||||||
<div className="flex items-center justify-center py-8">
|
<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>
|
<span className="ml-2 text-muted-foreground">Loading prompts...</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -163,7 +162,7 @@ export function PromptList({ category, onBack }: PromptListProps) {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isLoading || isGenerating ? (
|
{isLoading || isGenerating ? (
|
||||||
<Spinner size="md" />
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
) : isStarted ? (
|
) : isStarted ? (
|
||||||
<CheckCircle2 className="w-5 h-5" />
|
<CheckCircle2 className="w-5 h-5" />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -11,8 +11,7 @@ import { PromptList } from './components/prompt-list';
|
|||||||
import { IdeationDashboard } from './components/ideation-dashboard';
|
import { IdeationDashboard } from './components/ideation-dashboard';
|
||||||
import { useGuidedPrompts } from '@/hooks/use-guided-prompts';
|
import { useGuidedPrompts } from '@/hooks/use-guided-prompts';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { ArrowLeft, ChevronRight, Lightbulb, CheckCheck, Trash2 } from 'lucide-react';
|
import { ArrowLeft, ChevronRight, Lightbulb, CheckCheck, Loader2, Trash2 } from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
|
||||||
import type { IdeaCategory } from '@automaker/types';
|
import type { IdeaCategory } from '@automaker/types';
|
||||||
import type { IdeationMode } from '@/store/ideation-store';
|
import type { IdeationMode } from '@/store/ideation-store';
|
||||||
|
|
||||||
@@ -153,7 +152,11 @@ function IdeationHeader({
|
|||||||
className="gap-2"
|
className="gap-2"
|
||||||
disabled={isAcceptingAll}
|
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})
|
Accept All ({acceptAllCount})
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ import { useAppStore, Feature } from '@/store/app-store';
|
|||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Bot, Send, User, Sparkles, FileText, ArrowLeft, CheckCircle } from 'lucide-react';
|
import { Bot, Send, User, Loader2, Sparkles, FileText, ArrowLeft, CheckCircle } from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
|
||||||
import { cn, generateUUID } from '@/lib/utils';
|
import { cn, generateUUID } from '@/lib/utils';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { Markdown } from '@/components/ui/markdown';
|
import { Markdown } from '@/components/ui/markdown';
|
||||||
@@ -492,7 +491,7 @@ export function InterviewView() {
|
|||||||
<Card className="border border-primary/30 bg-card">
|
<Card className="border border-primary/30 bg-card">
|
||||||
<CardContent className="p-3">
|
<CardContent className="p-3">
|
||||||
<div className="flex items-center gap-2">
|
<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>
|
<span className="text-sm text-primary">Generating specification...</span>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -572,7 +571,7 @@ export function InterviewView() {
|
|||||||
>
|
>
|
||||||
{isGenerating ? (
|
{isGenerating ? (
|
||||||
<>
|
<>
|
||||||
<Spinner size="sm" className="mr-2" />
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
Creating...
|
Creating...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -24,8 +24,7 @@ import {
|
|||||||
} from '@/lib/http-api-client';
|
} from '@/lib/http-api-client';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { KeyRound, AlertCircle, RefreshCw, ServerCrash } from 'lucide-react';
|
import { KeyRound, AlertCircle, Loader2, RefreshCw, ServerCrash } from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
|
||||||
import { useAuthStore } from '@/store/auth-store';
|
import { useAuthStore } from '@/store/auth-store';
|
||||||
import { useSetupStore } from '@/store/setup-store';
|
import { useSetupStore } from '@/store/setup-store';
|
||||||
|
|
||||||
@@ -350,7 +349,7 @@ export function LoginView() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||||
<div className="text-center space-y-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">
|
<p className="text-sm text-muted-foreground">
|
||||||
Connecting to server
|
Connecting to server
|
||||||
{state.attempt > 1 ? ` (attempt ${state.attempt}/${MAX_RETRIES})` : '...'}
|
{state.attempt > 1 ? ` (attempt ${state.attempt}/${MAX_RETRIES})` : '...'}
|
||||||
@@ -386,7 +385,7 @@ export function LoginView() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||||
<div className="text-center space-y-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">
|
<p className="text-sm text-muted-foreground">
|
||||||
{state.phase === 'checking_setup' ? 'Loading settings...' : 'Redirecting...'}
|
{state.phase === 'checking_setup' ? 'Loading settings...' : 'Redirecting...'}
|
||||||
</p>
|
</p>
|
||||||
@@ -448,7 +447,7 @@ export function LoginView() {
|
|||||||
>
|
>
|
||||||
{isLoggingIn ? (
|
{isLoggingIn ? (
|
||||||
<>
|
<>
|
||||||
<Spinner size="sm" className="mr-2" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
Authenticating...
|
Authenticating...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
FilePlus,
|
FilePlus,
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -300,7 +299,7 @@ export function MemoryView() {
|
|||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex items-center justify-center" data-testid="memory-view-loading">
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ import { useLoadNotifications, useNotificationEvents } from '@/hooks/use-notific
|
|||||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Bell, Check, CheckCheck, Trash2, ExternalLink } from 'lucide-react';
|
import { Bell, Check, CheckCheck, Trash2, ExternalLink, Loader2 } from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
|
||||||
import { useNavigate } from '@tanstack/react-router';
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
import type { Notification } from '@automaker/types';
|
import type { Notification } from '@automaker/types';
|
||||||
|
|
||||||
@@ -147,7 +146,7 @@ export function NotificationsView() {
|
|||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col items-center justify-center p-8">
|
<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>
|
<p className="text-muted-foreground mt-4">Loading notifications...</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import {
|
|||||||
Save,
|
Save,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
Loader2,
|
||||||
PanelBottomClose,
|
PanelBottomClose,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { apiGet, apiPut, apiDelete } from '@/lib/api-fetch';
|
import { apiGet, apiPut, apiDelete } from '@/lib/api-fetch';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -409,7 +409,7 @@ export function WorktreePreferencesSection({ project }: WorktreePreferencesSecti
|
|||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center py-8">
|
<div className="flex items-center justify-center py-8">
|
||||||
<Spinner size="md" />
|
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -448,7 +448,11 @@ npm install
|
|||||||
disabled={!scriptExists || isSaving || isDeleting}
|
disabled={!scriptExists || isSaving || isDeleting}
|
||||||
className="gap-1.5 text-destructive hover:text-destructive hover:bg-destructive/10"
|
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
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -457,7 +461,11 @@ npm install
|
|||||||
disabled={!hasChanges || isSaving || isDeleting}
|
disabled={!hasChanges || isSaving || isDeleting}
|
||||||
className="gap-1.5"
|
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
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
import { Bot, Folder, RefreshCw, Square, Activity, FileText } from 'lucide-react';
|
import { Bot, Folder, Loader2, RefreshCw, Square, Activity, FileText } from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
|
||||||
import { getElectronAPI, RunningAgent } from '@/lib/electron';
|
import { getElectronAPI, RunningAgent } from '@/lib/electron';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -147,7 +146,7 @@ export function RunningAgentsView() {
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex items-center justify-center">
|
<div className="flex-1 flex items-center justify-center">
|
||||||
<Spinner size="xl" />
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -170,11 +169,7 @@ export function RunningAgentsView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={refreshing}>
|
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={refreshing}>
|
||||||
{refreshing ? (
|
<RefreshCw className={cn('h-4 w-4 mr-2', refreshing && 'animate-spin')} />
|
||||||
<Spinner size="sm" className="mr-2" />
|
|
||||||
) : (
|
|
||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
|
||||||
)}
|
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import {
|
|||||||
CursorSettingsTab,
|
CursorSettingsTab,
|
||||||
CodexSettingsTab,
|
CodexSettingsTab,
|
||||||
OpencodeSettingsTab,
|
OpencodeSettingsTab,
|
||||||
CodeRabbitSettingsTab,
|
|
||||||
} from './settings-view/providers';
|
} from './settings-view/providers';
|
||||||
import { MCPServersSection } from './settings-view/mcp-servers';
|
import { MCPServersSection } from './settings-view/mcp-servers';
|
||||||
import { PromptCustomizationSection } from './settings-view/prompts';
|
import { PromptCustomizationSection } from './settings-view/prompts';
|
||||||
@@ -124,8 +123,6 @@ export function SettingsView() {
|
|||||||
return <CodexSettingsTab />;
|
return <CodexSettingsTab />;
|
||||||
case 'opencode-provider':
|
case 'opencode-provider':
|
||||||
return <OpencodeSettingsTab />;
|
return <OpencodeSettingsTab />;
|
||||||
case 'coderabbit-provider':
|
|
||||||
return <CodeRabbitSettingsTab />;
|
|
||||||
case 'providers':
|
case 'providers':
|
||||||
case 'claude': // Backwards compatibility - redirect to claude-provider
|
case 'claude': // Backwards compatibility - redirect to claude-provider
|
||||||
return <ClaudeSettingsTab />;
|
return <ClaudeSettingsTab />;
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { LogOut, User, Code2, RefreshCw } from 'lucide-react';
|
import { LogOut, User, Code2, RefreshCw } from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { logout } from '@/lib/http-api-client';
|
import { logout } from '@/lib/http-api-client';
|
||||||
import { useAuthStore } from '@/store/auth-store';
|
import { useAuthStore } from '@/store/auth-store';
|
||||||
@@ -144,7 +143,7 @@ export function AccountSection() {
|
|||||||
disabled={isRefreshing || isLoadingEditors}
|
disabled={isRefreshing || isLoadingEditors}
|
||||||
className="shrink-0 h-9 w-9"
|
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>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { AlertCircle, CheckCircle2, Eye, EyeOff, Zap } from 'lucide-react';
|
import { AlertCircle, CheckCircle2, Eye, EyeOff, Loader2, Zap } from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
|
||||||
import type { ProviderConfig } from '@/config/api-providers';
|
import type { ProviderConfig } from '@/config/api-providers';
|
||||||
|
|
||||||
interface ApiKeyFieldProps {
|
interface ApiKeyFieldProps {
|
||||||
@@ -71,7 +70,7 @@ export function ApiKeyField({ config }: ApiKeyFieldProps) {
|
|||||||
>
|
>
|
||||||
{testButton.loading ? (
|
{testButton.loading ? (
|
||||||
<>
|
<>
|
||||||
<Spinner size="sm" className="mr-2" />
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
Testing...
|
Testing...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { useSetupStore } from '@/store/setup-store';
|
import { useSetupStore } from '@/store/setup-store';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Key, CheckCircle2, Trash2 } from 'lucide-react';
|
import { Key, CheckCircle2, Trash2, Loader2 } from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
|
||||||
import { ApiKeyField } from './api-key-field';
|
import { ApiKeyField } from './api-key-field';
|
||||||
import { buildProviderConfigs } from '@/config/api-providers';
|
import { buildProviderConfigs } from '@/config/api-providers';
|
||||||
import { SecurityNotice } from './security-notice';
|
import { SecurityNotice } from './security-notice';
|
||||||
@@ -143,7 +142,7 @@ export function ApiKeysSection() {
|
|||||||
data-testid="delete-anthropic-key"
|
data-testid="delete-anthropic-key"
|
||||||
>
|
>
|
||||||
{isDeletingAnthropicKey ? (
|
{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" />
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
)}
|
)}
|
||||||
@@ -160,7 +159,7 @@ export function ApiKeysSection() {
|
|||||||
data-testid="delete-openai-key"
|
data-testid="delete-openai-key"
|
||||||
>
|
>
|
||||||
{isDeletingOpenaiKey ? (
|
{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" />
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { getElectronAPI } from '@/lib/electron';
|
|||||||
import { useSetupStore } from '@/store/setup-store';
|
import { useSetupStore } from '@/store/setup-store';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
|
||||||
import { RefreshCw, AlertCircle } from 'lucide-react';
|
import { RefreshCw, AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
const ERROR_NO_API = 'Claude usage API not available';
|
const ERROR_NO_API = 'Claude usage API not available';
|
||||||
@@ -179,7 +178,7 @@ export function ClaudeUsageSection() {
|
|||||||
data-testid="refresh-claude-usage"
|
data-testid="refresh-claude-usage"
|
||||||
title={CLAUDE_REFRESH_LABEL}
|
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground/80 ml-12">{CLAUDE_USAGE_SUBTITLE}</p>
|
<p className="text-sm text-muted-foreground/80 ml-12">{CLAUDE_USAGE_SUBTITLE}</p>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
|
||||||
import { CheckCircle2, AlertCircle, RefreshCw, XCircle } from 'lucide-react';
|
import { CheckCircle2, AlertCircle, RefreshCw, XCircle } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { CliStatus } from '../shared/types';
|
import type { CliStatus } from '../shared/types';
|
||||||
@@ -173,7 +172,7 @@ export function ClaudeCliStatus({ status, authStatus, isChecking, onRefresh }: C
|
|||||||
'transition-all duration-200'
|
'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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground/80 ml-12">
|
<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