mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 06:42:03 +00:00
Compare commits
1 Commits
security-s
...
weird-side
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2eb92a0402 |
@@ -61,7 +61,7 @@ Traditional development tools help you write code. Automaker helps you **orchest
|
|||||||
|
|
||||||
### Powered by Claude Code
|
### Powered by Claude Code
|
||||||
|
|
||||||
Automaker leverages the [Claude Agent SDK](https://platform.claude.com/docs/en/agent-sdk/overview) to give AI agents full access to your codebase. Agents can read files, write code, execute commands, run tests, and make git commits—all while working in isolated git worktrees to keep your main branch safe.
|
Automaker leverages the [Claude Agent SDK](https://docs.anthropic.com/en/docs/claude-code) to give AI agents full access to your codebase. Agents can read files, write code, execute commands, run tests, and make git commits—all while working in isolated git worktrees to keep your main branch safe.
|
||||||
|
|
||||||
### Why This Matters
|
### Why This Matters
|
||||||
|
|
||||||
@@ -106,7 +106,7 @@ https://discord.gg/jjem7aEDKU
|
|||||||
|
|
||||||
- Node.js 18+
|
- Node.js 18+
|
||||||
- npm
|
- npm
|
||||||
- [Claude Code CLI](https://code.claude.com/docs/en/overview) installed and authenticated
|
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and authenticated
|
||||||
|
|
||||||
### Quick Start
|
### Quick Start
|
||||||
|
|
||||||
|
|||||||
1282
apps/app/server-bundle/package-lock.json
generated
Normal file
1282
apps/app/server-bundle/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
apps/app/server-bundle/package.json
Normal file
15
apps/app/server-bundle/package.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "@automaker/server-bundle",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"dependencies": {
|
||||||
|
"@anthropic-ai/claude-agent-sdk": "^0.1.61",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
|
"express": "^5.1.0",
|
||||||
|
"morgan": "^1.10.1",
|
||||||
|
"node-pty": "1.1.0-beta41",
|
||||||
|
"ws": "^8.18.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,7 +48,6 @@ import { createClaudeRoutes } from './routes/claude/index.js';
|
|||||||
import { ClaudeUsageService } from './services/claude-usage-service.js';
|
import { ClaudeUsageService } from './services/claude-usage-service.js';
|
||||||
import { createGitHubRoutes } from './routes/github/index.js';
|
import { createGitHubRoutes } from './routes/github/index.js';
|
||||||
import { createContextRoutes } from './routes/context/index.js';
|
import { createContextRoutes } from './routes/context/index.js';
|
||||||
import { cleanupStaleValidations } from './routes/github/routes/validation-common.js';
|
|
||||||
|
|
||||||
// Load environment variables
|
// Load environment variables
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
@@ -124,15 +123,6 @@ const claudeUsageService = new ClaudeUsageService();
|
|||||||
console.log('[Server] Agent service initialized');
|
console.log('[Server] Agent service initialized');
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// Run stale validation cleanup every hour to prevent memory leaks from crashed validations
|
|
||||||
const VALIDATION_CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
|
||||||
setInterval(() => {
|
|
||||||
const cleaned = cleanupStaleValidations();
|
|
||||||
if (cleaned > 0) {
|
|
||||||
console.log(`[Server] Cleaned up ${cleaned} stale validation entries`);
|
|
||||||
}
|
|
||||||
}, VALIDATION_CLEANUP_INTERVAL_MS);
|
|
||||||
|
|
||||||
// Mount API routes - health is unauthenticated for monitoring
|
// Mount API routes - health is unauthenticated for monitoring
|
||||||
app.use('/api/health', createHealthRoutes());
|
app.use('/api/health', createHealthRoutes());
|
||||||
|
|
||||||
@@ -157,7 +147,7 @@ app.use('/api/templates', createTemplatesRoutes());
|
|||||||
app.use('/api/terminal', createTerminalRoutes());
|
app.use('/api/terminal', createTerminalRoutes());
|
||||||
app.use('/api/settings', createSettingsRoutes(settingsService));
|
app.use('/api/settings', createSettingsRoutes(settingsService));
|
||||||
app.use('/api/claude', createClaudeRoutes(claudeUsageService));
|
app.use('/api/claude', createClaudeRoutes(claudeUsageService));
|
||||||
app.use('/api/github', createGitHubRoutes(events));
|
app.use('/api/github', createGitHubRoutes());
|
||||||
app.use('/api/context', createContextRoutes());
|
app.use('/api/context', createContextRoutes());
|
||||||
|
|
||||||
// Create HTTP server
|
// Create HTTP server
|
||||||
|
|||||||
@@ -2,30 +2,9 @@
|
|||||||
* Authentication middleware for API security
|
* Authentication middleware for API security
|
||||||
*
|
*
|
||||||
* Supports API key authentication via header or environment variable.
|
* Supports API key authentication via header or environment variable.
|
||||||
* Includes rate limiting to prevent brute-force attacks.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as crypto from 'crypto';
|
|
||||||
import type { Request, Response, NextFunction } from 'express';
|
import type { Request, Response, NextFunction } from 'express';
|
||||||
import { apiKeyRateLimiter } from './rate-limiter.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Performs a constant-time string comparison to prevent timing attacks.
|
|
||||||
* Uses crypto.timingSafeEqual with proper buffer handling.
|
|
||||||
*/
|
|
||||||
function secureCompare(a: string, b: string): boolean {
|
|
||||||
const bufferA = Buffer.from(a, 'utf8');
|
|
||||||
const bufferB = Buffer.from(b, 'utf8');
|
|
||||||
|
|
||||||
// If lengths differ, we still need to do a constant-time comparison
|
|
||||||
// to avoid leaking length information. We compare against bufferA twice.
|
|
||||||
if (bufferA.length !== bufferB.length) {
|
|
||||||
crypto.timingSafeEqual(bufferA, bufferA);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return crypto.timingSafeEqual(bufferA, bufferB);
|
|
||||||
}
|
|
||||||
|
|
||||||
// API key from environment (optional - if not set, auth is disabled)
|
// API key from environment (optional - if not set, auth is disabled)
|
||||||
const API_KEY = process.env.AUTOMAKER_API_KEY;
|
const API_KEY = process.env.AUTOMAKER_API_KEY;
|
||||||
@@ -35,7 +14,6 @@ const API_KEY = process.env.AUTOMAKER_API_KEY;
|
|||||||
*
|
*
|
||||||
* If AUTOMAKER_API_KEY is set, requires matching key in X-API-Key header.
|
* If AUTOMAKER_API_KEY is set, requires matching key in X-API-Key header.
|
||||||
* If not set, allows all requests (development mode).
|
* If not set, allows all requests (development mode).
|
||||||
* Includes rate limiting to prevent brute-force attacks.
|
|
||||||
*/
|
*/
|
||||||
export function authMiddleware(req: Request, res: Response, next: NextFunction): void {
|
export function authMiddleware(req: Request, res: Response, next: NextFunction): void {
|
||||||
// If no API key is configured, allow all requests
|
// If no API key is configured, allow all requests
|
||||||
@@ -44,22 +22,6 @@ export function authMiddleware(req: Request, res: Response, next: NextFunction):
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const clientIp = apiKeyRateLimiter.getClientIp(req);
|
|
||||||
|
|
||||||
// Check if client is rate limited
|
|
||||||
if (apiKeyRateLimiter.isBlocked(clientIp)) {
|
|
||||||
const retryAfterMs = apiKeyRateLimiter.getBlockTimeRemaining(clientIp);
|
|
||||||
const retryAfterSeconds = Math.ceil(retryAfterMs / 1000);
|
|
||||||
|
|
||||||
res.setHeader('Retry-After', retryAfterSeconds.toString());
|
|
||||||
res.status(429).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Too many failed authentication attempts. Please try again later.',
|
|
||||||
retryAfter: retryAfterSeconds,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for API key in header
|
// Check for API key in header
|
||||||
const providedKey = req.headers['x-api-key'] as string | undefined;
|
const providedKey = req.headers['x-api-key'] as string | undefined;
|
||||||
|
|
||||||
@@ -71,10 +33,7 @@ export function authMiddleware(req: Request, res: Response, next: NextFunction):
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!secureCompare(providedKey, API_KEY)) {
|
if (providedKey !== API_KEY) {
|
||||||
// Record failed attempt
|
|
||||||
apiKeyRateLimiter.recordFailure(clientIp);
|
|
||||||
|
|
||||||
res.status(403).json({
|
res.status(403).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Invalid API key.',
|
error: 'Invalid API key.',
|
||||||
@@ -82,9 +41,6 @@ export function authMiddleware(req: Request, res: Response, next: NextFunction):
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Successful authentication - reset rate limiter for this IP
|
|
||||||
apiKeyRateLimiter.reset(clientIp);
|
|
||||||
|
|
||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,208 +0,0 @@
|
|||||||
/**
|
|
||||||
* In-memory rate limiter for authentication endpoints
|
|
||||||
*
|
|
||||||
* Provides brute-force protection by tracking failed attempts per IP address.
|
|
||||||
* Blocks requests after exceeding the maximum number of failures within a time window.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Request, Response, NextFunction } from 'express';
|
|
||||||
|
|
||||||
interface AttemptRecord {
|
|
||||||
count: number;
|
|
||||||
firstAttempt: number;
|
|
||||||
blockedUntil: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RateLimiterConfig {
|
|
||||||
maxAttempts: number;
|
|
||||||
windowMs: number;
|
|
||||||
blockDurationMs: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_CONFIG: RateLimiterConfig = {
|
|
||||||
maxAttempts: 5,
|
|
||||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
||||||
blockDurationMs: 15 * 60 * 1000, // 15 minutes
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rate limiter instance that tracks attempts by a key (typically IP address)
|
|
||||||
*/
|
|
||||||
export class RateLimiter {
|
|
||||||
private attempts: Map<string, AttemptRecord> = new Map();
|
|
||||||
private config: RateLimiterConfig;
|
|
||||||
|
|
||||||
constructor(config: Partial<RateLimiterConfig> = {}) {
|
|
||||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract client IP address from request
|
|
||||||
* Handles proxied requests via X-Forwarded-For header
|
|
||||||
*/
|
|
||||||
getClientIp(req: Request): string {
|
|
||||||
const forwarded = req.headers['x-forwarded-for'];
|
|
||||||
if (forwarded) {
|
|
||||||
const forwardedIp = Array.isArray(forwarded) ? forwarded[0] : forwarded.split(',')[0];
|
|
||||||
return forwardedIp.trim();
|
|
||||||
}
|
|
||||||
return req.socket.remoteAddress || 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a key is currently rate limited
|
|
||||||
*/
|
|
||||||
isBlocked(key: string): boolean {
|
|
||||||
const record = this.attempts.get(key);
|
|
||||||
if (!record) return false;
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
// Check if currently blocked
|
|
||||||
if (record.blockedUntil && now < record.blockedUntil) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear expired block
|
|
||||||
if (record.blockedUntil && now >= record.blockedUntil) {
|
|
||||||
this.attempts.delete(key);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get remaining time until block expires (in milliseconds)
|
|
||||||
*/
|
|
||||||
getBlockTimeRemaining(key: string): number {
|
|
||||||
const record = this.attempts.get(key);
|
|
||||||
if (!record?.blockedUntil) return 0;
|
|
||||||
|
|
||||||
const remaining = record.blockedUntil - Date.now();
|
|
||||||
return remaining > 0 ? remaining : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Record a failed authentication attempt
|
|
||||||
* Returns true if the key is now blocked
|
|
||||||
*/
|
|
||||||
recordFailure(key: string): boolean {
|
|
||||||
const now = Date.now();
|
|
||||||
const record = this.attempts.get(key);
|
|
||||||
|
|
||||||
if (!record) {
|
|
||||||
this.attempts.set(key, {
|
|
||||||
count: 1,
|
|
||||||
firstAttempt: now,
|
|
||||||
blockedUntil: null,
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If window has expired, reset the counter
|
|
||||||
if (now - record.firstAttempt > this.config.windowMs) {
|
|
||||||
this.attempts.set(key, {
|
|
||||||
count: 1,
|
|
||||||
firstAttempt: now,
|
|
||||||
blockedUntil: null,
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Increment counter
|
|
||||||
record.count += 1;
|
|
||||||
|
|
||||||
// Check if should be blocked
|
|
||||||
if (record.count >= this.config.maxAttempts) {
|
|
||||||
record.blockedUntil = now + this.config.blockDurationMs;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear a key's record (e.g., on successful authentication)
|
|
||||||
*/
|
|
||||||
reset(key: string): void {
|
|
||||||
this.attempts.delete(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the number of attempts remaining before block
|
|
||||||
*/
|
|
||||||
getAttemptsRemaining(key: string): number {
|
|
||||||
const record = this.attempts.get(key);
|
|
||||||
if (!record) return this.config.maxAttempts;
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
// If window expired, full attempts available
|
|
||||||
if (now - record.firstAttempt > this.config.windowMs) {
|
|
||||||
return this.config.maxAttempts;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Math.max(0, this.config.maxAttempts - record.count);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clean up expired records to prevent memory leaks
|
|
||||||
*/
|
|
||||||
cleanup(): void {
|
|
||||||
const now = Date.now();
|
|
||||||
const keysToDelete: string[] = [];
|
|
||||||
|
|
||||||
this.attempts.forEach((record, key) => {
|
|
||||||
// Mark for deletion if block has expired
|
|
||||||
if (record.blockedUntil && now >= record.blockedUntil) {
|
|
||||||
keysToDelete.push(key);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Mark for deletion if window has expired and not blocked
|
|
||||||
if (!record.blockedUntil && now - record.firstAttempt > this.config.windowMs) {
|
|
||||||
keysToDelete.push(key);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
keysToDelete.forEach((key) => this.attempts.delete(key));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shared rate limiter instances for authentication endpoints
|
|
||||||
export const apiKeyRateLimiter = new RateLimiter();
|
|
||||||
export const terminalAuthRateLimiter = new RateLimiter();
|
|
||||||
|
|
||||||
// Clean up expired records periodically (every 5 minutes)
|
|
||||||
setInterval(
|
|
||||||
() => {
|
|
||||||
apiKeyRateLimiter.cleanup();
|
|
||||||
terminalAuthRateLimiter.cleanup();
|
|
||||||
},
|
|
||||||
5 * 60 * 1000
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create rate limiting middleware for authentication endpoints
|
|
||||||
* This middleware checks if the request is rate limited before processing
|
|
||||||
*/
|
|
||||||
export function createRateLimitMiddleware(rateLimiter: RateLimiter) {
|
|
||||||
return (req: Request, res: Response, next: NextFunction): void => {
|
|
||||||
const clientIp = rateLimiter.getClientIp(req);
|
|
||||||
|
|
||||||
if (rateLimiter.isBlocked(clientIp)) {
|
|
||||||
const retryAfterMs = rateLimiter.getBlockTimeRemaining(clientIp);
|
|
||||||
const retryAfterSeconds = Math.ceil(retryAfterMs / 1000);
|
|
||||||
|
|
||||||
res.setHeader('Retry-After', retryAfterSeconds.toString());
|
|
||||||
res.status(429).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Too many failed authentication attempts. Please try again later.',
|
|
||||||
retryAfter: retryAfterSeconds,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
next();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
/**
|
|
||||||
* Validation Storage - CRUD operations for GitHub issue validation results
|
|
||||||
*
|
|
||||||
* Stores validation results in .automaker/validations/{issueNumber}/validation.json
|
|
||||||
* Results include the validation verdict, metadata, and timestamp for cache invalidation.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import * as secureFs from './secure-fs.js';
|
|
||||||
import { getValidationsDir, getValidationDir, getValidationPath } from '@automaker/platform';
|
|
||||||
import type { StoredValidation } from '@automaker/types';
|
|
||||||
|
|
||||||
// Re-export StoredValidation for convenience
|
|
||||||
export type { StoredValidation };
|
|
||||||
|
|
||||||
/** Number of hours before a validation is considered stale */
|
|
||||||
const VALIDATION_CACHE_TTL_HOURS = 24;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Write validation result to storage
|
|
||||||
*
|
|
||||||
* Creates the validation directory if needed and stores the result as JSON.
|
|
||||||
*
|
|
||||||
* @param projectPath - Absolute path to project directory
|
|
||||||
* @param issueNumber - GitHub issue number
|
|
||||||
* @param data - Validation data to store
|
|
||||||
*/
|
|
||||||
export async function writeValidation(
|
|
||||||
projectPath: string,
|
|
||||||
issueNumber: number,
|
|
||||||
data: StoredValidation
|
|
||||||
): Promise<void> {
|
|
||||||
const validationDir = getValidationDir(projectPath, issueNumber);
|
|
||||||
const validationPath = getValidationPath(projectPath, issueNumber);
|
|
||||||
|
|
||||||
// Ensure directory exists
|
|
||||||
await secureFs.mkdir(validationDir, { recursive: true });
|
|
||||||
|
|
||||||
// Write validation result
|
|
||||||
await secureFs.writeFile(validationPath, JSON.stringify(data, null, 2), 'utf-8');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read validation result from storage
|
|
||||||
*
|
|
||||||
* @param projectPath - Absolute path to project directory
|
|
||||||
* @param issueNumber - GitHub issue number
|
|
||||||
* @returns Stored validation or null if not found
|
|
||||||
*/
|
|
||||||
export async function readValidation(
|
|
||||||
projectPath: string,
|
|
||||||
issueNumber: number
|
|
||||||
): Promise<StoredValidation | null> {
|
|
||||||
try {
|
|
||||||
const validationPath = getValidationPath(projectPath, issueNumber);
|
|
||||||
const content = (await secureFs.readFile(validationPath, 'utf-8')) as string;
|
|
||||||
return JSON.parse(content) as StoredValidation;
|
|
||||||
} catch {
|
|
||||||
// File doesn't exist or can't be read
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all stored validations for a project
|
|
||||||
*
|
|
||||||
* @param projectPath - Absolute path to project directory
|
|
||||||
* @returns Array of stored validations
|
|
||||||
*/
|
|
||||||
export async function getAllValidations(projectPath: string): Promise<StoredValidation[]> {
|
|
||||||
const validationsDir = getValidationsDir(projectPath);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const dirs = await secureFs.readdir(validationsDir, { withFileTypes: true });
|
|
||||||
|
|
||||||
// Read all validation files in parallel for better performance
|
|
||||||
const promises = dirs
|
|
||||||
.filter((dir) => dir.isDirectory())
|
|
||||||
.map((dir) => {
|
|
||||||
const issueNumber = parseInt(dir.name, 10);
|
|
||||||
if (!isNaN(issueNumber)) {
|
|
||||||
return readValidation(projectPath, issueNumber);
|
|
||||||
}
|
|
||||||
return Promise.resolve(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
const results = await Promise.all(promises);
|
|
||||||
const validations = results.filter((v): v is StoredValidation => v !== null);
|
|
||||||
|
|
||||||
// Sort by issue number
|
|
||||||
validations.sort((a, b) => a.issueNumber - b.issueNumber);
|
|
||||||
|
|
||||||
return validations;
|
|
||||||
} catch {
|
|
||||||
// Directory doesn't exist
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a validation from storage
|
|
||||||
*
|
|
||||||
* @param projectPath - Absolute path to project directory
|
|
||||||
* @param issueNumber - GitHub issue number
|
|
||||||
* @returns true if validation was deleted, false if not found
|
|
||||||
*/
|
|
||||||
export async function deleteValidation(projectPath: string, issueNumber: number): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const validationDir = getValidationDir(projectPath, issueNumber);
|
|
||||||
await secureFs.rm(validationDir, { recursive: true, force: true });
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a validation is stale (older than TTL)
|
|
||||||
*
|
|
||||||
* @param validation - Stored validation to check
|
|
||||||
* @returns true if validation is older than 24 hours
|
|
||||||
*/
|
|
||||||
export function isValidationStale(validation: StoredValidation): boolean {
|
|
||||||
const validatedAt = new Date(validation.validatedAt);
|
|
||||||
const now = new Date();
|
|
||||||
const hoursDiff = (now.getTime() - validatedAt.getTime()) / (1000 * 60 * 60);
|
|
||||||
return hoursDiff > VALIDATION_CACHE_TTL_HOURS;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get validation with freshness info
|
|
||||||
*
|
|
||||||
* @param projectPath - Absolute path to project directory
|
|
||||||
* @param issueNumber - GitHub issue number
|
|
||||||
* @returns Object with validation and isStale flag, or null if not found
|
|
||||||
*/
|
|
||||||
export async function getValidationWithFreshness(
|
|
||||||
projectPath: string,
|
|
||||||
issueNumber: number
|
|
||||||
): Promise<{ validation: StoredValidation; isStale: boolean } | null> {
|
|
||||||
const validation = await readValidation(projectPath, issueNumber);
|
|
||||||
if (!validation) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
validation,
|
|
||||||
isStale: isValidationStale(validation),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mark a validation as viewed by the user
|
|
||||||
*
|
|
||||||
* @param projectPath - Absolute path to project directory
|
|
||||||
* @param issueNumber - GitHub issue number
|
|
||||||
* @returns true if validation was marked as viewed, false if not found
|
|
||||||
*/
|
|
||||||
export async function markValidationViewed(
|
|
||||||
projectPath: string,
|
|
||||||
issueNumber: number
|
|
||||||
): Promise<boolean> {
|
|
||||||
const validation = await readValidation(projectPath, issueNumber);
|
|
||||||
if (!validation) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
validation.viewedAt = new Date().toISOString();
|
|
||||||
await writeValidation(projectPath, issueNumber, validation);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get count of unviewed, non-stale validations for a project
|
|
||||||
*
|
|
||||||
* @param projectPath - Absolute path to project directory
|
|
||||||
* @returns Number of unviewed validations
|
|
||||||
*/
|
|
||||||
export async function getUnviewedValidationsCount(projectPath: string): Promise<number> {
|
|
||||||
const validations = await getAllValidations(projectPath);
|
|
||||||
return validations.filter((v) => !v.viewedAt && !isValidationStale(v)).length;
|
|
||||||
}
|
|
||||||
@@ -7,29 +7,6 @@
|
|||||||
import type { Request, Response, NextFunction } from 'express';
|
import type { Request, Response, NextFunction } from 'express';
|
||||||
import { validatePath, PathNotAllowedError } from '@automaker/platform';
|
import { validatePath, PathNotAllowedError } from '@automaker/platform';
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom error for invalid path type
|
|
||||||
*/
|
|
||||||
class InvalidPathTypeError extends Error {
|
|
||||||
constructor(paramName: string, expectedType: string, actualType: string) {
|
|
||||||
super(`Invalid type for '${paramName}': expected ${expectedType}, got ${actualType}`);
|
|
||||||
this.name = 'InvalidPathTypeError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates that a value is a non-empty string suitable for path validation
|
|
||||||
*
|
|
||||||
* @param value - The value to check
|
|
||||||
* @param paramName - The parameter name for error messages
|
|
||||||
* @throws InvalidPathTypeError if value is not a valid string
|
|
||||||
*/
|
|
||||||
function assertValidPathString(value: unknown, paramName: string): asserts value is string {
|
|
||||||
if (typeof value !== 'string') {
|
|
||||||
throw new InvalidPathTypeError(paramName, 'string', typeof value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a middleware that validates specified path parameters in req.body
|
* Creates a middleware that validates specified path parameters in req.body
|
||||||
* @param paramNames - Names of parameters to validate (e.g., 'projectPath', 'worktreePath')
|
* @param paramNames - Names of parameters to validate (e.g., 'projectPath', 'worktreePath')
|
||||||
@@ -50,8 +27,7 @@ export function validatePathParams(...paramNames: string[]) {
|
|||||||
if (paramName.endsWith('?')) {
|
if (paramName.endsWith('?')) {
|
||||||
const actualName = paramName.slice(0, -1);
|
const actualName = paramName.slice(0, -1);
|
||||||
const value = req.body[actualName];
|
const value = req.body[actualName];
|
||||||
if (value !== undefined && value !== null) {
|
if (value) {
|
||||||
assertValidPathString(value, actualName);
|
|
||||||
validatePath(value);
|
validatePath(value);
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
@@ -61,30 +37,17 @@ export function validatePathParams(...paramNames: string[]) {
|
|||||||
if (paramName.endsWith('[]')) {
|
if (paramName.endsWith('[]')) {
|
||||||
const actualName = paramName.slice(0, -2);
|
const actualName = paramName.slice(0, -2);
|
||||||
const values = req.body[actualName];
|
const values = req.body[actualName];
|
||||||
|
if (Array.isArray(values) && values.length > 0) {
|
||||||
// Skip if not provided or empty
|
for (const value of values) {
|
||||||
if (values === undefined || values === null) {
|
validatePath(value);
|
||||||
continue;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Validate that it's actually an array
|
|
||||||
if (!Array.isArray(values)) {
|
|
||||||
throw new InvalidPathTypeError(actualName, 'array', typeof values);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate each element in the array
|
|
||||||
for (let i = 0; i < values.length; i++) {
|
|
||||||
const value = values[i];
|
|
||||||
assertValidPathString(value, `${actualName}[${i}]`);
|
|
||||||
validatePath(value);
|
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle regular parameters
|
// Handle regular parameters
|
||||||
const value = req.body[paramName];
|
const value = req.body[paramName];
|
||||||
if (value !== undefined && value !== null) {
|
if (value) {
|
||||||
assertValidPathString(value, paramName);
|
|
||||||
validatePath(value);
|
validatePath(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,14 +62,6 @@ export function validatePathParams(...paramNames: string[]) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof InvalidPathTypeError) {
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: error.message,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-throw unexpected errors
|
// Re-throw unexpected errors
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,50 +3,16 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import type { EventEmitter } from '../../lib/events.js';
|
|
||||||
import { validatePathParams } from '../../middleware/validate-paths.js';
|
|
||||||
import { createCheckGitHubRemoteHandler } from './routes/check-github-remote.js';
|
import { createCheckGitHubRemoteHandler } from './routes/check-github-remote.js';
|
||||||
import { createListIssuesHandler } from './routes/list-issues.js';
|
import { createListIssuesHandler } from './routes/list-issues.js';
|
||||||
import { createListPRsHandler } from './routes/list-prs.js';
|
import { createListPRsHandler } from './routes/list-prs.js';
|
||||||
import { createValidateIssueHandler } from './routes/validate-issue.js';
|
|
||||||
import {
|
|
||||||
createValidationStatusHandler,
|
|
||||||
createValidationStopHandler,
|
|
||||||
createGetValidationsHandler,
|
|
||||||
createDeleteValidationHandler,
|
|
||||||
createMarkViewedHandler,
|
|
||||||
} from './routes/validation-endpoints.js';
|
|
||||||
|
|
||||||
export function createGitHubRoutes(events: EventEmitter): Router {
|
export function createGitHubRoutes(): Router {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.post('/check-remote', validatePathParams('projectPath'), createCheckGitHubRemoteHandler());
|
router.post('/check-remote', createCheckGitHubRemoteHandler());
|
||||||
router.post('/issues', validatePathParams('projectPath'), createListIssuesHandler());
|
router.post('/issues', createListIssuesHandler());
|
||||||
router.post('/prs', validatePathParams('projectPath'), createListPRsHandler());
|
router.post('/prs', createListPRsHandler());
|
||||||
router.post(
|
|
||||||
'/validate-issue',
|
|
||||||
validatePathParams('projectPath'),
|
|
||||||
createValidateIssueHandler(events)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Validation management endpoints
|
|
||||||
router.post(
|
|
||||||
'/validation-status',
|
|
||||||
validatePathParams('projectPath'),
|
|
||||||
createValidationStatusHandler()
|
|
||||||
);
|
|
||||||
router.post('/validation-stop', validatePathParams('projectPath'), createValidationStopHandler());
|
|
||||||
router.post('/validations', validatePathParams('projectPath'), createGetValidationsHandler());
|
|
||||||
router.post(
|
|
||||||
'/validation-delete',
|
|
||||||
validatePathParams('projectPath'),
|
|
||||||
createDeleteValidationHandler()
|
|
||||||
);
|
|
||||||
router.post(
|
|
||||||
'/validation-mark-viewed',
|
|
||||||
validatePathParams('projectPath'),
|
|
||||||
createMarkViewedHandler(events)
|
|
||||||
);
|
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
* POST /list-issues endpoint - List GitHub issues for a project
|
* POST /list-issues endpoint - List GitHub issues for a project
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { spawn } from 'child_process';
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { execAsync, execEnv, getErrorMessage, logError } from './common.js';
|
import { execAsync, execEnv, getErrorMessage, logError } from './common.js';
|
||||||
import { checkGitHubRemote } from './check-github-remote.js';
|
import { checkGitHubRemote } from './check-github-remote.js';
|
||||||
@@ -14,19 +13,6 @@ export interface GitHubLabel {
|
|||||||
|
|
||||||
export interface GitHubAuthor {
|
export interface GitHubAuthor {
|
||||||
login: string;
|
login: string;
|
||||||
avatarUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GitHubAssignee {
|
|
||||||
login: string;
|
|
||||||
avatarUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LinkedPullRequest {
|
|
||||||
number: number;
|
|
||||||
title: string;
|
|
||||||
state: string;
|
|
||||||
url: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GitHubIssue {
|
export interface GitHubIssue {
|
||||||
@@ -38,8 +24,6 @@ export interface GitHubIssue {
|
|||||||
labels: GitHubLabel[];
|
labels: GitHubLabel[];
|
||||||
url: string;
|
url: string;
|
||||||
body: string;
|
body: string;
|
||||||
assignees: GitHubAssignee[];
|
|
||||||
linkedPRs?: LinkedPullRequest[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListIssuesResult {
|
export interface ListIssuesResult {
|
||||||
@@ -49,146 +33,6 @@ export interface ListIssuesResult {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch linked PRs for a list of issues using GitHub GraphQL API
|
|
||||||
*/
|
|
||||||
async function fetchLinkedPRs(
|
|
||||||
projectPath: string,
|
|
||||||
owner: string,
|
|
||||||
repo: string,
|
|
||||||
issueNumbers: number[]
|
|
||||||
): Promise<Map<number, LinkedPullRequest[]>> {
|
|
||||||
const linkedPRsMap = new Map<number, LinkedPullRequest[]>();
|
|
||||||
|
|
||||||
if (issueNumbers.length === 0) {
|
|
||||||
return linkedPRsMap;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build GraphQL query for batch fetching linked PRs
|
|
||||||
// We fetch up to 20 issues at a time to avoid query limits
|
|
||||||
const batchSize = 20;
|
|
||||||
for (let i = 0; i < issueNumbers.length; i += batchSize) {
|
|
||||||
const batch = issueNumbers.slice(i, i + batchSize);
|
|
||||||
|
|
||||||
const issueQueries = batch
|
|
||||||
.map(
|
|
||||||
(num, idx) => `
|
|
||||||
issue${idx}: issue(number: ${num}) {
|
|
||||||
number
|
|
||||||
timelineItems(first: 10, itemTypes: [CROSS_REFERENCED_EVENT, CONNECTED_EVENT]) {
|
|
||||||
nodes {
|
|
||||||
... on CrossReferencedEvent {
|
|
||||||
source {
|
|
||||||
... on PullRequest {
|
|
||||||
number
|
|
||||||
title
|
|
||||||
state
|
|
||||||
url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
... on ConnectedEvent {
|
|
||||||
subject {
|
|
||||||
... on PullRequest {
|
|
||||||
number
|
|
||||||
title
|
|
||||||
state
|
|
||||||
url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}`
|
|
||||||
)
|
|
||||||
.join('\n');
|
|
||||||
|
|
||||||
const query = `{
|
|
||||||
repository(owner: "${owner}", name: "${repo}") {
|
|
||||||
${issueQueries}
|
|
||||||
}
|
|
||||||
}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Use spawn with stdin to avoid shell injection vulnerabilities
|
|
||||||
// --input - reads the JSON request body from stdin
|
|
||||||
const requestBody = JSON.stringify({ query });
|
|
||||||
const response = await new Promise<Record<string, unknown>>((resolve, reject) => {
|
|
||||||
const gh = spawn('gh', ['api', 'graphql', '--input', '-'], {
|
|
||||||
cwd: projectPath,
|
|
||||||
env: execEnv,
|
|
||||||
});
|
|
||||||
|
|
||||||
let stdout = '';
|
|
||||||
let stderr = '';
|
|
||||||
gh.stdout.on('data', (data: Buffer) => (stdout += data.toString()));
|
|
||||||
gh.stderr.on('data', (data: Buffer) => (stderr += data.toString()));
|
|
||||||
|
|
||||||
gh.on('close', (code) => {
|
|
||||||
if (code !== 0) {
|
|
||||||
return reject(new Error(`gh process exited with code ${code}: ${stderr}`));
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
resolve(JSON.parse(stdout));
|
|
||||||
} catch (e) {
|
|
||||||
reject(e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
gh.stdin.write(requestBody);
|
|
||||||
gh.stdin.end();
|
|
||||||
});
|
|
||||||
|
|
||||||
const repoData = (response?.data as Record<string, unknown>)?.repository as Record<
|
|
||||||
string,
|
|
||||||
unknown
|
|
||||||
> | null;
|
|
||||||
|
|
||||||
if (repoData) {
|
|
||||||
batch.forEach((issueNum, idx) => {
|
|
||||||
const issueData = repoData[`issue${idx}`] as {
|
|
||||||
timelineItems?: {
|
|
||||||
nodes?: Array<{
|
|
||||||
source?: { number?: number; title?: string; state?: string; url?: string };
|
|
||||||
subject?: { number?: number; title?: string; state?: string; url?: string };
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
} | null;
|
|
||||||
if (issueData?.timelineItems?.nodes) {
|
|
||||||
const linkedPRs: LinkedPullRequest[] = [];
|
|
||||||
const seenPRs = new Set<number>();
|
|
||||||
|
|
||||||
for (const node of issueData.timelineItems.nodes) {
|
|
||||||
const pr = node?.source || node?.subject;
|
|
||||||
if (pr?.number && !seenPRs.has(pr.number)) {
|
|
||||||
seenPRs.add(pr.number);
|
|
||||||
linkedPRs.push({
|
|
||||||
number: pr.number,
|
|
||||||
title: pr.title || '',
|
|
||||||
state: (pr.state || '').toLowerCase(),
|
|
||||||
url: pr.url || '',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (linkedPRs.length > 0) {
|
|
||||||
linkedPRsMap.set(issueNum, linkedPRs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// If GraphQL fails, continue without linked PRs
|
|
||||||
console.warn(
|
|
||||||
'Failed to fetch linked PRs via GraphQL:',
|
|
||||||
error instanceof Error ? error.message : error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return linkedPRsMap;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createListIssuesHandler() {
|
export function createListIssuesHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
@@ -209,17 +53,17 @@ export function createListIssuesHandler() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch open and closed issues in parallel (now including assignees)
|
// Fetch open and closed issues in parallel
|
||||||
const [openResult, closedResult] = await Promise.all([
|
const [openResult, closedResult] = await Promise.all([
|
||||||
execAsync(
|
execAsync(
|
||||||
'gh issue list --state open --json number,title,state,author,createdAt,labels,url,body,assignees --limit 100',
|
'gh issue list --state open --json number,title,state,author,createdAt,labels,url,body --limit 100',
|
||||||
{
|
{
|
||||||
cwd: projectPath,
|
cwd: projectPath,
|
||||||
env: execEnv,
|
env: execEnv,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
execAsync(
|
execAsync(
|
||||||
'gh issue list --state closed --json number,title,state,author,createdAt,labels,url,body,assignees --limit 50',
|
'gh issue list --state closed --json number,title,state,author,createdAt,labels,url,body --limit 50',
|
||||||
{
|
{
|
||||||
cwd: projectPath,
|
cwd: projectPath,
|
||||||
env: execEnv,
|
env: execEnv,
|
||||||
@@ -233,24 +77,6 @@ export function createListIssuesHandler() {
|
|||||||
const openIssues: GitHubIssue[] = JSON.parse(openStdout || '[]');
|
const openIssues: GitHubIssue[] = JSON.parse(openStdout || '[]');
|
||||||
const closedIssues: GitHubIssue[] = JSON.parse(closedStdout || '[]');
|
const closedIssues: GitHubIssue[] = JSON.parse(closedStdout || '[]');
|
||||||
|
|
||||||
// Fetch linked PRs for open issues (more relevant for active work)
|
|
||||||
if (remoteStatus.owner && remoteStatus.repo && openIssues.length > 0) {
|
|
||||||
const linkedPRsMap = await fetchLinkedPRs(
|
|
||||||
projectPath,
|
|
||||||
remoteStatus.owner,
|
|
||||||
remoteStatus.repo,
|
|
||||||
openIssues.map((i) => i.number)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Attach linked PRs to issues
|
|
||||||
for (const issue of openIssues) {
|
|
||||||
const linkedPRs = linkedPRsMap.get(issue.number);
|
|
||||||
if (linkedPRs) {
|
|
||||||
issue.linkedPRs = linkedPRs;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
openIssues,
|
openIssues,
|
||||||
|
|||||||
@@ -1,287 +0,0 @@
|
|||||||
/**
|
|
||||||
* POST /validate-issue endpoint - Validate a GitHub issue using Claude SDK (async)
|
|
||||||
*
|
|
||||||
* Scans the codebase to determine if an issue is valid, invalid, or needs clarification.
|
|
||||||
* Runs asynchronously and emits events for progress and completion.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
|
||||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
||||||
import type { EventEmitter } from '../../../lib/events.js';
|
|
||||||
import type { IssueValidationResult, IssueValidationEvent, AgentModel } from '@automaker/types';
|
|
||||||
import { createSuggestionsOptions } from '../../../lib/sdk-options.js';
|
|
||||||
import { writeValidation } from '../../../lib/validation-storage.js';
|
|
||||||
import {
|
|
||||||
issueValidationSchema,
|
|
||||||
ISSUE_VALIDATION_SYSTEM_PROMPT,
|
|
||||||
buildValidationPrompt,
|
|
||||||
} from './validation-schema.js';
|
|
||||||
import {
|
|
||||||
trySetValidationRunning,
|
|
||||||
clearValidationStatus,
|
|
||||||
getErrorMessage,
|
|
||||||
logError,
|
|
||||||
logger,
|
|
||||||
} from './validation-common.js';
|
|
||||||
|
|
||||||
/** Valid model values for validation */
|
|
||||||
const VALID_MODELS: readonly AgentModel[] = ['opus', 'sonnet', 'haiku'] as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Request body for issue validation
|
|
||||||
*/
|
|
||||||
interface ValidateIssueRequestBody {
|
|
||||||
projectPath: string;
|
|
||||||
issueNumber: number;
|
|
||||||
issueTitle: string;
|
|
||||||
issueBody: string;
|
|
||||||
issueLabels?: string[];
|
|
||||||
/** Model to use for validation (opus, sonnet, haiku) */
|
|
||||||
model?: AgentModel;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run the validation asynchronously
|
|
||||||
*
|
|
||||||
* Emits events for start, progress, complete, and error.
|
|
||||||
* Stores result on completion.
|
|
||||||
*/
|
|
||||||
async function runValidation(
|
|
||||||
projectPath: string,
|
|
||||||
issueNumber: number,
|
|
||||||
issueTitle: string,
|
|
||||||
issueBody: string,
|
|
||||||
issueLabels: string[] | undefined,
|
|
||||||
model: AgentModel,
|
|
||||||
events: EventEmitter,
|
|
||||||
abortController: AbortController
|
|
||||||
): Promise<void> {
|
|
||||||
// Emit start event
|
|
||||||
const startEvent: IssueValidationEvent = {
|
|
||||||
type: 'issue_validation_start',
|
|
||||||
issueNumber,
|
|
||||||
issueTitle,
|
|
||||||
projectPath,
|
|
||||||
};
|
|
||||||
events.emit('issue-validation:event', startEvent);
|
|
||||||
|
|
||||||
// Set up timeout (6 minutes)
|
|
||||||
const VALIDATION_TIMEOUT_MS = 360000;
|
|
||||||
const timeoutId = setTimeout(() => {
|
|
||||||
logger.warn(`Validation timeout reached after ${VALIDATION_TIMEOUT_MS}ms`);
|
|
||||||
abortController.abort();
|
|
||||||
}, VALIDATION_TIMEOUT_MS);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Build the prompt
|
|
||||||
const prompt = buildValidationPrompt(issueNumber, issueTitle, issueBody, issueLabels);
|
|
||||||
|
|
||||||
// Create SDK options with structured output and abort controller
|
|
||||||
const options = createSuggestionsOptions({
|
|
||||||
cwd: projectPath,
|
|
||||||
model,
|
|
||||||
systemPrompt: ISSUE_VALIDATION_SYSTEM_PROMPT,
|
|
||||||
abortController,
|
|
||||||
outputFormat: {
|
|
||||||
type: 'json_schema',
|
|
||||||
schema: issueValidationSchema as Record<string, unknown>,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Execute the query
|
|
||||||
const stream = query({ prompt, options });
|
|
||||||
let validationResult: IssueValidationResult | null = null;
|
|
||||||
let responseText = '';
|
|
||||||
|
|
||||||
for await (const msg of stream) {
|
|
||||||
// Collect assistant text for debugging and emit progress
|
|
||||||
if (msg.type === 'assistant' && msg.message?.content) {
|
|
||||||
for (const block of msg.message.content) {
|
|
||||||
if (block.type === 'text') {
|
|
||||||
responseText += block.text;
|
|
||||||
|
|
||||||
// Emit progress event
|
|
||||||
const progressEvent: IssueValidationEvent = {
|
|
||||||
type: 'issue_validation_progress',
|
|
||||||
issueNumber,
|
|
||||||
content: block.text,
|
|
||||||
projectPath,
|
|
||||||
};
|
|
||||||
events.emit('issue-validation:event', progressEvent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract structured output on success
|
|
||||||
if (msg.type === 'result' && msg.subtype === 'success') {
|
|
||||||
const resultMsg = msg as { structured_output?: IssueValidationResult };
|
|
||||||
if (resultMsg.structured_output) {
|
|
||||||
validationResult = resultMsg.structured_output;
|
|
||||||
logger.debug('Received structured output:', validationResult);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle errors
|
|
||||||
if (msg.type === 'result') {
|
|
||||||
const resultMsg = msg as { subtype?: string };
|
|
||||||
if (resultMsg.subtype === 'error_max_structured_output_retries') {
|
|
||||||
logger.error('Failed to produce valid structured output after retries');
|
|
||||||
throw new Error('Could not produce valid validation output');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear timeout
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
|
|
||||||
// Require structured output
|
|
||||||
if (!validationResult) {
|
|
||||||
logger.error('No structured output received from Claude SDK');
|
|
||||||
logger.debug('Raw response text:', responseText);
|
|
||||||
throw new Error('Validation failed: no structured output received');
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Issue #${issueNumber} validation complete: ${validationResult.verdict}`);
|
|
||||||
|
|
||||||
// Store the result
|
|
||||||
await writeValidation(projectPath, issueNumber, {
|
|
||||||
issueNumber,
|
|
||||||
issueTitle,
|
|
||||||
validatedAt: new Date().toISOString(),
|
|
||||||
model,
|
|
||||||
result: validationResult,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Emit completion event
|
|
||||||
const completeEvent: IssueValidationEvent = {
|
|
||||||
type: 'issue_validation_complete',
|
|
||||||
issueNumber,
|
|
||||||
issueTitle,
|
|
||||||
result: validationResult,
|
|
||||||
projectPath,
|
|
||||||
model,
|
|
||||||
};
|
|
||||||
events.emit('issue-validation:event', completeEvent);
|
|
||||||
} catch (error) {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
|
|
||||||
const errorMessage = getErrorMessage(error);
|
|
||||||
logError(error, `Issue #${issueNumber} validation failed`);
|
|
||||||
|
|
||||||
// Emit error event
|
|
||||||
const errorEvent: IssueValidationEvent = {
|
|
||||||
type: 'issue_validation_error',
|
|
||||||
issueNumber,
|
|
||||||
error: errorMessage,
|
|
||||||
projectPath,
|
|
||||||
};
|
|
||||||
events.emit('issue-validation:event', errorEvent);
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates the handler for validating GitHub issues against the codebase.
|
|
||||||
*
|
|
||||||
* Uses Claude SDK with:
|
|
||||||
* - Read-only tools (Read, Glob, Grep) for codebase analysis
|
|
||||||
* - JSON schema structured output for reliable parsing
|
|
||||||
* - System prompt guiding the validation process
|
|
||||||
* - Async execution with event emission
|
|
||||||
*/
|
|
||||||
export function createValidateIssueHandler(events: EventEmitter) {
|
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
|
||||||
try {
|
|
||||||
const {
|
|
||||||
projectPath,
|
|
||||||
issueNumber,
|
|
||||||
issueTitle,
|
|
||||||
issueBody,
|
|
||||||
issueLabels,
|
|
||||||
model = 'opus',
|
|
||||||
} = req.body as ValidateIssueRequestBody;
|
|
||||||
|
|
||||||
// Validate required fields
|
|
||||||
if (!projectPath) {
|
|
||||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!issueNumber || typeof issueNumber !== 'number') {
|
|
||||||
res
|
|
||||||
.status(400)
|
|
||||||
.json({ success: false, error: 'issueNumber is required and must be a number' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!issueTitle || typeof issueTitle !== 'string') {
|
|
||||||
res.status(400).json({ success: false, error: 'issueTitle is required' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof issueBody !== 'string') {
|
|
||||||
res.status(400).json({ success: false, error: 'issueBody must be a string' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate model parameter at runtime
|
|
||||||
if (!VALID_MODELS.includes(model)) {
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: `Invalid model. Must be one of: ${VALID_MODELS.join(', ')}`,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Starting async validation for issue #${issueNumber}: ${issueTitle}`);
|
|
||||||
|
|
||||||
// Create abort controller and atomically try to claim validation slot
|
|
||||||
// This prevents TOCTOU race conditions
|
|
||||||
const abortController = new AbortController();
|
|
||||||
if (!trySetValidationRunning(projectPath, issueNumber, abortController)) {
|
|
||||||
res.json({
|
|
||||||
success: false,
|
|
||||||
error: `Validation is already running for issue #${issueNumber}`,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start validation in background (fire-and-forget)
|
|
||||||
runValidation(
|
|
||||||
projectPath,
|
|
||||||
issueNumber,
|
|
||||||
issueTitle,
|
|
||||||
issueBody,
|
|
||||||
issueLabels,
|
|
||||||
model,
|
|
||||||
events,
|
|
||||||
abortController
|
|
||||||
)
|
|
||||||
.catch((error) => {
|
|
||||||
// Error is already handled inside runValidation (event emitted)
|
|
||||||
logger.debug('Validation error caught in background handler:', error);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
clearValidationStatus(projectPath, issueNumber);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Return immediately
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: `Validation started for issue #${issueNumber}`,
|
|
||||||
issueNumber,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logError(error, `Issue validation failed`);
|
|
||||||
logger.error('Issue validation error:', error);
|
|
||||||
|
|
||||||
if (!res.headersSent) {
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: getErrorMessage(error),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
/**
|
|
||||||
* Common utilities and state for issue validation routes
|
|
||||||
*
|
|
||||||
* Tracks running validation status per issue to support:
|
|
||||||
* - Checking if a validation is in progress
|
|
||||||
* - Cancelling a running validation
|
|
||||||
* - Preventing duplicate validations for the same issue
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { createLogger } from '@automaker/utils';
|
|
||||||
import { getErrorMessage as getErrorMessageShared, createLogError } from '../../common.js';
|
|
||||||
|
|
||||||
const logger = createLogger('IssueValidation');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Status of a validation in progress
|
|
||||||
*/
|
|
||||||
interface ValidationStatus {
|
|
||||||
isRunning: boolean;
|
|
||||||
abortController: AbortController;
|
|
||||||
startedAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map of issue number to validation status
|
|
||||||
* Key format: `${projectPath}||${issueNumber}` to support multiple projects
|
|
||||||
* Note: Using `||` as delimiter since `:` appears in Windows paths (e.g., C:\)
|
|
||||||
*/
|
|
||||||
const validationStatusMap = new Map<string, ValidationStatus>();
|
|
||||||
|
|
||||||
/** Maximum age for stale validation entries before cleanup (1 hour) */
|
|
||||||
const MAX_VALIDATION_AGE_MS = 60 * 60 * 1000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a unique key for a validation
|
|
||||||
* Uses `||` as delimiter since `:` appears in Windows paths
|
|
||||||
*/
|
|
||||||
function getValidationKey(projectPath: string, issueNumber: number): string {
|
|
||||||
return `${projectPath}||${issueNumber}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a validation is currently running for an issue
|
|
||||||
*/
|
|
||||||
export function isValidationRunning(projectPath: string, issueNumber: number): boolean {
|
|
||||||
const key = getValidationKey(projectPath, issueNumber);
|
|
||||||
const status = validationStatusMap.get(key);
|
|
||||||
return status?.isRunning ?? false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get validation status for an issue
|
|
||||||
*/
|
|
||||||
export function getValidationStatus(
|
|
||||||
projectPath: string,
|
|
||||||
issueNumber: number
|
|
||||||
): { isRunning: boolean; startedAt?: Date } | null {
|
|
||||||
const key = getValidationKey(projectPath, issueNumber);
|
|
||||||
const status = validationStatusMap.get(key);
|
|
||||||
if (!status) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
isRunning: status.isRunning,
|
|
||||||
startedAt: status.startedAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all running validations for a project
|
|
||||||
*/
|
|
||||||
export function getRunningValidations(projectPath: string): number[] {
|
|
||||||
const runningIssues: number[] = [];
|
|
||||||
const prefix = `${projectPath}||`;
|
|
||||||
for (const [key, status] of validationStatusMap.entries()) {
|
|
||||||
if (status.isRunning && key.startsWith(prefix)) {
|
|
||||||
const issueNumber = parseInt(key.slice(prefix.length), 10);
|
|
||||||
if (!isNaN(issueNumber)) {
|
|
||||||
runningIssues.push(issueNumber);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return runningIssues;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set a validation as running
|
|
||||||
*/
|
|
||||||
export function setValidationRunning(
|
|
||||||
projectPath: string,
|
|
||||||
issueNumber: number,
|
|
||||||
abortController: AbortController
|
|
||||||
): void {
|
|
||||||
const key = getValidationKey(projectPath, issueNumber);
|
|
||||||
validationStatusMap.set(key, {
|
|
||||||
isRunning: true,
|
|
||||||
abortController,
|
|
||||||
startedAt: new Date(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Atomically try to set a validation as running (check-and-set)
|
|
||||||
* Prevents TOCTOU race conditions when starting validations
|
|
||||||
*
|
|
||||||
* @returns true if successfully claimed, false if already running
|
|
||||||
*/
|
|
||||||
export function trySetValidationRunning(
|
|
||||||
projectPath: string,
|
|
||||||
issueNumber: number,
|
|
||||||
abortController: AbortController
|
|
||||||
): boolean {
|
|
||||||
const key = getValidationKey(projectPath, issueNumber);
|
|
||||||
if (validationStatusMap.has(key)) {
|
|
||||||
return false; // Already running
|
|
||||||
}
|
|
||||||
validationStatusMap.set(key, {
|
|
||||||
isRunning: true,
|
|
||||||
abortController,
|
|
||||||
startedAt: new Date(),
|
|
||||||
});
|
|
||||||
return true; // Successfully claimed
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cleanup stale validation entries (e.g., from crashed validations)
|
|
||||||
* Should be called periodically to prevent memory leaks
|
|
||||||
*/
|
|
||||||
export function cleanupStaleValidations(): number {
|
|
||||||
const now = Date.now();
|
|
||||||
let cleanedCount = 0;
|
|
||||||
for (const [key, status] of validationStatusMap.entries()) {
|
|
||||||
if (now - status.startedAt.getTime() > MAX_VALIDATION_AGE_MS) {
|
|
||||||
status.abortController.abort();
|
|
||||||
validationStatusMap.delete(key);
|
|
||||||
cleanedCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (cleanedCount > 0) {
|
|
||||||
logger.info(`Cleaned up ${cleanedCount} stale validation entries`);
|
|
||||||
}
|
|
||||||
return cleanedCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear validation status (call when validation completes or errors)
|
|
||||||
*/
|
|
||||||
export function clearValidationStatus(projectPath: string, issueNumber: number): void {
|
|
||||||
const key = getValidationKey(projectPath, issueNumber);
|
|
||||||
validationStatusMap.delete(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Abort a running validation
|
|
||||||
*
|
|
||||||
* @returns true if validation was aborted, false if not running
|
|
||||||
*/
|
|
||||||
export function abortValidation(projectPath: string, issueNumber: number): boolean {
|
|
||||||
const key = getValidationKey(projectPath, issueNumber);
|
|
||||||
const status = validationStatusMap.get(key);
|
|
||||||
|
|
||||||
if (!status || !status.isRunning) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
status.abortController.abort();
|
|
||||||
validationStatusMap.delete(key);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-export shared utilities
|
|
||||||
export { getErrorMessageShared as getErrorMessage };
|
|
||||||
export const logError = createLogError(logger);
|
|
||||||
export { logger };
|
|
||||||
@@ -1,236 +0,0 @@
|
|||||||
/**
|
|
||||||
* Additional validation endpoints for status, stop, and retrieving stored validations
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
|
||||||
import type { EventEmitter } from '../../../lib/events.js';
|
|
||||||
import type { IssueValidationEvent } from '@automaker/types';
|
|
||||||
import {
|
|
||||||
isValidationRunning,
|
|
||||||
getValidationStatus,
|
|
||||||
getRunningValidations,
|
|
||||||
abortValidation,
|
|
||||||
getErrorMessage,
|
|
||||||
logError,
|
|
||||||
logger,
|
|
||||||
} from './validation-common.js';
|
|
||||||
import {
|
|
||||||
readValidation,
|
|
||||||
getAllValidations,
|
|
||||||
getValidationWithFreshness,
|
|
||||||
deleteValidation,
|
|
||||||
markValidationViewed,
|
|
||||||
} from '../../../lib/validation-storage.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /validation-status - Check if validation is running for an issue
|
|
||||||
*/
|
|
||||||
export function createValidationStatusHandler() {
|
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
|
||||||
try {
|
|
||||||
const { projectPath, issueNumber } = req.body as {
|
|
||||||
projectPath: string;
|
|
||||||
issueNumber?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!projectPath) {
|
|
||||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If issueNumber provided, check specific issue
|
|
||||||
if (issueNumber !== undefined) {
|
|
||||||
const status = getValidationStatus(projectPath, issueNumber);
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
isRunning: status?.isRunning ?? false,
|
|
||||||
startedAt: status?.startedAt?.toISOString(),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise, return all running validations for the project
|
|
||||||
const runningIssues = getRunningValidations(projectPath);
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
runningIssues,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logError(error, 'Validation status check failed');
|
|
||||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /validation-stop - Cancel a running validation
|
|
||||||
*/
|
|
||||||
export function createValidationStopHandler() {
|
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
|
||||||
try {
|
|
||||||
const { projectPath, issueNumber } = req.body as {
|
|
||||||
projectPath: string;
|
|
||||||
issueNumber: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!projectPath) {
|
|
||||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!issueNumber || typeof issueNumber !== 'number') {
|
|
||||||
res
|
|
||||||
.status(400)
|
|
||||||
.json({ success: false, error: 'issueNumber is required and must be a number' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const wasAborted = abortValidation(projectPath, issueNumber);
|
|
||||||
|
|
||||||
if (wasAborted) {
|
|
||||||
logger.info(`Validation for issue #${issueNumber} was stopped`);
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: `Validation for issue #${issueNumber} has been stopped`,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
res.json({
|
|
||||||
success: false,
|
|
||||||
error: `No validation is running for issue #${issueNumber}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logError(error, 'Validation stop failed');
|
|
||||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /validations - Get stored validations for a project
|
|
||||||
*/
|
|
||||||
export function createGetValidationsHandler() {
|
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
|
||||||
try {
|
|
||||||
const { projectPath, issueNumber } = req.body as {
|
|
||||||
projectPath: string;
|
|
||||||
issueNumber?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!projectPath) {
|
|
||||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If issueNumber provided, get specific validation with freshness info
|
|
||||||
if (issueNumber !== undefined) {
|
|
||||||
const result = await getValidationWithFreshness(projectPath, issueNumber);
|
|
||||||
|
|
||||||
if (!result) {
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
validation: null,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
validation: result.validation,
|
|
||||||
isStale: result.isStale,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise, get all validations for the project
|
|
||||||
const validations = await getAllValidations(projectPath);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
validations,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logError(error, 'Get validations failed');
|
|
||||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /validation-delete - Delete a stored validation
|
|
||||||
*/
|
|
||||||
export function createDeleteValidationHandler() {
|
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
|
||||||
try {
|
|
||||||
const { projectPath, issueNumber } = req.body as {
|
|
||||||
projectPath: string;
|
|
||||||
issueNumber: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!projectPath) {
|
|
||||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!issueNumber || typeof issueNumber !== 'number') {
|
|
||||||
res
|
|
||||||
.status(400)
|
|
||||||
.json({ success: false, error: 'issueNumber is required and must be a number' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleted = await deleteValidation(projectPath, issueNumber);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
deleted,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logError(error, 'Delete validation failed');
|
|
||||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /validation-mark-viewed - Mark a validation as viewed by the user
|
|
||||||
*/
|
|
||||||
export function createMarkViewedHandler(events: EventEmitter) {
|
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
|
||||||
try {
|
|
||||||
const { projectPath, issueNumber } = req.body as {
|
|
||||||
projectPath: string;
|
|
||||||
issueNumber: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!projectPath) {
|
|
||||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!issueNumber || typeof issueNumber !== 'number') {
|
|
||||||
res
|
|
||||||
.status(400)
|
|
||||||
.json({ success: false, error: 'issueNumber is required and must be a number' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const success = await markValidationViewed(projectPath, issueNumber);
|
|
||||||
|
|
||||||
if (success) {
|
|
||||||
// Emit event so UI can update the unviewed count
|
|
||||||
const viewedEvent: IssueValidationEvent = {
|
|
||||||
type: 'issue_validation_viewed',
|
|
||||||
issueNumber,
|
|
||||||
projectPath,
|
|
||||||
};
|
|
||||||
events.emit('issue-validation:event', viewedEvent);
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({ success });
|
|
||||||
} catch (error) {
|
|
||||||
logError(error, 'Mark validation viewed failed');
|
|
||||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
/**
|
|
||||||
* Issue Validation Schema and System Prompt
|
|
||||||
*
|
|
||||||
* Defines the JSON schema for Claude's structured output and
|
|
||||||
* the system prompt that guides the validation process.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* JSON Schema for issue validation structured output.
|
|
||||||
* Used with Claude SDK's outputFormat option to ensure reliable parsing.
|
|
||||||
*/
|
|
||||||
export const issueValidationSchema = {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
verdict: {
|
|
||||||
type: 'string',
|
|
||||||
enum: ['valid', 'invalid', 'needs_clarification'],
|
|
||||||
description: 'The validation verdict for the issue',
|
|
||||||
},
|
|
||||||
confidence: {
|
|
||||||
type: 'string',
|
|
||||||
enum: ['high', 'medium', 'low'],
|
|
||||||
description: 'How confident the AI is in its assessment',
|
|
||||||
},
|
|
||||||
reasoning: {
|
|
||||||
type: 'string',
|
|
||||||
description: 'Detailed explanation of the verdict',
|
|
||||||
},
|
|
||||||
bugConfirmed: {
|
|
||||||
type: 'boolean',
|
|
||||||
description: 'For bug reports: whether the bug was confirmed in the codebase',
|
|
||||||
},
|
|
||||||
relatedFiles: {
|
|
||||||
type: 'array',
|
|
||||||
items: { type: 'string' },
|
|
||||||
description: 'Files related to the issue found during analysis',
|
|
||||||
},
|
|
||||||
suggestedFix: {
|
|
||||||
type: 'string',
|
|
||||||
description: 'Suggested approach to fix or implement the issue',
|
|
||||||
},
|
|
||||||
missingInfo: {
|
|
||||||
type: 'array',
|
|
||||||
items: { type: 'string' },
|
|
||||||
description: 'Information needed when verdict is needs_clarification',
|
|
||||||
},
|
|
||||||
estimatedComplexity: {
|
|
||||||
type: 'string',
|
|
||||||
enum: ['trivial', 'simple', 'moderate', 'complex', 'very_complex'],
|
|
||||||
description: 'Estimated effort to address the issue',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
required: ['verdict', 'confidence', 'reasoning'],
|
|
||||||
additionalProperties: false,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* System prompt that guides Claude in validating GitHub issues.
|
|
||||||
* Instructs the model to use read-only tools to analyze the codebase.
|
|
||||||
*/
|
|
||||||
export const ISSUE_VALIDATION_SYSTEM_PROMPT = `You are an expert code analyst validating GitHub issues against a codebase.
|
|
||||||
|
|
||||||
Your task is to analyze a GitHub issue and determine if it's valid by scanning the codebase.
|
|
||||||
|
|
||||||
## Validation Process
|
|
||||||
|
|
||||||
1. **Read the issue carefully** - Understand what is being reported or requested
|
|
||||||
2. **Search the codebase** - Use Glob to find relevant files by pattern, Grep to search for keywords
|
|
||||||
3. **Examine the code** - Use Read to look at the actual implementation in relevant files
|
|
||||||
4. **Form your verdict** - Based on your analysis, determine if the issue is valid
|
|
||||||
|
|
||||||
## Verdicts
|
|
||||||
|
|
||||||
- **valid**: The issue describes a real problem that exists in the codebase, or a clear feature request that can be implemented. The referenced files/components exist and the issue is actionable.
|
|
||||||
|
|
||||||
- **invalid**: The issue describes behavior that doesn't exist, references non-existent files or components, is based on a misunderstanding of the code, or the described "bug" is actually expected behavior.
|
|
||||||
|
|
||||||
- **needs_clarification**: The issue lacks sufficient detail to verify. Specify what additional information is needed in the missingInfo field.
|
|
||||||
|
|
||||||
## For Bug Reports, Check:
|
|
||||||
- Do the referenced files/components exist?
|
|
||||||
- Does the code match what the issue describes?
|
|
||||||
- Is the described behavior actually a bug or expected?
|
|
||||||
- Can you locate the code that would cause the reported issue?
|
|
||||||
|
|
||||||
## For Feature Requests, Check:
|
|
||||||
- Does the feature already exist?
|
|
||||||
- Is the implementation location clear?
|
|
||||||
- Is the request technically feasible given the codebase structure?
|
|
||||||
|
|
||||||
## Response Guidelines
|
|
||||||
|
|
||||||
- **Always include relatedFiles** when you find relevant code
|
|
||||||
- **Set bugConfirmed to true** only if you can definitively confirm a bug exists in the code
|
|
||||||
- **Provide a suggestedFix** when you have a clear idea of how to address the issue
|
|
||||||
- **Use missingInfo** when the verdict is needs_clarification to list what's needed
|
|
||||||
- **Set estimatedComplexity** to help prioritize:
|
|
||||||
- trivial: Simple text changes, one-line fixes
|
|
||||||
- simple: Small changes to one file
|
|
||||||
- moderate: Changes to multiple files or moderate logic changes
|
|
||||||
- complex: Significant refactoring or new feature implementation
|
|
||||||
- very_complex: Major architectural changes or cross-cutting concerns
|
|
||||||
|
|
||||||
Be thorough in your analysis but focus on files that are directly relevant to the issue.`;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the user prompt for issue validation.
|
|
||||||
*
|
|
||||||
* Creates a structured prompt that includes the issue details for Claude
|
|
||||||
* to analyze against the codebase.
|
|
||||||
*
|
|
||||||
* @param issueNumber - The GitHub issue number
|
|
||||||
* @param issueTitle - The issue title
|
|
||||||
* @param issueBody - The issue body/description
|
|
||||||
* @param issueLabels - Optional array of label names
|
|
||||||
* @returns Formatted prompt string for the validation request
|
|
||||||
*/
|
|
||||||
export function buildValidationPrompt(
|
|
||||||
issueNumber: number,
|
|
||||||
issueTitle: string,
|
|
||||||
issueBody: string,
|
|
||||||
issueLabels?: string[]
|
|
||||||
): string {
|
|
||||||
const labelsSection = issueLabels?.length ? `\n\n**Labels:** ${issueLabels.join(', ')}` : '';
|
|
||||||
|
|
||||||
return `Please validate the following GitHub issue by analyzing the codebase:
|
|
||||||
|
|
||||||
## Issue #${issueNumber}: ${issueTitle}
|
|
||||||
${labelsSection}
|
|
||||||
|
|
||||||
### Description
|
|
||||||
|
|
||||||
${issueBody || '(No description provided)'}
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Scan the codebase to verify this issue. Look for the files, components, or functionality mentioned. Determine if this issue is valid, invalid, or needs clarification.`;
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import type { SettingsService } from '../../../services/settings-service.js';
|
import type { SettingsService } from '../../../services/settings-service.js';
|
||||||
import { logError } from '../common.js';
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create handler factory for GET /api/settings/credentials
|
* Create handler factory for GET /api/settings/credentials
|
||||||
@@ -29,7 +29,7 @@ export function createGetCredentialsHandler(settingsService: SettingsService) {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(error, 'Get credentials failed');
|
logError(error, 'Get credentials failed');
|
||||||
res.status(500).json({ success: false, error: 'Failed to retrieve credentials' });
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,71 +11,7 @@
|
|||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import type { SettingsService } from '../../../services/settings-service.js';
|
import type { SettingsService } from '../../../services/settings-service.js';
|
||||||
import type { Credentials } from '../../../types/settings.js';
|
import type { Credentials } from '../../../types/settings.js';
|
||||||
import { logError } from '../common.js';
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
|
||||||
/** Maximum allowed length for API keys to prevent abuse */
|
|
||||||
const MAX_API_KEY_LENGTH = 512;
|
|
||||||
|
|
||||||
/** Known API key provider names that are valid */
|
|
||||||
const VALID_API_KEY_PROVIDERS = ['anthropic', 'google', 'openai'] as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates that the provided updates object has the correct structure
|
|
||||||
* and all apiKeys values are strings within acceptable length limits.
|
|
||||||
*
|
|
||||||
* @param updates - The partial credentials update object to validate
|
|
||||||
* @returns An error message if validation fails, or null if valid
|
|
||||||
*/
|
|
||||||
function validateCredentialsUpdate(updates: unknown): string | null {
|
|
||||||
if (!updates || typeof updates !== 'object' || Array.isArray(updates)) {
|
|
||||||
return 'Invalid request body - expected credentials object';
|
|
||||||
}
|
|
||||||
|
|
||||||
const obj = updates as Record<string, unknown>;
|
|
||||||
|
|
||||||
// If apiKeys is provided, validate its structure
|
|
||||||
if ('apiKeys' in obj) {
|
|
||||||
const apiKeys = obj.apiKeys;
|
|
||||||
|
|
||||||
if (apiKeys === null || apiKeys === undefined) {
|
|
||||||
// Allow null/undefined to clear
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof apiKeys !== 'object' || Array.isArray(apiKeys)) {
|
|
||||||
return 'Invalid apiKeys - expected object';
|
|
||||||
}
|
|
||||||
|
|
||||||
const keysObj = apiKeys as Record<string, unknown>;
|
|
||||||
|
|
||||||
// Validate each provided API key
|
|
||||||
for (const [provider, value] of Object.entries(keysObj)) {
|
|
||||||
// Check provider name is valid
|
|
||||||
if (!VALID_API_KEY_PROVIDERS.includes(provider as (typeof VALID_API_KEY_PROVIDERS)[number])) {
|
|
||||||
return `Invalid API key provider: ${provider}. Valid providers: ${VALID_API_KEY_PROVIDERS.join(', ')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check value is a string
|
|
||||||
if (typeof value !== 'string') {
|
|
||||||
return `Invalid API key for ${provider} - expected string`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check length limit
|
|
||||||
if (value.length > MAX_API_KEY_LENGTH) {
|
|
||||||
return `API key for ${provider} exceeds maximum length of ${MAX_API_KEY_LENGTH} characters`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate version if provided
|
|
||||||
if ('version' in obj && obj.version !== undefined) {
|
|
||||||
if (typeof obj.version !== 'number' || !Number.isInteger(obj.version) || obj.version < 0) {
|
|
||||||
return 'Invalid version - expected non-negative integer';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create handler factory for PUT /api/settings/credentials
|
* Create handler factory for PUT /api/settings/credentials
|
||||||
@@ -86,19 +22,16 @@ function validateCredentialsUpdate(updates: unknown): string | null {
|
|||||||
export function createUpdateCredentialsHandler(settingsService: SettingsService) {
|
export function createUpdateCredentialsHandler(settingsService: SettingsService) {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
// Validate the request body before type assertion
|
const updates = req.body as Partial<Credentials>;
|
||||||
const validationError = validateCredentialsUpdate(req.body);
|
|
||||||
if (validationError) {
|
if (!updates || typeof updates !== 'object') {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: validationError,
|
error: 'Invalid request body - expected credentials object',
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Safe to cast after validation
|
|
||||||
const updates = req.body as Partial<Credentials>;
|
|
||||||
|
|
||||||
await settingsService.updateCredentials(updates);
|
await settingsService.updateCredentials(updates);
|
||||||
|
|
||||||
// Return masked credentials for confirmation
|
// Return masked credentials for confirmation
|
||||||
@@ -110,7 +43,7 @@ export function createUpdateCredentialsHandler(settingsService: SettingsService)
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(error, 'Update credentials failed');
|
logError(error, 'Update credentials failed');
|
||||||
res.status(500).json({ success: false, error: 'Failed to update credentials' });
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,17 +9,6 @@ import { getErrorMessage as getErrorMessageShared, createLogError } from '../com
|
|||||||
|
|
||||||
const logger = createLogger('Setup');
|
const logger = createLogger('Setup');
|
||||||
|
|
||||||
/**
|
|
||||||
* Escapes special regex characters in a string to prevent regex injection.
|
|
||||||
* This ensures user input can be safely used in RegExp constructors.
|
|
||||||
*
|
|
||||||
* @param str - The string to escape
|
|
||||||
* @returns The escaped string safe for use in RegExp
|
|
||||||
*/
|
|
||||||
export function escapeRegExp(str: string): string {
|
|
||||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Storage for API keys (in-memory cache) - private
|
// Storage for API keys (in-memory cache) - private
|
||||||
const apiKeys: Record<string, string> = {};
|
const apiKeys: Record<string, string> = {};
|
||||||
|
|
||||||
@@ -44,32 +33,6 @@ export function getAllApiKeys(): Record<string, string> {
|
|||||||
return { ...apiKeys };
|
return { ...apiKeys };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Escape a value for safe inclusion in a .env file.
|
|
||||||
* Handles special characters like quotes, newlines, dollar signs, and backslashes.
|
|
||||||
* Returns a properly quoted string if needed.
|
|
||||||
*/
|
|
||||||
function escapeEnvValue(value: string): string {
|
|
||||||
// Check if the value contains any characters that require quoting
|
|
||||||
const requiresQuoting = /[\s"'$`\\#\n\r]/.test(value) || value.includes('=');
|
|
||||||
|
|
||||||
if (!requiresQuoting) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use double quotes and escape special characters within
|
|
||||||
// Escape backslashes first to avoid double-escaping
|
|
||||||
let escaped = value
|
|
||||||
.replace(/\\/g, '\\\\') // Escape backslashes
|
|
||||||
.replace(/"/g, '\\"') // Escape double quotes
|
|
||||||
.replace(/\$/g, '\\$') // Escape dollar signs (prevents variable expansion)
|
|
||||||
.replace(/`/g, '\\`') // Escape backticks
|
|
||||||
.replace(/\n/g, '\\n') // Escape newlines
|
|
||||||
.replace(/\r/g, '\\r'); // Escape carriage returns
|
|
||||||
|
|
||||||
return `"${escaped}"`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper to persist API keys to .env file
|
* Helper to persist API keys to .env file
|
||||||
*/
|
*/
|
||||||
@@ -84,24 +47,21 @@ export async function persistApiKeyToEnv(key: string, value: string): Promise<vo
|
|||||||
// .env file doesn't exist, we'll create it
|
// .env file doesn't exist, we'll create it
|
||||||
}
|
}
|
||||||
|
|
||||||
// Escape the value for safe .env file storage
|
// Parse existing env content
|
||||||
const escapedValue = escapeEnvValue(value);
|
|
||||||
|
|
||||||
// Parse existing env content - match key with optional quoted values
|
|
||||||
const lines = envContent.split('\n');
|
const lines = envContent.split('\n');
|
||||||
const keyRegex = new RegExp(`^${escapeRegExp(key)}=`);
|
const keyRegex = new RegExp(`^${key}=`);
|
||||||
let found = false;
|
let found = false;
|
||||||
const newLines = lines.map((line) => {
|
const newLines = lines.map((line) => {
|
||||||
if (keyRegex.test(line)) {
|
if (keyRegex.test(line)) {
|
||||||
found = true;
|
found = true;
|
||||||
return `${key}=${escapedValue}`;
|
return `${key}=${value}`;
|
||||||
}
|
}
|
||||||
return line;
|
return line;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!found) {
|
if (!found) {
|
||||||
// Add the key at the end
|
// Add the key at the end
|
||||||
newLines.push(`${key}=${escapedValue}`);
|
newLines.push(`${key}=${value}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await fs.writeFile(envPath, newLines.join('\n'));
|
await fs.writeFile(envPath, newLines.join('\n'));
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { getApiKey, logError } from '../common.js';
|
import { getApiKey, getErrorMessage, logError } from '../common.js';
|
||||||
|
|
||||||
export function createApiKeysHandler() {
|
export function createApiKeysHandler() {
|
||||||
return async (_req: Request, res: Response): Promise<void> => {
|
return async (_req: Request, res: Response): Promise<void> => {
|
||||||
@@ -14,7 +14,7 @@ export function createApiKeysHandler() {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(error, 'Get API keys failed');
|
logError(error, 'Get API keys failed');
|
||||||
res.status(500).json({ success: false, error: 'Failed to retrieve API keys status' });
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const logger = createLogger('Setup');
|
|||||||
|
|
||||||
// In-memory storage reference (imported from common.ts pattern)
|
// In-memory storage reference (imported from common.ts pattern)
|
||||||
// We need to modify common.ts to export a deleteApiKey function
|
// We need to modify common.ts to export a deleteApiKey function
|
||||||
import { setApiKey, escapeRegExp } from '../common.js';
|
import { setApiKey } from '../common.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove an API key from the .env file
|
* Remove an API key from the .env file
|
||||||
@@ -30,7 +30,7 @@ async function removeApiKeyFromEnv(key: string): Promise<void> {
|
|||||||
|
|
||||||
// Parse existing env content and remove the key
|
// Parse existing env content and remove the key
|
||||||
const lines = envContent.split('\n');
|
const lines = envContent.split('\n');
|
||||||
const keyRegex = new RegExp(`^${escapeRegExp(key)}=`);
|
const keyRegex = new RegExp(`^${key}=`);
|
||||||
const newLines = lines.filter((line) => !keyRegex.test(line));
|
const newLines = lines.filter((line) => !keyRegex.test(line));
|
||||||
|
|
||||||
// Remove empty lines at the end
|
// Remove empty lines at the end
|
||||||
@@ -68,10 +68,9 @@ export function createDeleteApiKeyHandler() {
|
|||||||
|
|
||||||
const envKey = envKeyMap[provider];
|
const envKey = envKeyMap[provider];
|
||||||
if (!envKey) {
|
if (!envKey) {
|
||||||
logger.warn(`[Setup] Unknown provider requested for deletion: ${provider}`);
|
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Unknown provider. Only anthropic is supported.',
|
error: `Unknown provider: ${provider}. Only anthropic is supported.`,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -95,7 +94,7 @@ export function createDeleteApiKeyHandler() {
|
|||||||
logger.error('[Setup] Delete API key error:', error);
|
logger.error('[Setup] Delete API key error:', error);
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Failed to delete API key',
|
error: error instanceof Error ? error.message : 'Failed to delete API key',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { setApiKey, persistApiKeyToEnv, logError } from '../common.js';
|
import { setApiKey, persistApiKeyToEnv, getErrorMessage, logError } from '../common.js';
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
|
|
||||||
const logger = createLogger('Setup');
|
const logger = createLogger('Setup');
|
||||||
@@ -30,10 +30,9 @@ export function createStoreApiKeyHandler() {
|
|||||||
await persistApiKeyToEnv('ANTHROPIC_API_KEY', apiKey);
|
await persistApiKeyToEnv('ANTHROPIC_API_KEY', apiKey);
|
||||||
logger.info('[Setup] Stored API key as ANTHROPIC_API_KEY');
|
logger.info('[Setup] Stored API key as ANTHROPIC_API_KEY');
|
||||||
} else {
|
} else {
|
||||||
logger.warn(`[Setup] Unsupported provider requested: ${provider}`);
|
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Unsupported provider. Only anthropic is supported.',
|
error: `Unsupported provider: ${provider}. Only anthropic is supported.`,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -41,7 +40,7 @@ export function createStoreApiKeyHandler() {
|
|||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(error, 'Store API key failed');
|
logError(error, 'Store API key failed');
|
||||||
res.status(500).json({ success: false, error: 'Failed to store API key' });
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,41 +10,6 @@ import { getApiKey } from '../common.js';
|
|||||||
|
|
||||||
const logger = createLogger('Setup');
|
const logger = createLogger('Setup');
|
||||||
|
|
||||||
/**
|
|
||||||
* Simple mutex implementation to prevent race conditions when
|
|
||||||
* modifying process.env during concurrent verification requests.
|
|
||||||
*
|
|
||||||
* The Claude Agent SDK reads ANTHROPIC_API_KEY from process.env,
|
|
||||||
* so we must temporarily modify it for verification. This mutex
|
|
||||||
* ensures only one verification runs at a time.
|
|
||||||
*/
|
|
||||||
class VerificationMutex {
|
|
||||||
private locked = false;
|
|
||||||
private queue: Array<() => void> = [];
|
|
||||||
|
|
||||||
async acquire(): Promise<void> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
if (!this.locked) {
|
|
||||||
this.locked = true;
|
|
||||||
resolve();
|
|
||||||
} else {
|
|
||||||
this.queue.push(resolve);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
release(): void {
|
|
||||||
if (this.queue.length > 0) {
|
|
||||||
const next = this.queue.shift();
|
|
||||||
if (next) next();
|
|
||||||
} else {
|
|
||||||
this.locked = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const verificationMutex = new VerificationMutex();
|
|
||||||
|
|
||||||
// Known error patterns that indicate auth failure
|
// Known error patterns that indicate auth failure
|
||||||
const AUTH_ERROR_PATTERNS = [
|
const AUTH_ERROR_PATTERNS = [
|
||||||
'OAuth token revoked',
|
'OAuth token revoked',
|
||||||
@@ -103,79 +68,14 @@ function containsAuthError(text: string): boolean {
|
|||||||
return AUTH_ERROR_PATTERNS.some((pattern) => lowerText.includes(pattern.toLowerCase()));
|
return AUTH_ERROR_PATTERNS.some((pattern) => lowerText.includes(pattern.toLowerCase()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Valid authentication method values */
|
|
||||||
const VALID_AUTH_METHODS = ['cli', 'api_key'] as const;
|
|
||||||
type AuthMethod = (typeof VALID_AUTH_METHODS)[number];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates and extracts the authMethod from the request body.
|
|
||||||
*
|
|
||||||
* @param body - The request body to validate
|
|
||||||
* @returns The validated authMethod or undefined if not provided
|
|
||||||
* @throws Error if authMethod is provided but invalid
|
|
||||||
*/
|
|
||||||
function validateAuthMethod(body: unknown): AuthMethod | undefined {
|
|
||||||
if (!body || typeof body !== 'object') {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const obj = body as Record<string, unknown>;
|
|
||||||
|
|
||||||
if (!('authMethod' in obj) || obj.authMethod === undefined || obj.authMethod === null) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const authMethod = obj.authMethod;
|
|
||||||
|
|
||||||
if (typeof authMethod !== 'string') {
|
|
||||||
throw new Error(`Invalid authMethod type: expected string, got ${typeof authMethod}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!VALID_AUTH_METHODS.includes(authMethod as AuthMethod)) {
|
|
||||||
throw new Error(
|
|
||||||
`Invalid authMethod value: '${authMethod}'. Valid values: ${VALID_AUTH_METHODS.join(', ')}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return authMethod as AuthMethod;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createVerifyClaudeAuthHandler() {
|
export function createVerifyClaudeAuthHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
// Validate and extract the auth method from the request body
|
// Get the auth method from the request body
|
||||||
let authMethod: AuthMethod | undefined;
|
const { authMethod } = req.body as { authMethod?: 'cli' | 'api_key' };
|
||||||
try {
|
|
||||||
authMethod = validateAuthMethod(req.body);
|
|
||||||
} catch (validationError) {
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
authenticated: false,
|
|
||||||
error: validationError instanceof Error ? validationError.message : 'Invalid request',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`[Setup] Verifying Claude authentication using method: ${authMethod || 'auto'}`);
|
logger.info(`[Setup] Verifying Claude authentication using method: ${authMethod || 'auto'}`);
|
||||||
|
|
||||||
// Early validation before acquiring mutex - check if API key is needed but missing
|
|
||||||
if (authMethod === 'api_key') {
|
|
||||||
const storedApiKey = getApiKey('anthropic');
|
|
||||||
if (!storedApiKey && !process.env.ANTHROPIC_API_KEY) {
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
authenticated: false,
|
|
||||||
error: 'No API key configured. Please enter an API key first.',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Acquire mutex to prevent race conditions when modifying process.env
|
|
||||||
// The SDK reads ANTHROPIC_API_KEY from environment, so concurrent requests
|
|
||||||
// could interfere with each other without this lock
|
|
||||||
await verificationMutex.acquire();
|
|
||||||
|
|
||||||
// Create an AbortController with a 30-second timeout
|
// Create an AbortController with a 30-second timeout
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
const timeoutId = setTimeout(() => abortController.abort(), 30000);
|
const timeoutId = setTimeout(() => abortController.abort(), 30000);
|
||||||
@@ -184,7 +84,7 @@ export function createVerifyClaudeAuthHandler() {
|
|||||||
let errorMessage = '';
|
let errorMessage = '';
|
||||||
let receivedAnyContent = false;
|
let receivedAnyContent = false;
|
||||||
|
|
||||||
// Save original env values (inside mutex to ensure consistency)
|
// Save original env values
|
||||||
const originalAnthropicKey = process.env.ANTHROPIC_API_KEY;
|
const originalAnthropicKey = process.env.ANTHROPIC_API_KEY;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -199,8 +99,17 @@ export function createVerifyClaudeAuthHandler() {
|
|||||||
if (storedApiKey) {
|
if (storedApiKey) {
|
||||||
process.env.ANTHROPIC_API_KEY = storedApiKey;
|
process.env.ANTHROPIC_API_KEY = storedApiKey;
|
||||||
logger.info('[Setup] Using stored API key for verification');
|
logger.info('[Setup] Using stored API key for verification');
|
||||||
|
} else {
|
||||||
|
// Check env var
|
||||||
|
if (!process.env.ANTHROPIC_API_KEY) {
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
authenticated: false,
|
||||||
|
error: 'No API key configured. Please enter an API key first.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Note: if no stored key, we use the existing env var (already validated above)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run a minimal query to verify authentication
|
// Run a minimal query to verify authentication
|
||||||
@@ -220,8 +129,7 @@ export function createVerifyClaudeAuthHandler() {
|
|||||||
for await (const msg of stream) {
|
for await (const msg of stream) {
|
||||||
const msgStr = JSON.stringify(msg);
|
const msgStr = JSON.stringify(msg);
|
||||||
allMessages.push(msgStr);
|
allMessages.push(msgStr);
|
||||||
// Debug log only message type to avoid leaking sensitive data
|
logger.info('[Setup] Stream message:', msgStr.substring(0, 500));
|
||||||
logger.debug('[Setup] Stream message type:', msg.type);
|
|
||||||
|
|
||||||
// Check for billing errors FIRST - these should fail verification
|
// Check for billing errors FIRST - these should fail verification
|
||||||
if (isBillingError(msgStr)) {
|
if (isBillingError(msgStr)) {
|
||||||
@@ -313,8 +221,7 @@ export function createVerifyClaudeAuthHandler() {
|
|||||||
} else {
|
} else {
|
||||||
// No content received - might be an issue
|
// No content received - might be an issue
|
||||||
logger.warn('[Setup] No content received from stream');
|
logger.warn('[Setup] No content received from stream');
|
||||||
// Log only message count to avoid leaking sensitive data
|
logger.warn('[Setup] All messages:', allMessages.join('\n'));
|
||||||
logger.warn('[Setup] Total messages received:', allMessages.length);
|
|
||||||
errorMessage = 'No response received from Claude. Please check your authentication.';
|
errorMessage = 'No response received from Claude. Please check your authentication.';
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
@@ -370,8 +277,6 @@ export function createVerifyClaudeAuthHandler() {
|
|||||||
// If we cleared it and there was no original, keep it cleared
|
// If we cleared it and there was no original, keep it cleared
|
||||||
delete process.env.ANTHROPIC_API_KEY;
|
delete process.env.ANTHROPIC_API_KEY;
|
||||||
}
|
}
|
||||||
// Release the mutex so other verification requests can proceed
|
|
||||||
verificationMutex.release();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info('[Setup] Verification result:', {
|
logger.info('[Setup] Verification result:', {
|
||||||
|
|||||||
@@ -63,8 +63,10 @@ For each suggestion, provide:
|
|||||||
|
|
||||||
The response will be automatically formatted as structured JSON.`;
|
The response will be automatically formatted as structured JSON.`;
|
||||||
|
|
||||||
// Don't send initial message - let the agent output speak for itself
|
events.emit('suggestions:event', {
|
||||||
// The first agent message will be captured as an info entry
|
type: 'suggestions_progress',
|
||||||
|
content: `Starting ${suggestionType} analysis...\n`,
|
||||||
|
});
|
||||||
|
|
||||||
const options = createSuggestionsOptions({
|
const options = createSuggestionsOptions({
|
||||||
cwd: projectPath,
|
cwd: projectPath,
|
||||||
|
|||||||
@@ -65,18 +65,6 @@ export function cleanupExpiredTokens(): void {
|
|||||||
// Clean up expired tokens every 5 minutes
|
// Clean up expired tokens every 5 minutes
|
||||||
setInterval(cleanupExpiredTokens, 5 * 60 * 1000);
|
setInterval(cleanupExpiredTokens, 5 * 60 * 1000);
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract Bearer token from Authorization header
|
|
||||||
* Returns undefined if header is missing or malformed
|
|
||||||
*/
|
|
||||||
export function extractBearerToken(req: Request): string | undefined {
|
|
||||||
const authHeader = req.headers.authorization;
|
|
||||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return authHeader.slice(7); // Remove 'Bearer ' prefix
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate a terminal session token
|
* Validate a terminal session token
|
||||||
*/
|
*/
|
||||||
@@ -128,9 +116,8 @@ export function terminalAuthMiddleware(req: Request, res: Response, next: NextFu
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract token from Authorization header only (Bearer token format)
|
// Check for session token
|
||||||
// Query string tokens are not supported due to security risks (URL logging, referrer leakage)
|
const token = (req.headers['x-terminal-token'] as string) || (req.query.token as string);
|
||||||
const token = extractBearerToken(req);
|
|
||||||
|
|
||||||
if (!validateTerminalToken(token)) {
|
if (!validateTerminalToken(token)) {
|
||||||
res.status(401).json({
|
res.status(401).json({
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* POST /auth endpoint - Authenticate with password to get a session token
|
* POST /auth endpoint - Authenticate with password to get a session token
|
||||||
* Includes rate limiting to prevent brute-force attacks.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as crypto from 'crypto';
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import {
|
import {
|
||||||
getTerminalEnabledConfigValue,
|
getTerminalEnabledConfigValue,
|
||||||
@@ -13,25 +11,6 @@ import {
|
|||||||
getTokenExpiryMs,
|
getTokenExpiryMs,
|
||||||
getErrorMessage,
|
getErrorMessage,
|
||||||
} from '../common.js';
|
} from '../common.js';
|
||||||
import { terminalAuthRateLimiter } from '../../../lib/rate-limiter.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Performs a constant-time string comparison to prevent timing attacks.
|
|
||||||
* Uses crypto.timingSafeEqual with proper buffer handling.
|
|
||||||
*/
|
|
||||||
function secureCompare(a: string, b: string): boolean {
|
|
||||||
const bufferA = Buffer.from(a, 'utf8');
|
|
||||||
const bufferB = Buffer.from(b, 'utf8');
|
|
||||||
|
|
||||||
// If lengths differ, we still need to do a constant-time comparison
|
|
||||||
// to avoid leaking length information. We compare against bufferA twice.
|
|
||||||
if (bufferA.length !== bufferB.length) {
|
|
||||||
crypto.timingSafeEqual(bufferA, bufferA);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return crypto.timingSafeEqual(bufferA, bufferB);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createAuthHandler() {
|
export function createAuthHandler() {
|
||||||
return (req: Request, res: Response): void => {
|
return (req: Request, res: Response): void => {
|
||||||
@@ -57,28 +36,9 @@ export function createAuthHandler() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const clientIp = terminalAuthRateLimiter.getClientIp(req);
|
|
||||||
|
|
||||||
// Check if client is rate limited
|
|
||||||
if (terminalAuthRateLimiter.isBlocked(clientIp)) {
|
|
||||||
const retryAfterMs = terminalAuthRateLimiter.getBlockTimeRemaining(clientIp);
|
|
||||||
const retryAfterSeconds = Math.ceil(retryAfterMs / 1000);
|
|
||||||
|
|
||||||
res.setHeader('Retry-After', retryAfterSeconds.toString());
|
|
||||||
res.status(429).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Too many failed authentication attempts. Please try again later.',
|
|
||||||
retryAfter: retryAfterSeconds,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { password } = req.body;
|
const { password } = req.body;
|
||||||
|
|
||||||
if (!password || !secureCompare(password, terminalPassword)) {
|
if (!password || password !== terminalPassword) {
|
||||||
// Record failed attempt
|
|
||||||
terminalAuthRateLimiter.recordFailure(clientIp);
|
|
||||||
|
|
||||||
res.status(401).json({
|
res.status(401).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Invalid password',
|
error: 'Invalid password',
|
||||||
@@ -86,9 +46,6 @@ export function createAuthHandler() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Successful authentication - reset rate limiter for this IP
|
|
||||||
terminalAuthRateLimiter.reset(clientIp);
|
|
||||||
|
|
||||||
// Generate session token
|
// Generate session token
|
||||||
const token = generateToken();
|
const token = generateToken();
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|||||||
@@ -1,36 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* POST /logout endpoint - Invalidate a session token
|
* POST /logout endpoint - Invalidate a session token
|
||||||
*
|
|
||||||
* Security: Only allows invalidating the token used for authentication.
|
|
||||||
* This ensures users can only log out their own sessions.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { deleteToken, extractBearerToken, validateTerminalToken } from '../common.js';
|
import { deleteToken } from '../common.js';
|
||||||
|
|
||||||
export function createLogoutHandler() {
|
export function createLogoutHandler() {
|
||||||
return (req: Request, res: Response): void => {
|
return (req: Request, res: Response): void => {
|
||||||
const token = extractBearerToken(req);
|
const token = (req.headers['x-terminal-token'] as string) || req.body.token;
|
||||||
|
|
||||||
if (!token) {
|
if (token) {
|
||||||
res.status(401).json({
|
deleteToken(token);
|
||||||
success: false,
|
|
||||||
error: 'Authorization header with Bearer token is required',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!validateTerminalToken(token)) {
|
|
||||||
res.status(401).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Invalid or expired token',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Token is valid and belongs to the requester - safe to invalidate
|
|
||||||
deleteToken(token);
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -111,19 +111,6 @@ export async function isGitRepo(repoPath: string): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a git repository has at least one commit (i.e., HEAD exists)
|
|
||||||
* Returns false for freshly initialized repos with no commits
|
|
||||||
*/
|
|
||||||
export async function hasCommits(repoPath: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
await execAsync('git rev-parse --verify HEAD', { cwd: repoPath });
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if an error is ENOENT (file/path not found or spawn failed)
|
* Check if an error is ENOENT (file/path not found or spawn failed)
|
||||||
* These are expected in test environments with mock paths
|
* These are expected in test environments with mock paths
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { validatePathParams } from '../../middleware/validate-paths.js';
|
import { validatePathParams } from '../../middleware/validate-paths.js';
|
||||||
import { requireValidWorktree, requireValidProject, requireGitRepoOnly } from './middleware.js';
|
|
||||||
import { createInfoHandler } from './routes/info.js';
|
import { createInfoHandler } from './routes/info.js';
|
||||||
import { createStatusHandler } from './routes/status.js';
|
import { createStatusHandler } from './routes/status.js';
|
||||||
import { createListHandler } from './routes/list.js';
|
import { createListHandler } from './routes/list.js';
|
||||||
@@ -39,42 +38,17 @@ export function createWorktreeRoutes(): Router {
|
|||||||
router.post('/list', createListHandler());
|
router.post('/list', createListHandler());
|
||||||
router.post('/diffs', validatePathParams('projectPath'), createDiffsHandler());
|
router.post('/diffs', validatePathParams('projectPath'), createDiffsHandler());
|
||||||
router.post('/file-diff', validatePathParams('projectPath', 'filePath'), createFileDiffHandler());
|
router.post('/file-diff', validatePathParams('projectPath', 'filePath'), createFileDiffHandler());
|
||||||
router.post(
|
router.post('/merge', validatePathParams('projectPath'), createMergeHandler());
|
||||||
'/merge',
|
|
||||||
validatePathParams('projectPath'),
|
|
||||||
requireValidProject,
|
|
||||||
createMergeHandler()
|
|
||||||
);
|
|
||||||
router.post('/create', validatePathParams('projectPath'), createCreateHandler());
|
router.post('/create', validatePathParams('projectPath'), createCreateHandler());
|
||||||
router.post('/delete', validatePathParams('projectPath', 'worktreePath'), createDeleteHandler());
|
router.post('/delete', validatePathParams('projectPath', 'worktreePath'), createDeleteHandler());
|
||||||
router.post('/create-pr', createCreatePRHandler());
|
router.post('/create-pr', createCreatePRHandler());
|
||||||
router.post('/pr-info', createPRInfoHandler());
|
router.post('/pr-info', createPRInfoHandler());
|
||||||
router.post(
|
router.post('/commit', validatePathParams('worktreePath'), createCommitHandler());
|
||||||
'/commit',
|
router.post('/push', validatePathParams('worktreePath'), createPushHandler());
|
||||||
validatePathParams('worktreePath'),
|
router.post('/pull', validatePathParams('worktreePath'), createPullHandler());
|
||||||
requireGitRepoOnly,
|
router.post('/checkout-branch', createCheckoutBranchHandler());
|
||||||
createCommitHandler()
|
router.post('/list-branches', validatePathParams('worktreePath'), createListBranchesHandler());
|
||||||
);
|
router.post('/switch-branch', createSwitchBranchHandler());
|
||||||
router.post(
|
|
||||||
'/push',
|
|
||||||
validatePathParams('worktreePath'),
|
|
||||||
requireValidWorktree,
|
|
||||||
createPushHandler()
|
|
||||||
);
|
|
||||||
router.post(
|
|
||||||
'/pull',
|
|
||||||
validatePathParams('worktreePath'),
|
|
||||||
requireValidWorktree,
|
|
||||||
createPullHandler()
|
|
||||||
);
|
|
||||||
router.post('/checkout-branch', requireValidWorktree, createCheckoutBranchHandler());
|
|
||||||
router.post(
|
|
||||||
'/list-branches',
|
|
||||||
validatePathParams('worktreePath'),
|
|
||||||
requireValidWorktree,
|
|
||||||
createListBranchesHandler()
|
|
||||||
);
|
|
||||||
router.post('/switch-branch', requireValidWorktree, createSwitchBranchHandler());
|
|
||||||
router.post('/open-in-editor', validatePathParams('worktreePath'), createOpenInEditorHandler());
|
router.post('/open-in-editor', validatePathParams('worktreePath'), createOpenInEditorHandler());
|
||||||
router.get('/default-editor', createGetDefaultEditorHandler());
|
router.get('/default-editor', createGetDefaultEditorHandler());
|
||||||
router.post('/init-git', validatePathParams('projectPath'), createInitGitHandler());
|
router.post('/init-git', validatePathParams('projectPath'), createInitGitHandler());
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
/**
|
|
||||||
* Middleware for worktree route validation
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Request, Response, NextFunction } from 'express';
|
|
||||||
import { isGitRepo, hasCommits } from './common.js';
|
|
||||||
|
|
||||||
interface ValidationOptions {
|
|
||||||
/** Check if the path is a git repository (default: true) */
|
|
||||||
requireGitRepo?: boolean;
|
|
||||||
/** Check if the repository has at least one commit (default: true) */
|
|
||||||
requireCommits?: boolean;
|
|
||||||
/** The name of the request body field containing the path (default: 'worktreePath') */
|
|
||||||
pathField?: 'worktreePath' | 'projectPath';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Middleware factory to validate that a path is a valid git repository with commits.
|
|
||||||
* This reduces code duplication across route handlers.
|
|
||||||
*
|
|
||||||
* @param options - Validation options
|
|
||||||
* @returns Express middleware function
|
|
||||||
*/
|
|
||||||
export function requireValidGitRepo(options: ValidationOptions = {}) {
|
|
||||||
const { requireGitRepo = true, requireCommits = true, pathField = 'worktreePath' } = options;
|
|
||||||
|
|
||||||
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
|
||||||
const repoPath = req.body[pathField] as string | undefined;
|
|
||||||
|
|
||||||
if (!repoPath) {
|
|
||||||
// Let the route handler deal with missing path validation
|
|
||||||
next();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (requireGitRepo && !(await isGitRepo(repoPath))) {
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Not a git repository',
|
|
||||||
code: 'NOT_GIT_REPO',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (requireCommits && !(await hasCommits(repoPath))) {
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Repository has no commits yet',
|
|
||||||
code: 'NO_COMMITS',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
next();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Middleware to validate git repo for worktreePath field
|
|
||||||
*/
|
|
||||||
export const requireValidWorktree = requireValidGitRepo({ pathField: 'worktreePath' });
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Middleware to validate git repo for projectPath field
|
|
||||||
*/
|
|
||||||
export const requireValidProject = requireValidGitRepo({ pathField: 'projectPath' });
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Middleware to validate git repo without requiring commits (for commit route)
|
|
||||||
*/
|
|
||||||
export const requireGitRepoOnly = requireValidGitRepo({
|
|
||||||
pathField: 'worktreePath',
|
|
||||||
requireCommits: false,
|
|
||||||
});
|
|
||||||
@@ -1,8 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* POST /checkout-branch endpoint - Create and checkout a new branch
|
* POST /checkout-branch endpoint - Create and checkout a new branch
|
||||||
*
|
|
||||||
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
|
|
||||||
* the requireValidWorktree middleware in index.ts
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* POST /commit endpoint - Commit changes in a worktree
|
* POST /commit endpoint - Commit changes in a worktree
|
||||||
*
|
|
||||||
* Note: Git repository validation (isGitRepo) is handled by
|
|
||||||
* the requireGitRepoOnly middleware in index.ts
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* POST /list-branches endpoint - List all local branches
|
* POST /list-branches endpoint - List all local branches
|
||||||
*
|
|
||||||
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
|
|
||||||
* the requireValidWorktree middleware in index.ts
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* POST /merge endpoint - Merge feature (merge worktree branch into main)
|
* POST /merge endpoint - Merge feature (merge worktree branch into main)
|
||||||
*
|
|
||||||
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
|
|
||||||
* the requireValidProject middleware in index.ts
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* POST /pull endpoint - Pull latest changes for a worktree/branch
|
* POST /pull endpoint - Pull latest changes for a worktree/branch
|
||||||
*
|
|
||||||
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
|
|
||||||
* the requireValidWorktree middleware in index.ts
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* POST /push endpoint - Push a worktree branch to remote
|
* POST /push endpoint - Push a worktree branch to remote
|
||||||
*
|
|
||||||
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
|
|
||||||
* the requireValidWorktree middleware in index.ts
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
|
|||||||
@@ -4,9 +4,6 @@
|
|||||||
* Simple branch switching.
|
* Simple branch switching.
|
||||||
* If there are uncommitted changes, the switch will fail and
|
* If there are uncommitted changes, the switch will fail and
|
||||||
* the user should commit first.
|
* the user should commit first.
|
||||||
*
|
|
||||||
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
|
|
||||||
* the requireValidWorktree middleware in index.ts
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
|
|||||||
@@ -1,16 +1,6 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
import { createMockExpressContext } from '../../utils/mocks.js';
|
import { createMockExpressContext } from '../../utils/mocks.js';
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a mock Express context with socket properties for rate limiter support
|
|
||||||
*/
|
|
||||||
function createMockExpressContextWithSocket() {
|
|
||||||
const ctx = createMockExpressContext();
|
|
||||||
ctx.req.socket = { remoteAddress: '127.0.0.1' } as any;
|
|
||||||
ctx.res.setHeader = vi.fn().mockReturnThis();
|
|
||||||
return ctx;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Note: auth.ts reads AUTOMAKER_API_KEY at module load time.
|
* Note: auth.ts reads AUTOMAKER_API_KEY at module load time.
|
||||||
* We need to reset modules and reimport for each test to get fresh state.
|
* We need to reset modules and reimport for each test to get fresh state.
|
||||||
@@ -39,7 +29,7 @@ describe('auth.ts', () => {
|
|||||||
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
|
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
|
||||||
|
|
||||||
const { authMiddleware } = await import('@/lib/auth.js');
|
const { authMiddleware } = await import('@/lib/auth.js');
|
||||||
const { req, res, next } = createMockExpressContextWithSocket();
|
const { req, res, next } = createMockExpressContext();
|
||||||
|
|
||||||
authMiddleware(req, res, next);
|
authMiddleware(req, res, next);
|
||||||
|
|
||||||
@@ -55,7 +45,7 @@ describe('auth.ts', () => {
|
|||||||
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
|
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
|
||||||
|
|
||||||
const { authMiddleware } = await import('@/lib/auth.js');
|
const { authMiddleware } = await import('@/lib/auth.js');
|
||||||
const { req, res, next } = createMockExpressContextWithSocket();
|
const { req, res, next } = createMockExpressContext();
|
||||||
req.headers['x-api-key'] = 'wrong-key';
|
req.headers['x-api-key'] = 'wrong-key';
|
||||||
|
|
||||||
authMiddleware(req, res, next);
|
authMiddleware(req, res, next);
|
||||||
@@ -72,7 +62,7 @@ describe('auth.ts', () => {
|
|||||||
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
|
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
|
||||||
|
|
||||||
const { authMiddleware } = await import('@/lib/auth.js');
|
const { authMiddleware } = await import('@/lib/auth.js');
|
||||||
const { req, res, next } = createMockExpressContextWithSocket();
|
const { req, res, next } = createMockExpressContext();
|
||||||
req.headers['x-api-key'] = 'test-secret-key';
|
req.headers['x-api-key'] = 'test-secret-key';
|
||||||
|
|
||||||
authMiddleware(req, res, next);
|
authMiddleware(req, res, next);
|
||||||
@@ -123,197 +113,4 @@ describe('auth.ts', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('security - AUTOMAKER_API_KEY not set', () => {
|
|
||||||
it('should allow requests without any authentication when API key is not configured', async () => {
|
|
||||||
delete process.env.AUTOMAKER_API_KEY;
|
|
||||||
|
|
||||||
const { authMiddleware } = await import('@/lib/auth.js');
|
|
||||||
const { req, res, next } = createMockExpressContext();
|
|
||||||
|
|
||||||
authMiddleware(req, res, next);
|
|
||||||
|
|
||||||
expect(next).toHaveBeenCalled();
|
|
||||||
expect(res.status).not.toHaveBeenCalled();
|
|
||||||
expect(res.json).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should allow requests even with invalid key header when API key is not configured', async () => {
|
|
||||||
delete process.env.AUTOMAKER_API_KEY;
|
|
||||||
|
|
||||||
const { authMiddleware } = await import('@/lib/auth.js');
|
|
||||||
const { req, res, next } = createMockExpressContext();
|
|
||||||
req.headers['x-api-key'] = 'some-random-key';
|
|
||||||
|
|
||||||
authMiddleware(req, res, next);
|
|
||||||
|
|
||||||
expect(next).toHaveBeenCalled();
|
|
||||||
expect(res.status).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should report auth as disabled when no API key is configured', async () => {
|
|
||||||
delete process.env.AUTOMAKER_API_KEY;
|
|
||||||
|
|
||||||
const { isAuthEnabled, getAuthStatus } = await import('@/lib/auth.js');
|
|
||||||
|
|
||||||
expect(isAuthEnabled()).toBe(false);
|
|
||||||
expect(getAuthStatus()).toEqual({
|
|
||||||
enabled: false,
|
|
||||||
method: 'none',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('security - authentication correctness', () => {
|
|
||||||
it('should correctly authenticate with matching API key', async () => {
|
|
||||||
const testKey = 'correct-secret-key-12345';
|
|
||||||
process.env.AUTOMAKER_API_KEY = testKey;
|
|
||||||
|
|
||||||
const { authMiddleware } = await import('@/lib/auth.js');
|
|
||||||
const { req, res, next } = createMockExpressContextWithSocket();
|
|
||||||
req.headers['x-api-key'] = testKey;
|
|
||||||
|
|
||||||
authMiddleware(req, res, next);
|
|
||||||
|
|
||||||
expect(next).toHaveBeenCalled();
|
|
||||||
expect(res.status).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject keys that differ by a single character', async () => {
|
|
||||||
process.env.AUTOMAKER_API_KEY = 'correct-secret-key';
|
|
||||||
|
|
||||||
const { authMiddleware } = await import('@/lib/auth.js');
|
|
||||||
const { req, res, next } = createMockExpressContextWithSocket();
|
|
||||||
req.headers['x-api-key'] = 'correct-secret-keY'; // Last char uppercase
|
|
||||||
|
|
||||||
authMiddleware(req, res, next);
|
|
||||||
|
|
||||||
expect(res.status).toHaveBeenCalledWith(403);
|
|
||||||
expect(next).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject keys with extra characters', async () => {
|
|
||||||
process.env.AUTOMAKER_API_KEY = 'secret-key';
|
|
||||||
|
|
||||||
const { authMiddleware } = await import('@/lib/auth.js');
|
|
||||||
const { req, res, next } = createMockExpressContextWithSocket();
|
|
||||||
req.headers['x-api-key'] = 'secret-key-extra';
|
|
||||||
|
|
||||||
authMiddleware(req, res, next);
|
|
||||||
|
|
||||||
expect(res.status).toHaveBeenCalledWith(403);
|
|
||||||
expect(next).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject keys that are a prefix of the actual key', async () => {
|
|
||||||
process.env.AUTOMAKER_API_KEY = 'full-secret-key';
|
|
||||||
|
|
||||||
const { authMiddleware } = await import('@/lib/auth.js');
|
|
||||||
const { req, res, next } = createMockExpressContextWithSocket();
|
|
||||||
req.headers['x-api-key'] = 'full-secret';
|
|
||||||
|
|
||||||
authMiddleware(req, res, next);
|
|
||||||
|
|
||||||
expect(res.status).toHaveBeenCalledWith(403);
|
|
||||||
expect(next).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject empty string API key header', async () => {
|
|
||||||
process.env.AUTOMAKER_API_KEY = 'secret-key';
|
|
||||||
|
|
||||||
const { authMiddleware } = await import('@/lib/auth.js');
|
|
||||||
const { req, res, next } = createMockExpressContextWithSocket();
|
|
||||||
req.headers['x-api-key'] = '';
|
|
||||||
|
|
||||||
authMiddleware(req, res, next);
|
|
||||||
|
|
||||||
// Empty string is falsy, so should get 401 (no key provided)
|
|
||||||
expect(res.status).toHaveBeenCalledWith(401);
|
|
||||||
expect(next).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle keys with special characters correctly', async () => {
|
|
||||||
const specialKey = 'key-with-$pecial!@#chars_123';
|
|
||||||
process.env.AUTOMAKER_API_KEY = specialKey;
|
|
||||||
|
|
||||||
const { authMiddleware } = await import('@/lib/auth.js');
|
|
||||||
const { req, res, next } = createMockExpressContextWithSocket();
|
|
||||||
req.headers['x-api-key'] = specialKey;
|
|
||||||
|
|
||||||
authMiddleware(req, res, next);
|
|
||||||
|
|
||||||
expect(next).toHaveBeenCalled();
|
|
||||||
expect(res.status).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('security - rate limiting', () => {
|
|
||||||
it('should block requests after multiple failed attempts', async () => {
|
|
||||||
process.env.AUTOMAKER_API_KEY = 'correct-key';
|
|
||||||
|
|
||||||
const { authMiddleware } = await import('@/lib/auth.js');
|
|
||||||
const { apiKeyRateLimiter } = await import('@/lib/rate-limiter.js');
|
|
||||||
|
|
||||||
// Reset the rate limiter for this test
|
|
||||||
apiKeyRateLimiter.reset('192.168.1.100');
|
|
||||||
|
|
||||||
// Simulate multiple failed attempts
|
|
||||||
for (let i = 0; i < 5; i++) {
|
|
||||||
const { req, res, next } = createMockExpressContextWithSocket();
|
|
||||||
req.socket.remoteAddress = '192.168.1.100';
|
|
||||||
req.headers['x-api-key'] = 'wrong-key';
|
|
||||||
authMiddleware(req, res, next);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Next request should be rate limited
|
|
||||||
const { req, res, next } = createMockExpressContextWithSocket();
|
|
||||||
req.socket.remoteAddress = '192.168.1.100';
|
|
||||||
req.headers['x-api-key'] = 'correct-key'; // Even with correct key
|
|
||||||
|
|
||||||
authMiddleware(req, res, next);
|
|
||||||
|
|
||||||
expect(res.status).toHaveBeenCalledWith(429);
|
|
||||||
expect(next).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
apiKeyRateLimiter.reset('192.168.1.100');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reset rate limit on successful authentication', async () => {
|
|
||||||
process.env.AUTOMAKER_API_KEY = 'correct-key';
|
|
||||||
|
|
||||||
const { authMiddleware } = await import('@/lib/auth.js');
|
|
||||||
const { apiKeyRateLimiter } = await import('@/lib/rate-limiter.js');
|
|
||||||
|
|
||||||
// Reset the rate limiter for this test
|
|
||||||
apiKeyRateLimiter.reset('192.168.1.101');
|
|
||||||
|
|
||||||
// Simulate a few failed attempts (not enough to trigger block)
|
|
||||||
for (let i = 0; i < 3; i++) {
|
|
||||||
const { req, res, next } = createMockExpressContextWithSocket();
|
|
||||||
req.socket.remoteAddress = '192.168.1.101';
|
|
||||||
req.headers['x-api-key'] = 'wrong-key';
|
|
||||||
authMiddleware(req, res, next);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Successful authentication should reset the counter
|
|
||||||
const {
|
|
||||||
req: successReq,
|
|
||||||
res: successRes,
|
|
||||||
next: successNext,
|
|
||||||
} = createMockExpressContextWithSocket();
|
|
||||||
successReq.socket.remoteAddress = '192.168.1.101';
|
|
||||||
successReq.headers['x-api-key'] = 'correct-key';
|
|
||||||
|
|
||||||
authMiddleware(successReq, successRes, successNext);
|
|
||||||
|
|
||||||
expect(successNext).toHaveBeenCalled();
|
|
||||||
|
|
||||||
// After reset, we should have full attempts available again
|
|
||||||
expect(apiKeyRateLimiter.getAttemptsRemaining('192.168.1.101')).toBe(5);
|
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
apiKeyRateLimiter.reset('192.168.1.101');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,249 +0,0 @@
|
|||||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
|
||||||
import { RateLimiter } from '../../../src/lib/rate-limiter.js';
|
|
||||||
import type { Request } from 'express';
|
|
||||||
|
|
||||||
describe('RateLimiter', () => {
|
|
||||||
let rateLimiter: RateLimiter;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
rateLimiter = new RateLimiter({
|
|
||||||
maxAttempts: 3,
|
|
||||||
windowMs: 60000, // 1 minute
|
|
||||||
blockDurationMs: 60000, // 1 minute
|
|
||||||
});
|
|
||||||
vi.useFakeTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getClientIp', () => {
|
|
||||||
it('should extract IP from x-forwarded-for header', () => {
|
|
||||||
const req = {
|
|
||||||
headers: { 'x-forwarded-for': '192.168.1.100' },
|
|
||||||
socket: { remoteAddress: '127.0.0.1' },
|
|
||||||
} as unknown as Request;
|
|
||||||
|
|
||||||
expect(rateLimiter.getClientIp(req)).toBe('192.168.1.100');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use first IP from x-forwarded-for with multiple IPs', () => {
|
|
||||||
const req = {
|
|
||||||
headers: { 'x-forwarded-for': '192.168.1.100, 10.0.0.1, 172.16.0.1' },
|
|
||||||
socket: { remoteAddress: '127.0.0.1' },
|
|
||||||
} as unknown as Request;
|
|
||||||
|
|
||||||
expect(rateLimiter.getClientIp(req)).toBe('192.168.1.100');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should fall back to socket remoteAddress when no x-forwarded-for', () => {
|
|
||||||
const req = {
|
|
||||||
headers: {},
|
|
||||||
socket: { remoteAddress: '127.0.0.1' },
|
|
||||||
} as unknown as Request;
|
|
||||||
|
|
||||||
expect(rateLimiter.getClientIp(req)).toBe('127.0.0.1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return "unknown" when no IP can be determined', () => {
|
|
||||||
const req = {
|
|
||||||
headers: {},
|
|
||||||
socket: { remoteAddress: undefined },
|
|
||||||
} as unknown as Request;
|
|
||||||
|
|
||||||
expect(rateLimiter.getClientIp(req)).toBe('unknown');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('isBlocked', () => {
|
|
||||||
it('should return false for unknown keys', () => {
|
|
||||||
expect(rateLimiter.isBlocked('192.168.1.1')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false after recording fewer failures than max', () => {
|
|
||||||
rateLimiter.recordFailure('192.168.1.1');
|
|
||||||
rateLimiter.recordFailure('192.168.1.1');
|
|
||||||
|
|
||||||
expect(rateLimiter.isBlocked('192.168.1.1')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return true after reaching max failures', () => {
|
|
||||||
rateLimiter.recordFailure('192.168.1.1');
|
|
||||||
rateLimiter.recordFailure('192.168.1.1');
|
|
||||||
rateLimiter.recordFailure('192.168.1.1');
|
|
||||||
|
|
||||||
expect(rateLimiter.isBlocked('192.168.1.1')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false after block expires', () => {
|
|
||||||
rateLimiter.recordFailure('192.168.1.1');
|
|
||||||
rateLimiter.recordFailure('192.168.1.1');
|
|
||||||
rateLimiter.recordFailure('192.168.1.1');
|
|
||||||
|
|
||||||
expect(rateLimiter.isBlocked('192.168.1.1')).toBe(true);
|
|
||||||
|
|
||||||
// Advance time past block duration
|
|
||||||
vi.advanceTimersByTime(60001);
|
|
||||||
|
|
||||||
expect(rateLimiter.isBlocked('192.168.1.1')).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('recordFailure', () => {
|
|
||||||
it('should return false when not yet blocked', () => {
|
|
||||||
expect(rateLimiter.recordFailure('192.168.1.1')).toBe(false);
|
|
||||||
expect(rateLimiter.recordFailure('192.168.1.1')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return true when threshold is reached', () => {
|
|
||||||
rateLimiter.recordFailure('192.168.1.1');
|
|
||||||
rateLimiter.recordFailure('192.168.1.1');
|
|
||||||
expect(rateLimiter.recordFailure('192.168.1.1')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reset counter after window expires', () => {
|
|
||||||
rateLimiter.recordFailure('192.168.1.1');
|
|
||||||
rateLimiter.recordFailure('192.168.1.1');
|
|
||||||
|
|
||||||
// Advance time past window
|
|
||||||
vi.advanceTimersByTime(60001);
|
|
||||||
|
|
||||||
// Should start fresh
|
|
||||||
expect(rateLimiter.recordFailure('192.168.1.1')).toBe(false);
|
|
||||||
expect(rateLimiter.getAttemptsRemaining('192.168.1.1')).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should track different IPs independently', () => {
|
|
||||||
rateLimiter.recordFailure('192.168.1.1');
|
|
||||||
rateLimiter.recordFailure('192.168.1.1');
|
|
||||||
rateLimiter.recordFailure('192.168.1.1');
|
|
||||||
|
|
||||||
rateLimiter.recordFailure('192.168.1.2');
|
|
||||||
|
|
||||||
expect(rateLimiter.isBlocked('192.168.1.1')).toBe(true);
|
|
||||||
expect(rateLimiter.isBlocked('192.168.1.2')).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('reset', () => {
|
|
||||||
it('should clear record for a key', () => {
|
|
||||||
rateLimiter.recordFailure('192.168.1.1');
|
|
||||||
rateLimiter.recordFailure('192.168.1.1');
|
|
||||||
|
|
||||||
rateLimiter.reset('192.168.1.1');
|
|
||||||
|
|
||||||
expect(rateLimiter.getAttemptsRemaining('192.168.1.1')).toBe(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should clear blocked status', () => {
|
|
||||||
rateLimiter.recordFailure('192.168.1.1');
|
|
||||||
rateLimiter.recordFailure('192.168.1.1');
|
|
||||||
rateLimiter.recordFailure('192.168.1.1');
|
|
||||||
|
|
||||||
expect(rateLimiter.isBlocked('192.168.1.1')).toBe(true);
|
|
||||||
|
|
||||||
rateLimiter.reset('192.168.1.1');
|
|
||||||
|
|
||||||
expect(rateLimiter.isBlocked('192.168.1.1')).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getAttemptsRemaining', () => {
|
|
||||||
it('should return max attempts for unknown key', () => {
|
|
||||||
expect(rateLimiter.getAttemptsRemaining('192.168.1.1')).toBe(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should decrease as failures are recorded', () => {
|
|
||||||
rateLimiter.recordFailure('192.168.1.1');
|
|
||||||
expect(rateLimiter.getAttemptsRemaining('192.168.1.1')).toBe(2);
|
|
||||||
|
|
||||||
rateLimiter.recordFailure('192.168.1.1');
|
|
||||||
expect(rateLimiter.getAttemptsRemaining('192.168.1.1')).toBe(1);
|
|
||||||
|
|
||||||
rateLimiter.recordFailure('192.168.1.1');
|
|
||||||
expect(rateLimiter.getAttemptsRemaining('192.168.1.1')).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return max attempts after window expires', () => {
|
|
||||||
rateLimiter.recordFailure('192.168.1.1');
|
|
||||||
rateLimiter.recordFailure('192.168.1.1');
|
|
||||||
|
|
||||||
vi.advanceTimersByTime(60001);
|
|
||||||
|
|
||||||
expect(rateLimiter.getAttemptsRemaining('192.168.1.1')).toBe(3);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getBlockTimeRemaining', () => {
|
|
||||||
it('should return 0 for non-blocked key', () => {
|
|
||||||
expect(rateLimiter.getBlockTimeRemaining('192.168.1.1')).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return remaining block time for blocked key', () => {
|
|
||||||
rateLimiter.recordFailure('192.168.1.1');
|
|
||||||
rateLimiter.recordFailure('192.168.1.1');
|
|
||||||
rateLimiter.recordFailure('192.168.1.1');
|
|
||||||
|
|
||||||
vi.advanceTimersByTime(30000); // Advance 30 seconds
|
|
||||||
|
|
||||||
const remaining = rateLimiter.getBlockTimeRemaining('192.168.1.1');
|
|
||||||
expect(remaining).toBeGreaterThan(29000);
|
|
||||||
expect(remaining).toBeLessThanOrEqual(30000);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 0 after block expires', () => {
|
|
||||||
rateLimiter.recordFailure('192.168.1.1');
|
|
||||||
rateLimiter.recordFailure('192.168.1.1');
|
|
||||||
rateLimiter.recordFailure('192.168.1.1');
|
|
||||||
|
|
||||||
vi.advanceTimersByTime(60001);
|
|
||||||
|
|
||||||
expect(rateLimiter.getBlockTimeRemaining('192.168.1.1')).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('cleanup', () => {
|
|
||||||
it('should remove expired blocks', () => {
|
|
||||||
rateLimiter.recordFailure('192.168.1.1');
|
|
||||||
rateLimiter.recordFailure('192.168.1.1');
|
|
||||||
rateLimiter.recordFailure('192.168.1.1');
|
|
||||||
|
|
||||||
vi.advanceTimersByTime(60001);
|
|
||||||
|
|
||||||
rateLimiter.cleanup();
|
|
||||||
|
|
||||||
// After cleanup, the record should be gone
|
|
||||||
expect(rateLimiter.getAttemptsRemaining('192.168.1.1')).toBe(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should remove expired windows', () => {
|
|
||||||
rateLimiter.recordFailure('192.168.1.1');
|
|
||||||
|
|
||||||
vi.advanceTimersByTime(60001);
|
|
||||||
|
|
||||||
rateLimiter.cleanup();
|
|
||||||
|
|
||||||
expect(rateLimiter.getAttemptsRemaining('192.168.1.1')).toBe(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should preserve active records', () => {
|
|
||||||
rateLimiter.recordFailure('192.168.1.1');
|
|
||||||
|
|
||||||
vi.advanceTimersByTime(30000); // Half the window
|
|
||||||
|
|
||||||
rateLimiter.cleanup();
|
|
||||||
|
|
||||||
expect(rateLimiter.getAttemptsRemaining('192.168.1.1')).toBe(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('default configuration', () => {
|
|
||||||
it('should use sensible defaults', () => {
|
|
||||||
const defaultLimiter = new RateLimiter();
|
|
||||||
|
|
||||||
// Should have 5 max attempts by default
|
|
||||||
expect(defaultLimiter.getAttemptsRemaining('test')).toBe(5);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,307 +0,0 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
||||||
import {
|
|
||||||
writeValidation,
|
|
||||||
readValidation,
|
|
||||||
getAllValidations,
|
|
||||||
deleteValidation,
|
|
||||||
isValidationStale,
|
|
||||||
getValidationWithFreshness,
|
|
||||||
markValidationViewed,
|
|
||||||
getUnviewedValidationsCount,
|
|
||||||
type StoredValidation,
|
|
||||||
} from '@/lib/validation-storage.js';
|
|
||||||
import fs from 'fs/promises';
|
|
||||||
import path from 'path';
|
|
||||||
import os from 'os';
|
|
||||||
|
|
||||||
describe('validation-storage.ts', () => {
|
|
||||||
let testProjectPath: string;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
testProjectPath = path.join(os.tmpdir(), `validation-storage-test-${Date.now()}`);
|
|
||||||
await fs.mkdir(testProjectPath, { recursive: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
try {
|
|
||||||
await fs.rm(testProjectPath, { recursive: true, force: true });
|
|
||||||
} catch {
|
|
||||||
// Ignore cleanup errors
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const createMockValidation = (overrides: Partial<StoredValidation> = {}): StoredValidation => ({
|
|
||||||
issueNumber: 123,
|
|
||||||
issueTitle: 'Test Issue',
|
|
||||||
validatedAt: new Date().toISOString(),
|
|
||||||
model: 'haiku',
|
|
||||||
result: {
|
|
||||||
verdict: 'valid',
|
|
||||||
confidence: 'high',
|
|
||||||
reasoning: 'Test reasoning',
|
|
||||||
},
|
|
||||||
...overrides,
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('writeValidation', () => {
|
|
||||||
it('should write validation to storage', async () => {
|
|
||||||
const validation = createMockValidation();
|
|
||||||
|
|
||||||
await writeValidation(testProjectPath, 123, validation);
|
|
||||||
|
|
||||||
// Verify file was created
|
|
||||||
const validationPath = path.join(
|
|
||||||
testProjectPath,
|
|
||||||
'.automaker',
|
|
||||||
'validations',
|
|
||||||
'123',
|
|
||||||
'validation.json'
|
|
||||||
);
|
|
||||||
const content = await fs.readFile(validationPath, 'utf-8');
|
|
||||||
expect(JSON.parse(content)).toEqual(validation);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create nested directories if they do not exist', async () => {
|
|
||||||
const validation = createMockValidation({ issueNumber: 456 });
|
|
||||||
|
|
||||||
await writeValidation(testProjectPath, 456, validation);
|
|
||||||
|
|
||||||
const validationPath = path.join(
|
|
||||||
testProjectPath,
|
|
||||||
'.automaker',
|
|
||||||
'validations',
|
|
||||||
'456',
|
|
||||||
'validation.json'
|
|
||||||
);
|
|
||||||
const content = await fs.readFile(validationPath, 'utf-8');
|
|
||||||
expect(JSON.parse(content)).toEqual(validation);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('readValidation', () => {
|
|
||||||
it('should read validation from storage', async () => {
|
|
||||||
const validation = createMockValidation();
|
|
||||||
await writeValidation(testProjectPath, 123, validation);
|
|
||||||
|
|
||||||
const result = await readValidation(testProjectPath, 123);
|
|
||||||
|
|
||||||
expect(result).toEqual(validation);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null when validation does not exist', async () => {
|
|
||||||
const result = await readValidation(testProjectPath, 999);
|
|
||||||
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getAllValidations', () => {
|
|
||||||
it('should return all validations for a project', async () => {
|
|
||||||
const validation1 = createMockValidation({ issueNumber: 1, issueTitle: 'Issue 1' });
|
|
||||||
const validation2 = createMockValidation({ issueNumber: 2, issueTitle: 'Issue 2' });
|
|
||||||
const validation3 = createMockValidation({ issueNumber: 3, issueTitle: 'Issue 3' });
|
|
||||||
|
|
||||||
await writeValidation(testProjectPath, 1, validation1);
|
|
||||||
await writeValidation(testProjectPath, 2, validation2);
|
|
||||||
await writeValidation(testProjectPath, 3, validation3);
|
|
||||||
|
|
||||||
const result = await getAllValidations(testProjectPath);
|
|
||||||
|
|
||||||
expect(result).toHaveLength(3);
|
|
||||||
expect(result[0]).toEqual(validation1);
|
|
||||||
expect(result[1]).toEqual(validation2);
|
|
||||||
expect(result[2]).toEqual(validation3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return empty array when no validations exist', async () => {
|
|
||||||
const result = await getAllValidations(testProjectPath);
|
|
||||||
|
|
||||||
expect(result).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should skip non-numeric directories', async () => {
|
|
||||||
const validation = createMockValidation({ issueNumber: 1 });
|
|
||||||
await writeValidation(testProjectPath, 1, validation);
|
|
||||||
|
|
||||||
// Create a non-numeric directory
|
|
||||||
const invalidDir = path.join(testProjectPath, '.automaker', 'validations', 'invalid');
|
|
||||||
await fs.mkdir(invalidDir, { recursive: true });
|
|
||||||
|
|
||||||
const result = await getAllValidations(testProjectPath);
|
|
||||||
|
|
||||||
expect(result).toHaveLength(1);
|
|
||||||
expect(result[0]).toEqual(validation);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('deleteValidation', () => {
|
|
||||||
it('should delete validation from storage', async () => {
|
|
||||||
const validation = createMockValidation();
|
|
||||||
await writeValidation(testProjectPath, 123, validation);
|
|
||||||
|
|
||||||
const result = await deleteValidation(testProjectPath, 123);
|
|
||||||
|
|
||||||
expect(result).toBe(true);
|
|
||||||
|
|
||||||
const readResult = await readValidation(testProjectPath, 123);
|
|
||||||
expect(readResult).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return true even when validation does not exist', async () => {
|
|
||||||
const result = await deleteValidation(testProjectPath, 999);
|
|
||||||
|
|
||||||
expect(result).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('isValidationStale', () => {
|
|
||||||
it('should return false for recent validation', () => {
|
|
||||||
const validation = createMockValidation({
|
|
||||||
validatedAt: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = isValidationStale(validation);
|
|
||||||
|
|
||||||
expect(result).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return true for validation older than 24 hours', () => {
|
|
||||||
const oldDate = new Date();
|
|
||||||
oldDate.setHours(oldDate.getHours() - 25); // 25 hours ago
|
|
||||||
|
|
||||||
const validation = createMockValidation({
|
|
||||||
validatedAt: oldDate.toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = isValidationStale(validation);
|
|
||||||
|
|
||||||
expect(result).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false for validation exactly at 24 hours', () => {
|
|
||||||
const exactDate = new Date();
|
|
||||||
exactDate.setHours(exactDate.getHours() - 24);
|
|
||||||
|
|
||||||
const validation = createMockValidation({
|
|
||||||
validatedAt: exactDate.toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = isValidationStale(validation);
|
|
||||||
|
|
||||||
expect(result).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getValidationWithFreshness', () => {
|
|
||||||
it('should return validation with isStale false for recent validation', async () => {
|
|
||||||
const validation = createMockValidation({
|
|
||||||
validatedAt: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
await writeValidation(testProjectPath, 123, validation);
|
|
||||||
|
|
||||||
const result = await getValidationWithFreshness(testProjectPath, 123);
|
|
||||||
|
|
||||||
expect(result).not.toBeNull();
|
|
||||||
expect(result!.validation).toEqual(validation);
|
|
||||||
expect(result!.isStale).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return validation with isStale true for old validation', async () => {
|
|
||||||
const oldDate = new Date();
|
|
||||||
oldDate.setHours(oldDate.getHours() - 25);
|
|
||||||
|
|
||||||
const validation = createMockValidation({
|
|
||||||
validatedAt: oldDate.toISOString(),
|
|
||||||
});
|
|
||||||
await writeValidation(testProjectPath, 123, validation);
|
|
||||||
|
|
||||||
const result = await getValidationWithFreshness(testProjectPath, 123);
|
|
||||||
|
|
||||||
expect(result).not.toBeNull();
|
|
||||||
expect(result!.isStale).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null when validation does not exist', async () => {
|
|
||||||
const result = await getValidationWithFreshness(testProjectPath, 999);
|
|
||||||
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('markValidationViewed', () => {
|
|
||||||
it('should mark validation as viewed', async () => {
|
|
||||||
const validation = createMockValidation();
|
|
||||||
await writeValidation(testProjectPath, 123, validation);
|
|
||||||
|
|
||||||
const result = await markValidationViewed(testProjectPath, 123);
|
|
||||||
|
|
||||||
expect(result).toBe(true);
|
|
||||||
|
|
||||||
const updated = await readValidation(testProjectPath, 123);
|
|
||||||
expect(updated).not.toBeNull();
|
|
||||||
expect(updated!.viewedAt).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false when validation does not exist', async () => {
|
|
||||||
const result = await markValidationViewed(testProjectPath, 999);
|
|
||||||
|
|
||||||
expect(result).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getUnviewedValidationsCount', () => {
|
|
||||||
it('should return count of unviewed non-stale validations', async () => {
|
|
||||||
const validation1 = createMockValidation({ issueNumber: 1 });
|
|
||||||
const validation2 = createMockValidation({ issueNumber: 2 });
|
|
||||||
const validation3 = createMockValidation({
|
|
||||||
issueNumber: 3,
|
|
||||||
viewedAt: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
await writeValidation(testProjectPath, 1, validation1);
|
|
||||||
await writeValidation(testProjectPath, 2, validation2);
|
|
||||||
await writeValidation(testProjectPath, 3, validation3);
|
|
||||||
|
|
||||||
const result = await getUnviewedValidationsCount(testProjectPath);
|
|
||||||
|
|
||||||
expect(result).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not count stale validations', async () => {
|
|
||||||
const oldDate = new Date();
|
|
||||||
oldDate.setHours(oldDate.getHours() - 25);
|
|
||||||
|
|
||||||
const validation1 = createMockValidation({ issueNumber: 1 });
|
|
||||||
const validation2 = createMockValidation({
|
|
||||||
issueNumber: 2,
|
|
||||||
validatedAt: oldDate.toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
await writeValidation(testProjectPath, 1, validation1);
|
|
||||||
await writeValidation(testProjectPath, 2, validation2);
|
|
||||||
|
|
||||||
const result = await getUnviewedValidationsCount(testProjectPath);
|
|
||||||
|
|
||||||
expect(result).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 0 when no validations exist', async () => {
|
|
||||||
const result = await getUnviewedValidationsCount(testProjectPath);
|
|
||||||
|
|
||||||
expect(result).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 0 when all validations are viewed', async () => {
|
|
||||||
const validation = createMockValidation({
|
|
||||||
issueNumber: 1,
|
|
||||||
viewedAt: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
await writeValidation(testProjectPath, 1, validation);
|
|
||||||
|
|
||||||
const result = await getUnviewedValidationsCount(testProjectPath);
|
|
||||||
|
|
||||||
expect(result).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -69,6 +69,7 @@
|
|||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"dagre": "^0.8.5",
|
"dagre": "^0.8.5",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
|
"framer-motion": "^12.23.26",
|
||||||
"geist": "^1.5.1",
|
"geist": "^1.5.1",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
@@ -76,9 +77,9 @@
|
|||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-resizable-panels": "^3.0.6",
|
"react-resizable-panels": "^3.0.6",
|
||||||
"rehype-raw": "^7.0.0",
|
"rehype-raw": "^7.0.0",
|
||||||
|
"rehype-sanitize": "^6.0.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"usehooks-ts": "^3.1.1",
|
|
||||||
"zustand": "^5.0.9"
|
"zustand": "^5.0.9"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { useSettingsMigration } from './hooks/use-settings-migration';
|
|||||||
import './styles/global.css';
|
import './styles/global.css';
|
||||||
import './styles/theme-imports';
|
import './styles/theme-imports';
|
||||||
|
|
||||||
|
import { Shell } from './components/layout/shell';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [showSplash, setShowSplash] = useState(() => {
|
const [showSplash, setShowSplash] = useState(() => {
|
||||||
// Only show splash once per session
|
// Only show splash once per session
|
||||||
@@ -27,9 +29,9 @@ export default function App() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Shell>
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
{showSplash && <SplashScreen onComplete={handleSplashComplete} />}
|
{showSplash && <SplashScreen onComplete={handleSplashComplete} />}
|
||||||
</>
|
</Shell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -245,21 +245,18 @@ export function NewProjectModal({
|
|||||||
{/* Workspace Directory Display */}
|
{/* Workspace Directory Display */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-start gap-2 text-sm',
|
'flex items-center gap-2 text-sm',
|
||||||
errors.workspaceDir ? 'text-red-500' : 'text-muted-foreground'
|
errors.workspaceDir ? 'text-red-500' : 'text-muted-foreground'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Folder className="w-4 h-4 shrink-0 mt-0.5" />
|
<Folder className="w-4 h-4 shrink-0" />
|
||||||
<span className="flex-1 min-w-0 flex flex-col gap-1">
|
<span className="flex-1 min-w-0">
|
||||||
{isLoadingWorkspace ? (
|
{isLoadingWorkspace ? (
|
||||||
'Loading workspace...'
|
'Loading workspace...'
|
||||||
) : workspaceDir ? (
|
) : workspaceDir ? (
|
||||||
<>
|
<>
|
||||||
<span>Will be created at:</span>
|
Will be created at:{' '}
|
||||||
<code
|
<code className="text-xs bg-muted px-1.5 py-0.5 rounded truncate">
|
||||||
className="text-xs bg-muted px-1.5 py-0.5 rounded truncate block max-w-full"
|
|
||||||
title={projectPath || workspaceDir}
|
|
||||||
>
|
|
||||||
{projectPath || workspaceDir}
|
{projectPath || workspaceDir}
|
||||||
</code>
|
</code>
|
||||||
</>
|
</>
|
||||||
|
|||||||
118
apps/ui/src/components/layout/floating-dock.tsx
Normal file
118
apps/ui/src/components/layout/floating-dock.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { useRef } from 'react';
|
||||||
|
import { motion, useMotionValue, useSpring, useTransform } from 'framer-motion';
|
||||||
|
import { useNavigate, useLocation } from '@tanstack/react-router';
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Bot,
|
||||||
|
FileText,
|
||||||
|
Database,
|
||||||
|
Terminal,
|
||||||
|
Settings,
|
||||||
|
Users,
|
||||||
|
type LucideIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useAppStore } from '@/store/app-store';
|
||||||
|
|
||||||
|
export function FloatingDock() {
|
||||||
|
const mouseX = useMotionValue(Infinity);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const { currentProject } = useAppStore();
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ id: 'board', icon: LayoutDashboard, label: 'Board', path: '/board' },
|
||||||
|
{ id: 'agent', icon: Bot, label: 'Agent', path: '/agent' },
|
||||||
|
{ id: 'spec', icon: FileText, label: 'Spec', path: '/spec' },
|
||||||
|
{ id: 'context', icon: Database, label: 'Context', path: '/context' },
|
||||||
|
{ id: 'profiles', icon: Users, label: 'Profiles', path: '/profiles' },
|
||||||
|
{ id: 'terminal', icon: Terminal, label: 'Terminal', path: '/terminal' },
|
||||||
|
{ id: 'settings', icon: Settings, label: 'Settings', path: '/settings' },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!currentProject) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-8 left-1/2 -translate-x-1/2 z-50">
|
||||||
|
<motion.div
|
||||||
|
onMouseMove={(e) => mouseX.set(e.pageX)}
|
||||||
|
onMouseLeave={() => mouseX.set(Infinity)}
|
||||||
|
className={cn(
|
||||||
|
'flex h-16 items-end gap-4 rounded-2xl px-4 pb-3',
|
||||||
|
'bg-white/5 backdrop-blur-2xl border border-white/10 shadow-2xl'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<DockIcon
|
||||||
|
key={item.id}
|
||||||
|
mouseX={mouseX}
|
||||||
|
icon={item.icon}
|
||||||
|
path={item.path}
|
||||||
|
label={item.label}
|
||||||
|
isActive={location.pathname.startsWith(item.path)}
|
||||||
|
onClick={() => navigate({ to: item.path })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DockIcon({
|
||||||
|
mouseX,
|
||||||
|
icon: Icon,
|
||||||
|
path,
|
||||||
|
label,
|
||||||
|
isActive,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
mouseX: any;
|
||||||
|
icon: LucideIcon;
|
||||||
|
path: string;
|
||||||
|
label: string;
|
||||||
|
isActive: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const distance = useTransform(mouseX, (val: number) => {
|
||||||
|
const bounds = ref.current?.getBoundingClientRect() ?? { x: 0, width: 0 };
|
||||||
|
return val - bounds.x - bounds.width / 2;
|
||||||
|
});
|
||||||
|
|
||||||
|
const widthSync = useTransform(distance, [-150, 0, 150], [40, 80, 40]);
|
||||||
|
const width = useSpring(widthSync, { mass: 0.1, stiffness: 150, damping: 12 });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
ref={ref}
|
||||||
|
style={{ width }}
|
||||||
|
className="aspect-square cursor-pointer group relative"
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{/* Tooltip */}
|
||||||
|
<div className="absolute -top-10 left-1/2 -translate-x-1/2 opacity-0 group-hover:opacity-100 transition-opacity text-xs font-mono bg-black/80 text-white px-2 py-1 rounded backdrop-blur-md border border-white/10 pointer-events-none whitespace-nowrap">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex h-full w-full items-center justify-center rounded-full transition-colors',
|
||||||
|
isActive
|
||||||
|
? 'bg-primary text-primary-foreground shadow-[0_0_20px_rgba(34,211,238,0.3)]'
|
||||||
|
: 'bg-white/5 text-muted-foreground hover:bg-white/10'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-[40%] w-[40%]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active Dot */}
|
||||||
|
{isActive && (
|
||||||
|
<motion.div
|
||||||
|
layoutId="activeDockDot"
|
||||||
|
className="absolute -bottom-2 left-1/2 w-1 h-1 bg-primary rounded-full -translate-x-1/2"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
apps/ui/src/components/layout/hud.tsx
Normal file
70
apps/ui/src/components/layout/hud.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { ChevronDown, Command, Folder } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useAppStore } from '@/store/app-store';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
|
||||||
|
interface HudProps {
|
||||||
|
onOpenProjectPicker: () => void;
|
||||||
|
onOpenFolder: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Hud({ onOpenProjectPicker, onOpenFolder }: HudProps) {
|
||||||
|
const { currentProject, projects, setCurrentProject } = useAppStore();
|
||||||
|
|
||||||
|
if (!currentProject) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed top-4 left-4 z-50 flex items-center gap-3">
|
||||||
|
{/* Project Pill */}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'group flex items-center gap-3 px-4 py-2 rounded-full cursor-pointer',
|
||||||
|
'bg-white/5 backdrop-blur-md border border-white/10',
|
||||||
|
'hover:bg-white/10 transition-colors'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="w-2 h-2 rounded-full bg-emerald-500 shadow-[0_0_10px_rgba(16,185,129,0.4)] animate-pulse" />
|
||||||
|
<span className="font-mono text-sm font-medium tracking-tight">
|
||||||
|
{currentProject.name}
|
||||||
|
</span>
|
||||||
|
<ChevronDown className="w-3 h-3 text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||||
|
</div>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent className="w-56 glass border-white/10" align="start">
|
||||||
|
<DropdownMenuLabel>Switch Project</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{projects.slice(0, 5).map((p) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={p.id}
|
||||||
|
onClick={() => setCurrentProject(p)}
|
||||||
|
className="font-mono text-xs"
|
||||||
|
>
|
||||||
|
{p.name}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={onOpenProjectPicker}>
|
||||||
|
<Command className="mr-2 w-3 h-3" />
|
||||||
|
All Projects...
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={onOpenFolder}>
|
||||||
|
<Folder className="mr-2 w-3 h-3" />
|
||||||
|
Open Local Folder...
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
{/* Dynamic Status / Breadcrumbs could go here */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
apps/ui/src/components/layout/noise-overlay.tsx
Normal file
17
apps/ui/src/components/layout/noise-overlay.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
export function NoiseOverlay() {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 pointer-events-none opacity-[0.015] mix-blend-overlay">
|
||||||
|
<svg className="w-full h-full">
|
||||||
|
<filter id="noiseFilter">
|
||||||
|
<feTurbulence
|
||||||
|
type="fractalNoise"
|
||||||
|
baseFrequency="0.80"
|
||||||
|
numOctaves="3"
|
||||||
|
stitchTiles="stitch"
|
||||||
|
/>
|
||||||
|
</filter>
|
||||||
|
<rect width="100%" height="100%" filter="url(#noiseFilter)" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
apps/ui/src/components/layout/page-shell.tsx
Normal file
30
apps/ui/src/components/layout/page-shell.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { ReactNode } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
|
interface PageShellProps {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
fullWidth?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageShell({ children, className, fullWidth = false }: PageShellProps) {
|
||||||
|
return (
|
||||||
|
<div className="relative w-full h-full pt-16 pb-24 px-6 overflow-hidden">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.98, y: 10 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.4, ease: [0.2, 0, 0, 1] }}
|
||||||
|
className={cn(
|
||||||
|
'w-full h-full rounded-3xl overflow-hidden',
|
||||||
|
'bg-black/20 backdrop-blur-2xl border border-white/5 shadow-2xl',
|
||||||
|
'flex flex-col',
|
||||||
|
!fullWidth && 'max-w-7xl mx-auto',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
apps/ui/src/components/layout/prism-field.tsx
Normal file
69
apps/ui/src/components/layout/prism-field.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export function PrismField() {
|
||||||
|
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
setMousePosition({
|
||||||
|
x: e.clientX,
|
||||||
|
y: e.clientY,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('mousemove', handleMouseMove);
|
||||||
|
return () => window.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-0 overflow-hidden pointer-events-none bg-[#0b101a]">
|
||||||
|
{/* Deep Space Base */}
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_50%,rgba(17,24,39,1)_0%,rgba(11,16,26,1)_100%)]" />
|
||||||
|
|
||||||
|
{/* Animated Orbs */}
|
||||||
|
<motion.div
|
||||||
|
animate={{
|
||||||
|
x: mousePosition.x * 0.02,
|
||||||
|
y: mousePosition.y * 0.02,
|
||||||
|
}}
|
||||||
|
transition={{ type: 'spring', damping: 50, stiffness: 400 }}
|
||||||
|
className="absolute top-[-20%] left-[-10%] w-[70vw] h-[70vw] rounded-full bg-cyan-500/5 blur-[120px] mix-blend-screen"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
animate={{
|
||||||
|
x: mousePosition.x * -0.03,
|
||||||
|
y: mousePosition.y * -0.03,
|
||||||
|
}}
|
||||||
|
transition={{ type: 'spring', damping: 50, stiffness: 400 }}
|
||||||
|
className="absolute bottom-[-20%] right-[-10%] w-[60vw] h-[60vw] rounded-full bg-violet-600/5 blur-[120px] mix-blend-screen"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
animate={{
|
||||||
|
scale: [1, 1.1, 1],
|
||||||
|
opacity: [0.3, 0.5, 0.3],
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: 8,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: 'easeInOut',
|
||||||
|
}}
|
||||||
|
className="absolute top-[30%] left-[50%] transform -translate-x-1/2 -translate-y-1/2 w-[40vw] h-[40vw] rounded-full bg-blue-500/5 blur-[100px] mix-blend-screen"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Grid Overlay */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 z-10 opacity-[0.03]"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `linear-gradient(#fff 1px, transparent 1px), linear-gradient(90deg, #fff 1px, transparent 1px)`,
|
||||||
|
backgroundSize: '50px 50px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Vignette */}
|
||||||
|
<div className="absolute inset-0 z-20 bg-[radial-gradient(circle_at_center,transparent_0%,rgba(11,16,26,0.8)_100%)]" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
apps/ui/src/components/layout/shell.tsx
Normal file
32
apps/ui/src/components/layout/shell.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { ReactNode } from 'react';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
import { PrismField } from './prism-field';
|
||||||
|
import { NoiseOverlay } from './noise-overlay';
|
||||||
|
|
||||||
|
interface ShellProps {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
showBackgroundElements?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Shell({ children, className, showBackgroundElements = true }: ShellProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'relative min-h-screen w-full overflow-hidden bg-background text-foreground transition-colors duration-500',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Animated Background Layers */}
|
||||||
|
{showBackgroundElements && (
|
||||||
|
<>
|
||||||
|
<PrismField />
|
||||||
|
<NoiseOverlay />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Content wrapper */}
|
||||||
|
<div className="relative z-10 flex h-screen flex-col">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,8 +17,9 @@ import {
|
|||||||
ProjectActions,
|
ProjectActions,
|
||||||
SidebarNavigation,
|
SidebarNavigation,
|
||||||
ProjectSelectorWithOptions,
|
ProjectSelectorWithOptions,
|
||||||
SidebarFooter,
|
|
||||||
} from './sidebar/components';
|
} from './sidebar/components';
|
||||||
|
import { Hud } from './hud';
|
||||||
|
import { FloatingDock } from './floating-dock';
|
||||||
import { TrashDialog, OnboardingDialog } from './sidebar/dialogs';
|
import { TrashDialog, OnboardingDialog } from './sidebar/dialogs';
|
||||||
import { SIDEBAR_FEATURE_FLAGS } from './sidebar/constants';
|
import { SIDEBAR_FEATURE_FLAGS } from './sidebar/constants';
|
||||||
import {
|
import {
|
||||||
@@ -30,7 +31,6 @@ import {
|
|||||||
useSetupDialog,
|
useSetupDialog,
|
||||||
useTrashDialog,
|
useTrashDialog,
|
||||||
useProjectTheme,
|
useProjectTheme,
|
||||||
useUnviewedValidations,
|
|
||||||
} from './sidebar/hooks';
|
} from './sidebar/hooks';
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
@@ -128,9 +128,6 @@ export function Sidebar() {
|
|||||||
// Running agents count
|
// Running agents count
|
||||||
const { runningAgentsCount } = useRunningAgents();
|
const { runningAgentsCount } = useRunningAgents();
|
||||||
|
|
||||||
// Unviewed validations count
|
|
||||||
const { count: unviewedValidationsCount } = useUnviewedValidations(currentProject);
|
|
||||||
|
|
||||||
// Trash dialog and operations
|
// Trash dialog and operations
|
||||||
const {
|
const {
|
||||||
showTrashDialog,
|
showTrashDialog,
|
||||||
@@ -239,7 +236,6 @@ export function Sidebar() {
|
|||||||
setIsProjectPickerOpen,
|
setIsProjectPickerOpen,
|
||||||
cyclePrevProject,
|
cyclePrevProject,
|
||||||
cycleNextProject,
|
cycleNextProject,
|
||||||
unviewedValidationsCount,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Register keyboard shortcuts
|
// Register keyboard shortcuts
|
||||||
@@ -252,64 +248,27 @@ export function Sidebar() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<>
|
||||||
className={cn(
|
{/* Heads-Up Display (Top Bar) */}
|
||||||
'flex-shrink-0 flex flex-col z-30 relative',
|
<Hud
|
||||||
// Glass morphism background with gradient
|
onOpenProjectPicker={() => setIsProjectPickerOpen(true)}
|
||||||
'bg-gradient-to-b from-sidebar/95 via-sidebar/85 to-sidebar/90 backdrop-blur-2xl',
|
onOpenFolder={handleOpenFolder}
|
||||||
// Premium border with subtle glow
|
|
||||||
'border-r border-border/60 shadow-[1px_0_20px_-5px_rgba(0,0,0,0.1)]',
|
|
||||||
// Smooth width transition
|
|
||||||
'transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]',
|
|
||||||
sidebarOpen ? 'w-16 lg:w-72' : 'w-16'
|
|
||||||
)}
|
|
||||||
data-testid="sidebar"
|
|
||||||
>
|
|
||||||
<CollapseToggleButton
|
|
||||||
sidebarOpen={sidebarOpen}
|
|
||||||
toggleSidebar={toggleSidebar}
|
|
||||||
shortcut={shortcuts.toggleSidebar}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
{/* Floating Navigation Dock */}
|
||||||
<SidebarHeader sidebarOpen={sidebarOpen} navigate={navigate} />
|
<FloatingDock />
|
||||||
|
|
||||||
{/* Project Actions - Moved above project selector */}
|
|
||||||
{sidebarOpen && (
|
|
||||||
<ProjectActions
|
|
||||||
setShowNewProjectModal={setShowNewProjectModal}
|
|
||||||
handleOpenFolder={handleOpenFolder}
|
|
||||||
setShowTrashDialog={setShowTrashDialog}
|
|
||||||
trashedProjects={trashedProjects}
|
|
||||||
shortcuts={{ openProject: shortcuts.openProject }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
|
{/* Project Selector Dialog (Hidden logic, controlled by state) */}
|
||||||
|
<div className="hidden">
|
||||||
<ProjectSelectorWithOptions
|
<ProjectSelectorWithOptions
|
||||||
sidebarOpen={sidebarOpen}
|
sidebarOpen={true}
|
||||||
isProjectPickerOpen={isProjectPickerOpen}
|
isProjectPickerOpen={isProjectPickerOpen}
|
||||||
setIsProjectPickerOpen={setIsProjectPickerOpen}
|
setIsProjectPickerOpen={setIsProjectPickerOpen}
|
||||||
setShowDeleteProjectDialog={setShowDeleteProjectDialog}
|
setShowDeleteProjectDialog={setShowDeleteProjectDialog}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SidebarNavigation
|
|
||||||
currentProject={currentProject}
|
|
||||||
sidebarOpen={sidebarOpen}
|
|
||||||
navSections={navSections}
|
|
||||||
isActiveRoute={isActiveRoute}
|
|
||||||
navigate={navigate}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SidebarFooter
|
{/* Dialogs & Modals - Preservation of Logic */}
|
||||||
sidebarOpen={sidebarOpen}
|
|
||||||
isActiveRoute={isActiveRoute}
|
|
||||||
navigate={navigate}
|
|
||||||
hideWiki={hideWiki}
|
|
||||||
hideRunningAgents={hideRunningAgents}
|
|
||||||
runningAgentsCount={runningAgentsCount}
|
|
||||||
shortcuts={{ settings: shortcuts.settings }}
|
|
||||||
/>
|
|
||||||
<TrashDialog
|
<TrashDialog
|
||||||
open={showTrashDialog}
|
open={showTrashDialog}
|
||||||
onOpenChange={setShowTrashDialog}
|
onOpenChange={setShowTrashDialog}
|
||||||
@@ -322,7 +281,6 @@ export function Sidebar() {
|
|||||||
isEmptyingTrash={isEmptyingTrash}
|
isEmptyingTrash={isEmptyingTrash}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* New Project Setup Dialog */}
|
|
||||||
<CreateSpecDialog
|
<CreateSpecDialog
|
||||||
open={showSetupDialog}
|
open={showSetupDialog}
|
||||||
onOpenChange={setShowSetupDialog}
|
onOpenChange={setShowSetupDialog}
|
||||||
@@ -350,7 +308,6 @@ export function Sidebar() {
|
|||||||
onGenerateSpec={handleOnboardingGenerateSpec}
|
onGenerateSpec={handleOnboardingGenerateSpec}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Delete Project Confirmation Dialog */}
|
|
||||||
<DeleteProjectDialog
|
<DeleteProjectDialog
|
||||||
open={showDeleteProjectDialog}
|
open={showDeleteProjectDialog}
|
||||||
onOpenChange={setShowDeleteProjectDialog}
|
onOpenChange={setShowDeleteProjectDialog}
|
||||||
@@ -358,7 +315,6 @@ export function Sidebar() {
|
|||||||
onConfirm={moveProjectToTrash}
|
onConfirm={moveProjectToTrash}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* New Project Modal */}
|
|
||||||
<NewProjectModal
|
<NewProjectModal
|
||||||
open={showNewProjectModal}
|
open={showNewProjectModal}
|
||||||
onOpenChange={setShowNewProjectModal}
|
onOpenChange={setShowNewProjectModal}
|
||||||
@@ -367,6 +323,6 @@ export function Sidebar() {
|
|||||||
onCreateFromCustomUrl={handleCreateFromCustomUrl}
|
onCreateFromCustomUrl={handleCreateFromCustomUrl}
|
||||||
isCreating={isCreatingProject}
|
isCreating={isCreatingProject}
|
||||||
/>
|
/>
|
||||||
</aside>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export function ProjectSelectorWithOptions({
|
|||||||
setIsProjectPickerOpen,
|
setIsProjectPickerOpen,
|
||||||
setShowDeleteProjectDialog,
|
setShowDeleteProjectDialog,
|
||||||
}: ProjectSelectorWithOptionsProps) {
|
}: ProjectSelectorWithOptionsProps) {
|
||||||
|
// Get data from store
|
||||||
const {
|
const {
|
||||||
projects,
|
projects,
|
||||||
currentProject,
|
currentProject,
|
||||||
@@ -58,24 +59,25 @@ export function ProjectSelectorWithOptions({
|
|||||||
clearProjectHistory,
|
clearProjectHistory,
|
||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
|
|
||||||
|
// Get keyboard shortcuts
|
||||||
const shortcuts = useKeyboardShortcutsConfig();
|
const shortcuts = useKeyboardShortcutsConfig();
|
||||||
const {
|
const {
|
||||||
projectSearchQuery,
|
projectSearchQuery,
|
||||||
setProjectSearchQuery,
|
setProjectSearchQuery,
|
||||||
selectedProjectIndex,
|
selectedProjectIndex,
|
||||||
projectSearchInputRef,
|
projectSearchInputRef,
|
||||||
scrollContainerRef,
|
|
||||||
filteredProjects,
|
filteredProjects,
|
||||||
} = useProjectPicker({
|
} = useProjectPicker({
|
||||||
projects,
|
projects,
|
||||||
currentProject,
|
|
||||||
isProjectPickerOpen,
|
isProjectPickerOpen,
|
||||||
setIsProjectPickerOpen,
|
setIsProjectPickerOpen,
|
||||||
setCurrentProject,
|
setCurrentProject,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Drag-and-drop handlers
|
||||||
const { sensors, handleDragEnd } = useDragAndDrop({ projects, reorderProjects });
|
const { sensors, handleDragEnd } = useDragAndDrop({ projects, reorderProjects });
|
||||||
|
|
||||||
|
// Theme management
|
||||||
const {
|
const {
|
||||||
globalTheme,
|
globalTheme,
|
||||||
setTheme,
|
setTheme,
|
||||||
@@ -104,6 +106,7 @@ export function ProjectSelectorWithOptions({
|
|||||||
'shadow-sm shadow-black/5',
|
'shadow-sm shadow-black/5',
|
||||||
'text-foreground titlebar-no-drag min-w-0',
|
'text-foreground titlebar-no-drag min-w-0',
|
||||||
'transition-all duration-200 ease-out',
|
'transition-all duration-200 ease-out',
|
||||||
|
'hover:scale-[1.01] active:scale-[0.99]',
|
||||||
isProjectPickerOpen &&
|
isProjectPickerOpen &&
|
||||||
'from-brand-500/10 to-brand-600/5 border-brand-500/30 ring-2 ring-brand-500/20 shadow-lg shadow-brand-500/5'
|
'from-brand-500/10 to-brand-600/5 border-brand-500/30 ring-2 ring-brand-500/20 shadow-lg shadow-brand-500/5'
|
||||||
)}
|
)}
|
||||||
@@ -136,7 +139,7 @@ export function ProjectSelectorWithOptions({
|
|||||||
align="start"
|
align="start"
|
||||||
data-testid="project-picker-dropdown"
|
data-testid="project-picker-dropdown"
|
||||||
>
|
>
|
||||||
{/* Search input */}
|
{/* Search input for type-ahead filtering */}
|
||||||
<div className="px-1 pb-2">
|
<div className="px-1 pb-2">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||||
@@ -147,10 +150,10 @@ export function ProjectSelectorWithOptions({
|
|||||||
value={projectSearchQuery}
|
value={projectSearchQuery}
|
||||||
onChange={(e) => setProjectSearchQuery(e.target.value)}
|
onChange={(e) => setProjectSearchQuery(e.target.value)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full h-8 pl-8 pr-3 text-sm rounded-lg',
|
'w-full h-9 pl-8 pr-3 text-sm rounded-lg',
|
||||||
'border border-border bg-background/50',
|
'border border-border bg-background/50',
|
||||||
'text-foreground placeholder:text-muted-foreground',
|
'text-foreground placeholder:text-muted-foreground',
|
||||||
'focus:outline-none focus:ring-1 focus:ring-brand-500/30 focus:border-brand-500/50',
|
'focus:outline-none focus:ring-2 focus:ring-brand-500/30 focus:border-brand-500/50',
|
||||||
'transition-all duration-200'
|
'transition-all duration-200'
|
||||||
)}
|
)}
|
||||||
data-testid="project-search-input"
|
data-testid="project-search-input"
|
||||||
@@ -172,10 +175,7 @@ export function ProjectSelectorWithOptions({
|
|||||||
items={filteredProjects.map((p) => p.id)}
|
items={filteredProjects.map((p) => p.id)}
|
||||||
strategy={verticalListSortingStrategy}
|
strategy={verticalListSortingStrategy}
|
||||||
>
|
>
|
||||||
<div
|
<div className="space-y-0.5 max-h-64 overflow-y-auto">
|
||||||
ref={scrollContainerRef}
|
|
||||||
className="space-y-0.5 max-h-64 overflow-y-auto overflow-x-hidden scroll-smooth scrollbar-styled"
|
|
||||||
>
|
|
||||||
{filteredProjects.map((project, index) => (
|
{filteredProjects.map((project, index) => (
|
||||||
<SortableProjectItem
|
<SortableProjectItem
|
||||||
key={project.id}
|
key={project.id}
|
||||||
@@ -196,9 +196,9 @@ export function ProjectSelectorWithOptions({
|
|||||||
{/* Keyboard hint */}
|
{/* Keyboard hint */}
|
||||||
<div className="px-2 pt-2 mt-1.5 border-t border-border/50">
|
<div className="px-2 pt-2 mt-1.5 border-t border-border/50">
|
||||||
<p className="text-[10px] text-muted-foreground text-center tracking-wide">
|
<p className="text-[10px] text-muted-foreground text-center tracking-wide">
|
||||||
<span className="text-foreground/60">↑↓</span> navigate{' '}
|
<span className="text-foreground/60">arrow</span> navigate{' '}
|
||||||
<span className="mx-1 text-foreground/30">|</span>{' '}
|
<span className="mx-1 text-foreground/30">|</span>{' '}
|
||||||
<span className="text-foreground/60">↵</span> select{' '}
|
<span className="text-foreground/60">enter</span> select{' '}
|
||||||
<span className="mx-1 text-foreground/30">|</span>{' '}
|
<span className="mx-1 text-foreground/30">|</span>{' '}
|
||||||
<span className="text-foreground/60">esc</span> close
|
<span className="text-foreground/60">esc</span> close
|
||||||
</p>
|
</p>
|
||||||
@@ -206,7 +206,7 @@ export function ProjectSelectorWithOptions({
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
{/* Project Options Menu */}
|
{/* Project Options Menu - theme and history */}
|
||||||
{currentProject && (
|
{currentProject && (
|
||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
@@ -223,7 +223,8 @@ export function ProjectSelectorWithOptions({
|
|||||||
'text-muted-foreground hover:text-foreground',
|
'text-muted-foreground hover:text-foreground',
|
||||||
'bg-transparent hover:bg-accent/60',
|
'bg-transparent hover:bg-accent/60',
|
||||||
'border border-border/50 hover:border-border',
|
'border border-border/50 hover:border-border',
|
||||||
'transition-all duration-200 ease-out titlebar-no-drag'
|
'transition-all duration-200 ease-out titlebar-no-drag',
|
||||||
|
'hover:scale-[1.02] active:scale-[0.98]'
|
||||||
)}
|
)}
|
||||||
title="Project options"
|
title="Project options"
|
||||||
data-testid="project-options-menu"
|
data-testid="project-options-menu"
|
||||||
@@ -251,6 +252,7 @@ export function ProjectSelectorWithOptions({
|
|||||||
setPreviewTheme(null);
|
setPreviewTheme(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* Use Global Option */}
|
||||||
<DropdownMenuRadioGroup
|
<DropdownMenuRadioGroup
|
||||||
value={currentProject.theme || ''}
|
value={currentProject.theme || ''}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
@@ -326,7 +328,7 @@ export function ProjectSelectorWithOptions({
|
|||||||
</DropdownMenuSubContent>
|
</DropdownMenuSubContent>
|
||||||
</DropdownMenuSub>
|
</DropdownMenuSub>
|
||||||
|
|
||||||
{/* Project History Section */}
|
{/* Project History Section - only show when there's history */}
|
||||||
{projectHistory.length > 1 && (
|
{projectHistory.length > 1 && (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|||||||
@@ -78,29 +78,14 @@ export function SidebarNavigation({
|
|||||||
title={!sidebarOpen ? item.label : undefined}
|
title={!sidebarOpen ? item.label : undefined}
|
||||||
data-testid={`nav-${item.id}`}
|
data-testid={`nav-${item.id}`}
|
||||||
>
|
>
|
||||||
<div className="relative">
|
<Icon
|
||||||
<Icon
|
className={cn(
|
||||||
className={cn(
|
'w-[18px] h-[18px] shrink-0 transition-all duration-200',
|
||||||
'w-[18px] h-[18px] shrink-0 transition-all duration-200',
|
isActive
|
||||||
isActive
|
? 'text-brand-500 drop-shadow-sm'
|
||||||
? 'text-brand-500 drop-shadow-sm'
|
: 'group-hover:text-brand-400 group-hover:scale-110'
|
||||||
: 'group-hover:text-brand-400 group-hover:scale-110'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{/* Count badge for collapsed state */}
|
|
||||||
{!sidebarOpen && item.count !== undefined && item.count > 0 && (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'absolute -top-1.5 -right-1.5 flex items-center justify-center',
|
|
||||||
'min-w-4 h-4 px-1 text-[9px] font-bold rounded-full',
|
|
||||||
'bg-primary text-primary-foreground shadow-sm',
|
|
||||||
'animate-in fade-in zoom-in duration-200'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item.count > 99 ? '99' : item.count}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
/>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'ml-3 font-medium text-sm flex-1 text-left',
|
'ml-3 font-medium text-sm flex-1 text-left',
|
||||||
@@ -109,21 +94,7 @@ export function SidebarNavigation({
|
|||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</span>
|
</span>
|
||||||
{/* Count badge */}
|
{item.shortcut && sidebarOpen && (
|
||||||
{item.count !== undefined && item.count > 0 && sidebarOpen && (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'hidden lg:flex items-center justify-center',
|
|
||||||
'min-w-5 h-5 px-1.5 text-[10px] font-bold rounded-full',
|
|
||||||
'bg-primary text-primary-foreground shadow-sm',
|
|
||||||
'animate-in fade-in zoom-in duration-200'
|
|
||||||
)}
|
|
||||||
data-testid={`count-${item.id}`}
|
|
||||||
>
|
|
||||||
{item.count > 99 ? '99+' : item.count}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{item.shortcut && sidebarOpen && !item.count && (
|
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'hidden lg:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200',
|
'hidden lg:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200',
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ export function SortableProjectItem({
|
|||||||
isHighlighted && 'bg-brand-500/10 text-foreground ring-1 ring-brand-500/20'
|
isHighlighted && 'bg-brand-500/10 text-foreground ring-1 ring-brand-500/20'
|
||||||
)}
|
)}
|
||||||
data-testid={`project-option-${project.id}`}
|
data-testid={`project-option-${project.id}`}
|
||||||
onClick={() => onSelect(project)}
|
|
||||||
>
|
>
|
||||||
{/* Drag Handle */}
|
{/* Drag Handle */}
|
||||||
<button
|
<button
|
||||||
@@ -44,14 +43,9 @@ export function SortableProjectItem({
|
|||||||
<GripVertical className="h-3.5 w-3.5 text-muted-foreground/60" />
|
<GripVertical className="h-3.5 w-3.5 text-muted-foreground/60" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Project content */}
|
{/* Project content - clickable area */}
|
||||||
<div className="flex items-center gap-2.5 flex-1 min-w-0">
|
<div className="flex items-center gap-2.5 flex-1 min-w-0" onClick={() => onSelect(project)}>
|
||||||
<Folder
|
<Folder className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
className={cn(
|
|
||||||
'h-4 w-4 shrink-0',
|
|
||||||
currentProjectId === project.id ? 'text-brand-500' : 'text-muted-foreground'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span className="flex-1 truncate text-sm font-medium">{project.name}</span>
|
<span className="flex-1 truncate text-sm font-medium">{project.name}</span>
|
||||||
{currentProjectId === project.id && <Check className="h-4 w-4 text-brand-500 shrink-0" />}
|
{currentProjectId === project.id && <Check className="h-4 w-4 text-brand-500 shrink-0" />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -37,11 +37,11 @@ export function OnboardingDialog({
|
|||||||
<DialogContent className="max-w-2xl bg-popover/95 backdrop-blur-xl">
|
<DialogContent className="max-w-2xl bg-popover/95 backdrop-blur-xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<div className="flex items-center justify-center w-12 h-12 rounded-full bg-brand-500/10 border border-brand-500/20 shrink-0">
|
<div className="flex items-center justify-center w-12 h-12 rounded-full bg-brand-500/10 border border-brand-500/20">
|
||||||
<Rocket className="w-6 h-6 text-brand-500" />
|
<Rocket className="w-6 h-6 text-brand-500" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div>
|
||||||
<DialogTitle className="text-2xl truncate">Welcome to {newProjectName}!</DialogTitle>
|
<DialogTitle className="text-2xl">Welcome to {newProjectName}!</DialogTitle>
|
||||||
<DialogDescription className="text-muted-foreground mt-1">
|
<DialogDescription className="text-muted-foreground mt-1">
|
||||||
Your new project is ready. Let's get you started.
|
Your new project is ready. Let's get you started.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
|
|||||||
@@ -10,4 +10,3 @@ export { useProjectCreation } from './use-project-creation';
|
|||||||
export { useSetupDialog } from './use-setup-dialog';
|
export { useSetupDialog } from './use-setup-dialog';
|
||||||
export { useTrashDialog } from './use-trash-dialog';
|
export { useTrashDialog } from './use-trash-dialog';
|
||||||
export { useProjectTheme } from './use-project-theme';
|
export { useProjectTheme } from './use-project-theme';
|
||||||
export { useUnviewedValidations } from './use-unviewed-validations';
|
|
||||||
|
|||||||
@@ -44,8 +44,6 @@ interface UseNavigationProps {
|
|||||||
setIsProjectPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
|
setIsProjectPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
|
||||||
cyclePrevProject: () => void;
|
cyclePrevProject: () => void;
|
||||||
cycleNextProject: () => void;
|
cycleNextProject: () => void;
|
||||||
/** Count of unviewed validations to show on GitHub Issues nav item */
|
|
||||||
unviewedValidationsCount?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useNavigation({
|
export function useNavigation({
|
||||||
@@ -63,7 +61,6 @@ export function useNavigation({
|
|||||||
setIsProjectPickerOpen,
|
setIsProjectPickerOpen,
|
||||||
cyclePrevProject,
|
cyclePrevProject,
|
||||||
cycleNextProject,
|
cycleNextProject,
|
||||||
unviewedValidationsCount,
|
|
||||||
}: UseNavigationProps) {
|
}: UseNavigationProps) {
|
||||||
// Track if current project has a GitHub remote
|
// Track if current project has a GitHub remote
|
||||||
const [hasGitHubRemote, setHasGitHubRemote] = useState(false);
|
const [hasGitHubRemote, setHasGitHubRemote] = useState(false);
|
||||||
@@ -172,7 +169,6 @@ export function useNavigation({
|
|||||||
id: 'github-issues',
|
id: 'github-issues',
|
||||||
label: 'Issues',
|
label: 'Issues',
|
||||||
icon: CircleDot,
|
icon: CircleDot,
|
||||||
count: unviewedValidationsCount,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'github-prs',
|
id: 'github-prs',
|
||||||
@@ -184,15 +180,7 @@ export function useNavigation({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return sections;
|
return sections;
|
||||||
}, [
|
}, [shortcuts, hideSpecEditor, hideContext, hideTerminal, hideAiProfiles, hasGitHubRemote]);
|
||||||
shortcuts,
|
|
||||||
hideSpecEditor,
|
|
||||||
hideContext,
|
|
||||||
hideTerminal,
|
|
||||||
hideAiProfiles,
|
|
||||||
hasGitHubRemote,
|
|
||||||
unviewedValidationsCount,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Build keyboard shortcuts for navigation
|
// Build keyboard shortcuts for navigation
|
||||||
const navigationShortcuts: KeyboardShortcut[] = useMemo(() => {
|
const navigationShortcuts: KeyboardShortcut[] = useMemo(() => {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import type { Project } from '@/lib/electron';
|
|||||||
|
|
||||||
interface UseProjectPickerProps {
|
interface UseProjectPickerProps {
|
||||||
projects: Project[];
|
projects: Project[];
|
||||||
currentProject: Project | null;
|
|
||||||
isProjectPickerOpen: boolean;
|
isProjectPickerOpen: boolean;
|
||||||
setIsProjectPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
|
setIsProjectPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
|
||||||
setCurrentProject: (project: Project) => void;
|
setCurrentProject: (project: Project) => void;
|
||||||
@@ -11,7 +10,6 @@ interface UseProjectPickerProps {
|
|||||||
|
|
||||||
export function useProjectPicker({
|
export function useProjectPicker({
|
||||||
projects,
|
projects,
|
||||||
currentProject,
|
|
||||||
isProjectPickerOpen,
|
isProjectPickerOpen,
|
||||||
setIsProjectPickerOpen,
|
setIsProjectPickerOpen,
|
||||||
setCurrentProject,
|
setCurrentProject,
|
||||||
@@ -19,7 +17,6 @@ export function useProjectPicker({
|
|||||||
const [projectSearchQuery, setProjectSearchQuery] = useState('');
|
const [projectSearchQuery, setProjectSearchQuery] = useState('');
|
||||||
const [selectedProjectIndex, setSelectedProjectIndex] = useState(0);
|
const [selectedProjectIndex, setSelectedProjectIndex] = useState(0);
|
||||||
const projectSearchInputRef = useRef<HTMLInputElement>(null);
|
const projectSearchInputRef = useRef<HTMLInputElement>(null);
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// Filtered projects based on search query
|
// Filtered projects based on search query
|
||||||
const filteredProjects = useMemo(() => {
|
const filteredProjects = useMemo(() => {
|
||||||
@@ -30,66 +27,28 @@ export function useProjectPicker({
|
|||||||
return projects.filter((project) => project.name.toLowerCase().includes(query));
|
return projects.filter((project) => project.name.toLowerCase().includes(query));
|
||||||
}, [projects, projectSearchQuery]);
|
}, [projects, projectSearchQuery]);
|
||||||
|
|
||||||
// Helper function to scroll to a specific project
|
// Reset selection when filtered results change
|
||||||
const scrollToProject = useCallback((projectId: string) => {
|
|
||||||
if (!scrollContainerRef.current) return;
|
|
||||||
|
|
||||||
const element = scrollContainerRef.current.querySelector(
|
|
||||||
`[data-testid="project-option-${projectId}"]`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (element) {
|
|
||||||
element.scrollIntoView({
|
|
||||||
behavior: 'smooth',
|
|
||||||
block: 'nearest',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// On open/close, handle search query reset and focus
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isProjectPickerOpen) {
|
setSelectedProjectIndex(0);
|
||||||
// Focus search input after DOM renders
|
}, [filteredProjects.length, projectSearchQuery]);
|
||||||
requestAnimationFrame(() => {
|
|
||||||
projectSearchInputRef.current?.focus();
|
// Reset search query when dropdown closes
|
||||||
});
|
useEffect(() => {
|
||||||
} else {
|
if (!isProjectPickerOpen) {
|
||||||
// Reset search when closing
|
|
||||||
setProjectSearchQuery('');
|
setProjectSearchQuery('');
|
||||||
|
setSelectedProjectIndex(0);
|
||||||
}
|
}
|
||||||
}, [isProjectPickerOpen]);
|
}, [isProjectPickerOpen]);
|
||||||
|
|
||||||
// Update selection when search query changes (while picker is open)
|
// Focus the search input when dropdown opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isProjectPickerOpen) {
|
if (isProjectPickerOpen) {
|
||||||
setSelectedProjectIndex(0);
|
// Small delay to ensure the dropdown is rendered
|
||||||
return;
|
setTimeout(() => {
|
||||||
|
projectSearchInputRef.current?.focus();
|
||||||
|
}, 0);
|
||||||
}
|
}
|
||||||
|
}, [isProjectPickerOpen]);
|
||||||
if (projectSearchQuery.trim()) {
|
|
||||||
// When searching, reset to first result
|
|
||||||
setSelectedProjectIndex(0);
|
|
||||||
} else {
|
|
||||||
// When not searching (e.g., on open or search cleared), find and select the current project
|
|
||||||
const currentIndex = currentProject
|
|
||||||
? filteredProjects.findIndex((p) => p.id === currentProject.id)
|
|
||||||
: -1;
|
|
||||||
setSelectedProjectIndex(currentIndex !== -1 ? currentIndex : 0);
|
|
||||||
}
|
|
||||||
}, [isProjectPickerOpen, projectSearchQuery, filteredProjects, currentProject]);
|
|
||||||
|
|
||||||
// Scroll to highlighted item when selection changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isProjectPickerOpen) return;
|
|
||||||
|
|
||||||
const targetProject = filteredProjects[selectedProjectIndex];
|
|
||||||
if (targetProject) {
|
|
||||||
// Use requestAnimationFrame to ensure DOM is rendered before scrolling
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
scrollToProject(targetProject.id);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [selectedProjectIndex, isProjectPickerOpen, filteredProjects, scrollToProject]);
|
|
||||||
|
|
||||||
// Handle selecting the currently highlighted project
|
// Handle selecting the currently highlighted project
|
||||||
const selectHighlightedProject = useCallback(() => {
|
const selectHighlightedProject = useCallback(() => {
|
||||||
@@ -140,7 +99,6 @@ export function useProjectPicker({
|
|||||||
selectedProjectIndex,
|
selectedProjectIndex,
|
||||||
setSelectedProjectIndex,
|
setSelectedProjectIndex,
|
||||||
projectSearchInputRef,
|
projectSearchInputRef,
|
||||||
scrollContainerRef,
|
|
||||||
filteredProjects,
|
filteredProjects,
|
||||||
selectHighlightedProject,
|
selectHighlightedProject,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
|
||||||
import type { Project, StoredValidation } from '@/lib/electron';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook to track the count of unviewed (fresh) issue validations for a project.
|
|
||||||
* Also provides a function to decrement the count when a validation is viewed.
|
|
||||||
*/
|
|
||||||
export function useUnviewedValidations(currentProject: Project | null) {
|
|
||||||
const [count, setCount] = useState(0);
|
|
||||||
const projectPathRef = useRef<string | null>(null);
|
|
||||||
|
|
||||||
// Keep project path in ref for use in async functions
|
|
||||||
useEffect(() => {
|
|
||||||
projectPathRef.current = currentProject?.path ?? null;
|
|
||||||
}, [currentProject?.path]);
|
|
||||||
|
|
||||||
// Fetch and update count from server
|
|
||||||
const fetchUnviewedCount = useCallback(async () => {
|
|
||||||
const projectPath = projectPathRef.current;
|
|
||||||
if (!projectPath) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (api.github?.getValidations) {
|
|
||||||
const result = await api.github.getValidations(projectPath);
|
|
||||||
if (result.success && result.validations) {
|
|
||||||
const unviewed = result.validations.filter((v: StoredValidation) => {
|
|
||||||
if (v.viewedAt) return false;
|
|
||||||
// Check if not stale (< 24 hours)
|
|
||||||
const hoursSince = (Date.now() - new Date(v.validatedAt).getTime()) / (1000 * 60 * 60);
|
|
||||||
return hoursSince <= 24;
|
|
||||||
});
|
|
||||||
// Only update count if we're still on the same project (guard against race condition)
|
|
||||||
if (projectPathRef.current === projectPath) {
|
|
||||||
setCount(unviewed.length);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[useUnviewedValidations] Failed to load count:', err);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Load initial count and subscribe to events
|
|
||||||
useEffect(() => {
|
|
||||||
if (!currentProject?.path) {
|
|
||||||
setCount(0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load initial count
|
|
||||||
fetchUnviewedCount();
|
|
||||||
|
|
||||||
// Subscribe to validation events to update count
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (api.github?.onValidationEvent) {
|
|
||||||
const unsubscribe = api.github.onValidationEvent((event) => {
|
|
||||||
if (event.projectPath === currentProject.path) {
|
|
||||||
if (event.type === 'issue_validation_complete') {
|
|
||||||
// New validation completed - refresh count from server for consistency
|
|
||||||
fetchUnviewedCount();
|
|
||||||
} else if (event.type === 'issue_validation_viewed') {
|
|
||||||
// Validation was viewed - refresh count from server for consistency
|
|
||||||
fetchUnviewedCount();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return () => unsubscribe();
|
|
||||||
}
|
|
||||||
}, [currentProject?.path, fetchUnviewedCount]);
|
|
||||||
|
|
||||||
// Function to decrement count when a validation is viewed
|
|
||||||
const decrementCount = useCallback(() => {
|
|
||||||
setCount((prev) => Math.max(0, prev - 1));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Expose refreshCount as an alias to fetchUnviewedCount for external use
|
|
||||||
const refreshCount = fetchUnviewedCount;
|
|
||||||
|
|
||||||
return { count, decrementCount, refreshCount };
|
|
||||||
}
|
|
||||||
@@ -11,8 +11,6 @@ export interface NavItem {
|
|||||||
label: string;
|
label: string;
|
||||||
icon: React.ComponentType<{ className?: string }>;
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
shortcut?: string;
|
shortcut?: string;
|
||||||
/** Optional count badge to display next to the nav item */
|
|
||||||
count?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SortableProjectItemProps {
|
export interface SortableProjectItemProps {
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ const badgeVariants = cva(
|
|||||||
// Muted variants for subtle indication
|
// Muted variants for subtle indication
|
||||||
muted: 'border-border/50 bg-muted/50 text-muted-foreground',
|
muted: 'border-border/50 bg-muted/50 text-muted-foreground',
|
||||||
brand: 'border-transparent bg-brand-500/15 text-brand-500 border border-brand-500/30',
|
brand: 'border-transparent bg-brand-500/15 text-brand-500 border border-brand-500/30',
|
||||||
|
// Prism variants
|
||||||
|
prism:
|
||||||
|
'border-cyan-500/30 bg-cyan-500/10 text-cyan-400 hover:bg-cyan-500/20 font-mono tracking-wide rounded-md',
|
||||||
|
'prism-orange':
|
||||||
|
'border-amber-500/30 bg-amber-500/10 text-amber-400 hover:bg-amber-500/20 font-mono tracking-wide rounded-md',
|
||||||
|
'prism-green':
|
||||||
|
'border-emerald-500/30 bg-emerald-500/10 text-emerald-400 hover:bg-emerald-500/20 font-mono tracking-wide rounded-md',
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: 'px-2.5 py-0.5 text-xs',
|
default: 'px-2.5 py-0.5 text-xs',
|
||||||
|
|||||||
@@ -6,25 +6,32 @@ import { Loader2 } from 'lucide-react';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
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-lg text-sm font-medium transition-all duration-300 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]",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default:
|
||||||
'bg-primary text-primary-foreground shadow-sm hover:bg-primary/90 hover:shadow-md hover:shadow-primary/25',
|
'bg-primary text-primary-foreground shadow-lg shadow-primary/20 hover:bg-primary/90 hover:shadow-primary/40 hover:-translate-y-0.5',
|
||||||
destructive:
|
destructive:
|
||||||
'bg-destructive text-white shadow-sm hover:bg-destructive/90 hover:shadow-md hover:shadow-destructive/25 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
'bg-destructive text-white shadow-sm hover:bg-destructive/90 hover:shadow-md hover:shadow-destructive/25 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
||||||
outline:
|
outline:
|
||||||
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
|
'border border-border/50 bg-background/50 backdrop-blur-sm shadow-sm hover:bg-accent hover:text-accent-foreground dark:bg-white/5 dark:hover:bg-white/10 hover:border-accent',
|
||||||
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
|
secondary:
|
||||||
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80 hover:shadow-md',
|
||||||
|
ghost: 'hover:bg-accent/50 hover:text-accent-foreground hover:backdrop-blur-sm',
|
||||||
link: 'text-primary underline-offset-4 hover:underline active:scale-100',
|
link: 'text-primary underline-offset-4 hover:underline active:scale-100',
|
||||||
|
glass:
|
||||||
|
'border border-white/10 bg-white/5 text-foreground shadow-sm drop-shadow-sm backdrop-blur-md hover:bg-white/10 hover:border-white/20 hover:shadow-md transition-all duration-300',
|
||||||
'animated-outline': 'relative overflow-hidden rounded-xl hover:bg-transparent shadow-none',
|
'animated-outline': 'relative overflow-hidden rounded-xl hover:bg-transparent shadow-none',
|
||||||
|
'prism-primary':
|
||||||
|
'bg-cyan-400 text-slate-950 font-extrabold shadow-lg shadow-cyan-400/20 hover:brightness-110 hover:shadow-cyan-400/40 transition-all duration-200 tracking-wide',
|
||||||
|
'prism-glass':
|
||||||
|
'glass hover:bg-white/10 text-xs font-bold rounded-xl transition-all duration-200',
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
||||||
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
|
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5 text-xs',
|
||||||
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
lg: 'h-11 rounded-md px-8 has-[>svg]:px-5 text-base',
|
||||||
icon: 'size-9',
|
icon: 'size-9',
|
||||||
'icon-sm': 'size-8',
|
'icon-sm': 'size-8',
|
||||||
'icon-lg': 'size-10',
|
'icon-lg': 'size-10',
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ function Card({ className, gradient = false, ...props }: CardProps) {
|
|||||||
<div
|
<div
|
||||||
data-slot="card"
|
data-slot="card"
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-card text-card-foreground flex flex-col gap-1 rounded-xl border border-white/10 backdrop-blur-md py-6',
|
'bg-white/5 text-card-foreground flex flex-col gap-1 rounded-[1.5rem] border border-white/10 backdrop-blur-xl py-6 transition-all duration-300',
|
||||||
// Premium layered shadow
|
// Prism hover effect
|
||||||
'shadow-[0_1px_2px_rgba(0,0,0,0.05),0_4px_6px_rgba(0,0,0,0.05),0_10px_20px_rgba(0,0,0,0.04)]',
|
'hover:-translate-y-1 hover:bg-white/[0.06] hover:border-white/15',
|
||||||
// Gradient border option
|
// Gradient border option
|
||||||
gradient &&
|
gradient &&
|
||||||
'relative before:absolute before:inset-0 before:rounded-xl before:p-[1px] before:bg-gradient-to-br before:from-white/20 before:to-transparent before:pointer-events-none before:-z-10',
|
'relative before:absolute before:inset-0 before:rounded-xl before:p-[1px] before:bg-gradient-to-br before:from-white/20 before:to-transparent before:pointer-events-none before:-z-10',
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
import type { ReactNode } from 'react';
|
|
||||||
import { LucideIcon } from 'lucide-react';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { HotkeyButton } from '@/components/ui/hotkey-button';
|
|
||||||
|
|
||||||
interface ConfirmDialogProps {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
onConfirm: () => void;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
/** Optional icon to show in the title */
|
|
||||||
icon?: LucideIcon;
|
|
||||||
/** Icon color class. Defaults to "text-primary" */
|
|
||||||
iconClassName?: string;
|
|
||||||
/** Optional content to show between description and buttons */
|
|
||||||
children?: ReactNode;
|
|
||||||
/** Text for the confirm button. Defaults to "Confirm" */
|
|
||||||
confirmText?: string;
|
|
||||||
/** Text for the cancel button. Defaults to "Cancel" */
|
|
||||||
cancelText?: string;
|
|
||||||
/** Variant for the confirm button. Defaults to "default" */
|
|
||||||
confirmVariant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ConfirmDialog({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
onConfirm,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
icon: Icon,
|
|
||||||
iconClassName = 'text-primary',
|
|
||||||
children,
|
|
||||||
confirmText = 'Confirm',
|
|
||||||
cancelText = 'Cancel',
|
|
||||||
confirmVariant = 'default',
|
|
||||||
}: ConfirmDialogProps) {
|
|
||||||
const handleConfirm = () => {
|
|
||||||
onConfirm();
|
|
||||||
onOpenChange(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="bg-popover border-border max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="flex items-center gap-2">
|
|
||||||
{Icon && <Icon className={`w-5 h-5 ${iconClassName}`} />}
|
|
||||||
{title}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription className="text-muted-foreground">{description}</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{children}
|
|
||||||
|
|
||||||
<DialogFooter className="gap-2 sm:gap-2 pt-4">
|
|
||||||
<Button variant="ghost" onClick={() => onOpenChange(false)} className="px-4">
|
|
||||||
{cancelText}
|
|
||||||
</Button>
|
|
||||||
<HotkeyButton
|
|
||||||
variant={confirmVariant}
|
|
||||||
onClick={handleConfirm}
|
|
||||||
hotkey={{ key: 'Enter', cmdCtrl: true }}
|
|
||||||
hotkeyActive={open}
|
|
||||||
className="px-4"
|
|
||||||
>
|
|
||||||
{Icon && <Icon className="w-4 h-4 mr-2" />}
|
|
||||||
{confirmText}
|
|
||||||
</HotkeyButton>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -66,10 +66,10 @@ function DialogOverlay({
|
|||||||
<DialogOverlayPrimitive
|
<DialogOverlayPrimitive
|
||||||
data-slot="dialog-overlay"
|
data-slot="dialog-overlay"
|
||||||
className={cn(
|
className={cn(
|
||||||
'fixed inset-0 z-50 bg-black/60 backdrop-blur-sm',
|
'fixed inset-0 z-50 bg-black/40 backdrop-blur-md',
|
||||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
'duration-200',
|
'duration-300',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -99,15 +99,15 @@ const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>(
|
|||||||
className={cn(
|
className={cn(
|
||||||
'fixed top-[50%] left-[50%] z-50 translate-x-[-50%] translate-y-[-50%]',
|
'fixed top-[50%] left-[50%] z-50 translate-x-[-50%] translate-y-[-50%]',
|
||||||
'flex flex-col w-full max-w-[calc(100%-2rem)] max-h-[calc(100vh-4rem)]',
|
'flex flex-col w-full max-w-[calc(100%-2rem)] max-h-[calc(100vh-4rem)]',
|
||||||
'bg-card border border-border rounded-xl shadow-2xl',
|
'bg-card/90 border border-white/10 rounded-2xl shadow-2xl backdrop-blur-xl',
|
||||||
// Premium shadow
|
// Premium shadow
|
||||||
'shadow-[0_25px_50px_-12px_rgba(0,0,0,0.25)]',
|
'shadow-[0_40px_80px_-12px_rgba(0,0,0,0.5)]',
|
||||||
// Animations - smoother with scale
|
// Animations - smoother with scale
|
||||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||||
'data-[state=closed]:slide-out-to-top-[2%] data-[state=open]:slide-in-from-top-[2%]',
|
'data-[state=closed]:slide-out-to-top-[2%] data-[state=open]:slide-in-from-top-[2%]',
|
||||||
'duration-200',
|
'duration-300 ease-out',
|
||||||
compact ? 'max-w-4xl p-4' : !hasCustomMaxWidth ? 'sm:max-w-2xl p-6' : 'p-6',
|
compact ? 'max-w-4xl p-4' : !hasCustomMaxWidth ? 'sm:max-w-2xl p-6' : 'p-6',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -157,7 +157,8 @@ const DropdownMenuContent = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
'z-50 min-w-[8rem] overflow-hidden rounded-lg border border-white/10 bg-popover/80 p-1 text-popover-foreground shadow-xl backdrop-blur-xl',
|
||||||
|
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
import { CircleDot, RefreshCw } from 'lucide-react';
|
|
||||||
import { Button } from './button';
|
|
||||||
|
|
||||||
interface ErrorStateProps {
|
|
||||||
/** Error message to display */
|
|
||||||
error: string;
|
|
||||||
/** Title for the error state (default: "Failed to Load") */
|
|
||||||
title?: string;
|
|
||||||
/** Callback when retry button is clicked */
|
|
||||||
onRetry?: () => void;
|
|
||||||
/** Text for the retry button (default: "Try Again") */
|
|
||||||
retryText?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ErrorState({
|
|
||||||
error,
|
|
||||||
title = 'Failed to Load',
|
|
||||||
onRetry,
|
|
||||||
retryText = 'Try Again',
|
|
||||||
}: ErrorStateProps) {
|
|
||||||
return (
|
|
||||||
<div className="flex-1 flex flex-col items-center justify-center text-center p-6">
|
|
||||||
<div className="p-4 rounded-full bg-destructive/10 mb-4">
|
|
||||||
<CircleDot className="h-12 w-12 text-destructive" />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-lg font-medium mb-2">{title}</h2>
|
|
||||||
<p className="text-muted-foreground max-w-md mb-4">{error}</p>
|
|
||||||
{onRetry && (
|
|
||||||
<Button variant="outline" onClick={onRetry}>
|
|
||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
|
||||||
{retryText}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -15,17 +15,21 @@ function Input({ className, type, startAddon, endAddon, ...props }: InputProps)
|
|||||||
type={type}
|
type={type}
|
||||||
data-slot="input"
|
data-slot="input"
|
||||||
className={cn(
|
className={cn(
|
||||||
'file:text-foreground placeholder:text-muted-foreground/60 selection:bg-primary selection:text-primary-foreground bg-input border-border h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
'file:text-foreground placeholder:text-muted-foreground/50 selection:bg-cyan-500/30 selection:text-cyan-100',
|
||||||
// Inner shadow for depth
|
'bg-white/5 border-white/10 h-9 w-full min-w-0 rounded-xl border px-3 py-1 text-sm shadow-sm outline-none transition-all duration-200',
|
||||||
'shadow-[inset_0_1px_2px_rgba(0,0,0,0.05)]',
|
'file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium',
|
||||||
// Animated focus ring
|
'disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
'transition-[color,box-shadow,border-color] duration-200 ease-out',
|
'backdrop-blur-sm',
|
||||||
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
|
// Hover state
|
||||||
|
'hover:bg-white/10 hover:border-white/20',
|
||||||
|
// Focus state with ring
|
||||||
|
'focus:bg-white/10 focus:border-cyan-500/50',
|
||||||
|
'focus-visible:border-cyan-500/50 focus-visible:ring-cyan-500/20 focus-visible:ring-[4px]',
|
||||||
'aria-invalid:ring-destructive/20 aria-invalid:border-destructive',
|
'aria-invalid:ring-destructive/20 aria-invalid:border-destructive',
|
||||||
// Adjust padding for addons
|
// Adjust padding for addons
|
||||||
startAddon && 'pl-0',
|
startAddon && 'pl-0',
|
||||||
endAddon && 'pr-0',
|
endAddon && 'pr-0',
|
||||||
hasAddons && 'border-0 shadow-none focus-visible:ring-0',
|
hasAddons && 'border-0 shadow-none focus-visible:ring-0 bg-transparent',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -39,10 +43,10 @@ function Input({ className, type, startAddon, endAddon, ...props }: InputProps)
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center h-9 w-full rounded-md border border-border bg-input shadow-xs',
|
'flex items-center h-9 w-full rounded-lg border border-input/50 bg-input/50 shadow-xs backdrop-blur-sm transition-all duration-300',
|
||||||
'shadow-[inset_0_1px_2px_rgba(0,0,0,0.05)]',
|
'shadow-[inset_0_1px_2px_rgba(0,0,0,0.05)]',
|
||||||
'transition-[box-shadow,border-color] duration-200 ease-out',
|
'focus-within:bg-input/80 focus-within:border-ring/50',
|
||||||
'focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]',
|
'focus-within:border-ring focus-within:ring-ring/20 focus-within:ring-[4px]',
|
||||||
'has-[input:disabled]:opacity-50 has-[input:disabled]:cursor-not-allowed',
|
'has-[input:disabled]:opacity-50 has-[input:disabled]:cursor-not-allowed',
|
||||||
'has-[input[aria-invalid]]:ring-destructive/20 has-[input[aria-invalid]]:border-destructive'
|
'has-[input[aria-invalid]]:ring-destructive/20 has-[input[aria-invalid]]:border-destructive'
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
import { Loader2 } from 'lucide-react';
|
|
||||||
|
|
||||||
interface LoadingStateProps {
|
|
||||||
/** Optional custom message to display below the spinner */
|
|
||||||
message?: string;
|
|
||||||
/** Optional custom size class for the spinner (default: h-8 w-8) */
|
|
||||||
size?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LoadingState({ message, size = 'h-8 w-8' }: LoadingStateProps) {
|
|
||||||
return (
|
|
||||||
<div className="flex-1 flex flex-col items-center justify-center">
|
|
||||||
<Loader2 className={`${size} animate-spin text-muted-foreground`} />
|
|
||||||
{message && <p className="mt-4 text-sm text-muted-foreground">{message}</p>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -50,10 +50,10 @@ const Slider = React.forwardRef<HTMLSpanElement, SliderProps>(({ className, ...p
|
|||||||
className={cn('relative flex w-full touch-none select-none items-center', className)}
|
className={cn('relative flex w-full touch-none select-none items-center', className)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<SliderTrackPrimitive className="slider-track relative h-1.5 w-full grow overflow-hidden rounded-full bg-muted cursor-pointer">
|
<SliderTrackPrimitive className="slider-track relative h-1.5 w-full grow overflow-hidden rounded-full bg-white/10 cursor-pointer">
|
||||||
<SliderRangePrimitive className="slider-range absolute h-full bg-primary" />
|
<SliderRangePrimitive className="slider-range absolute h-full bg-cyan-400" />
|
||||||
</SliderTrackPrimitive>
|
</SliderTrackPrimitive>
|
||||||
<SliderThumbPrimitive className="slider-thumb block h-4 w-4 rounded-full border border-border bg-card shadow transition-colors cursor-grab active:cursor-grabbing focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed hover:bg-accent" />
|
<SliderThumbPrimitive className="slider-thumb block h-4 w-4 rounded-full border border-cyan-400/50 bg-background shadow-none transition-colors cursor-grab active:cursor-grabbing focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-cyan-400 disabled:pointer-events-none disabled:opacity-50 hover:bg-cyan-950/30 hover:border-cyan-400" />
|
||||||
</SliderRootPrimitive>
|
</SliderRootPrimitive>
|
||||||
));
|
));
|
||||||
Slider.displayName = SliderPrimitive.Root.displayName;
|
Slider.displayName = SliderPrimitive.Root.displayName;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const Switch = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<SwitchPrimitives.Root
|
<SwitchPrimitives.Root
|
||||||
className={cn(
|
className={cn(
|
||||||
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
|
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400 focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-cyan-500 data-[state=unchecked]:bg-white/10',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -19,7 +19,7 @@ const Switch = React.forwardRef<
|
|||||||
>
|
>
|
||||||
<SwitchPrimitives.Thumb
|
<SwitchPrimitives.Thumb
|
||||||
className={cn(
|
className={cn(
|
||||||
'pointer-events-none block h-5 w-5 rounded-full bg-foreground shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0'
|
'pointer-events-none block h-4 w-4 rounded-full bg-white shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</SwitchPrimitives.Root>
|
</SwitchPrimitives.Root>
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
ImageIcon,
|
ImageIcon,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
FileText,
|
FileText,
|
||||||
Square,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useElectronAgent } from '@/hooks/use-electron-agent';
|
import { useElectronAgent } from '@/hooks/use-electron-agent';
|
||||||
@@ -84,7 +83,6 @@ export function AgentView() {
|
|||||||
isConnected,
|
isConnected,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
clearHistory,
|
clearHistory,
|
||||||
stopExecution,
|
|
||||||
error: agentError,
|
error: agentError,
|
||||||
} = useElectronAgent({
|
} = useElectronAgent({
|
||||||
sessionId: currentSessionId || '',
|
sessionId: currentSessionId || '',
|
||||||
@@ -916,33 +914,21 @@ export function AgentView() {
|
|||||||
<Paperclip className="w-4 h-4" />
|
<Paperclip className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Send / Stop Button */}
|
{/* Send Button */}
|
||||||
{isProcessing ? (
|
<Button
|
||||||
<Button
|
onClick={handleSend}
|
||||||
onClick={stopExecution}
|
disabled={
|
||||||
disabled={!isConnected}
|
(!input.trim() &&
|
||||||
className="h-11 px-4 rounded-xl"
|
selectedImages.length === 0 &&
|
||||||
variant="destructive"
|
selectedTextFiles.length === 0) ||
|
||||||
data-testid="stop-agent"
|
isProcessing ||
|
||||||
title="Stop generation"
|
!isConnected
|
||||||
>
|
}
|
||||||
<Square className="w-4 h-4 fill-current" />
|
className="h-11 px-4 rounded-xl"
|
||||||
</Button>
|
data-testid="send-message"
|
||||||
) : (
|
>
|
||||||
<Button
|
<Send className="w-4 h-4" />
|
||||||
onClick={handleSend}
|
</Button>
|
||||||
disabled={
|
|
||||||
(!input.trim() &&
|
|
||||||
selectedImages.length === 0 &&
|
|
||||||
selectedTextFiles.length === 0) ||
|
|
||||||
!isConnected
|
|
||||||
}
|
|
||||||
className="h-11 px-4 rounded-xl"
|
|
||||||
data-testid="send-message"
|
|
||||||
>
|
|
||||||
<Send className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Keyboard hint */}
|
{/* Keyboard hint */}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ 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';
|
||||||
|
import { PageShell } from '@/components/layout/page-shell';
|
||||||
// Board-view specific imports
|
// Board-view specific imports
|
||||||
import { BoardHeader } from './board-view/board-header';
|
import { BoardHeader } from './board-view/board-header';
|
||||||
import { BoardSearchBar } from './board-view/board-search-bar';
|
import { BoardSearchBar } from './board-view/board-search-bar';
|
||||||
@@ -1036,13 +1037,8 @@ export function BoardView() {
|
|||||||
currentWorktreePath={currentWorktreePath}
|
currentWorktreePath={currentWorktreePath}
|
||||||
currentWorktreeBranch={currentWorktreeBranch}
|
currentWorktreeBranch={currentWorktreeBranch}
|
||||||
projectPath={currentProject?.path || null}
|
projectPath={currentProject?.path || null}
|
||||||
searchQuery={searchQuery}
|
|
||||||
onSearchQueryChange={setSearchQuery}
|
|
||||||
onEditFeature={(feature) => setEditingFeature(feature)}
|
onEditFeature={(feature) => setEditingFeature(feature)}
|
||||||
onViewOutput={handleViewOutput}
|
onViewOutput={handleViewOutput}
|
||||||
onStartTask={handleStartImplementation}
|
|
||||||
onStopTask={handleForceStopFeature}
|
|
||||||
onResumeTask={handleResumeFeature}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { HotkeyButton } from '@/components/ui/hotkey-button';
|
import { HotkeyButton } from '@/components/ui/hotkey-button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
import { Slider } from '@/components/ui/slider';
|
import { Slider } from '@/components/ui/slider';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
@@ -39,23 +40,20 @@ export function BoardHeader({
|
|||||||
const showUsageTracking = !apiKeys.anthropic && !isWindows;
|
const showUsageTracking = !apiKeys.anthropic && !isWindows;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
|
<header className="h-16 flex items-center justify-between px-8 border-b border-white/5 bg-[#0b101a]/40 backdrop-blur-md z-20 shrink-0">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold">Kanban Board</h1>
|
<h2 className="text-lg font-bold text-white tracking-tight">Kanban Board</h2>
|
||||||
<p className="text-sm text-muted-foreground">{projectName}</p>
|
<p className="text-[10px] text-slate-500 uppercase tracking-[0.2em] font-bold mono">
|
||||||
|
{projectName}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 items-center">
|
|
||||||
{/* Usage Popover - only show for CLI users (not API key users) */}
|
|
||||||
{isMounted && showUsageTracking && <ClaudeUsagePopover />}
|
|
||||||
|
|
||||||
{/* Concurrency Slider - only show after mount to prevent hydration issues */}
|
<div className="flex items-center gap-5">
|
||||||
|
{/* Concurrency/Agent Control - Styled as Toggle for visual matching, but keeps slider logic if needed or simplified */}
|
||||||
{isMounted && (
|
{isMounted && (
|
||||||
<div
|
<div className="flex items-center bg-white/5 border border-white/10 rounded-full px-4 py-1.5 gap-3">
|
||||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-secondary border border-border"
|
<Bot className="w-4 h-4 text-slate-500" />
|
||||||
data-testid="concurrency-slider-container"
|
{/* We keep the slider for functionality, but could style it to look like the toggle or just use the slider cleanly */}
|
||||||
>
|
|
||||||
<Bot className="w-4 h-4 text-muted-foreground" />
|
|
||||||
<span className="text-sm font-medium">Agents</span>
|
|
||||||
<Slider
|
<Slider
|
||||||
value={[maxConcurrency]}
|
value={[maxConcurrency]}
|
||||||
onValueChange={(value) => onConcurrencyChange(value[0])}
|
onValueChange={(value) => onConcurrencyChange(value[0])}
|
||||||
@@ -63,43 +61,43 @@ export function BoardHeader({
|
|||||||
max={10}
|
max={10}
|
||||||
step={1}
|
step={1}
|
||||||
className="w-20"
|
className="w-20"
|
||||||
data-testid="concurrency-slider"
|
|
||||||
/>
|
/>
|
||||||
<span
|
<span className="mono text-xs font-bold text-slate-400">
|
||||||
className="text-sm text-muted-foreground min-w-[5ch] text-center"
|
|
||||||
data-testid="concurrency-value"
|
|
||||||
>
|
|
||||||
{runningAgentsCount} / {maxConcurrency}
|
{runningAgentsCount} / {maxConcurrency}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Auto Mode Toggle - only show after mount to prevent hydration issues */}
|
{/* Auto Mode Button */}
|
||||||
{isMounted && (
|
{isMounted && (
|
||||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-secondary border border-border">
|
<button
|
||||||
<Label htmlFor="auto-mode-toggle" className="text-sm font-medium cursor-pointer">
|
onClick={() => onAutoModeToggle(!isAutoModeRunning)}
|
||||||
Auto Mode
|
className={cn(
|
||||||
</Label>
|
'flex items-center gap-2 px-5 py-2 rounded-xl text-xs font-bold transition',
|
||||||
<Switch
|
isAutoModeRunning
|
||||||
id="auto-mode-toggle"
|
? 'bg-cyan-500/10 text-cyan-400 border border-cyan-500/20'
|
||||||
checked={isAutoModeRunning}
|
: 'glass hover:bg-white/10'
|
||||||
onCheckedChange={onAutoModeToggle}
|
)}
|
||||||
data-testid="auto-mode-toggle"
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-2 h-2 rounded-full',
|
||||||
|
isAutoModeRunning ? 'bg-cyan-400 animate-pulse' : 'bg-slate-500'
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
Auto Mode
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<HotkeyButton
|
{/* Add Feature Button */}
|
||||||
size="sm"
|
<button
|
||||||
onClick={onAddFeature}
|
onClick={onAddFeature}
|
||||||
hotkey={addFeatureShortcut}
|
className="btn-cyan px-6 py-2 rounded-xl text-xs font-black flex items-center gap-2 shadow-lg shadow-cyan-500/20"
|
||||||
hotkeyActive={false}
|
|
||||||
data-testid="add-feature-button"
|
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
<Plus className="w-4 h-4 stroke-[3.5px]" />
|
||||||
Add Feature
|
ADD FEATURE
|
||||||
</HotkeyButton>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ interface KanbanColumnProps {
|
|||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
colorClass: string;
|
colorClass: string;
|
||||||
|
columnClass?: string;
|
||||||
count: number;
|
count: number;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
headerAction?: ReactNode;
|
headerAction?: ReactNode;
|
||||||
@@ -21,6 +22,7 @@ export const KanbanColumn = memo(function KanbanColumn({
|
|||||||
id,
|
id,
|
||||||
title,
|
title,
|
||||||
colorClass,
|
colorClass,
|
||||||
|
columnClass,
|
||||||
count,
|
count,
|
||||||
children,
|
children,
|
||||||
headerAction,
|
headerAction,
|
||||||
@@ -43,7 +45,8 @@ export const KanbanColumn = memo(function KanbanColumn({
|
|||||||
'transition-[box-shadow,ring] duration-200',
|
'transition-[box-shadow,ring] duration-200',
|
||||||
!width && 'w-72', // Only apply w-72 if no custom width
|
!width && 'w-72', // Only apply w-72 if no custom width
|
||||||
showBorder && 'border border-border/60',
|
showBorder && 'border border-border/60',
|
||||||
isOver && 'ring-2 ring-primary/30 ring-offset-1 ring-offset-background'
|
isOver && 'ring-2 ring-primary/30 ring-offset-1 ring-offset-background',
|
||||||
|
columnClass
|
||||||
)}
|
)}
|
||||||
style={widthStyle}
|
style={widthStyle}
|
||||||
data-testid={`kanban-column-${id}`}
|
data-testid={`kanban-column-${id}`}
|
||||||
|
|||||||
@@ -2,21 +2,25 @@ import { Feature } from '@/store/app-store';
|
|||||||
|
|
||||||
export type ColumnId = Feature['status'];
|
export type ColumnId = Feature['status'];
|
||||||
|
|
||||||
export const COLUMNS: { id: ColumnId; title: string; colorClass: string }[] = [
|
export const COLUMNS: { id: ColumnId; title: string; colorClass: string; columnClass?: string }[] =
|
||||||
{ id: 'backlog', title: 'Backlog', colorClass: 'bg-[var(--status-backlog)]' },
|
[
|
||||||
{
|
{ id: 'backlog', title: 'Backlog', colorClass: 'bg-white/20', columnClass: '' },
|
||||||
id: 'in_progress',
|
{
|
||||||
title: 'In Progress',
|
id: 'in_progress',
|
||||||
colorClass: 'bg-[var(--status-in-progress)]',
|
title: 'In Progress',
|
||||||
},
|
colorClass: 'bg-cyan-400',
|
||||||
{
|
columnClass: 'col-in-progress',
|
||||||
id: 'waiting_approval',
|
},
|
||||||
title: 'Waiting Approval',
|
{
|
||||||
colorClass: 'bg-[var(--status-waiting)]',
|
id: 'waiting_approval',
|
||||||
},
|
title: 'Waiting Approval',
|
||||||
{
|
colorClass: 'bg-amber-500',
|
||||||
id: 'verified',
|
columnClass: 'col-waiting',
|
||||||
title: 'Verified',
|
},
|
||||||
colorClass: 'bg-[var(--status-success)]',
|
{
|
||||||
},
|
id: 'verified',
|
||||||
];
|
title: 'Verified',
|
||||||
|
colorClass: 'bg-emerald-500',
|
||||||
|
columnClass: 'col-verified',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|||||||
@@ -21,8 +21,6 @@ import {
|
|||||||
RefreshCw,
|
RefreshCw,
|
||||||
Shield,
|
Shield,
|
||||||
Zap,
|
Zap,
|
||||||
List,
|
|
||||||
FileText,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
getElectronAPI,
|
getElectronAPI,
|
||||||
@@ -32,7 +30,6 @@ import {
|
|||||||
} from '@/lib/electron';
|
} from '@/lib/electron';
|
||||||
import { useAppStore, Feature } from '@/store/app-store';
|
import { useAppStore, Feature } from '@/store/app-store';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { LogViewer } from '@/components/ui/log-viewer';
|
|
||||||
|
|
||||||
interface FeatureSuggestionsDialogProps {
|
interface FeatureSuggestionsDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -95,7 +92,6 @@ export function FeatureSuggestionsDialog({
|
|||||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
||||||
const [isImporting, setIsImporting] = useState(false);
|
const [isImporting, setIsImporting] = useState(false);
|
||||||
const [currentSuggestionType, setCurrentSuggestionType] = useState<SuggestionType | null>(null);
|
const [currentSuggestionType, setCurrentSuggestionType] = useState<SuggestionType | null>(null);
|
||||||
const [viewMode, setViewMode] = useState<'parsed' | 'raw'>('parsed');
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const autoScrollRef = useRef(true);
|
const autoScrollRef = useRef(true);
|
||||||
|
|
||||||
@@ -127,9 +123,7 @@ export function FeatureSuggestionsDialog({
|
|||||||
setProgress((prev) => [...prev, event.content || '']);
|
setProgress((prev) => [...prev, event.content || '']);
|
||||||
} else if (event.type === 'suggestions_tool') {
|
} else if (event.type === 'suggestions_tool') {
|
||||||
const toolName = event.tool || 'Unknown Tool';
|
const toolName = event.tool || 'Unknown Tool';
|
||||||
const toolInput = event.input ? JSON.stringify(event.input, null, 2) : '';
|
setProgress((prev) => [...prev, `Using tool: ${toolName}\n`]);
|
||||||
const formattedTool = `\n🔧 Tool: ${toolName}\n${toolInput ? `Input: ${toolInput}\n` : ''}`;
|
|
||||||
setProgress((prev) => [...prev, formattedTool]);
|
|
||||||
} else if (event.type === 'suggestions_complete') {
|
} else if (event.type === 'suggestions_complete') {
|
||||||
setIsGenerating(false);
|
setIsGenerating(false);
|
||||||
if (event.suggestions && event.suggestions.length > 0) {
|
if (event.suggestions && event.suggestions.length > 0) {
|
||||||
@@ -251,7 +245,6 @@ export function FeatureSuggestionsDialog({
|
|||||||
id: `feature-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
id: `feature-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||||
category: s.category,
|
category: s.category,
|
||||||
description: s.description,
|
description: s.description,
|
||||||
steps: [], // Required empty steps array for new features
|
|
||||||
status: 'backlog' as const,
|
status: 'backlog' as const,
|
||||||
skipTests: true, // As specified, testing mode true
|
skipTests: true, // As specified, testing mode true
|
||||||
priority: s.priority, // Preserve priority from suggestion
|
priority: s.priority, // Preserve priority from suggestion
|
||||||
@@ -304,7 +297,7 @@ export function FeatureSuggestionsDialog({
|
|||||||
setCurrentSuggestionType(null);
|
setCurrentSuggestionType(null);
|
||||||
}, [setSuggestions]);
|
}, [setSuggestions]);
|
||||||
|
|
||||||
const hasStarted = isGenerating || progress.length > 0 || suggestions.length > 0;
|
const hasStarted = progress.length > 0 || suggestions.length > 0;
|
||||||
const hasSuggestions = suggestions.length > 0;
|
const hasSuggestions = suggestions.length > 0;
|
||||||
const currentConfig = currentSuggestionType ? suggestionTypeConfig[currentSuggestionType] : null;
|
const currentConfig = currentSuggestionType ? suggestionTypeConfig[currentSuggestionType] : null;
|
||||||
|
|
||||||
@@ -378,56 +371,19 @@ export function FeatureSuggestionsDialog({
|
|||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
Analyzing project...
|
Analyzing project...
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<Button variant="destructive" size="sm" onClick={handleStop}>
|
||||||
<div className="flex items-center gap-1 bg-muted rounded-lg p-1">
|
<StopCircle className="w-4 h-4 mr-2" />
|
||||||
<button
|
Stop
|
||||||
onClick={() => setViewMode('parsed')}
|
</Button>
|
||||||
className={`flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium transition-all ${
|
|
||||||
viewMode === 'parsed'
|
|
||||||
? 'bg-primary/20 text-primary shadow-sm'
|
|
||||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
|
||||||
}`}
|
|
||||||
data-testid="view-mode-parsed"
|
|
||||||
>
|
|
||||||
<List className="w-3 h-3" />
|
|
||||||
Logs
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setViewMode('raw')}
|
|
||||||
className={`flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium transition-all ${
|
|
||||||
viewMode === 'raw'
|
|
||||||
? 'bg-primary/20 text-primary shadow-sm'
|
|
||||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
|
||||||
}`}
|
|
||||||
data-testid="view-mode-raw"
|
|
||||||
>
|
|
||||||
<FileText className="w-3 h-3" />
|
|
||||||
Raw
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<Button variant="destructive" size="sm" onClick={handleStop}>
|
|
||||||
<StopCircle className="w-4 h-4 mr-2" />
|
|
||||||
Stop
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
className="flex-1 overflow-y-auto bg-zinc-950 rounded-lg p-4 font-mono text-xs min-h-[200px] max-h-[400px]"
|
className="flex-1 overflow-y-auto bg-zinc-950 rounded-lg p-4 font-mono text-xs min-h-[200px] max-h-[400px]"
|
||||||
>
|
>
|
||||||
{progress.length === 0 ? (
|
<div className="whitespace-pre-wrap break-words text-zinc-300">
|
||||||
<div className="flex items-center justify-center min-h-[168px] text-muted-foreground">
|
{progress.join('')}
|
||||||
<Loader2 className="w-6 h-6 animate-spin mr-2" />
|
</div>
|
||||||
Waiting for AI response...
|
|
||||||
</div>
|
|
||||||
) : viewMode === 'parsed' ? (
|
|
||||||
<LogViewer output={progress.join('')} />
|
|
||||||
) : (
|
|
||||||
<div className="whitespace-pre-wrap break-words text-zinc-300">
|
|
||||||
{progress.join('')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : hasSuggestions ? (
|
) : hasSuggestions ? (
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ export function KanbanBoard({
|
|||||||
id={column.id}
|
id={column.id}
|
||||||
title={column.title}
|
title={column.title}
|
||||||
colorClass={column.colorClass}
|
colorClass={column.colorClass}
|
||||||
|
columnClass={column.columnClass}
|
||||||
count={columnFeatures.length}
|
count={columnFeatures.length}
|
||||||
width={columnWidth}
|
width={columnWidth}
|
||||||
opacity={backgroundSettings.columnOpacity}
|
opacity={backgroundSettings.columnOpacity}
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
import type { ReactElement, ReactNode } from 'react';
|
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
|
||||||
|
|
||||||
interface TooltipWrapperProps {
|
|
||||||
/** The element to wrap with a tooltip */
|
|
||||||
children: ReactElement;
|
|
||||||
/** The content to display in the tooltip */
|
|
||||||
tooltipContent: ReactNode;
|
|
||||||
/** Whether to show the tooltip (if false, renders children without tooltip) */
|
|
||||||
showTooltip: boolean;
|
|
||||||
/** The side where the tooltip should appear */
|
|
||||||
side?: 'top' | 'right' | 'bottom' | 'left';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A reusable wrapper that conditionally adds a tooltip to its children.
|
|
||||||
* When showTooltip is false, it renders the children directly without any tooltip.
|
|
||||||
* This is useful for adding tooltips to disabled elements that need to show
|
|
||||||
* a reason for being disabled.
|
|
||||||
*/
|
|
||||||
export function TooltipWrapper({
|
|
||||||
children,
|
|
||||||
tooltipContent,
|
|
||||||
showTooltip,
|
|
||||||
side = 'left',
|
|
||||||
}: TooltipWrapperProps) {
|
|
||||||
if (!showTooltip) {
|
|
||||||
return children;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
{/* The div wrapper is necessary for tooltips to work on disabled elements */}
|
|
||||||
<div>{children}</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side={side}>
|
|
||||||
<p>{tooltipContent}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -20,11 +20,9 @@ import {
|
|||||||
Globe,
|
Globe,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
GitMerge,
|
GitMerge,
|
||||||
AlertCircle,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { WorktreeInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types';
|
import type { WorktreeInfo, DevServerInfo, PRInfo } from '../types';
|
||||||
import { TooltipWrapper } from './tooltip-wrapper';
|
|
||||||
|
|
||||||
interface WorktreeActionsDropdownProps {
|
interface WorktreeActionsDropdownProps {
|
||||||
worktree: WorktreeInfo;
|
worktree: WorktreeInfo;
|
||||||
@@ -37,7 +35,6 @@ interface WorktreeActionsDropdownProps {
|
|||||||
isStartingDevServer: boolean;
|
isStartingDevServer: boolean;
|
||||||
isDevServerRunning: boolean;
|
isDevServerRunning: boolean;
|
||||||
devServerInfo?: DevServerInfo;
|
devServerInfo?: DevServerInfo;
|
||||||
gitRepoStatus: GitRepoStatus;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
onPull: (worktree: WorktreeInfo) => void;
|
onPull: (worktree: WorktreeInfo) => void;
|
||||||
onPush: (worktree: WorktreeInfo) => void;
|
onPush: (worktree: WorktreeInfo) => void;
|
||||||
@@ -63,7 +60,6 @@ export function WorktreeActionsDropdown({
|
|||||||
isStartingDevServer,
|
isStartingDevServer,
|
||||||
isDevServerRunning,
|
isDevServerRunning,
|
||||||
devServerInfo,
|
devServerInfo,
|
||||||
gitRepoStatus,
|
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onPull,
|
onPull,
|
||||||
onPush,
|
onPush,
|
||||||
@@ -80,14 +76,6 @@ export function WorktreeActionsDropdown({
|
|||||||
// Check if there's a PR associated with this worktree from stored metadata
|
// Check if there's a PR associated with this worktree from stored metadata
|
||||||
const hasPR = !!worktree.pr;
|
const hasPR = !!worktree.pr;
|
||||||
|
|
||||||
// Check git operations availability
|
|
||||||
const canPerformGitOps = gitRepoStatus.isGitRepo && gitRepoStatus.hasCommits;
|
|
||||||
const gitOpsDisabledReason = !gitRepoStatus.isGitRepo
|
|
||||||
? 'Not a git repository'
|
|
||||||
: !gitRepoStatus.hasCommits
|
|
||||||
? 'Repository has no commits yet'
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu onOpenChange={onOpenChange}>
|
<DropdownMenu onOpenChange={onOpenChange}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
@@ -104,16 +92,6 @@ export function WorktreeActionsDropdown({
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start" className="w-56">
|
<DropdownMenuContent align="start" className="w-56">
|
||||||
{/* Warning label when git operations are not available */}
|
|
||||||
{!canPerformGitOps && (
|
|
||||||
<>
|
|
||||||
<DropdownMenuLabel className="text-xs flex items-center gap-2 text-amber-600 dark:text-amber-400">
|
|
||||||
<AlertCircle className="w-3.5 h-3.5" />
|
|
||||||
{gitOpsDisabledReason}
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{isDevServerRunning ? (
|
{isDevServerRunning ? (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuLabel className="text-xs flex items-center gap-2">
|
<DropdownMenuLabel className="text-xs flex items-center gap-2">
|
||||||
@@ -146,58 +124,36 @@ export function WorktreeActionsDropdown({
|
|||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
|
<DropdownMenuItem onClick={() => onPull(worktree)} disabled={isPulling} className="text-xs">
|
||||||
<DropdownMenuItem
|
<Download className={cn('w-3.5 h-3.5 mr-2', isPulling && 'animate-pulse')} />
|
||||||
onClick={() => canPerformGitOps && onPull(worktree)}
|
{isPulling ? 'Pulling...' : 'Pull'}
|
||||||
disabled={isPulling || !canPerformGitOps}
|
{behindCount > 0 && (
|
||||||
className={cn('text-xs', !canPerformGitOps && 'opacity-50 cursor-not-allowed')}
|
<span className="ml-auto text-[10px] bg-muted px-1.5 py-0.5 rounded">
|
||||||
>
|
{behindCount} behind
|
||||||
<Download className={cn('w-3.5 h-3.5 mr-2', isPulling && 'animate-pulse')} />
|
</span>
|
||||||
{isPulling ? 'Pulling...' : 'Pull'}
|
)}
|
||||||
{!canPerformGitOps && <AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />}
|
</DropdownMenuItem>
|
||||||
{canPerformGitOps && behindCount > 0 && (
|
<DropdownMenuItem
|
||||||
<span className="ml-auto text-[10px] bg-muted px-1.5 py-0.5 rounded">
|
onClick={() => onPush(worktree)}
|
||||||
{behindCount} behind
|
disabled={isPushing || aheadCount === 0}
|
||||||
</span>
|
className="text-xs"
|
||||||
)}
|
>
|
||||||
</DropdownMenuItem>
|
<Upload className={cn('w-3.5 h-3.5 mr-2', isPushing && 'animate-pulse')} />
|
||||||
</TooltipWrapper>
|
{isPushing ? 'Pushing...' : 'Push'}
|
||||||
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
|
{aheadCount > 0 && (
|
||||||
<DropdownMenuItem
|
<span className="ml-auto text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded">
|
||||||
onClick={() => canPerformGitOps && onPush(worktree)}
|
{aheadCount} ahead
|
||||||
disabled={isPushing || aheadCount === 0 || !canPerformGitOps}
|
</span>
|
||||||
className={cn('text-xs', !canPerformGitOps && 'opacity-50 cursor-not-allowed')}
|
)}
|
||||||
>
|
</DropdownMenuItem>
|
||||||
<Upload className={cn('w-3.5 h-3.5 mr-2', isPushing && 'animate-pulse')} />
|
|
||||||
{isPushing ? 'Pushing...' : 'Push'}
|
|
||||||
{!canPerformGitOps && <AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />}
|
|
||||||
{canPerformGitOps && aheadCount > 0 && (
|
|
||||||
<span className="ml-auto text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded">
|
|
||||||
{aheadCount} ahead
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</TooltipWrapper>
|
|
||||||
{!worktree.isMain && (
|
{!worktree.isMain && (
|
||||||
<TooltipWrapper
|
<DropdownMenuItem
|
||||||
showTooltip={!!gitOpsDisabledReason}
|
onClick={() => onResolveConflicts(worktree)}
|
||||||
tooltipContent={gitOpsDisabledReason}
|
className="text-xs text-purple-500 focus:text-purple-600"
|
||||||
>
|
>
|
||||||
<DropdownMenuItem
|
<GitMerge className="w-3.5 h-3.5 mr-2" />
|
||||||
onClick={() => canPerformGitOps && onResolveConflicts(worktree)}
|
Pull & Resolve Conflicts
|
||||||
disabled={!canPerformGitOps}
|
</DropdownMenuItem>
|
||||||
className={cn(
|
|
||||||
'text-xs text-purple-500 focus:text-purple-600',
|
|
||||||
!canPerformGitOps && 'opacity-50 cursor-not-allowed'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<GitMerge className="w-3.5 h-3.5 mr-2" />
|
|
||||||
Pull & Resolve Conflicts
|
|
||||||
{!canPerformGitOps && (
|
|
||||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</TooltipWrapper>
|
|
||||||
)}
|
)}
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={() => onOpenInEditor(worktree)} className="text-xs">
|
<DropdownMenuItem onClick={() => onOpenInEditor(worktree)} className="text-xs">
|
||||||
@@ -206,41 +162,17 @@ export function WorktreeActionsDropdown({
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
{worktree.hasChanges && (
|
{worktree.hasChanges && (
|
||||||
<TooltipWrapper
|
<DropdownMenuItem onClick={() => onCommit(worktree)} className="text-xs">
|
||||||
showTooltip={!gitRepoStatus.isGitRepo}
|
<GitCommit className="w-3.5 h-3.5 mr-2" />
|
||||||
tooltipContent="Not a git repository"
|
Commit Changes
|
||||||
>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => gitRepoStatus.isGitRepo && onCommit(worktree)}
|
|
||||||
disabled={!gitRepoStatus.isGitRepo}
|
|
||||||
className={cn('text-xs', !gitRepoStatus.isGitRepo && 'opacity-50 cursor-not-allowed')}
|
|
||||||
>
|
|
||||||
<GitCommit className="w-3.5 h-3.5 mr-2" />
|
|
||||||
Commit Changes
|
|
||||||
{!gitRepoStatus.isGitRepo && (
|
|
||||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</TooltipWrapper>
|
|
||||||
)}
|
)}
|
||||||
{/* Show PR option for non-primary worktrees, or primary worktree with changes */}
|
{/* Show PR option for non-primary worktrees, or primary worktree with changes */}
|
||||||
{(!worktree.isMain || worktree.hasChanges) && !hasPR && (
|
{(!worktree.isMain || worktree.hasChanges) && !hasPR && (
|
||||||
<TooltipWrapper
|
<DropdownMenuItem onClick={() => onCreatePR(worktree)} className="text-xs">
|
||||||
showTooltip={!!gitOpsDisabledReason}
|
<GitPullRequest className="w-3.5 h-3.5 mr-2" />
|
||||||
tooltipContent={gitOpsDisabledReason}
|
Create Pull Request
|
||||||
>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => canPerformGitOps && onCreatePR(worktree)}
|
|
||||||
disabled={!canPerformGitOps}
|
|
||||||
className={cn('text-xs', !canPerformGitOps && 'opacity-50 cursor-not-allowed')}
|
|
||||||
>
|
|
||||||
<GitPullRequest className="w-3.5 h-3.5 mr-2" />
|
|
||||||
Create Pull Request
|
|
||||||
{!canPerformGitOps && (
|
|
||||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</TooltipWrapper>
|
|
||||||
)}
|
)}
|
||||||
{/* Show PR info and Address Comments button if PR exists */}
|
{/* Show PR info and Address Comments button if PR exists */}
|
||||||
{!worktree.isMain && hasPR && worktree.pr && (
|
{!worktree.isMain && hasPR && worktree.pr && (
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { RefreshCw, Globe, Loader2, CircleDot, GitPullRequest } from 'lucide-react';
|
import { RefreshCw, Globe, Loader2, CircleDot, GitPullRequest } from 'lucide-react';
|
||||||
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 } from '../types';
|
||||||
import { BranchSwitchDropdown } from './branch-switch-dropdown';
|
import { BranchSwitchDropdown } from './branch-switch-dropdown';
|
||||||
import { WorktreeActionsDropdown } from './worktree-actions-dropdown';
|
import { WorktreeActionsDropdown } from './worktree-actions-dropdown';
|
||||||
|
|
||||||
@@ -27,7 +27,6 @@ interface WorktreeTabProps {
|
|||||||
isStartingDevServer: boolean;
|
isStartingDevServer: boolean;
|
||||||
aheadCount: number;
|
aheadCount: number;
|
||||||
behindCount: number;
|
behindCount: number;
|
||||||
gitRepoStatus: GitRepoStatus;
|
|
||||||
onSelectWorktree: (worktree: WorktreeInfo) => void;
|
onSelectWorktree: (worktree: WorktreeInfo) => void;
|
||||||
onBranchDropdownOpenChange: (open: boolean) => void;
|
onBranchDropdownOpenChange: (open: boolean) => void;
|
||||||
onActionsDropdownOpenChange: (open: boolean) => void;
|
onActionsDropdownOpenChange: (open: boolean) => void;
|
||||||
@@ -68,7 +67,6 @@ export function WorktreeTab({
|
|||||||
isStartingDevServer,
|
isStartingDevServer,
|
||||||
aheadCount,
|
aheadCount,
|
||||||
behindCount,
|
behindCount,
|
||||||
gitRepoStatus,
|
|
||||||
onSelectWorktree,
|
onSelectWorktree,
|
||||||
onBranchDropdownOpenChange,
|
onBranchDropdownOpenChange,
|
||||||
onActionsDropdownOpenChange,
|
onActionsDropdownOpenChange,
|
||||||
@@ -322,7 +320,6 @@ export function WorktreeTab({
|
|||||||
isStartingDevServer={isStartingDevServer}
|
isStartingDevServer={isStartingDevServer}
|
||||||
isDevServerRunning={isDevServerRunning}
|
isDevServerRunning={isDevServerRunning}
|
||||||
devServerInfo={devServerInfo}
|
devServerInfo={devServerInfo}
|
||||||
gitRepoStatus={gitRepoStatus}
|
|
||||||
onOpenChange={onActionsDropdownOpenChange}
|
onOpenChange={onActionsDropdownOpenChange}
|
||||||
onPull={onPull}
|
onPull={onPull}
|
||||||
onPush={onPush}
|
onPush={onPush}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import type { BranchInfo, GitRepoStatus } from '../types';
|
import type { BranchInfo } from '../types';
|
||||||
|
|
||||||
export function useBranches() {
|
export function useBranches() {
|
||||||
const [branches, setBranches] = useState<BranchInfo[]>([]);
|
const [branches, setBranches] = useState<BranchInfo[]>([]);
|
||||||
@@ -8,57 +8,27 @@ export function useBranches() {
|
|||||||
const [behindCount, setBehindCount] = useState(0);
|
const [behindCount, setBehindCount] = useState(0);
|
||||||
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
|
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
|
||||||
const [branchFilter, setBranchFilter] = useState('');
|
const [branchFilter, setBranchFilter] = useState('');
|
||||||
const [gitRepoStatus, setGitRepoStatus] = useState<GitRepoStatus>({
|
|
||||||
isGitRepo: true,
|
|
||||||
hasCommits: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Helper to reset branch state to initial values */
|
const fetchBranches = useCallback(async (worktreePath: string) => {
|
||||||
const resetBranchState = useCallback(() => {
|
setIsLoadingBranches(true);
|
||||||
setBranches([]);
|
try {
|
||||||
setAheadCount(0);
|
const api = getElectronAPI();
|
||||||
setBehindCount(0);
|
if (!api?.worktree?.listBranches) {
|
||||||
}, []);
|
console.warn('List branches API not available');
|
||||||
|
return;
|
||||||
const fetchBranches = useCallback(
|
|
||||||
async (worktreePath: string) => {
|
|
||||||
setIsLoadingBranches(true);
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (!api?.worktree?.listBranches) {
|
|
||||||
console.warn('List branches API not available');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const result = await api.worktree.listBranches(worktreePath);
|
|
||||||
if (result.success && result.result) {
|
|
||||||
setBranches(result.result.branches);
|
|
||||||
setAheadCount(result.result.aheadCount || 0);
|
|
||||||
setBehindCount(result.result.behindCount || 0);
|
|
||||||
setGitRepoStatus({ isGitRepo: true, hasCommits: true });
|
|
||||||
} else if (result.code === 'NOT_GIT_REPO') {
|
|
||||||
// Not a git repository - clear branches silently without logging an error
|
|
||||||
resetBranchState();
|
|
||||||
setGitRepoStatus({ isGitRepo: false, hasCommits: false });
|
|
||||||
} else if (result.code === 'NO_COMMITS') {
|
|
||||||
// Git repo but no commits yet - clear branches silently without logging an error
|
|
||||||
resetBranchState();
|
|
||||||
setGitRepoStatus({ isGitRepo: true, hasCommits: false });
|
|
||||||
} else if (!result.success) {
|
|
||||||
// Other errors - log them
|
|
||||||
console.warn('Failed to fetch branches:', result.error);
|
|
||||||
resetBranchState();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch branches:', error);
|
|
||||||
resetBranchState();
|
|
||||||
// Reset git status to unknown state on network/API errors
|
|
||||||
setGitRepoStatus({ isGitRepo: true, hasCommits: true });
|
|
||||||
} finally {
|
|
||||||
setIsLoadingBranches(false);
|
|
||||||
}
|
}
|
||||||
},
|
const result = await api.worktree.listBranches(worktreePath);
|
||||||
[resetBranchState]
|
if (result.success && result.result) {
|
||||||
);
|
setBranches(result.result.branches);
|
||||||
|
setAheadCount(result.result.aheadCount || 0);
|
||||||
|
setBehindCount(result.result.behindCount || 0);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch branches:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingBranches(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const resetBranchFilter = useCallback(() => {
|
const resetBranchFilter = useCallback(() => {
|
||||||
setBranchFilter('');
|
setBranchFilter('');
|
||||||
@@ -78,6 +48,5 @@ export function useBranches() {
|
|||||||
setBranchFilter,
|
setBranchFilter,
|
||||||
resetBranchFilter,
|
resetBranchFilter,
|
||||||
fetchBranches,
|
fetchBranches,
|
||||||
gitRepoStatus,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,12 +114,13 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
|
|||||||
|
|
||||||
const handleOpenDevServerUrl = useCallback(
|
const handleOpenDevServerUrl = useCallback(
|
||||||
(worktree: WorktreeInfo) => {
|
(worktree: WorktreeInfo) => {
|
||||||
const serverInfo = runningDevServers.get(getWorktreeKey(worktree));
|
const targetPath = worktree.isMain ? projectPath : worktree.path;
|
||||||
|
const serverInfo = runningDevServers.get(targetPath);
|
||||||
if (serverInfo) {
|
if (serverInfo) {
|
||||||
window.open(serverInfo.url, '_blank');
|
window.open(serverInfo.url, '_blank');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[runningDevServers, getWorktreeKey]
|
[projectPath, runningDevServers]
|
||||||
);
|
);
|
||||||
|
|
||||||
const isDevServerRunning = useCallback(
|
const isDevServerRunning = useCallback(
|
||||||
|
|||||||
@@ -3,29 +3,6 @@ import { getElectronAPI } from '@/lib/electron';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import type { WorktreeInfo } from '../types';
|
import type { WorktreeInfo } from '../types';
|
||||||
|
|
||||||
// Error codes that need special user-friendly handling
|
|
||||||
const GIT_STATUS_ERROR_CODES = ['NOT_GIT_REPO', 'NO_COMMITS'] as const;
|
|
||||||
type GitStatusErrorCode = (typeof GIT_STATUS_ERROR_CODES)[number];
|
|
||||||
|
|
||||||
// User-friendly messages for git status errors
|
|
||||||
const GIT_STATUS_ERROR_MESSAGES: Record<GitStatusErrorCode, string> = {
|
|
||||||
NOT_GIT_REPO: 'This directory is not a git repository',
|
|
||||||
NO_COMMITS: 'Repository has no commits yet. Create an initial commit first.',
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper to handle git status errors with user-friendly messages.
|
|
||||||
* @returns true if the error was a git status error and was handled, false otherwise.
|
|
||||||
*/
|
|
||||||
function handleGitStatusError(result: { code?: string; error?: string }): boolean {
|
|
||||||
const errorCode = result.code as GitStatusErrorCode | undefined;
|
|
||||||
if (errorCode && GIT_STATUS_ERROR_CODES.includes(errorCode)) {
|
|
||||||
toast.info(GIT_STATUS_ERROR_MESSAGES[errorCode] || result.error);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseWorktreeActionsOptions {
|
interface UseWorktreeActionsOptions {
|
||||||
fetchWorktrees: () => Promise<Array<{ path: string; branch: string }> | undefined>;
|
fetchWorktrees: () => Promise<Array<{ path: string; branch: string }> | undefined>;
|
||||||
fetchBranches: (worktreePath: string) => Promise<void>;
|
fetchBranches: (worktreePath: string) => Promise<void>;
|
||||||
@@ -52,7 +29,6 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre
|
|||||||
toast.success(result.result.message);
|
toast.success(result.result.message);
|
||||||
fetchWorktrees();
|
fetchWorktrees();
|
||||||
} else {
|
} else {
|
||||||
if (handleGitStatusError(result)) return;
|
|
||||||
toast.error(result.error || 'Failed to switch branch');
|
toast.error(result.error || 'Failed to switch branch');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -80,7 +56,6 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre
|
|||||||
toast.success(result.result.message);
|
toast.success(result.result.message);
|
||||||
fetchWorktrees();
|
fetchWorktrees();
|
||||||
} else {
|
} else {
|
||||||
if (handleGitStatusError(result)) return;
|
|
||||||
toast.error(result.error || 'Failed to pull latest changes');
|
toast.error(result.error || 'Failed to pull latest changes');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -109,7 +84,6 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre
|
|||||||
fetchBranches(worktree.path);
|
fetchBranches(worktree.path);
|
||||||
fetchWorktrees();
|
fetchWorktrees();
|
||||||
} else {
|
} else {
|
||||||
if (handleGitStatusError(result)) return;
|
|
||||||
toast.error(result.error || 'Failed to push changes');
|
toast.error(result.error || 'Failed to push changes');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -23,11 +23,6 @@ export interface BranchInfo {
|
|||||||
isRemote: boolean;
|
isRemote: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GitRepoStatus {
|
|
||||||
isGitRepo: boolean;
|
|
||||||
hasCommits: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DevServerInfo {
|
export interface DevServerInfo {
|
||||||
worktreePath: string;
|
worktreePath: string;
|
||||||
port: number;
|
port: number;
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ export function WorktreePanel({
|
|||||||
setBranchFilter,
|
setBranchFilter,
|
||||||
resetBranchFilter,
|
resetBranchFilter,
|
||||||
fetchBranches,
|
fetchBranches,
|
||||||
gitRepoStatus,
|
|
||||||
} = useBranches();
|
} = useBranches();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -211,7 +210,6 @@ export function WorktreePanel({
|
|||||||
isStartingDevServer={isStartingDevServer}
|
isStartingDevServer={isStartingDevServer}
|
||||||
aheadCount={aheadCount}
|
aheadCount={aheadCount}
|
||||||
behindCount={behindCount}
|
behindCount={behindCount}
|
||||||
gitRepoStatus={gitRepoStatus}
|
|
||||||
onSelectWorktree={handleSelectWorktree}
|
onSelectWorktree={handleSelectWorktree}
|
||||||
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(mainWorktree)}
|
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(mainWorktree)}
|
||||||
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(mainWorktree)}
|
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(mainWorktree)}
|
||||||
@@ -266,7 +264,6 @@ export function WorktreePanel({
|
|||||||
isStartingDevServer={isStartingDevServer}
|
isStartingDevServer={isStartingDevServer}
|
||||||
aheadCount={aheadCount}
|
aheadCount={aheadCount}
|
||||||
behindCount={behindCount}
|
behindCount={behindCount}
|
||||||
gitRepoStatus={gitRepoStatus}
|
|
||||||
onSelectWorktree={handleSelectWorktree}
|
onSelectWorktree={handleSelectWorktree}
|
||||||
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)}
|
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)}
|
||||||
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)}
|
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)}
|
||||||
|
|||||||
@@ -1,126 +1,93 @@
|
|||||||
import { useState, useCallback, useMemo } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { CircleDot, RefreshCw } from 'lucide-react';
|
import { CircleDot, Loader2, RefreshCw, ExternalLink, CheckCircle2, Circle, X } from 'lucide-react';
|
||||||
import { getElectronAPI, GitHubIssue, IssueValidationResult } from '@/lib/electron';
|
import { getElectronAPI, GitHubIssue } from '@/lib/electron';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
import { Button } from '@/components/ui/button';
|
||||||
import { LoadingState } from '@/components/ui/loading-state';
|
import { Markdown } from '@/components/ui/markdown';
|
||||||
import { ErrorState } from '@/components/ui/error-state';
|
import { cn } from '@/lib/utils';
|
||||||
import { cn, pathsEqual } from '@/lib/utils';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { useGithubIssues, useIssueValidation } from './github-issues-view/hooks';
|
|
||||||
import { IssueRow, IssueDetailPanel, IssuesListHeader } from './github-issues-view/components';
|
|
||||||
import { ValidationDialog } from './github-issues-view/dialogs';
|
|
||||||
import { formatDate, getFeaturePriority } from './github-issues-view/utils';
|
|
||||||
|
|
||||||
export function GitHubIssuesView() {
|
export function GitHubIssuesView() {
|
||||||
|
const [openIssues, setOpenIssues] = useState<GitHubIssue[]>([]);
|
||||||
|
const [closedIssues, setClosedIssues] = useState<GitHubIssue[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [selectedIssue, setSelectedIssue] = useState<GitHubIssue | null>(null);
|
const [selectedIssue, setSelectedIssue] = useState<GitHubIssue | null>(null);
|
||||||
const [validationResult, setValidationResult] = useState<IssueValidationResult | null>(null);
|
const { currentProject } = useAppStore();
|
||||||
const [showValidationDialog, setShowValidationDialog] = useState(false);
|
|
||||||
const [showRevalidateConfirm, setShowRevalidateConfirm] = useState(false);
|
|
||||||
|
|
||||||
const { currentProject, defaultAIProfileId, aiProfiles, getCurrentWorktree, worktreesByProject } =
|
const fetchIssues = useCallback(async () => {
|
||||||
useAppStore();
|
if (!currentProject?.path) {
|
||||||
|
setError('No project selected');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const { openIssues, closedIssues, loading, refreshing, error, refresh } = useGithubIssues();
|
try {
|
||||||
|
setError(null);
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (api.github) {
|
||||||
|
const result = await api.github.listIssues(currentProject.path);
|
||||||
|
if (result.success) {
|
||||||
|
setOpenIssues(result.openIssues || []);
|
||||||
|
setClosedIssues(result.closedIssues || []);
|
||||||
|
} else {
|
||||||
|
setError(result.error || 'Failed to fetch issues');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[GitHubIssuesView] Error fetching issues:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to fetch issues');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
}, [currentProject?.path]);
|
||||||
|
|
||||||
const { validatingIssues, cachedValidations, handleValidateIssue, handleViewCachedValidation } =
|
useEffect(() => {
|
||||||
useIssueValidation({
|
fetchIssues();
|
||||||
selectedIssue,
|
}, [fetchIssues]);
|
||||||
showValidationDialog,
|
|
||||||
onValidationResultChange: setValidationResult,
|
|
||||||
onShowValidationDialogChange: setShowValidationDialog,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get default AI profile for task creation
|
const handleRefresh = useCallback(() => {
|
||||||
const defaultProfile = useMemo(() => {
|
setRefreshing(true);
|
||||||
if (!defaultAIProfileId) return null;
|
fetchIssues();
|
||||||
return aiProfiles.find((p) => p.id === defaultAIProfileId) ?? null;
|
}, [fetchIssues]);
|
||||||
}, [defaultAIProfileId, aiProfiles]);
|
|
||||||
|
|
||||||
// Get current branch from selected worktree
|
|
||||||
const currentBranch = useMemo(() => {
|
|
||||||
if (!currentProject?.path) return '';
|
|
||||||
const currentWorktreeInfo = getCurrentWorktree(currentProject.path);
|
|
||||||
const worktrees = worktreesByProject[currentProject.path] ?? [];
|
|
||||||
const currentWorktreePath = currentWorktreeInfo?.path ?? null;
|
|
||||||
|
|
||||||
const selectedWorktree =
|
|
||||||
currentWorktreePath === null
|
|
||||||
? worktrees.find((w) => w.isMain)
|
|
||||||
: worktrees.find((w) => !w.isMain && pathsEqual(w.path, currentWorktreePath));
|
|
||||||
|
|
||||||
return selectedWorktree?.branch || worktrees.find((w) => w.isMain)?.branch || '';
|
|
||||||
}, [currentProject?.path, getCurrentWorktree, worktreesByProject]);
|
|
||||||
|
|
||||||
const handleOpenInGitHub = useCallback((url: string) => {
|
const handleOpenInGitHub = useCallback((url: string) => {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
api.openExternalLink(url);
|
api.openExternalLink(url);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleConvertToTask = useCallback(
|
const formatDate = (dateString: string) => {
|
||||||
async (issue: GitHubIssue, validation: IssueValidationResult) => {
|
const date = new Date(dateString);
|
||||||
if (!currentProject?.path) {
|
return date.toLocaleDateString('en-US', {
|
||||||
toast.error('No project selected');
|
month: 'short',
|
||||||
return;
|
day: 'numeric',
|
||||||
}
|
year: 'numeric',
|
||||||
|
});
|
||||||
try {
|
};
|
||||||
const api = getElectronAPI();
|
|
||||||
if (api.features?.create) {
|
|
||||||
// Build description from issue body + validation info
|
|
||||||
const description = [
|
|
||||||
`**From GitHub Issue #${issue.number}**`,
|
|
||||||
'',
|
|
||||||
issue.body || 'No description provided.',
|
|
||||||
'',
|
|
||||||
'---',
|
|
||||||
'',
|
|
||||||
'**AI Validation Analysis:**',
|
|
||||||
validation.reasoning,
|
|
||||||
validation.suggestedFix ? `\n**Suggested Approach:**\n${validation.suggestedFix}` : '',
|
|
||||||
validation.relatedFiles?.length
|
|
||||||
? `\n**Related Files:**\n${validation.relatedFiles.map((f) => `- \`${f}\``).join('\n')}`
|
|
||||||
: '',
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join('\n');
|
|
||||||
|
|
||||||
const feature = {
|
|
||||||
id: `issue-${issue.number}-${crypto.randomUUID()}`,
|
|
||||||
title: issue.title,
|
|
||||||
description,
|
|
||||||
category: 'From GitHub',
|
|
||||||
status: 'backlog' as const,
|
|
||||||
passes: false,
|
|
||||||
priority: getFeaturePriority(validation.estimatedComplexity),
|
|
||||||
model: defaultProfile?.model ?? 'opus',
|
|
||||||
thinkingLevel: defaultProfile?.thinkingLevel ?? 'none',
|
|
||||||
branchName: currentBranch,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await api.features.create(currentProject.path, feature);
|
|
||||||
if (result.success) {
|
|
||||||
toast.success(`Created task: ${issue.title}`);
|
|
||||||
} else {
|
|
||||||
toast.error(result.error || 'Failed to create task');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[GitHubIssuesView] Convert to task error:', err);
|
|
||||||
toast.error(err instanceof Error ? err.message : 'Failed to create task');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[currentProject?.path, defaultProfile, currentBranch]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <LoadingState />;
|
return (
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return <ErrorState error={error} title="Failed to Load Issues" onRetry={refresh} />;
|
return (
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center text-center p-6">
|
||||||
|
<div className="p-4 rounded-full bg-destructive/10 mb-4">
|
||||||
|
<CircleDot className="h-12 w-12 text-destructive" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-lg font-medium mb-2">Failed to Load Issues</h2>
|
||||||
|
<p className="text-muted-foreground max-w-md mb-4">{error}</p>
|
||||||
|
<Button variant="outline" onClick={handleRefresh}>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
|
Try Again
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalIssues = openIssues.length + closedIssues.length;
|
const totalIssues = openIssues.length + closedIssues.length;
|
||||||
@@ -135,12 +102,24 @@ export function GitHubIssuesView() {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<IssuesListHeader
|
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||||
openCount={openIssues.length}
|
<div className="flex items-center gap-3">
|
||||||
closedCount={closedIssues.length}
|
<div className="p-2 rounded-lg bg-green-500/10">
|
||||||
refreshing={refreshing}
|
<CircleDot className="h-5 w-5 text-green-500" />
|
||||||
onRefresh={refresh}
|
</div>
|
||||||
/>
|
<div>
|
||||||
|
<h1 className="text-lg font-bold">Issues</h1>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{totalIssues === 0
|
||||||
|
? 'No issues found'
|
||||||
|
: `${openIssues.length} open, ${closedIssues.length} closed`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={refreshing}>
|
||||||
|
<RefreshCw className={cn('h-4 w-4', refreshing && 'animate-spin')} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Issues List */}
|
{/* Issues List */}
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
@@ -163,8 +142,6 @@ export function GitHubIssuesView() {
|
|||||||
onClick={() => setSelectedIssue(issue)}
|
onClick={() => setSelectedIssue(issue)}
|
||||||
onOpenExternal={() => handleOpenInGitHub(issue.url)}
|
onOpenExternal={() => handleOpenInGitHub(issue.url)}
|
||||||
formatDate={formatDate}
|
formatDate={formatDate}
|
||||||
cachedValidation={cachedValidations.get(issue.number)}
|
|
||||||
isValidating={validatingIssues.has(issue.number)}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -182,8 +159,6 @@ export function GitHubIssuesView() {
|
|||||||
onClick={() => setSelectedIssue(issue)}
|
onClick={() => setSelectedIssue(issue)}
|
||||||
onOpenExternal={() => handleOpenInGitHub(issue.url)}
|
onOpenExternal={() => handleOpenInGitHub(issue.url)}
|
||||||
formatDate={formatDate}
|
formatDate={formatDate}
|
||||||
cachedValidation={cachedValidations.get(issue.number)}
|
|
||||||
isValidating={validatingIssues.has(issue.number)}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
@@ -195,43 +170,164 @@ export function GitHubIssuesView() {
|
|||||||
|
|
||||||
{/* Issue Detail Panel */}
|
{/* Issue Detail Panel */}
|
||||||
{selectedIssue && (
|
{selectedIssue && (
|
||||||
<IssueDetailPanel
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
issue={selectedIssue}
|
{/* Detail Header */}
|
||||||
validatingIssues={validatingIssues}
|
<div className="flex items-center justify-between p-3 border-b border-border bg-muted/30">
|
||||||
cachedValidations={cachedValidations}
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
onValidateIssue={handleValidateIssue}
|
{selectedIssue.state === 'OPEN' ? (
|
||||||
onViewCachedValidation={handleViewCachedValidation}
|
<Circle className="h-4 w-4 text-green-500 flex-shrink-0" />
|
||||||
onOpenInGitHub={handleOpenInGitHub}
|
) : (
|
||||||
onClose={() => setSelectedIssue(null)}
|
<CheckCircle2 className="h-4 w-4 text-purple-500 flex-shrink-0" />
|
||||||
onShowRevalidateConfirm={() => setShowRevalidateConfirm(true)}
|
)}
|
||||||
formatDate={formatDate}
|
<span className="text-sm font-medium truncate">
|
||||||
/>
|
#{selectedIssue.number} {selectedIssue.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleOpenInGitHub(selectedIssue.url)}
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-4 w-4 mr-1" />
|
||||||
|
Open in GitHub
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setSelectedIssue(null)}>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Issue Detail Content */}
|
||||||
|
<div className="flex-1 overflow-auto p-6">
|
||||||
|
{/* Title */}
|
||||||
|
<h1 className="text-xl font-bold mb-2">{selectedIssue.title}</h1>
|
||||||
|
|
||||||
|
{/* Meta info */}
|
||||||
|
<div className="flex items-center gap-3 text-sm text-muted-foreground mb-4">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'px-2 py-0.5 rounded-full text-xs font-medium',
|
||||||
|
selectedIssue.state === 'OPEN'
|
||||||
|
? 'bg-green-500/10 text-green-500'
|
||||||
|
: 'bg-purple-500/10 text-purple-500'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{selectedIssue.state === 'OPEN' ? 'Open' : 'Closed'}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
#{selectedIssue.number} opened {formatDate(selectedIssue.createdAt)} by{' '}
|
||||||
|
<span className="font-medium text-foreground">{selectedIssue.author.login}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Labels */}
|
||||||
|
{selectedIssue.labels.length > 0 && (
|
||||||
|
<div className="flex items-center gap-2 mb-6 flex-wrap">
|
||||||
|
{selectedIssue.labels.map((label) => (
|
||||||
|
<span
|
||||||
|
key={label.name}
|
||||||
|
className="px-2 py-0.5 text-xs font-medium rounded-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `#${label.color}20`,
|
||||||
|
color: `#${label.color}`,
|
||||||
|
border: `1px solid #${label.color}40`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
{selectedIssue.body ? (
|
||||||
|
<Markdown className="text-sm">{selectedIssue.body}</Markdown>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground italic">No description provided.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Open in GitHub CTA */}
|
||||||
|
<div className="mt-8 p-4 rounded-lg bg-muted/50 border border-border">
|
||||||
|
<p className="text-sm text-muted-foreground mb-3">
|
||||||
|
View comments, add reactions, and more on GitHub.
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => handleOpenInGitHub(selectedIssue.url)}>
|
||||||
|
<ExternalLink className="h-4 w-4 mr-2" />
|
||||||
|
View Full Issue on GitHub
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
{/* Validation Dialog */}
|
);
|
||||||
<ValidationDialog
|
}
|
||||||
open={showValidationDialog}
|
|
||||||
onOpenChange={setShowValidationDialog}
|
interface IssueRowProps {
|
||||||
issue={selectedIssue}
|
issue: GitHubIssue;
|
||||||
validationResult={validationResult}
|
isSelected: boolean;
|
||||||
onConvertToTask={handleConvertToTask}
|
onClick: () => void;
|
||||||
/>
|
onOpenExternal: () => void;
|
||||||
|
formatDate: (date: string) => string;
|
||||||
{/* Revalidate Confirmation Dialog */}
|
}
|
||||||
<ConfirmDialog
|
|
||||||
open={showRevalidateConfirm}
|
function IssueRow({ issue, isSelected, onClick, onOpenExternal, formatDate }: IssueRowProps) {
|
||||||
onOpenChange={setShowRevalidateConfirm}
|
return (
|
||||||
title="Re-validate Issue"
|
<div
|
||||||
description={`Are you sure you want to re-validate issue #${selectedIssue?.number}? This will run a new AI analysis and replace the existing validation result.`}
|
className={cn(
|
||||||
icon={RefreshCw}
|
'flex items-start gap-3 p-3 cursor-pointer hover:bg-accent/50 transition-colors',
|
||||||
iconClassName="text-primary"
|
isSelected && 'bg-accent'
|
||||||
confirmText="Re-validate"
|
)}
|
||||||
onConfirm={() => {
|
onClick={onClick}
|
||||||
if (selectedIssue) {
|
>
|
||||||
handleValidateIssue(selectedIssue, { forceRevalidate: true });
|
{issue.state === 'OPEN' ? (
|
||||||
}
|
<Circle className="h-4 w-4 text-green-500 mt-0.5 flex-shrink-0" />
|
||||||
}}
|
) : (
|
||||||
/>
|
<CheckCircle2 className="h-4 w-4 text-purple-500 mt-0.5 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium truncate">{issue.title}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
#{issue.number} opened {formatDate(issue.createdAt)} by {issue.author.login}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{issue.labels.length > 0 && (
|
||||||
|
<div className="flex items-center gap-1 mt-2 flex-wrap">
|
||||||
|
{issue.labels.map((label) => (
|
||||||
|
<span
|
||||||
|
key={label.name}
|
||||||
|
className="px-1.5 py-0.5 text-[10px] font-medium rounded-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `#${label.color}20`,
|
||||||
|
color: `#${label.color}`,
|
||||||
|
border: `1px solid #${label.color}40`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="flex-shrink-0 opacity-0 group-hover:opacity-100"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onOpenExternal();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
export { IssueRow } from './issue-row';
|
|
||||||
export { IssueDetailPanel } from './issue-detail-panel';
|
|
||||||
export { IssuesListHeader } from './issues-list-header';
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
import {
|
|
||||||
Circle,
|
|
||||||
CheckCircle2,
|
|
||||||
X,
|
|
||||||
Wand2,
|
|
||||||
ExternalLink,
|
|
||||||
Loader2,
|
|
||||||
CheckCircle,
|
|
||||||
Clock,
|
|
||||||
GitPullRequest,
|
|
||||||
User,
|
|
||||||
RefreshCw,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Markdown } from '@/components/ui/markdown';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import type { IssueDetailPanelProps } from '../types';
|
|
||||||
import { isValidationStale } from '../utils';
|
|
||||||
|
|
||||||
export function IssueDetailPanel({
|
|
||||||
issue,
|
|
||||||
validatingIssues,
|
|
||||||
cachedValidations,
|
|
||||||
onValidateIssue,
|
|
||||||
onViewCachedValidation,
|
|
||||||
onOpenInGitHub,
|
|
||||||
onClose,
|
|
||||||
onShowRevalidateConfirm,
|
|
||||||
formatDate,
|
|
||||||
}: IssueDetailPanelProps) {
|
|
||||||
const isValidating = validatingIssues.has(issue.number);
|
|
||||||
const cached = cachedValidations.get(issue.number);
|
|
||||||
const isStale = cached ? isValidationStale(cached.validatedAt) : false;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
|
||||||
{/* Detail Header */}
|
|
||||||
<div className="flex items-center justify-between p-3 border-b border-border bg-muted/30">
|
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
|
||||||
{issue.state === 'OPEN' ? (
|
|
||||||
<Circle className="h-4 w-4 text-green-500 shrink-0" />
|
|
||||||
) : (
|
|
||||||
<CheckCircle2 className="h-4 w-4 text-purple-500 shrink-0" />
|
|
||||||
)}
|
|
||||||
<span className="text-sm font-medium truncate">
|
|
||||||
#{issue.number} {issue.title}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
|
||||||
{(() => {
|
|
||||||
if (isValidating) {
|
|
||||||
return (
|
|
||||||
<Button variant="default" size="sm" disabled>
|
|
||||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
|
||||||
Validating...
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cached && !isStale) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Button variant="outline" size="sm" onClick={() => onViewCachedValidation(issue)}>
|
|
||||||
<CheckCircle className="h-4 w-4 mr-1 text-green-500" />
|
|
||||||
View Result
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={onShowRevalidateConfirm}
|
|
||||||
title="Re-validate"
|
|
||||||
>
|
|
||||||
<RefreshCw className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cached && isStale) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Button variant="outline" size="sm" onClick={() => onViewCachedValidation(issue)}>
|
|
||||||
<Clock className="h-4 w-4 mr-1 text-yellow-500" />
|
|
||||||
View (stale)
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onValidateIssue(issue, { forceRevalidate: true })}
|
|
||||||
>
|
|
||||||
<Wand2 className="h-4 w-4 mr-1" />
|
|
||||||
Re-validate
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button variant="default" size="sm" onClick={() => onValidateIssue(issue)}>
|
|
||||||
<Wand2 className="h-4 w-4 mr-1" />
|
|
||||||
Validate with AI
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
<Button variant="outline" size="sm" onClick={() => onOpenInGitHub(issue.url)}>
|
|
||||||
<ExternalLink className="h-4 w-4 mr-1" />
|
|
||||||
Open in GitHub
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Issue Detail Content */}
|
|
||||||
<div className="flex-1 overflow-auto p-6">
|
|
||||||
{/* Title */}
|
|
||||||
<h1 className="text-xl font-bold mb-2">{issue.title}</h1>
|
|
||||||
|
|
||||||
{/* Meta info */}
|
|
||||||
<div className="flex items-center gap-3 text-sm text-muted-foreground mb-4">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'px-2 py-0.5 rounded-full text-xs font-medium',
|
|
||||||
issue.state === 'OPEN'
|
|
||||||
? 'bg-green-500/10 text-green-500'
|
|
||||||
: 'bg-purple-500/10 text-purple-500'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{issue.state === 'OPEN' ? 'Open' : 'Closed'}
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
#{issue.number} opened {formatDate(issue.createdAt)} by{' '}
|
|
||||||
<span className="font-medium text-foreground">{issue.author.login}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Labels */}
|
|
||||||
{issue.labels.length > 0 && (
|
|
||||||
<div className="flex items-center gap-2 mb-4 flex-wrap">
|
|
||||||
{issue.labels.map((label) => (
|
|
||||||
<span
|
|
||||||
key={label.name}
|
|
||||||
className="px-2 py-0.5 text-xs font-medium rounded-full"
|
|
||||||
style={{
|
|
||||||
backgroundColor: `#${label.color}20`,
|
|
||||||
color: `#${label.color}`,
|
|
||||||
border: `1px solid #${label.color}40`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{label.name}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Assignees */}
|
|
||||||
{issue.assignees && issue.assignees.length > 0 && (
|
|
||||||
<div className="flex items-center gap-2 mb-4">
|
|
||||||
<User className="h-4 w-4 text-muted-foreground" />
|
|
||||||
<span className="text-sm text-muted-foreground">Assigned to:</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{issue.assignees.map((assignee) => (
|
|
||||||
<span
|
|
||||||
key={assignee.login}
|
|
||||||
className="inline-flex items-center gap-1.5 px-2 py-0.5 text-xs font-medium rounded-full bg-blue-500/10 text-blue-500 border border-blue-500/20"
|
|
||||||
>
|
|
||||||
{assignee.avatarUrl && (
|
|
||||||
<img
|
|
||||||
src={assignee.avatarUrl}
|
|
||||||
alt={assignee.login}
|
|
||||||
className="h-4 w-4 rounded-full"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{assignee.login}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Linked Pull Requests */}
|
|
||||||
{issue.linkedPRs && issue.linkedPRs.length > 0 && (
|
|
||||||
<div className="mb-6 p-3 rounded-lg bg-muted/30 border border-border">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<GitPullRequest className="h-4 w-4 text-purple-500" />
|
|
||||||
<span className="text-sm font-medium">Linked Pull Requests</span>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{issue.linkedPRs.map((pr) => (
|
|
||||||
<div key={pr.number} className="flex items-center justify-between text-sm">
|
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'px-1.5 py-0.5 text-xs font-medium rounded',
|
|
||||||
pr.state === 'open'
|
|
||||||
? 'bg-green-500/10 text-green-500'
|
|
||||||
: pr.state === 'merged'
|
|
||||||
? 'bg-purple-500/10 text-purple-500'
|
|
||||||
: 'bg-red-500/10 text-red-500'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{pr.state === 'open' ? 'Open' : pr.state === 'merged' ? 'Merged' : 'Closed'}
|
|
||||||
</span>
|
|
||||||
<span className="text-muted-foreground">#{pr.number}</span>
|
|
||||||
<span className="truncate">{pr.title}</span>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-6 px-2 shrink-0"
|
|
||||||
onClick={() => onOpenInGitHub(pr.url)}
|
|
||||||
>
|
|
||||||
<ExternalLink className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Body */}
|
|
||||||
{issue.body ? (
|
|
||||||
<Markdown className="text-sm">{issue.body}</Markdown>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-muted-foreground italic">No description provided.</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Open in GitHub CTA */}
|
|
||||||
<div className="mt-8 p-4 rounded-lg bg-muted/50 border border-border">
|
|
||||||
<p className="text-sm text-muted-foreground mb-3">
|
|
||||||
View comments, add reactions, and more on GitHub.
|
|
||||||
</p>
|
|
||||||
<Button onClick={() => onOpenInGitHub(issue.url)}>
|
|
||||||
<ExternalLink className="h-4 w-4 mr-2" />
|
|
||||||
View Full Issue on GitHub
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
import {
|
|
||||||
Circle,
|
|
||||||
CheckCircle2,
|
|
||||||
ExternalLink,
|
|
||||||
Loader2,
|
|
||||||
CheckCircle,
|
|
||||||
Sparkles,
|
|
||||||
GitPullRequest,
|
|
||||||
User,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import type { IssueRowProps } from '../types';
|
|
||||||
import { isValidationStale } from '../utils';
|
|
||||||
|
|
||||||
export function IssueRow({
|
|
||||||
issue,
|
|
||||||
isSelected,
|
|
||||||
onClick,
|
|
||||||
onOpenExternal,
|
|
||||||
formatDate,
|
|
||||||
cachedValidation,
|
|
||||||
isValidating,
|
|
||||||
}: IssueRowProps) {
|
|
||||||
// Check if validation exists and calculate staleness
|
|
||||||
const validationHoursSince = cachedValidation
|
|
||||||
? (Date.now() - new Date(cachedValidation.validatedAt).getTime()) / (1000 * 60 * 60)
|
|
||||||
: null;
|
|
||||||
const isValidationStaleValue =
|
|
||||||
validationHoursSince !== null && isValidationStale(cachedValidation!.validatedAt);
|
|
||||||
|
|
||||||
// Check if validation is unviewed (exists, not stale, not viewed)
|
|
||||||
const hasUnviewedValidation =
|
|
||||||
cachedValidation && !cachedValidation.viewedAt && !isValidationStaleValue;
|
|
||||||
|
|
||||||
// Check if validation has been viewed (exists and was viewed)
|
|
||||||
const hasViewedValidation =
|
|
||||||
cachedValidation && cachedValidation.viewedAt && !isValidationStaleValue;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'group flex items-start gap-3 p-3 cursor-pointer hover:bg-accent/50 transition-colors',
|
|
||||||
isSelected && 'bg-accent'
|
|
||||||
)}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
{issue.state === 'OPEN' ? (
|
|
||||||
<Circle className="h-4 w-4 text-green-500 mt-0.5 flex-shrink-0" />
|
|
||||||
) : (
|
|
||||||
<CheckCircle2 className="h-4 w-4 text-purple-500 mt-0.5 flex-shrink-0" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm font-medium truncate">{issue.title}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
#{issue.number} opened {formatDate(issue.createdAt)} by {issue.author.login}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 mt-2 flex-wrap">
|
|
||||||
{/* Labels */}
|
|
||||||
{issue.labels.map((label) => (
|
|
||||||
<span
|
|
||||||
key={label.name}
|
|
||||||
className="px-1.5 py-0.5 text-[10px] font-medium rounded-full"
|
|
||||||
style={{
|
|
||||||
backgroundColor: `#${label.color}20`,
|
|
||||||
color: `#${label.color}`,
|
|
||||||
border: `1px solid #${label.color}40`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{label.name}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Linked PR indicator */}
|
|
||||||
{issue.linkedPRs && issue.linkedPRs.length > 0 && (
|
|
||||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full bg-purple-500/10 text-purple-500 border border-purple-500/20">
|
|
||||||
<GitPullRequest className="h-3 w-3" />
|
|
||||||
{issue.linkedPRs.length} PR{issue.linkedPRs.length > 1 ? 's' : ''}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Assignee indicator */}
|
|
||||||
{issue.assignees && issue.assignees.length > 0 && (
|
|
||||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full bg-blue-500/10 text-blue-500 border border-blue-500/20">
|
|
||||||
<User className="h-3 w-3" />
|
|
||||||
{issue.assignees.map((a) => a.login).join(', ')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Validating indicator */}
|
|
||||||
{isValidating && (
|
|
||||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full bg-primary/10 text-primary border border-primary/20 animate-in fade-in duration-200">
|
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
|
||||||
Analyzing...
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Unviewed validation indicator */}
|
|
||||||
{!isValidating && hasUnviewedValidation && (
|
|
||||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full bg-amber-500/10 text-amber-500 border border-amber-500/20 animate-in fade-in duration-200">
|
|
||||||
<Sparkles className="h-3 w-3" />
|
|
||||||
Analysis Ready
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Viewed validation indicator */}
|
|
||||||
{!isValidating && hasViewedValidation && (
|
|
||||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full bg-green-500/10 text-green-500 border border-green-500/20">
|
|
||||||
<CheckCircle className="h-3 w-3" />
|
|
||||||
Validated
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="flex-shrink-0 opacity-0 group-hover:opacity-100"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onOpenExternal();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ExternalLink className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import { CircleDot, RefreshCw } from 'lucide-react';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
interface IssuesListHeaderProps {
|
|
||||||
openCount: number;
|
|
||||||
closedCount: number;
|
|
||||||
refreshing: boolean;
|
|
||||||
onRefresh: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function IssuesListHeader({
|
|
||||||
openCount,
|
|
||||||
closedCount,
|
|
||||||
refreshing,
|
|
||||||
onRefresh,
|
|
||||||
}: IssuesListHeaderProps) {
|
|
||||||
const totalIssues = openCount + closedCount;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="p-2 rounded-lg bg-green-500/10">
|
|
||||||
<CircleDot className="h-5 w-5 text-green-500" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-lg font-bold">Issues</h1>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{totalIssues === 0 ? 'No issues found' : `${openCount} open, ${closedCount} closed`}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" size="sm" onClick={onRefresh} disabled={refreshing}>
|
|
||||||
<RefreshCw className={cn('h-4 w-4', refreshing && 'animate-spin')} />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export const VALIDATION_STALENESS_HOURS = 24;
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { ValidationDialog } from './validation-dialog';
|
|
||||||
@@ -1,231 +0,0 @@
|
|||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Markdown } from '@/components/ui/markdown';
|
|
||||||
import {
|
|
||||||
CheckCircle2,
|
|
||||||
XCircle,
|
|
||||||
AlertCircle,
|
|
||||||
FileCode,
|
|
||||||
Lightbulb,
|
|
||||||
AlertTriangle,
|
|
||||||
Plus,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import type {
|
|
||||||
IssueValidationResult,
|
|
||||||
IssueValidationVerdict,
|
|
||||||
IssueValidationConfidence,
|
|
||||||
IssueComplexity,
|
|
||||||
GitHubIssue,
|
|
||||||
} from '@/lib/electron';
|
|
||||||
|
|
||||||
interface ValidationDialogProps {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
issue: GitHubIssue | null;
|
|
||||||
validationResult: IssueValidationResult | null;
|
|
||||||
onConvertToTask?: (issue: GitHubIssue, validation: IssueValidationResult) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const verdictConfig: Record<
|
|
||||||
IssueValidationVerdict,
|
|
||||||
{ label: string; color: string; bgColor: string; icon: typeof CheckCircle2 }
|
|
||||||
> = {
|
|
||||||
valid: {
|
|
||||||
label: 'Valid',
|
|
||||||
color: 'text-green-500',
|
|
||||||
bgColor: 'bg-green-500/10',
|
|
||||||
icon: CheckCircle2,
|
|
||||||
},
|
|
||||||
invalid: {
|
|
||||||
label: 'Invalid',
|
|
||||||
color: 'text-red-500',
|
|
||||||
bgColor: 'bg-red-500/10',
|
|
||||||
icon: XCircle,
|
|
||||||
},
|
|
||||||
needs_clarification: {
|
|
||||||
label: 'Needs Clarification',
|
|
||||||
color: 'text-yellow-500',
|
|
||||||
bgColor: 'bg-yellow-500/10',
|
|
||||||
icon: AlertCircle,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const confidenceConfig: Record<IssueValidationConfidence, { label: string; color: string }> = {
|
|
||||||
high: { label: 'High Confidence', color: 'text-green-500' },
|
|
||||||
medium: { label: 'Medium Confidence', color: 'text-yellow-500' },
|
|
||||||
low: { label: 'Low Confidence', color: 'text-orange-500' },
|
|
||||||
};
|
|
||||||
|
|
||||||
const complexityConfig: Record<IssueComplexity, { label: string; color: string }> = {
|
|
||||||
trivial: { label: 'Trivial', color: 'text-green-500' },
|
|
||||||
simple: { label: 'Simple', color: 'text-blue-500' },
|
|
||||||
moderate: { label: 'Moderate', color: 'text-yellow-500' },
|
|
||||||
complex: { label: 'Complex', color: 'text-orange-500' },
|
|
||||||
very_complex: { label: 'Very Complex', color: 'text-red-500' },
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ValidationDialog({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
issue,
|
|
||||||
validationResult,
|
|
||||||
onConvertToTask,
|
|
||||||
}: ValidationDialogProps) {
|
|
||||||
if (!issue) return null;
|
|
||||||
|
|
||||||
const handleConvertToTask = () => {
|
|
||||||
if (validationResult && onConvertToTask) {
|
|
||||||
onConvertToTask(issue, validationResult);
|
|
||||||
onOpenChange(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="flex items-center gap-2">Issue Validation Result</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
#{issue.number}: {issue.title}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{validationResult ? (
|
|
||||||
<div className="space-y-6 py-4">
|
|
||||||
{/* Verdict Badge */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{(() => {
|
|
||||||
const config = verdictConfig[validationResult.verdict];
|
|
||||||
const Icon = config.icon;
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className={cn('p-2 rounded-lg', config.bgColor)}>
|
|
||||||
<Icon className={cn('h-6 w-6', config.color)} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className={cn('text-lg font-semibold', config.color)}>{config.label}</p>
|
|
||||||
<p
|
|
||||||
className={cn(
|
|
||||||
'text-sm',
|
|
||||||
confidenceConfig[validationResult.confidence].color
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{confidenceConfig[validationResult.confidence].label}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
{validationResult.estimatedComplexity && (
|
|
||||||
<div className="text-right">
|
|
||||||
<p className="text-xs text-muted-foreground">Estimated Complexity</p>
|
|
||||||
<p
|
|
||||||
className={cn(
|
|
||||||
'text-sm font-medium',
|
|
||||||
complexityConfig[validationResult.estimatedComplexity].color
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{complexityConfig[validationResult.estimatedComplexity].label}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bug Confirmed Badge */}
|
|
||||||
{validationResult.bugConfirmed && (
|
|
||||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-red-500/10 border border-red-500/20">
|
|
||||||
<AlertTriangle className="h-5 w-5 text-red-500 shrink-0" />
|
|
||||||
<span className="text-sm font-medium text-red-500">Bug Confirmed in Codebase</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Reasoning */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h4 className="text-sm font-medium flex items-center gap-2">
|
|
||||||
<Lightbulb className="h-4 w-4 text-muted-foreground" />
|
|
||||||
Analysis
|
|
||||||
</h4>
|
|
||||||
<div className="bg-muted/30 p-3 rounded-lg border border-border">
|
|
||||||
<Markdown>{validationResult.reasoning}</Markdown>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Related Files */}
|
|
||||||
{validationResult.relatedFiles && validationResult.relatedFiles.length > 0 && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h4 className="text-sm font-medium flex items-center gap-2">
|
|
||||||
<FileCode className="h-4 w-4 text-muted-foreground" />
|
|
||||||
Related Files
|
|
||||||
</h4>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{validationResult.relatedFiles.map((file, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="text-sm font-mono bg-muted/50 px-2 py-1 rounded text-muted-foreground"
|
|
||||||
>
|
|
||||||
{file}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Suggested Fix */}
|
|
||||||
{validationResult.suggestedFix && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h4 className="text-sm font-medium">Suggested Approach</h4>
|
|
||||||
<div className="bg-muted/30 p-3 rounded-lg border border-border">
|
|
||||||
<Markdown>{validationResult.suggestedFix}</Markdown>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Missing Info (for needs_clarification) */}
|
|
||||||
{validationResult.missingInfo && validationResult.missingInfo.length > 0 && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h4 className="text-sm font-medium flex items-center gap-2">
|
|
||||||
<AlertCircle className="h-4 w-4 text-yellow-500" />
|
|
||||||
Missing Information
|
|
||||||
</h4>
|
|
||||||
<ul className="space-y-1 list-disc list-inside">
|
|
||||||
{validationResult.missingInfo.map((info, index) => (
|
|
||||||
<li key={index} className="text-sm text-muted-foreground">
|
|
||||||
{info}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
|
||||||
<AlertCircle className="h-8 w-8 text-muted-foreground mb-4" />
|
|
||||||
<p className="text-sm text-muted-foreground">No validation result available.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
{validationResult?.verdict === 'valid' && onConvertToTask && (
|
|
||||||
<Button onClick={handleConvertToTask}>
|
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
|
||||||
Convert to Task
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { useGithubIssues } from './use-github-issues';
|
|
||||||
export { useIssueValidation } from './use-issue-validation';
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
||||||
import { getElectronAPI, GitHubIssue } from '@/lib/electron';
|
|
||||||
import { useAppStore } from '@/store/app-store';
|
|
||||||
|
|
||||||
export function useGithubIssues() {
|
|
||||||
const { currentProject } = useAppStore();
|
|
||||||
const [openIssues, setOpenIssues] = useState<GitHubIssue[]>([]);
|
|
||||||
const [closedIssues, setClosedIssues] = useState<GitHubIssue[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const isMountedRef = useRef(true);
|
|
||||||
|
|
||||||
const fetchIssues = useCallback(async () => {
|
|
||||||
if (!currentProject?.path) {
|
|
||||||
if (isMountedRef.current) {
|
|
||||||
setError('No project selected');
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (isMountedRef.current) {
|
|
||||||
setError(null);
|
|
||||||
}
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (api.github) {
|
|
||||||
const result = await api.github.listIssues(currentProject.path);
|
|
||||||
if (isMountedRef.current) {
|
|
||||||
if (result.success) {
|
|
||||||
setOpenIssues(result.openIssues || []);
|
|
||||||
setClosedIssues(result.closedIssues || []);
|
|
||||||
} else {
|
|
||||||
setError(result.error || 'Failed to fetch issues');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (isMountedRef.current) {
|
|
||||||
console.error('[GitHubIssuesView] Error fetching issues:', err);
|
|
||||||
setError(err instanceof Error ? err.message : 'Failed to fetch issues');
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (isMountedRef.current) {
|
|
||||||
setLoading(false);
|
|
||||||
setRefreshing(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [currentProject?.path]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
isMountedRef.current = true;
|
|
||||||
fetchIssues();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
isMountedRef.current = false;
|
|
||||||
};
|
|
||||||
}, [fetchIssues]);
|
|
||||||
|
|
||||||
const refresh = useCallback(() => {
|
|
||||||
if (isMountedRef.current) {
|
|
||||||
setRefreshing(true);
|
|
||||||
}
|
|
||||||
fetchIssues();
|
|
||||||
}, [fetchIssues]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
openIssues,
|
|
||||||
closedIssues,
|
|
||||||
loading,
|
|
||||||
refreshing,
|
|
||||||
error,
|
|
||||||
refresh,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,317 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
||||||
import {
|
|
||||||
getElectronAPI,
|
|
||||||
GitHubIssue,
|
|
||||||
IssueValidationResult,
|
|
||||||
IssueValidationEvent,
|
|
||||||
StoredValidation,
|
|
||||||
} from '@/lib/electron';
|
|
||||||
import { useAppStore } from '@/store/app-store';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { isValidationStale } from '../utils';
|
|
||||||
|
|
||||||
interface UseIssueValidationOptions {
|
|
||||||
selectedIssue: GitHubIssue | null;
|
|
||||||
showValidationDialog: boolean;
|
|
||||||
onValidationResultChange: (result: IssueValidationResult | null) => void;
|
|
||||||
onShowValidationDialogChange: (show: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useIssueValidation({
|
|
||||||
selectedIssue,
|
|
||||||
showValidationDialog,
|
|
||||||
onValidationResultChange,
|
|
||||||
onShowValidationDialogChange,
|
|
||||||
}: UseIssueValidationOptions) {
|
|
||||||
const { currentProject, validationModel, muteDoneSound } = useAppStore();
|
|
||||||
const [validatingIssues, setValidatingIssues] = useState<Set<number>>(new Set());
|
|
||||||
const [cachedValidations, setCachedValidations] = useState<Map<number, StoredValidation>>(
|
|
||||||
new Map()
|
|
||||||
);
|
|
||||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
|
||||||
// Refs for stable event handler (avoids re-subscribing on state changes)
|
|
||||||
const selectedIssueRef = useRef<GitHubIssue | null>(null);
|
|
||||||
const showValidationDialogRef = useRef(false);
|
|
||||||
|
|
||||||
// Keep refs in sync with state for stable event handler
|
|
||||||
useEffect(() => {
|
|
||||||
selectedIssueRef.current = selectedIssue;
|
|
||||||
}, [selectedIssue]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
showValidationDialogRef.current = showValidationDialog;
|
|
||||||
}, [showValidationDialog]);
|
|
||||||
|
|
||||||
// Load cached validations on mount
|
|
||||||
useEffect(() => {
|
|
||||||
let isMounted = true;
|
|
||||||
|
|
||||||
const loadCachedValidations = async () => {
|
|
||||||
if (!currentProject?.path) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (api.github?.getValidations) {
|
|
||||||
const result = await api.github.getValidations(currentProject.path);
|
|
||||||
if (isMounted && result.success && result.validations) {
|
|
||||||
const map = new Map<number, StoredValidation>();
|
|
||||||
for (const v of result.validations) {
|
|
||||||
map.set(v.issueNumber, v);
|
|
||||||
}
|
|
||||||
setCachedValidations(map);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (isMounted) {
|
|
||||||
console.error('[GitHubIssuesView] Failed to load cached validations:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadCachedValidations();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
isMounted = false;
|
|
||||||
};
|
|
||||||
}, [currentProject?.path]);
|
|
||||||
|
|
||||||
// Load running validations on mount (restore validatingIssues state)
|
|
||||||
useEffect(() => {
|
|
||||||
let isMounted = true;
|
|
||||||
|
|
||||||
const loadRunningValidations = async () => {
|
|
||||||
if (!currentProject?.path) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (api.github?.getValidationStatus) {
|
|
||||||
const result = await api.github.getValidationStatus(currentProject.path);
|
|
||||||
if (isMounted && result.success && result.runningIssues) {
|
|
||||||
setValidatingIssues(new Set(result.runningIssues));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (isMounted) {
|
|
||||||
console.error('[GitHubIssuesView] Failed to load running validations:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadRunningValidations();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
isMounted = false;
|
|
||||||
};
|
|
||||||
}, [currentProject?.path]);
|
|
||||||
|
|
||||||
// Subscribe to validation events
|
|
||||||
useEffect(() => {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (!api.github?.onValidationEvent) return;
|
|
||||||
|
|
||||||
const handleValidationEvent = (event: IssueValidationEvent) => {
|
|
||||||
// Only handle events for current project
|
|
||||||
if (event.projectPath !== currentProject?.path) return;
|
|
||||||
|
|
||||||
switch (event.type) {
|
|
||||||
case 'issue_validation_start':
|
|
||||||
setValidatingIssues((prev) => new Set([...prev, event.issueNumber]));
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'issue_validation_complete':
|
|
||||||
setValidatingIssues((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
next.delete(event.issueNumber);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update cached validations (use event.model to avoid stale closure race condition)
|
|
||||||
setCachedValidations((prev) => {
|
|
||||||
const next = new Map(prev);
|
|
||||||
next.set(event.issueNumber, {
|
|
||||||
issueNumber: event.issueNumber,
|
|
||||||
issueTitle: event.issueTitle,
|
|
||||||
validatedAt: new Date().toISOString(),
|
|
||||||
model: event.model,
|
|
||||||
result: event.result,
|
|
||||||
});
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Show toast notification
|
|
||||||
toast.success(`Issue #${event.issueNumber} validated: ${event.result.verdict}`, {
|
|
||||||
description:
|
|
||||||
event.result.verdict === 'valid'
|
|
||||||
? 'Issue is ready to be converted to a task'
|
|
||||||
: event.result.verdict === 'invalid'
|
|
||||||
? 'Issue may have problems'
|
|
||||||
: 'Issue needs clarification',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Play audio notification (if not muted)
|
|
||||||
if (!muteDoneSound) {
|
|
||||||
try {
|
|
||||||
if (!audioRef.current) {
|
|
||||||
audioRef.current = new Audio('/sounds/ding.mp3');
|
|
||||||
}
|
|
||||||
audioRef.current.play().catch(() => {
|
|
||||||
// Audio play might fail due to browser restrictions
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// Ignore audio errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If validation dialog is open for this issue, update the result
|
|
||||||
if (
|
|
||||||
selectedIssueRef.current?.number === event.issueNumber &&
|
|
||||||
showValidationDialogRef.current
|
|
||||||
) {
|
|
||||||
onValidationResultChange(event.result);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'issue_validation_error':
|
|
||||||
setValidatingIssues((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
next.delete(event.issueNumber);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
toast.error(`Validation failed for issue #${event.issueNumber}`, {
|
|
||||||
description: event.error,
|
|
||||||
});
|
|
||||||
if (
|
|
||||||
selectedIssueRef.current?.number === event.issueNumber &&
|
|
||||||
showValidationDialogRef.current
|
|
||||||
) {
|
|
||||||
onShowValidationDialogChange(false);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const unsubscribe = api.github.onValidationEvent(handleValidationEvent);
|
|
||||||
return () => unsubscribe();
|
|
||||||
}, [currentProject?.path, muteDoneSound, onValidationResultChange, onShowValidationDialogChange]);
|
|
||||||
|
|
||||||
// Cleanup audio element on unmount to prevent memory leaks
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (audioRef.current) {
|
|
||||||
audioRef.current.pause();
|
|
||||||
audioRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleValidateIssue = useCallback(
|
|
||||||
async (issue: GitHubIssue, options: { forceRevalidate?: boolean } = {}) => {
|
|
||||||
const { forceRevalidate = false } = options;
|
|
||||||
|
|
||||||
if (!currentProject?.path) {
|
|
||||||
toast.error('No project selected');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if already validating this issue
|
|
||||||
if (validatingIssues.has(issue.number)) {
|
|
||||||
toast.info(`Validation already in progress for issue #${issue.number}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for cached result - if fresh, show it directly (unless force revalidate)
|
|
||||||
const cached = cachedValidations.get(issue.number);
|
|
||||||
if (cached && !forceRevalidate && !isValidationStale(cached.validatedAt)) {
|
|
||||||
// Show cached result directly
|
|
||||||
onValidationResultChange(cached.result);
|
|
||||||
onShowValidationDialogChange(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start async validation in background (no dialog - user will see badge when done)
|
|
||||||
toast.info(`Starting validation for issue #${issue.number}`, {
|
|
||||||
description: 'You will be notified when the analysis is complete',
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (api.github?.validateIssue) {
|
|
||||||
const result = await api.github.validateIssue(
|
|
||||||
currentProject.path,
|
|
||||||
{
|
|
||||||
issueNumber: issue.number,
|
|
||||||
issueTitle: issue.title,
|
|
||||||
issueBody: issue.body || '',
|
|
||||||
issueLabels: issue.labels.map((l) => l.name),
|
|
||||||
},
|
|
||||||
validationModel
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
toast.error(result.error || 'Failed to start validation');
|
|
||||||
}
|
|
||||||
// On success, the result will come through the event stream
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[GitHubIssuesView] Validation error:', err);
|
|
||||||
toast.error(err instanceof Error ? err.message : 'Failed to validate issue');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[
|
|
||||||
currentProject?.path,
|
|
||||||
validatingIssues,
|
|
||||||
cachedValidations,
|
|
||||||
validationModel,
|
|
||||||
onValidationResultChange,
|
|
||||||
onShowValidationDialogChange,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
// View cached validation result
|
|
||||||
const handleViewCachedValidation = useCallback(
|
|
||||||
async (issue: GitHubIssue) => {
|
|
||||||
const cached = cachedValidations.get(issue.number);
|
|
||||||
if (cached) {
|
|
||||||
onValidationResultChange(cached.result);
|
|
||||||
onShowValidationDialogChange(true);
|
|
||||||
|
|
||||||
// Mark as viewed if not already viewed
|
|
||||||
if (!cached.viewedAt && currentProject?.path) {
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (api.github?.markValidationViewed) {
|
|
||||||
await api.github.markValidationViewed(currentProject.path, issue.number);
|
|
||||||
// Update local state
|
|
||||||
setCachedValidations((prev) => {
|
|
||||||
const next = new Map(prev);
|
|
||||||
const updated = prev.get(issue.number);
|
|
||||||
if (updated) {
|
|
||||||
next.set(issue.number, {
|
|
||||||
...updated,
|
|
||||||
viewedAt: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[GitHubIssuesView] Failed to mark validation as viewed:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[
|
|
||||||
cachedValidations,
|
|
||||||
currentProject?.path,
|
|
||||||
onValidationResultChange,
|
|
||||||
onShowValidationDialogChange,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
validatingIssues,
|
|
||||||
cachedValidations,
|
|
||||||
handleValidateIssue,
|
|
||||||
handleViewCachedValidation,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import type { GitHubIssue, StoredValidation } from '@/lib/electron';
|
|
||||||
|
|
||||||
export interface IssueRowProps {
|
|
||||||
issue: GitHubIssue;
|
|
||||||
isSelected: boolean;
|
|
||||||
onClick: () => void;
|
|
||||||
onOpenExternal: () => void;
|
|
||||||
formatDate: (date: string) => string;
|
|
||||||
/** Cached validation for this issue (if any) */
|
|
||||||
cachedValidation?: StoredValidation | null;
|
|
||||||
/** Whether validation is currently running for this issue */
|
|
||||||
isValidating?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IssueDetailPanelProps {
|
|
||||||
issue: GitHubIssue;
|
|
||||||
validatingIssues: Set<number>;
|
|
||||||
cachedValidations: Map<number, StoredValidation>;
|
|
||||||
onValidateIssue: (
|
|
||||||
issue: GitHubIssue,
|
|
||||||
options?: { showDialog?: boolean; forceRevalidate?: boolean }
|
|
||||||
) => Promise<void>;
|
|
||||||
onViewCachedValidation: (issue: GitHubIssue) => Promise<void>;
|
|
||||||
onOpenInGitHub: (url: string) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
onShowRevalidateConfirm: () => void;
|
|
||||||
formatDate: (date: string) => string;
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import type { IssueComplexity } from '@/lib/electron';
|
|
||||||
import { VALIDATION_STALENESS_HOURS } from './constants';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map issue complexity to feature priority.
|
|
||||||
* Lower complexity issues get higher priority (1 = high, 2 = medium).
|
|
||||||
*/
|
|
||||||
export function getFeaturePriority(complexity: IssueComplexity | undefined): number {
|
|
||||||
switch (complexity) {
|
|
||||||
case 'trivial':
|
|
||||||
case 'simple':
|
|
||||||
return 1; // High priority for easy wins
|
|
||||||
case 'moderate':
|
|
||||||
case 'complex':
|
|
||||||
case 'very_complex':
|
|
||||||
default:
|
|
||||||
return 2; // Medium priority for larger efforts
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatDate(dateString: string): string {
|
|
||||||
const date = new Date(dateString);
|
|
||||||
return date.toLocaleDateString('en-US', {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
year: 'numeric',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isValidationStale(validatedAt: string): boolean {
|
|
||||||
const hoursSinceValidation = (Date.now() - new Date(validatedAt).getTime()) / (1000 * 60 * 60);
|
|
||||||
return hoursSinceValidation > VALIDATION_STALENESS_HOURS;
|
|
||||||
}
|
|
||||||
@@ -7,8 +7,6 @@ import { Feature } from '@/store/app-store';
|
|||||||
export interface DependencyEdgeData {
|
export interface DependencyEdgeData {
|
||||||
sourceStatus: Feature['status'];
|
sourceStatus: Feature['status'];
|
||||||
targetStatus: Feature['status'];
|
targetStatus: Feature['status'];
|
||||||
isHighlighted?: boolean;
|
|
||||||
isDimmed?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const getEdgeColor = (sourceStatus?: Feature['status'], targetStatus?: Feature['status']) => {
|
const getEdgeColor = (sourceStatus?: Feature['status'], targetStatus?: Feature['status']) => {
|
||||||
@@ -54,17 +52,11 @@ export const DependencyEdge = memo(function DependencyEdge(props: EdgeProps) {
|
|||||||
curvature: 0.25,
|
curvature: 0.25,
|
||||||
});
|
});
|
||||||
|
|
||||||
const isHighlighted = edgeData?.isHighlighted ?? false;
|
const edgeColor = edgeData
|
||||||
const isDimmed = edgeData?.isDimmed ?? false;
|
? getEdgeColor(edgeData.sourceStatus, edgeData.targetStatus)
|
||||||
|
: 'var(--border)';
|
||||||
|
|
||||||
const edgeColor = isHighlighted
|
const isCompleted = edgeData?.sourceStatus === 'completed' || edgeData?.sourceStatus === 'verified';
|
||||||
? 'var(--brand-500)'
|
|
||||||
: edgeData
|
|
||||||
? getEdgeColor(edgeData.sourceStatus, edgeData.targetStatus)
|
|
||||||
: 'var(--border)';
|
|
||||||
|
|
||||||
const isCompleted =
|
|
||||||
edgeData?.sourceStatus === 'completed' || edgeData?.sourceStatus === 'verified';
|
|
||||||
const isInProgress = edgeData?.targetStatus === 'in_progress';
|
const isInProgress = edgeData?.targetStatus === 'in_progress';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -74,9 +66,8 @@ export const DependencyEdge = memo(function DependencyEdge(props: EdgeProps) {
|
|||||||
id={`${id}-bg`}
|
id={`${id}-bg`}
|
||||||
path={edgePath}
|
path={edgePath}
|
||||||
style={{
|
style={{
|
||||||
strokeWidth: isHighlighted ? 6 : 4,
|
strokeWidth: 4,
|
||||||
stroke: 'var(--background)',
|
stroke: 'var(--background)',
|
||||||
opacity: isDimmed ? 0.3 : 1,
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -87,20 +78,13 @@ export const DependencyEdge = memo(function DependencyEdge(props: EdgeProps) {
|
|||||||
className={cn(
|
className={cn(
|
||||||
'transition-all duration-300',
|
'transition-all duration-300',
|
||||||
animated && 'animated-edge',
|
animated && 'animated-edge',
|
||||||
isInProgress && 'edge-flowing',
|
isInProgress && 'edge-flowing'
|
||||||
isHighlighted && 'graph-edge-highlighted',
|
|
||||||
isDimmed && 'graph-edge-dimmed'
|
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
strokeWidth: isHighlighted ? 4 : selected ? 3 : isDimmed ? 1 : 2,
|
strokeWidth: selected ? 3 : 2,
|
||||||
stroke: edgeColor,
|
stroke: edgeColor,
|
||||||
strokeDasharray: isCompleted ? 'none' : '5 5',
|
strokeDasharray: isCompleted ? 'none' : '5 5',
|
||||||
filter: isHighlighted
|
filter: selected ? 'drop-shadow(0 0 3px var(--brand-500))' : 'none',
|
||||||
? 'drop-shadow(0 0 6px var(--brand-500))'
|
|
||||||
: selected
|
|
||||||
? 'drop-shadow(0 0 3px var(--brand-500))'
|
|
||||||
: 'none',
|
|
||||||
opacity: isDimmed ? 0.2 : 1,
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function GraphControls({
|
|||||||
return (
|
return (
|
||||||
<Panel position="bottom-left" className="flex flex-col gap-2">
|
<Panel position="bottom-left" className="flex flex-col gap-2">
|
||||||
<TooltipProvider delayDuration={200}>
|
<TooltipProvider delayDuration={200}>
|
||||||
<div className="flex flex-col gap-1 p-1.5 rounded-lg bg-popover/90 backdrop-blur-sm border border-border shadow-lg text-popover-foreground">
|
<div className="flex flex-col gap-1 p-1.5 rounded-lg bg-popover/90 backdrop-blur-sm border border-border shadow-lg">
|
||||||
{/* Zoom controls */}
|
{/* Zoom controls */}
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@@ -120,13 +120,22 @@ export function GraphControls({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className={cn('h-8 w-8 p-0', isLocked && 'bg-brand-500/20 text-brand-500')}
|
className={cn(
|
||||||
|
'h-8 w-8 p-0',
|
||||||
|
isLocked && 'bg-brand-500/20 text-brand-500'
|
||||||
|
)}
|
||||||
onClick={onToggleLock}
|
onClick={onToggleLock}
|
||||||
>
|
>
|
||||||
{isLocked ? <Lock className="w-4 h-4" /> : <Unlock className="w-4 h-4" />}
|
{isLocked ? (
|
||||||
|
<Lock className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<Unlock className="w-4 h-4" />
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right">{isLocked ? 'Unlock Nodes' : 'Lock Nodes'}</TooltipContent>
|
<TooltipContent side="right">
|
||||||
|
{isLocked ? 'Unlock Nodes' : 'Lock Nodes'}
|
||||||
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|||||||
@@ -1,329 +0,0 @@
|
|||||||
import { Panel } from '@xyflow/react';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
|
||||||
import { Switch } from '@/components/ui/switch';
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
|
||||||
import {
|
|
||||||
Filter,
|
|
||||||
X,
|
|
||||||
Eye,
|
|
||||||
EyeOff,
|
|
||||||
ChevronDown,
|
|
||||||
Play,
|
|
||||||
Pause,
|
|
||||||
Clock,
|
|
||||||
CheckCircle2,
|
|
||||||
CircleDot,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import {
|
|
||||||
GraphFilterState,
|
|
||||||
STATUS_FILTER_OPTIONS,
|
|
||||||
StatusFilterValue,
|
|
||||||
} from '../hooks/use-graph-filter';
|
|
||||||
|
|
||||||
// Status display configuration
|
|
||||||
const statusDisplayConfig: Record<
|
|
||||||
StatusFilterValue,
|
|
||||||
{ label: string; icon: typeof Play; colorClass: string }
|
|
||||||
> = {
|
|
||||||
running: { label: 'Running', icon: Play, colorClass: 'text-[var(--status-in-progress)]' },
|
|
||||||
paused: { label: 'Paused', icon: Pause, colorClass: 'text-[var(--status-warning)]' },
|
|
||||||
backlog: { label: 'Backlog', icon: Clock, colorClass: 'text-muted-foreground' },
|
|
||||||
waiting_approval: {
|
|
||||||
label: 'Waiting Approval',
|
|
||||||
icon: CircleDot,
|
|
||||||
colorClass: 'text-[var(--status-waiting)]',
|
|
||||||
},
|
|
||||||
verified: { label: 'Verified', icon: CheckCircle2, colorClass: 'text-[var(--status-success)]' },
|
|
||||||
};
|
|
||||||
|
|
||||||
interface GraphFilterControlsProps {
|
|
||||||
filterState: GraphFilterState;
|
|
||||||
availableCategories: string[];
|
|
||||||
hasActiveFilter: boolean;
|
|
||||||
onCategoriesChange: (categories: string[]) => void;
|
|
||||||
onStatusesChange: (statuses: string[]) => void;
|
|
||||||
onNegativeFilterChange: (isNegative: boolean) => void;
|
|
||||||
onClearFilters: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GraphFilterControls({
|
|
||||||
filterState,
|
|
||||||
availableCategories,
|
|
||||||
hasActiveFilter,
|
|
||||||
onCategoriesChange,
|
|
||||||
onStatusesChange,
|
|
||||||
onNegativeFilterChange,
|
|
||||||
onClearFilters,
|
|
||||||
}: GraphFilterControlsProps) {
|
|
||||||
const { selectedCategories, selectedStatuses, isNegativeFilter } = filterState;
|
|
||||||
|
|
||||||
const handleCategoryToggle = (category: string) => {
|
|
||||||
if (selectedCategories.includes(category)) {
|
|
||||||
onCategoriesChange(selectedCategories.filter((c) => c !== category));
|
|
||||||
} else {
|
|
||||||
onCategoriesChange([...selectedCategories, category]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelectAllCategories = () => {
|
|
||||||
if (selectedCategories.length === availableCategories.length) {
|
|
||||||
onCategoriesChange([]);
|
|
||||||
} else {
|
|
||||||
onCategoriesChange([...availableCategories]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStatusToggle = (status: string) => {
|
|
||||||
if (selectedStatuses.includes(status)) {
|
|
||||||
onStatusesChange(selectedStatuses.filter((s) => s !== status));
|
|
||||||
} else {
|
|
||||||
onStatusesChange([...selectedStatuses, status]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelectAllStatuses = () => {
|
|
||||||
if (selectedStatuses.length === STATUS_FILTER_OPTIONS.length) {
|
|
||||||
onStatusesChange([]);
|
|
||||||
} else {
|
|
||||||
onStatusesChange([...STATUS_FILTER_OPTIONS]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const categoryButtonLabel =
|
|
||||||
selectedCategories.length === 0
|
|
||||||
? 'All Categories'
|
|
||||||
: selectedCategories.length === 1
|
|
||||||
? selectedCategories[0]
|
|
||||||
: `${selectedCategories.length} Categories`;
|
|
||||||
|
|
||||||
const statusButtonLabel =
|
|
||||||
selectedStatuses.length === 0
|
|
||||||
? 'All Statuses'
|
|
||||||
: selectedStatuses.length === 1
|
|
||||||
? statusDisplayConfig[selectedStatuses[0] as StatusFilterValue]?.label ||
|
|
||||||
selectedStatuses[0]
|
|
||||||
: `${selectedStatuses.length} Statuses`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Panel position="top-left" className="flex items-center gap-2">
|
|
||||||
<TooltipProvider delayDuration={200}>
|
|
||||||
<div className="flex items-center gap-2 p-2 rounded-lg bg-popover/90 backdrop-blur-sm border border-border shadow-lg text-popover-foreground">
|
|
||||||
{/* Category Filter Dropdown */}
|
|
||||||
<Popover>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className={cn(
|
|
||||||
'h-8 px-2 gap-1.5',
|
|
||||||
selectedCategories.length > 0 && 'bg-brand-500/20 text-brand-500'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Filter className="w-4 h-4" />
|
|
||||||
<span className="text-xs max-w-[100px] truncate">{categoryButtonLabel}</span>
|
|
||||||
<ChevronDown className="w-3 h-3 opacity-50" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Filter by Category</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<PopoverContent align="start" className="w-56 p-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="text-xs font-medium text-muted-foreground px-2 py-1">
|
|
||||||
Categories
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Select All option */}
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-accent cursor-pointer"
|
|
||||||
onClick={handleSelectAllCategories}
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
checked={
|
|
||||||
selectedCategories.length === availableCategories.length &&
|
|
||||||
availableCategories.length > 0
|
|
||||||
}
|
|
||||||
onCheckedChange={handleSelectAllCategories}
|
|
||||||
/>
|
|
||||||
<span className="text-sm font-medium">
|
|
||||||
{selectedCategories.length === availableCategories.length
|
|
||||||
? 'Deselect All'
|
|
||||||
: 'Select All'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="h-px bg-border" />
|
|
||||||
|
|
||||||
{/* Category list */}
|
|
||||||
<div className="max-h-48 overflow-y-auto space-y-0.5">
|
|
||||||
{availableCategories.length === 0 ? (
|
|
||||||
<div className="text-xs text-muted-foreground px-2 py-2">
|
|
||||||
No categories available
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
availableCategories.map((category) => (
|
|
||||||
<div
|
|
||||||
key={category}
|
|
||||||
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-accent cursor-pointer"
|
|
||||||
onClick={() => handleCategoryToggle(category)}
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
checked={selectedCategories.includes(category)}
|
|
||||||
onCheckedChange={() => handleCategoryToggle(category)}
|
|
||||||
/>
|
|
||||||
<span className="text-sm truncate">{category}</span>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
|
|
||||||
{/* Status Filter Dropdown */}
|
|
||||||
<Popover>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className={cn(
|
|
||||||
'h-8 px-2 gap-1.5',
|
|
||||||
selectedStatuses.length > 0 && 'bg-brand-500/20 text-brand-500'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<CircleDot className="w-4 h-4" />
|
|
||||||
<span className="text-xs max-w-[120px] truncate">{statusButtonLabel}</span>
|
|
||||||
<ChevronDown className="w-3 h-3 opacity-50" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Filter by Status</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<PopoverContent align="start" className="w-56 p-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="text-xs font-medium text-muted-foreground px-2 py-1">Status</div>
|
|
||||||
|
|
||||||
{/* Select All option */}
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-accent cursor-pointer"
|
|
||||||
onClick={handleSelectAllStatuses}
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
checked={selectedStatuses.length === STATUS_FILTER_OPTIONS.length}
|
|
||||||
onCheckedChange={handleSelectAllStatuses}
|
|
||||||
/>
|
|
||||||
<span className="text-sm font-medium">
|
|
||||||
{selectedStatuses.length === STATUS_FILTER_OPTIONS.length
|
|
||||||
? 'Deselect All'
|
|
||||||
: 'Select All'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="h-px bg-border" />
|
|
||||||
|
|
||||||
{/* Status list */}
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
{STATUS_FILTER_OPTIONS.map((status) => {
|
|
||||||
const config = statusDisplayConfig[status];
|
|
||||||
const StatusIcon = config.icon;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={status}
|
|
||||||
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-accent cursor-pointer"
|
|
||||||
onClick={() => handleStatusToggle(status)}
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
checked={selectedStatuses.includes(status)}
|
|
||||||
onCheckedChange={() => handleStatusToggle(status)}
|
|
||||||
/>
|
|
||||||
<StatusIcon className={cn('w-3.5 h-3.5', config.colorClass)} />
|
|
||||||
<span className="text-sm">{config.label}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
|
|
||||||
{/* Divider */}
|
|
||||||
<div className="h-6 w-px bg-border" />
|
|
||||||
|
|
||||||
{/* Positive/Negative Filter Toggle */}
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => onNegativeFilterChange(!isNegativeFilter)}
|
|
||||||
aria-label={
|
|
||||||
isNegativeFilter
|
|
||||||
? 'Switch to show matching nodes'
|
|
||||||
: 'Switch to hide matching nodes'
|
|
||||||
}
|
|
||||||
aria-pressed={isNegativeFilter}
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-1.5 px-2 py-1 rounded text-xs transition-colors',
|
|
||||||
isNegativeFilter
|
|
||||||
? 'bg-orange-500/20 text-orange-500'
|
|
||||||
: 'hover:bg-accent text-muted-foreground hover:text-foreground'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isNegativeFilter ? (
|
|
||||||
<>
|
|
||||||
<EyeOff className="w-3.5 h-3.5" />
|
|
||||||
<span>Hide</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Eye className="w-3.5 h-3.5" />
|
|
||||||
<span>Show</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<Switch
|
|
||||||
checked={isNegativeFilter}
|
|
||||||
onCheckedChange={onNegativeFilterChange}
|
|
||||||
aria-label="Toggle between show and hide filter modes"
|
|
||||||
className="h-5 w-9 data-[state=checked]:bg-orange-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
{isNegativeFilter
|
|
||||||
? 'Negative filter: Highlighting non-matching nodes'
|
|
||||||
: 'Positive filter: Highlighting matching nodes'}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
{/* Clear Filters Button - only show when filters are active */}
|
|
||||||
{hasActiveFilter && (
|
|
||||||
<>
|
|
||||||
<div className="h-6 w-px bg-border" />
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive"
|
|
||||||
onClick={onClearFilters}
|
|
||||||
aria-label="Clear all filters"
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Clear All Filters</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TooltipProvider>
|
|
||||||
</Panel>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
import { Panel } from '@xyflow/react';
|
import { Panel } from '@xyflow/react';
|
||||||
import { Clock, Play, Pause, CheckCircle2, Lock, AlertCircle } from 'lucide-react';
|
import {
|
||||||
|
Clock,
|
||||||
|
Play,
|
||||||
|
Pause,
|
||||||
|
CheckCircle2,
|
||||||
|
Lock,
|
||||||
|
AlertCircle,
|
||||||
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const legendItems = [
|
const legendItems = [
|
||||||
@@ -44,7 +51,7 @@ const legendItems = [
|
|||||||
export function GraphLegend() {
|
export function GraphLegend() {
|
||||||
return (
|
return (
|
||||||
<Panel position="bottom-right" className="pointer-events-none">
|
<Panel position="bottom-right" className="pointer-events-none">
|
||||||
<div className="flex flex-wrap gap-3 p-2 rounded-lg bg-popover/90 backdrop-blur-sm border border-border shadow-lg pointer-events-auto text-popover-foreground">
|
<div className="flex flex-wrap gap-3 p-2 rounded-lg bg-popover/90 backdrop-blur-sm border border-border shadow-lg pointer-events-auto">
|
||||||
{legendItems.map((item) => {
|
{legendItems.map((item) => {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
return (
|
return (
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user