mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 08:13:37 +00:00
Compare commits
1 Commits
security-s
...
new-ui-bas
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
379976aba7 |
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) {
|
|
||||||
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);
|
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,35 +1,17 @@
|
|||||||
/**
|
/**
|
||||||
* 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({
|
|
||||||
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);
|
deleteToken(token);
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
36
apps/ui/src/components/layout/app-layout.tsx
Normal file
36
apps/ui/src/components/layout/app-layout.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { ReactNode } from 'react';
|
||||||
|
import { Sidebar } from './sidebar';
|
||||||
|
// TopHeader removed from layout to be view-specific
|
||||||
|
|
||||||
|
interface AppLayoutProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppLayout({ children }: AppLayoutProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen w-full relative selection:bg-brand-cyan selection:text-black font-sans bg-dark-950 overflow-hidden">
|
||||||
|
{/* Ambient Background */}
|
||||||
|
<div
|
||||||
|
className="fixed bottom-[-25%] left-[-15%] w-[1000px] h-[1000px] opacity-80 pointer-events-none z-0 blob-rainbow"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
'radial-gradient(circle at center, rgba(6, 182, 212, 0.15) 0%, rgba(59, 130, 246, 0.12) 30%, rgba(249, 115, 22, 0.08) 60%, transparent 80%)',
|
||||||
|
filter: 'blur(100px)',
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
className="fixed top-[-20%] right-[-10%] w-[700px] h-[700px] pointer-events-none z-0"
|
||||||
|
style={{
|
||||||
|
background: 'radial-gradient(circle, rgba(16, 185, 129, 0.05) 0%, transparent 70%)',
|
||||||
|
filter: 'blur(100px)',
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<Sidebar />
|
||||||
|
|
||||||
|
<main className="flex-1 flex flex-col min-w-0 relative z-10 h-full">
|
||||||
|
<div className="flex-1 overflow-hidden relative">{children}</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,372 +1,213 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import React from 'react';
|
||||||
import { useNavigate, useLocation } from '@tanstack/react-router';
|
import {
|
||||||
|
Code2,
|
||||||
|
PanelLeft,
|
||||||
|
Plus,
|
||||||
|
Folder,
|
||||||
|
Bell,
|
||||||
|
FolderOpen,
|
||||||
|
MoreVertical,
|
||||||
|
LayoutGrid,
|
||||||
|
Bot,
|
||||||
|
FileJson,
|
||||||
|
BookOpen,
|
||||||
|
UserCircle,
|
||||||
|
TerminalSquare,
|
||||||
|
Book,
|
||||||
|
Activity,
|
||||||
|
Settings,
|
||||||
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useAppStore, type ThemeMode } from '@/store/app-store';
|
import { Link, useLocation } from '@tanstack/react-router';
|
||||||
import { useKeyboardShortcuts, useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
|
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
|
||||||
import { initializeProject, hasAppSpec, hasAutomakerDir } from '@/lib/project-init';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { DeleteProjectDialog } from '@/components/views/settings-view/components/delete-project-dialog';
|
|
||||||
import { NewProjectModal } from '@/components/dialogs/new-project-modal';
|
|
||||||
import { CreateSpecDialog } from '@/components/views/spec-view/dialogs';
|
|
||||||
|
|
||||||
// Local imports from subfolder
|
|
||||||
import {
|
|
||||||
CollapseToggleButton,
|
|
||||||
SidebarHeader,
|
|
||||||
ProjectActions,
|
|
||||||
SidebarNavigation,
|
|
||||||
ProjectSelectorWithOptions,
|
|
||||||
SidebarFooter,
|
|
||||||
} from './sidebar/components';
|
|
||||||
import { TrashDialog, OnboardingDialog } from './sidebar/dialogs';
|
|
||||||
import { SIDEBAR_FEATURE_FLAGS } from './sidebar/constants';
|
|
||||||
import {
|
|
||||||
useSidebarAutoCollapse,
|
|
||||||
useRunningAgents,
|
|
||||||
useSpecRegeneration,
|
|
||||||
useNavigation,
|
|
||||||
useProjectCreation,
|
|
||||||
useSetupDialog,
|
|
||||||
useTrashDialog,
|
|
||||||
useProjectTheme,
|
|
||||||
useUnviewedValidations,
|
|
||||||
} from './sidebar/hooks';
|
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const navigate = useNavigate();
|
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const {
|
|
||||||
projects,
|
|
||||||
trashedProjects,
|
|
||||||
currentProject,
|
|
||||||
sidebarOpen,
|
|
||||||
projectHistory,
|
|
||||||
upsertAndSetCurrentProject,
|
|
||||||
toggleSidebar,
|
|
||||||
restoreTrashedProject,
|
|
||||||
deleteTrashedProject,
|
|
||||||
emptyTrash,
|
|
||||||
cyclePrevProject,
|
|
||||||
cycleNextProject,
|
|
||||||
moveProjectToTrash,
|
|
||||||
specCreatingForProject,
|
|
||||||
setSpecCreatingForProject,
|
|
||||||
} = useAppStore();
|
|
||||||
|
|
||||||
// Environment variable flags for hiding sidebar items
|
|
||||||
const { hideTerminal, hideWiki, hideRunningAgents, hideContext, hideSpecEditor, hideAiProfiles } =
|
|
||||||
SIDEBAR_FEATURE_FLAGS;
|
|
||||||
|
|
||||||
// Get customizable keyboard shortcuts
|
|
||||||
const shortcuts = useKeyboardShortcutsConfig();
|
|
||||||
|
|
||||||
// State for project picker (needed for keyboard shortcuts)
|
|
||||||
const [isProjectPickerOpen, setIsProjectPickerOpen] = useState(false);
|
|
||||||
|
|
||||||
// State for delete project confirmation dialog
|
|
||||||
const [showDeleteProjectDialog, setShowDeleteProjectDialog] = useState(false);
|
|
||||||
|
|
||||||
// Project theme management (must come before useProjectCreation which uses globalTheme)
|
|
||||||
const { globalTheme } = useProjectTheme();
|
|
||||||
|
|
||||||
// Project creation state and handlers
|
|
||||||
const {
|
|
||||||
showNewProjectModal,
|
|
||||||
setShowNewProjectModal,
|
|
||||||
isCreatingProject,
|
|
||||||
showOnboardingDialog,
|
|
||||||
setShowOnboardingDialog,
|
|
||||||
newProjectName,
|
|
||||||
setNewProjectName,
|
|
||||||
newProjectPath,
|
|
||||||
setNewProjectPath,
|
|
||||||
handleCreateBlankProject,
|
|
||||||
handleCreateFromTemplate,
|
|
||||||
handleCreateFromCustomUrl,
|
|
||||||
} = useProjectCreation({
|
|
||||||
trashedProjects,
|
|
||||||
currentProject,
|
|
||||||
globalTheme,
|
|
||||||
upsertAndSetCurrentProject,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Setup dialog state and handlers
|
|
||||||
const {
|
|
||||||
showSetupDialog,
|
|
||||||
setShowSetupDialog,
|
|
||||||
setupProjectPath,
|
|
||||||
setSetupProjectPath,
|
|
||||||
projectOverview,
|
|
||||||
setProjectOverview,
|
|
||||||
generateFeatures,
|
|
||||||
setGenerateFeatures,
|
|
||||||
analyzeProject,
|
|
||||||
setAnalyzeProject,
|
|
||||||
featureCount,
|
|
||||||
setFeatureCount,
|
|
||||||
handleCreateInitialSpec,
|
|
||||||
handleSkipSetup,
|
|
||||||
handleOnboardingGenerateSpec,
|
|
||||||
handleOnboardingSkip,
|
|
||||||
} = useSetupDialog({
|
|
||||||
setSpecCreatingForProject,
|
|
||||||
newProjectPath,
|
|
||||||
setNewProjectName,
|
|
||||||
setNewProjectPath,
|
|
||||||
setShowOnboardingDialog,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Derive isCreatingSpec from store state
|
|
||||||
const isCreatingSpec = specCreatingForProject !== null;
|
|
||||||
const creatingSpecProjectPath = specCreatingForProject;
|
|
||||||
|
|
||||||
// Auto-collapse sidebar on small screens and update Electron window minWidth
|
|
||||||
useSidebarAutoCollapse({ sidebarOpen, toggleSidebar });
|
|
||||||
|
|
||||||
// Running agents count
|
|
||||||
const { runningAgentsCount } = useRunningAgents();
|
|
||||||
|
|
||||||
// Unviewed validations count
|
|
||||||
const { count: unviewedValidationsCount } = useUnviewedValidations(currentProject);
|
|
||||||
|
|
||||||
// Trash dialog and operations
|
|
||||||
const {
|
|
||||||
showTrashDialog,
|
|
||||||
setShowTrashDialog,
|
|
||||||
activeTrashId,
|
|
||||||
isEmptyingTrash,
|
|
||||||
handleRestoreProject,
|
|
||||||
handleDeleteProjectFromDisk,
|
|
||||||
handleEmptyTrash,
|
|
||||||
} = useTrashDialog({
|
|
||||||
restoreTrashedProject,
|
|
||||||
deleteTrashedProject,
|
|
||||||
emptyTrash,
|
|
||||||
trashedProjects,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Spec regeneration events
|
|
||||||
useSpecRegeneration({
|
|
||||||
creatingSpecProjectPath,
|
|
||||||
setupProjectPath,
|
|
||||||
setSpecCreatingForProject,
|
|
||||||
setShowSetupDialog,
|
|
||||||
setProjectOverview,
|
|
||||||
setSetupProjectPath,
|
|
||||||
setNewProjectName,
|
|
||||||
setNewProjectPath,
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Opens the system folder selection dialog and initializes the selected project.
|
|
||||||
* Used by both the 'O' keyboard shortcut and the folder icon button.
|
|
||||||
*/
|
|
||||||
const handleOpenFolder = useCallback(async () => {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
const result = await api.openDirectory();
|
|
||||||
|
|
||||||
if (!result.canceled && result.filePaths[0]) {
|
|
||||||
const path = result.filePaths[0];
|
|
||||||
// Extract folder name from path (works on both Windows and Mac/Linux)
|
|
||||||
const name = path.split(/[/\\]/).filter(Boolean).pop() || 'Untitled Project';
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Check if this is a brand new project (no .automaker directory)
|
|
||||||
const hadAutomakerDir = await hasAutomakerDir(path);
|
|
||||||
|
|
||||||
// Initialize the .automaker directory structure
|
|
||||||
const initResult = await initializeProject(path);
|
|
||||||
|
|
||||||
if (!initResult.success) {
|
|
||||||
toast.error('Failed to initialize project', {
|
|
||||||
description: initResult.error || 'Unknown error occurred',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upsert project and set as current (handles both create and update cases)
|
|
||||||
// Theme preservation is handled by the store action
|
|
||||||
const trashedProject = trashedProjects.find((p) => p.path === path);
|
|
||||||
const effectiveTheme =
|
|
||||||
(trashedProject?.theme as ThemeMode | undefined) ||
|
|
||||||
(currentProject?.theme as ThemeMode | undefined) ||
|
|
||||||
globalTheme;
|
|
||||||
upsertAndSetCurrentProject(path, name, effectiveTheme);
|
|
||||||
|
|
||||||
// Check if app_spec.txt exists
|
|
||||||
const specExists = await hasAppSpec(path);
|
|
||||||
|
|
||||||
if (!hadAutomakerDir && !specExists) {
|
|
||||||
// This is a brand new project - show setup dialog
|
|
||||||
setSetupProjectPath(path);
|
|
||||||
setShowSetupDialog(true);
|
|
||||||
toast.success('Project opened', {
|
|
||||||
description: `Opened ${name}. Let's set up your app specification!`,
|
|
||||||
});
|
|
||||||
} else if (initResult.createdFiles && initResult.createdFiles.length > 0) {
|
|
||||||
toast.success(initResult.isNewProject ? 'Project initialized' : 'Project updated', {
|
|
||||||
description: `Set up ${initResult.createdFiles.length} file(s) in .automaker`,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
toast.success('Project opened', {
|
|
||||||
description: `Opened ${name}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Sidebar] Failed to open project:', error);
|
|
||||||
toast.error('Failed to open project', {
|
|
||||||
description: error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [trashedProjects, upsertAndSetCurrentProject, currentProject, globalTheme]);
|
|
||||||
|
|
||||||
// Navigation sections and keyboard shortcuts (defined after handlers)
|
|
||||||
const { navSections, navigationShortcuts } = useNavigation({
|
|
||||||
shortcuts,
|
|
||||||
hideSpecEditor,
|
|
||||||
hideContext,
|
|
||||||
hideTerminal,
|
|
||||||
hideAiProfiles,
|
|
||||||
currentProject,
|
|
||||||
projects,
|
|
||||||
projectHistory,
|
|
||||||
navigate,
|
|
||||||
toggleSidebar,
|
|
||||||
handleOpenFolder,
|
|
||||||
setIsProjectPickerOpen,
|
|
||||||
cyclePrevProject,
|
|
||||||
cycleNextProject,
|
|
||||||
unviewedValidationsCount,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Register keyboard shortcuts
|
|
||||||
useKeyboardShortcuts(navigationShortcuts);
|
|
||||||
|
|
||||||
const isActiveRoute = (id: string) => {
|
|
||||||
// Map view IDs to route paths
|
|
||||||
const routePath = id === 'welcome' ? '/' : `/${id}`;
|
|
||||||
return location.pathname === routePath;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside className="w-[260px] flex-shrink-0 flex flex-col glass-sidebar z-30 relative h-full">
|
||||||
className={cn(
|
{/* Logo */}
|
||||||
'flex-shrink-0 flex flex-col z-30 relative',
|
<div className="h-16 flex items-center px-6 gap-3 flex-shrink-0">
|
||||||
// Glass morphism background with gradient
|
<div className="text-brand-cyan relative flex items-center justify-center">
|
||||||
'bg-gradient-to-b from-sidebar/95 via-sidebar/85 to-sidebar/90 backdrop-blur-2xl',
|
<div className="absolute inset-0 bg-brand-cyan blur-md opacity-30"></div>
|
||||||
// Premium border with subtle glow
|
<Code2 className="w-6 h-6 relative z-10" />
|
||||||
'border-r border-border/60 shadow-[1px_0_20px_-5px_rgba(0,0,0,0.1)]',
|
</div>
|
||||||
// Smooth width transition
|
<span className="text-white font-bold text-lg tracking-tight">automaker.</span>
|
||||||
'transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]',
|
<button className="ml-auto text-slate-600 hover:text-white transition">
|
||||||
sidebarOpen ? 'w-16 lg:w-72' : 'w-16'
|
<PanelLeft className="w-4 h-4" />
|
||||||
)}
|
</button>
|
||||||
data-testid="sidebar"
|
|
||||||
>
|
|
||||||
<CollapseToggleButton
|
|
||||||
sidebarOpen={sidebarOpen}
|
|
||||||
toggleSidebar={toggleSidebar}
|
|
||||||
shortcut={shortcuts.toggleSidebar}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
|
||||||
<SidebarHeader sidebarOpen={sidebarOpen} navigate={navigate} />
|
|
||||||
|
|
||||||
{/* Project Actions - Moved above project selector */}
|
|
||||||
{sidebarOpen && (
|
|
||||||
<ProjectActions
|
|
||||||
setShowNewProjectModal={setShowNewProjectModal}
|
|
||||||
handleOpenFolder={handleOpenFolder}
|
|
||||||
setShowTrashDialog={setShowTrashDialog}
|
|
||||||
trashedProjects={trashedProjects}
|
|
||||||
shortcuts={{ openProject: shortcuts.openProject }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ProjectSelectorWithOptions
|
|
||||||
sidebarOpen={sidebarOpen}
|
|
||||||
isProjectPickerOpen={isProjectPickerOpen}
|
|
||||||
setIsProjectPickerOpen={setIsProjectPickerOpen}
|
|
||||||
setShowDeleteProjectDialog={setShowDeleteProjectDialog}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SidebarNavigation
|
|
||||||
currentProject={currentProject}
|
|
||||||
sidebarOpen={sidebarOpen}
|
|
||||||
navSections={navSections}
|
|
||||||
isActiveRoute={isActiveRoute}
|
|
||||||
navigate={navigate}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SidebarFooter
|
{/* Top Actions */}
|
||||||
sidebarOpen={sidebarOpen}
|
<div className="px-5 pb-6 space-y-4 flex-shrink-0">
|
||||||
isActiveRoute={isActiveRoute}
|
<div className="grid grid-cols-4 gap-2">
|
||||||
navigate={navigate}
|
<button className="col-span-2 bg-dark-850/60 hover:bg-dark-700 text-slate-200 py-2 px-3 rounded-lg border border-white/5 flex items-center justify-center gap-2 transition text-xs font-medium shadow-lg shadow-black/20 group">
|
||||||
hideWiki={hideWiki}
|
<Plus className="w-3.5 h-3.5 group-hover:text-brand-cyan transition-colors" /> New
|
||||||
hideRunningAgents={hideRunningAgents}
|
</button>
|
||||||
runningAgentsCount={runningAgentsCount}
|
<button className="col-span-1 bg-dark-850/60 hover:bg-dark-700 text-slate-400 hover:text-white py-2 rounded-lg border border-white/5 flex items-center justify-center transition">
|
||||||
shortcuts={{ settings: shortcuts.settings }}
|
<Folder className="w-3.5 h-3.5" />
|
||||||
/>
|
<span className="ml-1 text-[10px]">0</span>
|
||||||
<TrashDialog
|
</button>
|
||||||
open={showTrashDialog}
|
<button className="col-span-1 bg-dark-850/60 hover:bg-dark-700 text-slate-400 hover:text-white py-2 rounded-lg border border-white/5 flex items-center justify-center transition relative">
|
||||||
onOpenChange={setShowTrashDialog}
|
<Bell className="w-3.5 h-3.5" />
|
||||||
trashedProjects={trashedProjects}
|
<span className="absolute top-2 right-2.5 w-1.5 h-1.5 bg-brand-red rounded-full ring-2 ring-dark-850"></span>
|
||||||
activeTrashId={activeTrashId}
|
</button>
|
||||||
handleRestoreProject={handleRestoreProject}
|
</div>
|
||||||
handleDeleteProjectFromDisk={handleDeleteProjectFromDisk}
|
|
||||||
deleteTrashedProject={deleteTrashedProject}
|
|
||||||
handleEmptyTrash={handleEmptyTrash}
|
|
||||||
isEmptyingTrash={isEmptyingTrash}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* New Project Setup Dialog */}
|
{/* Project Selector */}
|
||||||
<CreateSpecDialog
|
<div className="bg-dark-850/40 border border-white/5 rounded-xl p-1 flex items-center justify-between cursor-pointer hover:border-white/10 hover:bg-dark-850/60 transition group">
|
||||||
open={showSetupDialog}
|
<div className="flex items-center gap-3 px-2 py-1.5">
|
||||||
onOpenChange={setShowSetupDialog}
|
<FolderOpen className="w-4 h-4 text-brand-cyan group-hover:text-cyan-300 transition" />
|
||||||
projectOverview={projectOverview}
|
<span className="text-white font-medium text-sm">test case 1</span>
|
||||||
onProjectOverviewChange={setProjectOverview}
|
</div>
|
||||||
generateFeatures={generateFeatures}
|
<div className="flex items-center gap-1 pr-1">
|
||||||
onGenerateFeaturesChange={setGenerateFeatures}
|
<span className="w-5 h-5 rounded bg-dark-700 flex items-center justify-center text-[10px] text-slate-400 font-bold border border-white/5">
|
||||||
analyzeProject={analyzeProject}
|
P
|
||||||
onAnalyzeProjectChange={setAnalyzeProject}
|
</span>
|
||||||
featureCount={featureCount}
|
<MoreVertical className="w-4 h-4 text-slate-500" />
|
||||||
onFeatureCountChange={setFeatureCount}
|
</div>
|
||||||
onCreateSpec={handleCreateInitialSpec}
|
</div>
|
||||||
onSkip={handleSkipSetup}
|
</div>
|
||||||
isCreatingSpec={isCreatingSpec}
|
|
||||||
showSkipButton={true}
|
|
||||||
title="Set Up Your Project"
|
|
||||||
description="We didn't find an app_spec.txt file. Let us help you generate your app_spec.txt to help describe your project for our system. We'll analyze your project's tech stack and create a comprehensive specification."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<OnboardingDialog
|
{/* Navigation */}
|
||||||
open={showOnboardingDialog}
|
<div className="flex-1 overflow-y-auto px-0 space-y-6 custom-scrollbar">
|
||||||
onOpenChange={setShowOnboardingDialog}
|
{/* Project Section */}
|
||||||
newProjectName={newProjectName}
|
<div>
|
||||||
onSkip={handleOnboardingSkip}
|
<h3 className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-2 px-6 font-mono">
|
||||||
onGenerateSpec={handleOnboardingGenerateSpec}
|
Project
|
||||||
|
</h3>
|
||||||
|
<nav className="space-y-0.5">
|
||||||
|
<NavItem
|
||||||
|
to="/"
|
||||||
|
icon={<LayoutGrid className="w-4 h-4" />}
|
||||||
|
label="Kanban Board"
|
||||||
|
shortcut="L"
|
||||||
|
isActive={location.pathname === '/' || location.pathname === '/board'}
|
||||||
/>
|
/>
|
||||||
|
<NavItem
|
||||||
|
to="/agents"
|
||||||
|
icon={<Bot className="w-4 h-4" />}
|
||||||
|
label="Agent Runner"
|
||||||
|
shortcut="A"
|
||||||
|
isActive={location.pathname.startsWith('/agents')}
|
||||||
|
/>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Delete Project Confirmation Dialog */}
|
{/* Tools Section */}
|
||||||
<DeleteProjectDialog
|
<div>
|
||||||
open={showDeleteProjectDialog}
|
<h3 className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-2 px-6 font-mono">
|
||||||
onOpenChange={setShowDeleteProjectDialog}
|
Tools
|
||||||
project={currentProject}
|
</h3>
|
||||||
onConfirm={moveProjectToTrash}
|
<nav className="space-y-0.5">
|
||||||
|
<NavItem
|
||||||
|
to="/spec"
|
||||||
|
icon={<FileJson className="w-4 h-4" />}
|
||||||
|
label="Spec Editor"
|
||||||
|
shortcut="D"
|
||||||
|
isActive={location.pathname.startsWith('/spec')}
|
||||||
/>
|
/>
|
||||||
|
<NavItem
|
||||||
|
to="/context"
|
||||||
|
icon={<BookOpen className="w-4 h-4" />}
|
||||||
|
label="Context"
|
||||||
|
shortcut="C"
|
||||||
|
isActive={location.pathname.startsWith('/context')}
|
||||||
|
/>
|
||||||
|
<NavItem
|
||||||
|
to="/profiles"
|
||||||
|
icon={<UserCircle className="w-4 h-4" />}
|
||||||
|
label="AI Profiles"
|
||||||
|
shortcut="H"
|
||||||
|
isActive={location.pathname.startsWith('/profiles')}
|
||||||
|
/>
|
||||||
|
<NavItem
|
||||||
|
to="/terminal"
|
||||||
|
icon={<TerminalSquare className="w-4 h-4" />}
|
||||||
|
label="Terminal"
|
||||||
|
shortcut="T"
|
||||||
|
isActive={location.pathname.startsWith('/terminal')}
|
||||||
|
/>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* New Project Modal */}
|
{/* Footer */}
|
||||||
<NewProjectModal
|
<div className="p-4 border-t border-white/5 space-y-1 bg-dark-900/30 flex-shrink-0 backdrop-blur-sm">
|
||||||
open={showNewProjectModal}
|
<Link
|
||||||
onOpenChange={setShowNewProjectModal}
|
to="/wiki"
|
||||||
onCreateBlankProject={handleCreateBlankProject}
|
className="flex items-center gap-3 px-3 py-2 rounded-lg text-slate-400 hover:text-white hover:bg-white/5 transition"
|
||||||
onCreateFromTemplate={handleCreateFromTemplate}
|
>
|
||||||
onCreateFromCustomUrl={handleCreateFromCustomUrl}
|
<Book className="w-4 h-4" />
|
||||||
isCreating={isCreatingProject}
|
<span className="text-sm">Wiki</span>
|
||||||
/>
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/running-agents"
|
||||||
|
className="flex items-center justify-between px-3 py-2 rounded-lg text-slate-400 hover:text-white hover:bg-white/5 transition"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Activity className="w-4 h-4 text-brand-cyan" />
|
||||||
|
<span className="text-sm">Running Agents</span>
|
||||||
|
</div>
|
||||||
|
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-brand-cyan text-[10px] text-black font-bold shadow-glow-cyan">
|
||||||
|
3
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/settings"
|
||||||
|
className="flex items-center justify-between px-3 py-2 rounded-lg text-slate-400 hover:text-white hover:bg-white/5 transition"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Settings className="w-4 h-4" />
|
||||||
|
<span className="text-sm">Settings</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] bg-dark-700 text-slate-500 px-1.5 py-0.5 rounded font-mono border border-white/5">
|
||||||
|
S
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function NavItem({
|
||||||
|
to,
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
shortcut,
|
||||||
|
isActive,
|
||||||
|
}: {
|
||||||
|
to: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
shortcut: string;
|
||||||
|
isActive: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={to}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-between px-6 py-2.5 transition group border-l-[2px]',
|
||||||
|
isActive
|
||||||
|
? 'nav-item-active bg-gradient-to-r from-brand-cyan/10 to-transparent border-brand-cyan text-brand-cyan-hover'
|
||||||
|
: 'text-slate-400 hover:text-white hover:bg-white/5 border-transparent'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className={cn(isActive ? 'text-brand-cyan' : 'group-hover:text-slate-300')}>
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium">{label}</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-[10px] px-1.5 py-0.5 rounded font-mono border',
|
||||||
|
isActive
|
||||||
|
? 'bg-brand-cyan/10 text-brand-cyan border-brand-cyan/20'
|
||||||
|
: 'bg-dark-700 text-slate-500 border-white/5 group-hover:text-slate-300'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{shortcut}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -78,7 +78,6 @@ 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',
|
||||||
@@ -87,20 +86,6 @@ export function SidebarNavigation({
|
|||||||
: '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',
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
38
apps/ui/src/components/layout/top-header.tsx
Normal file
38
apps/ui/src/components/layout/top-header.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { Users, Play, Plus } from 'lucide-react';
|
||||||
|
|
||||||
|
export function TopHeader() {
|
||||||
|
return (
|
||||||
|
<header className="h-16 glass-header flex items-center justify-between px-8 flex-shrink-0 z-20">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-white font-bold text-lg tracking-tight">Kanban Board</h1>
|
||||||
|
<p className="text-xs text-slate-500 font-medium font-mono mt-0.5">test case 1</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{/* User Toggle */}
|
||||||
|
<div className="flex items-center bg-dark-850/60 rounded-lg p-1 border border-white/5 h-9 shadow-inner-light">
|
||||||
|
<div className="flex items-center gap-3 px-2 border-r border-white/5 h-full mr-2">
|
||||||
|
<Users className="w-3.5 h-3.5 text-slate-400" />
|
||||||
|
{/* Toggle Switch */}
|
||||||
|
<div className="w-[28px] h-[16px] bg-[#2d3546] rounded-full relative cursor-pointer border border-white/10 transition-colors">
|
||||||
|
<div className="absolute top-[2px] right-[2px] w-[10px] h-[10px] bg-brand-cyan rounded-full shadow-[0_0_6px_rgba(6,182,212,0.6)]"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-slate-400 px-1 font-mono">3</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Auto Mode */}
|
||||||
|
<button className="flex items-center gap-2 text-slate-300 hover:text-white px-3 py-1.5 rounded-lg border border-white/5 bg-dark-850/60 hover:bg-dark-700 transition text-xs font-medium h-9">
|
||||||
|
<Play className="w-3.5 h-3.5 fill-current" />
|
||||||
|
<span>Auto Mode</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Add Feature */}
|
||||||
|
<button className="flex items-center gap-2 bg-brand-cyan hover:bg-cyan-400 text-dark-950 font-bold px-4 py-1.5 rounded-lg transition shadow-glow-cyan text-xs h-9 btn-hover-effect">
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
<span>Add Feature</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
26
apps/ui/src/components/ui/glass-card.tsx
Normal file
26
apps/ui/src/components/ui/glass-card.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { HTMLAttributes, forwardRef } from 'react';
|
||||||
|
|
||||||
|
export interface GlassCardProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
variant?: 'default' | 'active-blue';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GlassCard = forwardRef<HTMLDivElement, GlassCardProps>(
|
||||||
|
({ className, variant = 'default', children, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
variant === 'default' && 'glass-card',
|
||||||
|
variant === 'active-blue' && 'glass-card card-active-blue',
|
||||||
|
'rounded-xl p-4',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
GlassCard.displayName = 'GlassCard';
|
||||||
28
apps/ui/src/components/ui/glass-panel.tsx
Normal file
28
apps/ui/src/components/ui/glass-panel.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { HTMLAttributes, forwardRef } from 'react';
|
||||||
|
|
||||||
|
export interface GlassPanelProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
accent?: 'cyan' | 'blue' | 'orange' | 'green' | 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GlassPanel = forwardRef<HTMLDivElement, GlassPanelProps>(
|
||||||
|
({ className, accent = 'none', children, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'glass-panel rounded-2xl flex flex-col',
|
||||||
|
accent === 'cyan' && 'col-accent-cyan',
|
||||||
|
accent === 'blue' && 'col-accent-blue',
|
||||||
|
accent === 'orange' && 'col-accent-orange',
|
||||||
|
accent === 'green' && 'col-accent-green',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
GlassPanel.displayName = 'GlassPanel';
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
@@ -18,6 +18,9 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
|
import { TopHeader } from '@/components/layout/top-header';
|
||||||
|
import { GlassPanel } from '@/components/ui/glass-panel';
|
||||||
|
import { GlassCard } from '@/components/ui/glass-card';
|
||||||
|
|
||||||
interface ToolResult {
|
interface ToolResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -190,12 +193,20 @@ export function AgentToolsView() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col overflow-hidden content-bg" data-testid="agent-tools-view">
|
<div className="flex-1 flex flex-col overflow-hidden bg-background">
|
||||||
|
<TopHeader />
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden p-6 pt-0">
|
||||||
|
<GlassPanel className="flex-1 flex flex-col overflow-hidden relative shadow-2xl bg-black/40 backdrop-blur-xl border-white/5">
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden p-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-3 p-4 border-b border-border bg-glass backdrop-blur-md">
|
<div className="flex items-center gap-4 mb-6">
|
||||||
<Wrench className="w-5 h-5 text-primary" />
|
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-purple-500/20 to-blue-600/20 border border-purple-500/30 flex items-center justify-center shadow-inner shadow-purple-500/20">
|
||||||
|
<Wrench className="w-6 h-6 text-purple-400" />
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold">Agent Tools</h1>
|
<h1 className="text-2xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-white to-white/70">
|
||||||
|
Agent Tools
|
||||||
|
</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Test file system and terminal tools for {currentProject.name}
|
Test file system and terminal tools for {currentProject.name}
|
||||||
</p>
|
</p>
|
||||||
@@ -203,18 +214,24 @@ export function AgentToolsView() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tools Grid */}
|
{/* Tools Grid */}
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="grid gap-6 md:grid-cols-1 lg:grid-cols-2 xl:grid-cols-3">
|
<div className="grid gap-6 md:grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 max-w-7xl">
|
||||||
{/* Read File Tool */}
|
{/* Read File Tool */}
|
||||||
<Card data-testid="read-file-tool">
|
<GlassCard
|
||||||
<CardHeader>
|
className="flex flex-col gap-4 bg-white/5 border-white/10"
|
||||||
<div className="flex items-center gap-2">
|
data-testid="read-file-tool"
|
||||||
<File className="w-5 h-5 text-blue-500" />
|
>
|
||||||
<CardTitle className="text-lg">Read File</CardTitle>
|
<div className="flex items-center gap-3 pb-2 border-b border-white/5">
|
||||||
|
<div className="p-2 rounded-lg bg-blue-500/10">
|
||||||
|
<File className="w-5 h-5 text-blue-400" />
|
||||||
</div>
|
</div>
|
||||||
<CardDescription>Agent requests to read a file from the filesystem</CardDescription>
|
<div>
|
||||||
</CardHeader>
|
<h3 className="font-semibold text-foreground">Read File</h3>
|
||||||
<CardContent className="space-y-4">
|
<p className="text-xs text-muted-foreground">Read from filesystem</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 flex-1">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="read-file-path">File Path</Label>
|
<Label htmlFor="read-file-path">File Path</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -223,12 +240,13 @@ export function AgentToolsView() {
|
|||||||
value={readFilePath}
|
value={readFilePath}
|
||||||
onChange={(e) => setReadFilePath(e.target.value)}
|
onChange={(e) => setReadFilePath(e.target.value)}
|
||||||
data-testid="read-file-path-input"
|
data-testid="read-file-path-input"
|
||||||
|
className="bg-black/20 border-white/10 focus:border-blue-500/50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleReadFile}
|
onClick={handleReadFile}
|
||||||
disabled={isReadingFile || !readFilePath.trim()}
|
disabled={isReadingFile || !readFilePath.trim()}
|
||||||
className="w-full"
|
className="w-full bg-blue-600/20 hover:bg-blue-600/30 text-blue-400 border border-blue-500/30"
|
||||||
data-testid="read-file-button"
|
data-testid="read-file-button"
|
||||||
>
|
>
|
||||||
{isReadingFile ? (
|
{isReadingFile ? (
|
||||||
@@ -248,41 +266,45 @@ export function AgentToolsView() {
|
|||||||
{readFileResult && (
|
{readFileResult && (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'p-3 rounded-md border',
|
'p-3 rounded-lg border text-xs',
|
||||||
readFileResult.success
|
readFileResult.success
|
||||||
? 'bg-green-500/10 border-green-500/20'
|
? 'bg-green-500/10 border-green-500/20 text-green-300'
|
||||||
: 'bg-red-500/10 border-red-500/20'
|
: 'bg-red-500/10 border-red-500/20 text-red-300'
|
||||||
)}
|
)}
|
||||||
data-testid="read-file-result"
|
data-testid="read-file-result"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2 font-medium">
|
||||||
{readFileResult.success ? (
|
{readFileResult.success ? (
|
||||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
<CheckCircle className="w-4 h-4 text-green-400" />
|
||||||
) : (
|
) : (
|
||||||
<XCircle className="w-4 h-4 text-red-500" />
|
<XCircle className="w-4 h-4 text-red-400" />
|
||||||
)}
|
)}
|
||||||
<span className="text-sm font-medium">
|
<span>{readFileResult.success ? 'Success' : 'Failed'}</span>
|
||||||
{readFileResult.success ? 'Success' : 'Failed'}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<pre className="text-xs overflow-auto max-h-40 whitespace-pre-wrap">
|
<pre className="overflow-auto max-h-40 whitespace-pre-wrap font-mono bg-black/30 p-2 rounded border border-white/5">
|
||||||
{readFileResult.success ? readFileResult.output : readFileResult.error}
|
{readFileResult.success ? readFileResult.output : readFileResult.error}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</GlassCard>
|
||||||
|
|
||||||
{/* Write File Tool */}
|
{/* Write File Tool */}
|
||||||
<Card data-testid="write-file-tool">
|
<GlassCard
|
||||||
<CardHeader>
|
className="flex flex-col gap-4 bg-white/5 border-white/10"
|
||||||
<div className="flex items-center gap-2">
|
data-testid="write-file-tool"
|
||||||
<Pencil className="w-5 h-5 text-green-500" />
|
>
|
||||||
<CardTitle className="text-lg">Write File</CardTitle>
|
<div className="flex items-center gap-3 pb-2 border-b border-white/5">
|
||||||
|
<div className="p-2 rounded-lg bg-green-500/10">
|
||||||
|
<Pencil className="w-5 h-5 text-green-400" />
|
||||||
</div>
|
</div>
|
||||||
<CardDescription>Agent requests to write content to a file</CardDescription>
|
<div>
|
||||||
</CardHeader>
|
<h3 className="font-semibold text-foreground">Write File</h3>
|
||||||
<CardContent className="space-y-4">
|
<p className="text-xs text-muted-foreground">Write to filesystem</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 flex-1">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="write-file-path">File Path</Label>
|
<Label htmlFor="write-file-path">File Path</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -291,6 +313,7 @@ export function AgentToolsView() {
|
|||||||
value={writeFilePath}
|
value={writeFilePath}
|
||||||
onChange={(e) => setWriteFilePath(e.target.value)}
|
onChange={(e) => setWriteFilePath(e.target.value)}
|
||||||
data-testid="write-file-path-input"
|
data-testid="write-file-path-input"
|
||||||
|
className="bg-black/20 border-white/10 focus:border-green-500/50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -300,14 +323,14 @@ export function AgentToolsView() {
|
|||||||
placeholder="File content..."
|
placeholder="File content..."
|
||||||
value={writeFileContent}
|
value={writeFileContent}
|
||||||
onChange={(e) => setWriteFileContent(e.target.value)}
|
onChange={(e) => setWriteFileContent(e.target.value)}
|
||||||
className="w-full min-h-[100px] px-3 py-2 text-sm rounded-md border border-input bg-background resize-y"
|
className="w-full min-h-[100px] px-3 py-2 text-sm rounded-md border border-white/10 bg-black/20 resize-y focus:outline-none focus:ring-1 focus:ring-green-500/50"
|
||||||
data-testid="write-file-content-input"
|
data-testid="write-file-content-input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleWriteFile}
|
onClick={handleWriteFile}
|
||||||
disabled={isWritingFile || !writeFilePath.trim() || !writeFileContent.trim()}
|
disabled={isWritingFile || !writeFilePath.trim() || !writeFileContent.trim()}
|
||||||
className="w-full"
|
className="w-full bg-green-600/20 hover:bg-green-600/30 text-green-400 border border-green-500/30"
|
||||||
data-testid="write-file-button"
|
data-testid="write-file-button"
|
||||||
>
|
>
|
||||||
{isWritingFile ? (
|
{isWritingFile ? (
|
||||||
@@ -327,41 +350,45 @@ export function AgentToolsView() {
|
|||||||
{writeFileResult && (
|
{writeFileResult && (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'p-3 rounded-md border',
|
'p-3 rounded-lg border text-xs',
|
||||||
writeFileResult.success
|
writeFileResult.success
|
||||||
? 'bg-green-500/10 border-green-500/20'
|
? 'bg-green-500/10 border-green-500/20 text-green-300'
|
||||||
: 'bg-red-500/10 border-red-500/20'
|
: 'bg-red-500/10 border-red-500/20 text-red-300'
|
||||||
)}
|
)}
|
||||||
data-testid="write-file-result"
|
data-testid="write-file-result"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2 font-medium">
|
||||||
{writeFileResult.success ? (
|
{writeFileResult.success ? (
|
||||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
<CheckCircle className="w-4 h-4 text-green-400" />
|
||||||
) : (
|
) : (
|
||||||
<XCircle className="w-4 h-4 text-red-500" />
|
<XCircle className="w-4 h-4 text-red-400" />
|
||||||
)}
|
)}
|
||||||
<span className="text-sm font-medium">
|
<span>{writeFileResult.success ? 'Success' : 'Failed'}</span>
|
||||||
{writeFileResult.success ? 'Success' : 'Failed'}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<pre className="text-xs overflow-auto max-h-40 whitespace-pre-wrap">
|
<pre className="overflow-auto max-h-40 whitespace-pre-wrap font-mono bg-black/30 p-2 rounded border border-white/5">
|
||||||
{writeFileResult.success ? writeFileResult.output : writeFileResult.error}
|
{writeFileResult.success ? writeFileResult.output : writeFileResult.error}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</GlassCard>
|
||||||
|
|
||||||
{/* Terminal Tool */}
|
{/* Terminal Tool */}
|
||||||
<Card data-testid="terminal-tool">
|
<GlassCard
|
||||||
<CardHeader>
|
className="flex flex-col gap-4 bg-white/5 border-white/10"
|
||||||
<div className="flex items-center gap-2">
|
data-testid="terminal-tool"
|
||||||
<Terminal className="w-5 h-5 text-purple-500" />
|
>
|
||||||
<CardTitle className="text-lg">Run Terminal</CardTitle>
|
<div className="flex items-center gap-3 pb-2 border-b border-white/5">
|
||||||
|
<div className="p-2 rounded-lg bg-purple-500/10">
|
||||||
|
<Terminal className="w-5 h-5 text-purple-400" />
|
||||||
</div>
|
</div>
|
||||||
<CardDescription>Agent requests to execute a terminal command</CardDescription>
|
<div>
|
||||||
</CardHeader>
|
<h3 className="font-semibold text-foreground">Run Terminal</h3>
|
||||||
<CardContent className="space-y-4">
|
<p className="text-xs text-muted-foreground">Execute shell commands</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 flex-1">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="terminal-command">Command</Label>
|
<Label htmlFor="terminal-command">Command</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -370,12 +397,13 @@ export function AgentToolsView() {
|
|||||||
value={terminalCommand}
|
value={terminalCommand}
|
||||||
onChange={(e) => setTerminalCommand(e.target.value)}
|
onChange={(e) => setTerminalCommand(e.target.value)}
|
||||||
data-testid="terminal-command-input"
|
data-testid="terminal-command-input"
|
||||||
|
className="bg-black/20 border-white/10 focus:border-purple-500/50 font-mono text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleRunCommand}
|
onClick={handleRunCommand}
|
||||||
disabled={isRunningCommand || !terminalCommand.trim()}
|
disabled={isRunningCommand || !terminalCommand.trim()}
|
||||||
className="w-full"
|
className="w-full bg-purple-600/20 hover:bg-purple-600/30 text-purple-400 border border-purple-500/30"
|
||||||
data-testid="run-terminal-button"
|
data-testid="run-terminal-button"
|
||||||
>
|
>
|
||||||
{isRunningCommand ? (
|
{isRunningCommand ? (
|
||||||
@@ -395,42 +423,41 @@ export function AgentToolsView() {
|
|||||||
{terminalResult && (
|
{terminalResult && (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'p-3 rounded-md border',
|
'p-3 rounded-lg border text-xs',
|
||||||
terminalResult.success
|
terminalResult.success
|
||||||
? 'bg-green-500/10 border-green-500/20'
|
? 'bg-green-500/10 border-green-500/20 text-green-300'
|
||||||
: 'bg-red-500/10 border-red-500/20'
|
: 'bg-red-500/10 border-red-500/20 text-red-300'
|
||||||
)}
|
)}
|
||||||
data-testid="terminal-result"
|
data-testid="terminal-result"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2 font-medium">
|
||||||
{terminalResult.success ? (
|
{terminalResult.success ? (
|
||||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
<CheckCircle className="w-4 h-4 text-green-400" />
|
||||||
) : (
|
) : (
|
||||||
<XCircle className="w-4 h-4 text-red-500" />
|
<XCircle className="w-4 h-4 text-red-400" />
|
||||||
)}
|
)}
|
||||||
<span className="text-sm font-medium">
|
<span>{terminalResult.success ? 'Success' : 'Failed'}</span>
|
||||||
{terminalResult.success ? 'Success' : 'Failed'}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<pre className="text-xs overflow-auto max-h-40 whitespace-pre-wrap font-mono bg-black/50 text-green-400 p-2 rounded">
|
<pre className="overflow-auto max-h-40 whitespace-pre-wrap font-mono bg-black/30 p-2 rounded border border-white/5">
|
||||||
$ {terminalCommand}
|
$ {terminalCommand}
|
||||||
{'\n'}
|
{'\n'}
|
||||||
{terminalResult.success ? terminalResult.output : terminalResult.error}
|
{terminalResult.success ? terminalResult.output : terminalResult.error}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</GlassCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tool Log Section */}
|
{/* Tool Log Section */}
|
||||||
<Card className="mt-6" data-testid="tool-log">
|
<GlassCard className="mt-6 bg-white/5 border-white/10" data-testid="tool-log">
|
||||||
<CardHeader>
|
<div className="flex flex-col gap-2">
|
||||||
<CardTitle className="text-lg">Tool Execution Log</CardTitle>
|
<h3 className="font-semibold text-foreground">Tool Execution Log</h3>
|
||||||
<CardDescription>View agent tool requests and responses</CardDescription>
|
<p className="text-sm text-muted-foreground">
|
||||||
</CardHeader>
|
View agent tool requests and responses
|
||||||
<CardContent>
|
</p>
|
||||||
<div className="space-y-2 text-sm">
|
|
||||||
|
<div className="mt-4 space-y-2 text-sm bg-black/20 p-4 rounded-lg border border-white/5">
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Open your browser's developer console to see detailed agent tool logs.
|
Open your browser's developer console to see detailed agent tool logs.
|
||||||
</p>
|
</p>
|
||||||
@@ -440,8 +467,11 @@ export function AgentToolsView() {
|
|||||||
<li>Run Terminal - Agent executes shell commands</li>
|
<li>Run Terminal - Agent executes shell commands</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</GlassCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</GlassPanel>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { CLAUDE_MODELS } from '@/components/views/board-view/shared/model-constants';
|
import { CLAUDE_MODELS } from '@/components/views/board-view/shared/model-constants';
|
||||||
|
import { TopHeader } from '@/components/layout/top-header';
|
||||||
|
import { GlassPanel } from '@/components/ui/glass-panel';
|
||||||
|
|
||||||
export function AgentView() {
|
export function AgentView() {
|
||||||
const { currentProject, setLastSelectedSession, getLastSelectedSession } = useAppStore();
|
const { currentProject, setLastSelectedSession, getLastSelectedSession } = useAppStore();
|
||||||
@@ -491,10 +493,14 @@ export function AgentView() {
|
|||||||
: messages;
|
: messages;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex overflow-hidden bg-background" data-testid="agent-view">
|
<div className="flex flex-col h-full w-full overflow-hidden" data-testid="agent-view">
|
||||||
|
<TopHeader />
|
||||||
|
|
||||||
|
<div className="flex-1 flex overflow-hidden p-4 pt-0 gap-4">
|
||||||
{/* Session Manager Sidebar */}
|
{/* Session Manager Sidebar */}
|
||||||
{showSessionManager && currentProject && (
|
{showSessionManager && currentProject && (
|
||||||
<div className="w-80 border-r border-border flex-shrink-0 bg-card/50">
|
<div className="w-80 flex-shrink-0">
|
||||||
|
<GlassPanel className="h-full flex flex-col overflow-hidden bg-black/40 backdrop-blur-xl border-white/5">
|
||||||
<SessionManager
|
<SessionManager
|
||||||
currentSessionId={currentSessionId}
|
currentSessionId={currentSessionId}
|
||||||
onSelectSession={handleSelectSession}
|
onSelectSession={handleSelectSession}
|
||||||
@@ -502,19 +508,21 @@ export function AgentView() {
|
|||||||
isCurrentSessionThinking={isProcessing}
|
isCurrentSessionThinking={isProcessing}
|
||||||
onQuickCreateRef={quickCreateSessionRef}
|
onQuickCreateRef={quickCreateSessionRef}
|
||||||
/>
|
/>
|
||||||
|
</GlassPanel>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Chat Area */}
|
{/* Chat Area */}
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
|
||||||
|
<GlassPanel className="h-full flex flex-col overflow-hidden shadow-2xl relative">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border bg-card/50 backdrop-blur-sm">
|
<div className="flex items-center justify-between px-4 py-3 border-b border-white/10 bg-white/5 backdrop-blur-md z-20">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-3">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowSessionManager(!showSessionManager)}
|
onClick={() => setShowSessionManager(!showSessionManager)}
|
||||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground hover:bg-white/10"
|
||||||
>
|
>
|
||||||
{showSessionManager ? (
|
{showSessionManager ? (
|
||||||
<PanelLeftClose className="w-4 h-4" />
|
<PanelLeftClose className="w-4 h-4" />
|
||||||
@@ -522,27 +530,33 @@ export function AgentView() {
|
|||||||
<PanelLeft className="w-4 h-4" />
|
<PanelLeft className="w-4 h-4" />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<div className="w-9 h-9 rounded-xl bg-primary/10 flex items-center justify-center">
|
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-cyan-500/20 to-blue-600/20 border border-cyan-500/30 flex items-center justify-center shadow-inner shadow-cyan-500/20">
|
||||||
<Bot className="w-5 h-5 text-primary" />
|
<Bot className="w-4 h-4 text-cyan-400" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-lg font-semibold text-foreground">AI Agent</h1>
|
<h1 className="text-sm font-semibold text-foreground flex items-center gap-2">
|
||||||
<p className="text-sm text-muted-foreground">
|
AI Agent
|
||||||
|
{currentSessionId && !isConnected && (
|
||||||
|
<span className="text-[10px] bg-yellow-500/20 text-yellow-500 px-1.5 py-0.5 rounded-full animate-pulse">
|
||||||
|
Connecting...
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs text-muted-foreground truncate max-w-[200px]">
|
||||||
{currentProject.name}
|
{currentProject.name}
|
||||||
{currentSessionId && !isConnected && ' - Connecting...'}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status indicators & actions */}
|
{/* Status indicators & actions */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-2">
|
||||||
{/* Model Selector */}
|
{/* Model Selector */}
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-8 gap-1.5 text-xs font-medium"
|
className="h-7 gap-1.5 text-xs font-medium bg-black/20 border-white/10 hover:bg-white/5 hover:text-cyan-400"
|
||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
data-testid="model-selector"
|
data-testid="model-selector"
|
||||||
>
|
>
|
||||||
@@ -554,17 +568,25 @@ export function AgentView() {
|
|||||||
<ChevronDown className="w-3 h-3 opacity-50" />
|
<ChevronDown className="w-3 h-3 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-48">
|
<DropdownMenuContent
|
||||||
|
align="end"
|
||||||
|
className="w-56 bg-zinc-950/95 border-white/10 backdrop-blur-xl"
|
||||||
|
>
|
||||||
{CLAUDE_MODELS.map((model) => (
|
{CLAUDE_MODELS.map((model) => (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={model.id}
|
key={model.id}
|
||||||
onClick={() => setSelectedModel(model.id)}
|
onClick={() => setSelectedModel(model.id)}
|
||||||
className={cn('cursor-pointer', selectedModel === model.id && 'bg-accent')}
|
className={cn(
|
||||||
|
'cursor-pointer focus:bg-white/10',
|
||||||
|
selectedModel === model.id && 'bg-cyan-500/10 text-cyan-400'
|
||||||
|
)}
|
||||||
data-testid={`model-option-${model.id}`}
|
data-testid={`model-option-${model.id}`}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col gap-0.5">
|
||||||
<span className="font-medium">{model.label}</span>
|
<span className="font-medium text-xs">{model.label}</span>
|
||||||
<span className="text-xs text-muted-foreground">{model.description}</span>
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{model.description}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
@@ -572,24 +594,26 @@ export function AgentView() {
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
{currentTool && (
|
{currentTool && (
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground bg-muted/50 px-3 py-1.5 rounded-full border border-border">
|
<div className="flex items-center gap-1.5 text-[10px] text-cyan-400 bg-cyan-950/40 px-2.5 py-1 rounded-full border border-cyan-500/30 shadow-sm shadow-cyan-900/20 animate-in fade-in zoom-in-95 duration-300">
|
||||||
<Wrench className="w-3 h-3 text-primary" />
|
<Wrench className="w-3 h-3" />
|
||||||
<span className="font-medium">{currentTool}</span>
|
<span className="font-medium">{currentTool}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{agentError && (
|
{agentError && (
|
||||||
<span className="text-xs text-destructive font-medium">{agentError}</span>
|
<span className="text-xs text-red-400 font-medium bg-red-950/30 px-2 py-0.5 rounded border border-red-500/30">
|
||||||
|
{agentError}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
{currentSessionId && messages.length > 0 && (
|
{currentSessionId && messages.length > 0 && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="icon"
|
||||||
onClick={handleClearChat}
|
onClick={handleClearChat}
|
||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
className="text-muted-foreground hover:text-foreground"
|
className="h-7 w-7 text-muted-foreground hover:text-red-400 hover:bg-red-500/10"
|
||||||
|
title="Clear Chat"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4 mr-2" />
|
<Trash2 className="w-4 h-4" />
|
||||||
Clear
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -605,7 +629,9 @@ export function AgentView() {
|
|||||||
<div className="w-16 h-16 rounded-2xl bg-muted/50 flex items-center justify-center mx-auto mb-6">
|
<div className="w-16 h-16 rounded-2xl bg-muted/50 flex items-center justify-center mx-auto mb-6">
|
||||||
<Bot className="w-8 h-8 text-muted-foreground" />
|
<Bot className="w-8 h-8 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-lg font-semibold mb-3 text-foreground">No Session Selected</h2>
|
<h2 className="text-lg font-semibold mb-3 text-foreground">
|
||||||
|
No Session Selected
|
||||||
|
</h2>
|
||||||
<p className="text-sm text-muted-foreground mb-6 leading-relaxed">
|
<p className="text-sm text-muted-foreground mb-6 leading-relaxed">
|
||||||
Create or select a session to start chatting with the AI agent
|
Create or select a session to start chatting with the AI agent
|
||||||
</p>
|
</p>
|
||||||
@@ -664,7 +690,9 @@ export function AgentView() {
|
|||||||
{message.content}
|
{message.content}
|
||||||
</Markdown>
|
</Markdown>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm whitespace-pre-wrap leading-relaxed">{message.content}</p>
|
<p className="text-sm whitespace-pre-wrap leading-relaxed">
|
||||||
|
{message.content}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Display attached images for user messages */}
|
{/* Display attached images for user messages */}
|
||||||
@@ -765,7 +793,8 @@ export function AgentView() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Selected Files Preview - only show when ImageDropZone is hidden to avoid duplicate display */}
|
{/* Selected Files Preview - only show when ImageDropZone is hidden to avoid duplicate display */}
|
||||||
{(selectedImages.length > 0 || selectedTextFiles.length > 0) && !showImageDropZone && (
|
{(selectedImages.length > 0 || selectedTextFiles.length > 0) &&
|
||||||
|
!showImageDropZone && (
|
||||||
<div className="mb-4 space-y-2">
|
<div className="mb-4 space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-xs font-medium text-foreground">
|
<p className="text-xs font-medium text-foreground">
|
||||||
@@ -869,7 +898,9 @@ export function AgentView() {
|
|||||||
<Input
|
<Input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
placeholder={
|
placeholder={
|
||||||
isDragOver ? 'Drop your files here...' : 'Describe what you want to build...'
|
isDragOver
|
||||||
|
? 'Drop your files here...'
|
||||||
|
: 'Describe what you want to build...'
|
||||||
}
|
}
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
@@ -948,11 +979,15 @@ export function AgentView() {
|
|||||||
{/* Keyboard hint */}
|
{/* Keyboard hint */}
|
||||||
<p className="text-[11px] text-muted-foreground mt-2 text-center">
|
<p className="text-[11px] text-muted-foreground mt-2 text-center">
|
||||||
Press{' '}
|
Press{' '}
|
||||||
<kbd className="px-1.5 py-0.5 bg-muted rounded text-[10px] font-medium">Enter</kbd> to
|
<kbd className="px-1.5 py-0.5 bg-muted rounded text-[10px] font-medium">
|
||||||
send
|
Enter
|
||||||
|
</kbd>{' '}
|
||||||
|
to send
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</GlassPanel>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ 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';
|
||||||
// Board-view specific imports
|
// Board-view specific imports
|
||||||
import { BoardHeader } from './board-view/board-header';
|
import { TopHeader } from '@/components/layout/top-header';
|
||||||
|
// BoardHeader removed
|
||||||
import { BoardSearchBar } from './board-view/board-search-bar';
|
import { BoardSearchBar } from './board-view/board-search-bar';
|
||||||
import { BoardControls } from './board-view/board-controls';
|
import { BoardControls } from './board-view/board-controls';
|
||||||
import { KanbanBoard } from './board-view/kanban-board';
|
import { KanbanBoard } from './board-view/kanban-board';
|
||||||
@@ -263,9 +264,9 @@ export function BoardView() {
|
|||||||
// Calculate unarchived card counts per branch
|
// Calculate unarchived card counts per branch
|
||||||
const branchCardCounts = useMemo(() => {
|
const branchCardCounts = useMemo(() => {
|
||||||
return hookFeatures.reduce(
|
return hookFeatures.reduce(
|
||||||
(counts, feature) => {
|
(counts: Record<string, number>, feature) => {
|
||||||
if (feature.status !== 'completed') {
|
if (feature.status !== 'completed') {
|
||||||
const branch = feature.branchName ?? 'main';
|
const branch = (feature.branchName as string) ?? 'main';
|
||||||
counts[branch] = (counts[branch] || 0) + 1;
|
counts[branch] = (counts[branch] || 0) + 1;
|
||||||
}
|
}
|
||||||
return counts;
|
return counts;
|
||||||
@@ -918,27 +919,8 @@ export function BoardView() {
|
|||||||
data-testid="board-view"
|
data-testid="board-view"
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<BoardHeader
|
{/* Top Header */}
|
||||||
projectName={currentProject.name}
|
<TopHeader />
|
||||||
maxConcurrency={maxConcurrency}
|
|
||||||
runningAgentsCount={runningAutoTasks.length}
|
|
||||||
onConcurrencyChange={setMaxConcurrency}
|
|
||||||
isAutoModeRunning={autoMode.isRunning}
|
|
||||||
onAutoModeToggle={(enabled) => {
|
|
||||||
if (enabled) {
|
|
||||||
autoMode.start();
|
|
||||||
} else {
|
|
||||||
autoMode.stop();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onAddFeature={() => setShowAddDialog(true)}
|
|
||||||
addFeatureShortcut={{
|
|
||||||
key: shortcuts.addFeature,
|
|
||||||
action: () => setShowAddDialog(true),
|
|
||||||
description: 'Add new feature',
|
|
||||||
}}
|
|
||||||
isMounted={isMounted}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Worktree Panel */}
|
{/* Worktree Panel */}
|
||||||
<WorktreePanel
|
<WorktreePanel
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ interface CardHeaderProps {
|
|||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onViewOutput?: () => void;
|
onViewOutput?: () => void;
|
||||||
|
hideActions?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CardHeaderSection({
|
export function CardHeaderSection({
|
||||||
@@ -40,6 +41,7 @@ export function CardHeaderSection({
|
|||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onViewOutput,
|
onViewOutput,
|
||||||
|
hideActions,
|
||||||
}: CardHeaderProps) {
|
}: CardHeaderProps) {
|
||||||
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
|
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
|
||||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { memo } from 'react';
|
|||||||
import { useSortable } from '@dnd-kit/sortable';
|
import { useSortable } from '@dnd-kit/sortable';
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { GlassCard } from '@/components/ui/glass-card';
|
||||||
import { Feature, useAppStore } from '@/store/app-store';
|
import { Feature, useAppStore } from '@/store/app-store';
|
||||||
import { CardBadges, PriorityBadges } from './card-badges';
|
import { CardBadges, PriorityBadges } from './card-badges';
|
||||||
import { CardHeaderSection } from './card-header';
|
import { CardHeaderSection } from './card-header';
|
||||||
@@ -56,10 +56,6 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
shortcutKey,
|
shortcutKey,
|
||||||
contextContent,
|
contextContent,
|
||||||
summary,
|
summary,
|
||||||
opacity = 100,
|
|
||||||
glassmorphism = true,
|
|
||||||
cardBorderEnabled = true,
|
|
||||||
cardBorderOpacity = 100,
|
|
||||||
}: KanbanCardProps) {
|
}: KanbanCardProps) {
|
||||||
const { useWorktrees } = useAppStore();
|
const { useWorktrees } = useAppStore();
|
||||||
|
|
||||||
@@ -68,6 +64,7 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
feature.status === 'waiting_approval' ||
|
feature.status === 'waiting_approval' ||
|
||||||
feature.status === 'verified' ||
|
feature.status === 'verified' ||
|
||||||
(feature.status === 'in_progress' && !isCurrentAutoTask);
|
(feature.status === 'in_progress' && !isCurrentAutoTask);
|
||||||
|
|
||||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
id: feature.id,
|
id: feature.id,
|
||||||
disabled: !isDraggable,
|
disabled: !isDraggable,
|
||||||
@@ -79,36 +76,15 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
opacity: isDragging ? 0.5 : undefined,
|
opacity: isDragging ? 0.5 : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
const borderStyle: React.CSSProperties = { ...style };
|
|
||||||
if (!cardBorderEnabled) {
|
|
||||||
(borderStyle as Record<string, string>).borderWidth = '0px';
|
|
||||||
(borderStyle as Record<string, string>).borderColor = 'transparent';
|
|
||||||
} else if (cardBorderOpacity !== 100) {
|
|
||||||
(borderStyle as Record<string, string>).borderWidth = '1px';
|
|
||||||
(borderStyle as Record<string, string>).borderColor =
|
|
||||||
`color-mix(in oklch, var(--border) ${cardBorderOpacity}%, transparent)`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cardElement = (
|
const cardElement = (
|
||||||
<Card
|
<GlassCard
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
style={isCurrentAutoTask ? style : borderStyle}
|
variant={isCurrentAutoTask ? 'active-blue' : 'default'}
|
||||||
|
style={style}
|
||||||
className={cn(
|
className={cn(
|
||||||
'cursor-grab active:cursor-grabbing relative kanban-card-content select-none',
|
'group relative min-h-[140px] flex flex-col',
|
||||||
'transition-all duration-200 ease-out',
|
|
||||||
// Premium shadow system
|
|
||||||
'shadow-sm hover:shadow-md hover:shadow-black/10',
|
|
||||||
// Subtle lift on hover
|
|
||||||
'hover:-translate-y-0.5',
|
|
||||||
!isCurrentAutoTask && cardBorderEnabled && cardBorderOpacity === 100 && 'border-border/50',
|
|
||||||
!isCurrentAutoTask && cardBorderEnabled && cardBorderOpacity !== 100 && 'border',
|
|
||||||
!isDragging && 'bg-transparent',
|
|
||||||
!glassmorphism && 'backdrop-blur-[0px]!',
|
|
||||||
isDragging && 'scale-105 shadow-xl shadow-black/20 rotate-1',
|
isDragging && 'scale-105 shadow-xl shadow-black/20 rotate-1',
|
||||||
// Error state - using CSS variable
|
feature.error && 'border-brand-red border-2 shadow-glow-red',
|
||||||
feature.error &&
|
|
||||||
!isCurrentAutoTask &&
|
|
||||||
'border-[var(--status-error)] border-2 shadow-[var(--status-error-bg)] shadow-lg',
|
|
||||||
!isDraggable && 'cursor-default'
|
!isDraggable && 'cursor-default'
|
||||||
)}
|
)}
|
||||||
data-testid={`kanban-card-${feature.id}`}
|
data-testid={`kanban-card-${feature.id}`}
|
||||||
@@ -116,29 +92,44 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
{...attributes}
|
{...attributes}
|
||||||
{...(isDraggable ? listeners : {})}
|
{...(isDraggable ? listeners : {})}
|
||||||
>
|
>
|
||||||
{/* Background overlay with opacity */}
|
{/* Top Row: Empty space + Delete (on hover) */}
|
||||||
{!isDragging && (
|
<div className="flex justify-between items-start mb-2 h-5">
|
||||||
<div
|
<div className="flex flex-wrap gap-1">
|
||||||
className={cn(
|
|
||||||
'absolute inset-0 rounded-xl bg-card -z-10',
|
|
||||||
glassmorphism && 'backdrop-blur-sm'
|
|
||||||
)}
|
|
||||||
style={{ opacity: opacity / 100 }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Status Badges Row */}
|
|
||||||
<CardBadges feature={feature} />
|
<CardBadges feature={feature} />
|
||||||
|
</div>
|
||||||
{/* Category row */}
|
{/* Delete/Actions on hover */}
|
||||||
<div className="px-3 pt-4">
|
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-2">
|
||||||
<span className="text-[11px] text-muted-foreground/70 font-medium">{feature.category}</span>
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete();
|
||||||
|
}}
|
||||||
|
className="text-slate-600 hover:text-brand-red transition"
|
||||||
|
>
|
||||||
|
<i data-lucide="trash" className="w-3.5 h-3.5"></i>
|
||||||
|
{/* Fallback to SVG if i tag fails */}
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="lucide lucide-trash w-3.5 h-3.5"
|
||||||
|
>
|
||||||
|
<path d="M3 6h18" />
|
||||||
|
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
|
||||||
|
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Priority and Manual Verification badges */}
|
{/* Description */}
|
||||||
<PriorityBadges feature={feature} />
|
<div className="mb-4">
|
||||||
|
|
||||||
{/* Card Header */}
|
|
||||||
<CardHeaderSection
|
<CardHeaderSection
|
||||||
feature={feature}
|
feature={feature}
|
||||||
isDraggable={isDraggable}
|
isDraggable={isDraggable}
|
||||||
@@ -146,21 +137,34 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
onEdit={onEdit}
|
onEdit={onEdit}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
onViewOutput={onViewOutput}
|
onViewOutput={onViewOutput}
|
||||||
|
hideActions={true} // We handle actions via hover/bottom bar
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<CardContent className="px-3 pt-0 pb-0">
|
{/* Middle Grid: Priority, etc */}
|
||||||
{/* Content Sections */}
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<PriorityBadges feature={feature} />
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Category / Model info */}
|
||||||
|
<span className="text-[10px] text-brand-cyan font-mono">
|
||||||
|
{feature.model || 'Opus 4.2'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content & Agent Info */}
|
||||||
|
<div className="mb-2">
|
||||||
<CardContentSections feature={feature} useWorktrees={useWorktrees} />
|
<CardContentSections feature={feature} useWorktrees={useWorktrees} />
|
||||||
|
|
||||||
{/* Agent Info Panel */}
|
|
||||||
<AgentInfoPanel
|
<AgentInfoPanel
|
||||||
feature={feature}
|
feature={feature}
|
||||||
contextContent={contextContent}
|
contextContent={contextContent}
|
||||||
summary={summary}
|
summary={summary}
|
||||||
isCurrentAutoTask={isCurrentAutoTask}
|
isCurrentAutoTask={isCurrentAutoTask}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Buttons Grid */}
|
||||||
|
<div className="mt-auto pt-2">
|
||||||
<CardActions
|
<CardActions
|
||||||
feature={feature}
|
feature={feature}
|
||||||
isCurrentAutoTask={!!isCurrentAutoTask}
|
isCurrentAutoTask={!!isCurrentAutoTask}
|
||||||
@@ -178,14 +182,9 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
onViewPlan={onViewPlan}
|
onViewPlan={onViewPlan}
|
||||||
onApprovePlan={onApprovePlan}
|
onApprovePlan={onApprovePlan}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</GlassCard>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Wrap with animated border when in progress
|
|
||||||
if (isCurrentAutoTask) {
|
|
||||||
return <div className="animated-border-wrapper">{cardElement}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return cardElement;
|
return cardElement;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,31 +2,30 @@ import { memo } from 'react';
|
|||||||
import { useDroppable } from '@dnd-kit/core';
|
import { useDroppable } from '@dnd-kit/core';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
import { GlassPanel } from '@/components/ui/glass-panel';
|
||||||
|
|
||||||
interface KanbanColumnProps {
|
interface KanbanColumnProps {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
colorClass: string;
|
accent: 'cyan' | 'blue' | 'orange' | 'green' | 'none';
|
||||||
count: number;
|
count: number;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
headerAction?: ReactNode;
|
headerAction?: ReactNode;
|
||||||
|
width?: number;
|
||||||
|
// Legacy props ignored or used for compatibility
|
||||||
|
colorClass?: string;
|
||||||
opacity?: number;
|
opacity?: number;
|
||||||
showBorder?: boolean;
|
showBorder?: boolean;
|
||||||
hideScrollbar?: boolean;
|
hideScrollbar?: boolean;
|
||||||
/** Custom width in pixels. If not provided, defaults to 288px (w-72) */
|
|
||||||
width?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const KanbanColumn = memo(function KanbanColumn({
|
export const KanbanColumn = memo(function KanbanColumn({
|
||||||
id,
|
id,
|
||||||
title,
|
title,
|
||||||
colorClass,
|
accent,
|
||||||
count,
|
count,
|
||||||
children,
|
children,
|
||||||
headerAction,
|
headerAction,
|
||||||
opacity = 100,
|
|
||||||
showBorder = true,
|
|
||||||
hideScrollbar = false,
|
|
||||||
width,
|
width,
|
||||||
}: KanbanColumnProps) {
|
}: KanbanColumnProps) {
|
||||||
const { setNodeRef, isOver } = useDroppable({ id });
|
const { setNodeRef, isOver } = useDroppable({ id });
|
||||||
@@ -35,60 +34,63 @@ export const KanbanColumn = memo(function KanbanColumn({
|
|||||||
const widthStyle = width ? { width: `${width}px`, flexShrink: 0 } : undefined;
|
const widthStyle = width ? { width: `${width}px`, flexShrink: 0 } : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<GlassPanel
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
|
accent={accent}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative flex flex-col h-full rounded-xl',
|
'relative flex flex-col h-full min-w-[300px] transition-[box-shadow,ring] duration-200',
|
||||||
// Only transition ring/shadow for drag-over effect, not width
|
!width && 'w-72',
|
||||||
'transition-[box-shadow,ring] duration-200',
|
|
||||||
!width && 'w-72', // Only apply w-72 if no custom width
|
|
||||||
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'
|
||||||
)}
|
)}
|
||||||
style={widthStyle}
|
style={widthStyle}
|
||||||
data-testid={`kanban-column-${id}`}
|
data-testid={`kanban-column-${id}`}
|
||||||
>
|
>
|
||||||
{/* Background layer with opacity */}
|
{/* Subtle Glow Top (Only for Blue/Orange/Green to match design, could make generic) */}
|
||||||
|
{(accent === 'blue' || accent === 'orange' || accent === 'green') && (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute inset-0 rounded-xl backdrop-blur-sm transition-colors duration-200',
|
'absolute top-0 left-0 w-full h-32 bg-gradient-to-b pointer-events-none rounded-t-2xl',
|
||||||
isOver ? 'bg-accent/80' : 'bg-card/80'
|
accent === 'blue' && 'from-brand-blue/10 to-transparent',
|
||||||
|
accent === 'orange' && 'from-brand-orange/10 to-transparent',
|
||||||
|
accent === 'green' && 'from-brand-green/10 to-transparent'
|
||||||
|
)}
|
||||||
|
></div>
|
||||||
)}
|
)}
|
||||||
style={{ opacity: opacity / 100 }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Column Header */}
|
{/* Column Header */}
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-white/5 relative z-10">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Status Dot */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative z-10 flex items-center gap-3 px-3 py-2.5',
|
'w-2 h-2 rounded-full',
|
||||||
showBorder && 'border-b border-border/40'
|
accent === 'cyan' && 'bg-slate-400', // Backlog is neutral in design
|
||||||
|
accent === 'blue' && 'bg-brand-orange shadow-glow-orange', // In Progress has orange dot in design
|
||||||
|
accent === 'orange' && 'bg-brand-orange shadow-glow-orange',
|
||||||
|
accent === 'green' && 'bg-brand-green shadow-glow-green'
|
||||||
)}
|
)}
|
||||||
>
|
></div>
|
||||||
<div className={cn('w-2.5 h-2.5 rounded-full shrink-0', colorClass)} />
|
<span className="font-bold text-slate-200 text-sm">{title}</span>
|
||||||
<h3 className="font-semibold text-sm text-foreground/90 flex-1 tracking-tight">{title}</h3>
|
|
||||||
|
{/* Action container (like "Make") */}
|
||||||
{headerAction}
|
{headerAction}
|
||||||
<span className="text-xs font-medium text-muted-foreground/80 bg-muted/50 px-2 py-0.5 rounded-md tabular-nums">
|
</div>
|
||||||
|
|
||||||
|
{/* Count Badge */}
|
||||||
|
<span className="text-[10px] bg-dark-700 text-slate-400 px-2 py-0.5 rounded border border-white/5 font-medium">
|
||||||
{count}
|
{count}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Column Content */}
|
{/* Column Content */}
|
||||||
<div
|
<div className="flex-1 overflow-y-auto p-3 space-y-3 custom-scrollbar relative z-10">
|
||||||
className={cn(
|
|
||||||
'relative z-10 flex-1 overflow-y-auto p-2 space-y-2.5',
|
|
||||||
hideScrollbar &&
|
|
||||||
'[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]',
|
|
||||||
// Smooth scrolling
|
|
||||||
'scroll-smooth'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Drop zone indicator when dragging over */}
|
{/* Drop zone indicator when dragging over */}
|
||||||
{isOver && (
|
{isOver && (
|
||||||
<div className="absolute inset-0 rounded-xl bg-primary/5 pointer-events-none z-5 border-2 border-dashed border-primary/20" />
|
<div className="absolute inset-0 rounded-2xl bg-white/5 pointer-events-none z-20 border-2 border-dashed border-white/10" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</GlassPanel>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,21 +2,29 @@ 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: 'backlog', title: 'Backlog', colorClass: 'bg-[var(--status-backlog)]' },
|
id: ColumnId;
|
||||||
|
title: string;
|
||||||
|
colorClass: string;
|
||||||
|
accent: 'cyan' | 'blue' | 'orange' | 'green';
|
||||||
|
}[] = [
|
||||||
|
{ id: 'backlog', title: 'Backlog', colorClass: 'bg-[var(--status-backlog)]', accent: 'cyan' },
|
||||||
{
|
{
|
||||||
id: 'in_progress',
|
id: 'in_progress',
|
||||||
title: 'In Progress',
|
title: 'In Progress',
|
||||||
colorClass: 'bg-[var(--status-in-progress)]',
|
colorClass: 'bg-[var(--status-in-progress)]',
|
||||||
|
accent: 'blue',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'waiting_approval',
|
id: 'waiting_approval',
|
||||||
title: 'Waiting Approval',
|
title: 'Waiting Approval',
|
||||||
colorClass: 'bg-[var(--status-waiting)]',
|
colorClass: 'bg-[var(--status-waiting)]',
|
||||||
|
accent: 'orange',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'verified',
|
id: 'verified',
|
||||||
title: 'Verified',
|
title: 'Verified',
|
||||||
colorClass: 'bg-[var(--status-success)]',
|
colorClass: 'bg-[var(--status-success)]',
|
||||||
|
accent: 'green',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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">
|
|
||||||
<div className="flex items-center gap-1 bg-muted rounded-lg p-1">
|
|
||||||
<button
|
|
||||||
onClick={() => setViewMode('parsed')}
|
|
||||||
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}>
|
<Button variant="destructive" size="sm" onClick={handleStop}>
|
||||||
<StopCircle className="w-4 h-4 mr-2" />
|
<StopCircle className="w-4 h-4 mr-2" />
|
||||||
Stop
|
Stop
|
||||||
</Button>
|
</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="flex items-center justify-center min-h-[168px] text-muted-foreground">
|
|
||||||
<Loader2 className="w-6 h-6 animate-spin mr-2" />
|
|
||||||
Waiting for AI response...
|
|
||||||
</div>
|
|
||||||
) : viewMode === 'parsed' ? (
|
|
||||||
<LogViewer output={progress.join('')} />
|
|
||||||
) : (
|
|
||||||
<div className="whitespace-pre-wrap break-words text-zinc-300">
|
<div className="whitespace-pre-wrap break-words text-zinc-300">
|
||||||
{progress.join('')}
|
{progress.join('')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : hasSuggestions ? (
|
) : hasSuggestions ? (
|
||||||
|
|||||||
@@ -109,7 +109,9 @@ export function useBoardColumnFeatures({
|
|||||||
// This ensures features appear in dependency order (dependencies before dependents)
|
// This ensures features appear in dependency order (dependencies before dependents)
|
||||||
// Within the same dependency level, features are sorted by priority
|
// Within the same dependency level, features are sorted by priority
|
||||||
if (map.backlog.length > 0) {
|
if (map.backlog.length > 0) {
|
||||||
const { orderedFeatures } = resolveDependencies(map.backlog);
|
const { orderedFeatures } = resolveDependencies(map.backlog as any) as {
|
||||||
|
orderedFeatures: Feature[];
|
||||||
|
};
|
||||||
|
|
||||||
// Get all features to check blocking dependencies against
|
// Get all features to check blocking dependencies against
|
||||||
const allFeatures = features;
|
const allFeatures = features;
|
||||||
@@ -122,7 +124,7 @@ export function useBoardColumnFeatures({
|
|||||||
const blocked: Feature[] = [];
|
const blocked: Feature[] = [];
|
||||||
|
|
||||||
for (const f of orderedFeatures) {
|
for (const f of orderedFeatures) {
|
||||||
if (getBlockingDependencies(f, allFeatures).length > 0) {
|
if (getBlockingDependencies(f as any, allFeatures as any).length > 0) {
|
||||||
blocked.push(f);
|
blocked.push(f);
|
||||||
} else {
|
} else {
|
||||||
unblocked.push(f);
|
unblocked.push(f);
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ export function KanbanBoard({
|
|||||||
key={column.id}
|
key={column.id}
|
||||||
id={column.id}
|
id={column.id}
|
||||||
title={column.title}
|
title={column.title}
|
||||||
|
accent={column.accent}
|
||||||
colorClass={column.colorClass}
|
colorClass={column.colorClass}
|
||||||
count={columnFeatures.length}
|
count={columnFeatures.length}
|
||||||
width={columnWidth}
|
width={columnWidth}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Button } from '@/components/ui/button';
|
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 React from '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, GitRepoStatus } from '../types';
|
||||||
@@ -87,7 +88,7 @@ export function WorktreeTab({
|
|||||||
onStopDevServer,
|
onStopDevServer,
|
||||||
onOpenDevServerUrl,
|
onOpenDevServerUrl,
|
||||||
}: WorktreeTabProps) {
|
}: WorktreeTabProps) {
|
||||||
let prBadge: JSX.Element | null = null;
|
let prBadge: React.ReactNode | null = null;
|
||||||
if (worktree.pr) {
|
if (worktree.pr) {
|
||||||
const prState = worktree.pr.state?.toLowerCase() ?? 'open';
|
const prState = worktree.pr.state?.toLowerCase() ?? 'open';
|
||||||
const prStateClasses = (() => {
|
const prStateClasses = (() => {
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Validation Dialog */}
|
{/* Body */}
|
||||||
<ValidationDialog
|
{selectedIssue.body ? (
|
||||||
open={showValidationDialog}
|
<Markdown className="text-sm">{selectedIssue.body}</Markdown>
|
||||||
onOpenChange={setShowValidationDialog}
|
) : (
|
||||||
issue={selectedIssue}
|
<p className="text-sm text-muted-foreground italic">No description provided.</p>
|
||||||
validationResult={validationResult}
|
)}
|
||||||
onConvertToTask={handleConvertToTask}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Revalidate Confirmation Dialog */}
|
{/* Open in GitHub CTA */}
|
||||||
<ConfirmDialog
|
<div className="mt-8 p-4 rounded-lg bg-muted/50 border border-border">
|
||||||
open={showRevalidateConfirm}
|
<p className="text-sm text-muted-foreground mb-3">
|
||||||
onOpenChange={setShowRevalidateConfirm}
|
View comments, add reactions, and more on GitHub.
|
||||||
title="Re-validate Issue"
|
</p>
|
||||||
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.`}
|
<Button onClick={() => handleOpenInGitHub(selectedIssue.url)}>
|
||||||
icon={RefreshCw}
|
<ExternalLink className="h-4 w-4 mr-2" />
|
||||||
iconClassName="text-primary"
|
View Full Issue on GitHub
|
||||||
confirmText="Re-validate"
|
</Button>
|
||||||
onConfirm={() => {
|
</div>
|
||||||
if (selectedIssue) {
|
</div>
|
||||||
handleValidateIssue(selectedIssue, { forceRevalidate: true });
|
</div>
|
||||||
}
|
)}
|
||||||
}}
|
</div>
|
||||||
/>
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IssueRowProps {
|
||||||
|
issue: GitHubIssue;
|
||||||
|
isSelected: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
onOpenExternal: () => void;
|
||||||
|
formatDate: (date: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function IssueRow({ issue, isSelected, onClick, onOpenExternal, formatDate }: IssueRowProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'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>
|
||||||
|
|
||||||
|
{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;
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { Bot, Folder, Loader2, RefreshCw, Square, Activity } from 'lucide-react';
|
import { Bot, Folder, Loader2, RefreshCw, Square, Activity } from 'lucide-react';
|
||||||
|
import { TopHeader } from '@/components/layout/top-header';
|
||||||
|
import { GlassPanel } from '@/components/ui/glass-panel';
|
||||||
import { getElectronAPI, RunningAgent } from '@/lib/electron';
|
import { getElectronAPI, RunningAgent } from '@/lib/electron';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -103,24 +105,37 @@ export function RunningAgentsView() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden bg-background">
|
||||||
|
<TopHeader />
|
||||||
|
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden p-6 pt-0">
|
||||||
|
<GlassPanel className="flex-1 flex flex-col overflow-hidden relative shadow-2xl bg-black/40 backdrop-blur-xl border-white/5">
|
||||||
<div className="flex-1 flex flex-col overflow-hidden p-6">
|
<div className="flex-1 flex flex-col overflow-hidden p-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="p-2 rounded-lg bg-brand-500/10">
|
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-brand-500/20 to-blue-600/20 border border-brand-500/30 flex items-center justify-center shadow-inner shadow-brand-500/20">
|
||||||
<Activity className="h-6 w-6 text-brand-500" />
|
<Activity className="h-5 w-5 text-brand-400" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Running Agents</h1>
|
<h1 className="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-white to-white/70">
|
||||||
<p className="text-sm text-muted-foreground">
|
Running Agents
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
{runningAgents.length === 0
|
{runningAgents.length === 0
|
||||||
? 'No agents currently running'
|
? 'No agents currently running'
|
||||||
: `${runningAgents.length} agent${runningAgents.length === 1 ? '' : 's'} running across all projects`}
|
: `${runningAgents.length} agent${runningAgents.length === 1 ? '' : 's'} running across all projects`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={refreshing}>
|
<Button
|
||||||
<RefreshCw className={cn('h-4 w-4 mr-2', refreshing && 'animate-spin')} />
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={refreshing}
|
||||||
|
className="bg-white/5 border-white/10 hover:bg-white/10 text-xs gap-2"
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn('h-3.5 w-3.5', refreshing && 'animate-spin')} />
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -128,70 +143,73 @@ export function RunningAgentsView() {
|
|||||||
{/* Content */}
|
{/* Content */}
|
||||||
{runningAgents.length === 0 ? (
|
{runningAgents.length === 0 ? (
|
||||||
<div className="flex-1 flex flex-col items-center justify-center text-center">
|
<div className="flex-1 flex flex-col items-center justify-center text-center">
|
||||||
<div className="p-4 rounded-full bg-muted/50 mb-4">
|
<div className="w-16 h-16 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center mb-6">
|
||||||
<Bot className="h-12 w-12 text-muted-foreground" />
|
<Bot className="h-8 w-8 text-muted-foreground/50" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-lg font-medium mb-2">No Running Agents</h2>
|
<h2 className="text-lg font-medium mb-2 text-foreground">No Running Agents</h2>
|
||||||
<p className="text-muted-foreground max-w-md">
|
<p className="text-muted-foreground max-w-sm text-sm">
|
||||||
Agents will appear here when they are actively working on features. Start an agent from
|
Agents will appear here when they are actively working on features. Start an agent
|
||||||
the Kanban board by dragging a feature to "In Progress".
|
from the Kanban board.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto pr-2">
|
||||||
<div className="space-y-3">
|
<div className="grid gap-3 grid-cols-1 md:grid-cols-2 xl:grid-cols-3">
|
||||||
{runningAgents.map((agent) => (
|
{runningAgents.map((agent) => (
|
||||||
<div
|
<div
|
||||||
key={`${agent.projectPath}-${agent.featureId}`}
|
key={`${agent.projectPath}-${agent.featureId}`}
|
||||||
className="flex items-center justify-between p-4 rounded-lg border border-border bg-card hover:bg-accent/50 transition-colors"
|
className="group relative flex flex-col p-4 rounded-xl border border-white/10 bg-white/5 hover:bg-white/10 hover:border-white/20 transition-all duration-300"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-4 min-w-0">
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
{/* Status indicator */}
|
{/* Status indicator */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Bot className="h-8 w-8 text-brand-500" />
|
<div className="w-10 h-10 rounded-lg bg-brand-500/10 flex items-center justify-center">
|
||||||
<span className="absolute -top-1 -right-1 flex h-3 w-3">
|
<Bot className="h-5 w-5 text-brand-400" />
|
||||||
|
</div>
|
||||||
|
<span className="absolute -top-1 -right-1 flex h-2.5 w-2.5">
|
||||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
|
||||||
<span className="relative inline-flex rounded-full h-3 w-3 bg-green-500" />
|
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-green-500" />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Agent info */}
|
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 mb-0.5">
|
||||||
<span className="font-medium truncate">{agent.featureId}</span>
|
<span className="font-semibold text-sm truncate text-foreground">
|
||||||
|
{agent.featureId}
|
||||||
|
</span>
|
||||||
{agent.isAutoMode && (
|
{agent.isAutoMode && (
|
||||||
<span className="px-2 py-0.5 text-[10px] font-medium rounded-full bg-brand-500/10 text-brand-500 border border-brand-500/30">
|
<span className="px-1.5 py-0.5 text-[10px] font-bold rounded bg-brand-500/20 text-brand-400 border border-brand-500/20">
|
||||||
AUTO
|
AUTO
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
onClick={() => handleNavigateToProject(agent)}
|
<Folder className="h-3 w-3" />
|
||||||
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
<span className="truncate max-w-[120px]">{agent.projectName}</span>
|
||||||
>
|
</div>
|
||||||
<Folder className="h-3.5 w-3.5" />
|
</div>
|
||||||
<span className="truncate">{agent.projectName}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
<div className="mt-auto pt-3 flex items-center gap-2 border-t border-white/5">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleNavigateToProject(agent)}
|
onClick={() => handleNavigateToProject(agent)}
|
||||||
className="text-muted-foreground hover:text-foreground"
|
className="flex-1 h-8 text-xs hover:bg-white/10"
|
||||||
>
|
>
|
||||||
View Project
|
View Project
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleStopAgent(agent.featureId)}
|
onClick={() => handleStopAgent(agent.featureId)}
|
||||||
|
className="h-8 w-8 p-0 text-muted-foreground hover:text-red-400 hover:bg-red-500/10"
|
||||||
|
title="Stop Agent"
|
||||||
>
|
>
|
||||||
<Square className="h-3.5 w-3.5 mr-1.5" />
|
<Square className="h-3.5 w-3.5 fill-current" />
|
||||||
Stop
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -200,5 +218,8 @@ export function RunningAgentsView() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</GlassPanel>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,8 +45,6 @@ export function SettingsView() {
|
|||||||
setDefaultAIProfileId,
|
setDefaultAIProfileId,
|
||||||
aiProfiles,
|
aiProfiles,
|
||||||
apiKeys,
|
apiKeys,
|
||||||
validationModel,
|
|
||||||
setValidationModel,
|
|
||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
|
|
||||||
// Hide usage tracking when using API key (only show for Claude Code CLI users)
|
// Hide usage tracking when using API key (only show for Claude Code CLI users)
|
||||||
@@ -112,7 +110,7 @@ export function SettingsView() {
|
|||||||
<AppearanceSection
|
<AppearanceSection
|
||||||
effectiveTheme={effectiveTheme}
|
effectiveTheme={effectiveTheme}
|
||||||
currentProject={settingsProject}
|
currentProject={settingsProject}
|
||||||
onThemeChange={handleSetTheme}
|
onThemeChange={handleSetTheme as (theme: Theme) => void}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 'terminal':
|
case 'terminal':
|
||||||
@@ -136,7 +134,6 @@ export function SettingsView() {
|
|||||||
defaultRequirePlanApproval={defaultRequirePlanApproval}
|
defaultRequirePlanApproval={defaultRequirePlanApproval}
|
||||||
defaultAIProfileId={defaultAIProfileId}
|
defaultAIProfileId={defaultAIProfileId}
|
||||||
aiProfiles={aiProfiles}
|
aiProfiles={aiProfiles}
|
||||||
validationModel={validationModel}
|
|
||||||
onShowProfilesOnlyChange={setShowProfilesOnly}
|
onShowProfilesOnlyChange={setShowProfilesOnly}
|
||||||
onDefaultSkipTestsChange={setDefaultSkipTests}
|
onDefaultSkipTestsChange={setDefaultSkipTests}
|
||||||
onEnableDependencyBlockingChange={setEnableDependencyBlocking}
|
onEnableDependencyBlockingChange={setEnableDependencyBlocking}
|
||||||
@@ -144,7 +141,6 @@ export function SettingsView() {
|
|||||||
onDefaultPlanningModeChange={setDefaultPlanningMode}
|
onDefaultPlanningModeChange={setDefaultPlanningMode}
|
||||||
onDefaultRequirePlanApprovalChange={setDefaultRequirePlanApproval}
|
onDefaultRequirePlanApprovalChange={setDefaultRequirePlanApproval}
|
||||||
onDefaultAIProfileIdChange={setDefaultAIProfileId}
|
onDefaultAIProfileIdChange={setDefaultAIProfileId}
|
||||||
onValidationModelChange={setValidationModel}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 'danger':
|
case 'danger':
|
||||||
|
|||||||
@@ -59,9 +59,8 @@ export function useApiKeyManagement() {
|
|||||||
hasGoogleKey: status.hasGoogleKey,
|
hasGoogleKey: status.hasGoogleKey,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (error) {
|
||||||
// Silently handle API key status check failures to avoid exposing
|
console.error('Failed to check API key status:', error);
|
||||||
// sensitive error details in the console
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -99,29 +98,26 @@ export function useApiKeyManagement() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Test Google/Gemini connection
|
// Test Google/Gemini connection
|
||||||
// NOTE: Full API key validation requires a backend call to verify the key
|
// TODO: Add backend endpoint for Gemini API key verification
|
||||||
// against Google's API. The current client-side validation only checks
|
|
||||||
// basic format requirements and cannot confirm the key is actually valid.
|
|
||||||
const handleTestGeminiConnection = async () => {
|
const handleTestGeminiConnection = async () => {
|
||||||
setTestingGeminiConnection(true);
|
setTestingGeminiConnection(true);
|
||||||
setGeminiTestResult(null);
|
setGeminiTestResult(null);
|
||||||
|
|
||||||
// Basic client-side format validation only
|
// Basic validation - check key format
|
||||||
// This does NOT verify the key is valid with Google's API
|
|
||||||
if (!googleKey || googleKey.trim().length < 10) {
|
if (!googleKey || googleKey.trim().length < 10) {
|
||||||
setGeminiTestResult({
|
setGeminiTestResult({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Please enter an API key with at least 10 characters.',
|
message: 'Please enter a valid API key.',
|
||||||
});
|
});
|
||||||
setTestingGeminiConnection(false);
|
setTestingGeminiConnection(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Client-side validation cannot confirm key validity.
|
// For now, just validate the key format (starts with expected prefix)
|
||||||
// The key will be verified when first used with the Gemini API.
|
// Full verification requires a backend endpoint
|
||||||
setGeminiTestResult({
|
setGeminiTestResult({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'API key format accepted. Key will be validated on first use with Gemini API.',
|
message: 'API key saved. Connection test not yet available.',
|
||||||
});
|
});
|
||||||
setTestingGeminiConnection(false);
|
setTestingGeminiConnection(false);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
ScrollText,
|
ScrollText,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
User,
|
User,
|
||||||
Sparkles,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
@@ -23,7 +22,6 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import type { AIProfile } from '@/store/app-store';
|
import type { AIProfile } from '@/store/app-store';
|
||||||
import type { AgentModel } from '@automaker/types';
|
|
||||||
|
|
||||||
type PlanningMode = 'skip' | 'lite' | 'spec' | 'full';
|
type PlanningMode = 'skip' | 'lite' | 'spec' | 'full';
|
||||||
|
|
||||||
@@ -36,7 +34,6 @@ interface FeatureDefaultsSectionProps {
|
|||||||
defaultRequirePlanApproval: boolean;
|
defaultRequirePlanApproval: boolean;
|
||||||
defaultAIProfileId: string | null;
|
defaultAIProfileId: string | null;
|
||||||
aiProfiles: AIProfile[];
|
aiProfiles: AIProfile[];
|
||||||
validationModel: AgentModel;
|
|
||||||
onShowProfilesOnlyChange: (value: boolean) => void;
|
onShowProfilesOnlyChange: (value: boolean) => void;
|
||||||
onDefaultSkipTestsChange: (value: boolean) => void;
|
onDefaultSkipTestsChange: (value: boolean) => void;
|
||||||
onEnableDependencyBlockingChange: (value: boolean) => void;
|
onEnableDependencyBlockingChange: (value: boolean) => void;
|
||||||
@@ -44,7 +41,6 @@ interface FeatureDefaultsSectionProps {
|
|||||||
onDefaultPlanningModeChange: (value: PlanningMode) => void;
|
onDefaultPlanningModeChange: (value: PlanningMode) => void;
|
||||||
onDefaultRequirePlanApprovalChange: (value: boolean) => void;
|
onDefaultRequirePlanApprovalChange: (value: boolean) => void;
|
||||||
onDefaultAIProfileIdChange: (value: string | null) => void;
|
onDefaultAIProfileIdChange: (value: string | null) => void;
|
||||||
onValidationModelChange: (value: AgentModel) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FeatureDefaultsSection({
|
export function FeatureDefaultsSection({
|
||||||
@@ -56,7 +52,6 @@ export function FeatureDefaultsSection({
|
|||||||
defaultRequirePlanApproval,
|
defaultRequirePlanApproval,
|
||||||
defaultAIProfileId,
|
defaultAIProfileId,
|
||||||
aiProfiles,
|
aiProfiles,
|
||||||
validationModel,
|
|
||||||
onShowProfilesOnlyChange,
|
onShowProfilesOnlyChange,
|
||||||
onDefaultSkipTestsChange,
|
onDefaultSkipTestsChange,
|
||||||
onEnableDependencyBlockingChange,
|
onEnableDependencyBlockingChange,
|
||||||
@@ -64,7 +59,6 @@ export function FeatureDefaultsSection({
|
|||||||
onDefaultPlanningModeChange,
|
onDefaultPlanningModeChange,
|
||||||
onDefaultRequirePlanApprovalChange,
|
onDefaultRequirePlanApprovalChange,
|
||||||
onDefaultAIProfileIdChange,
|
onDefaultAIProfileIdChange,
|
||||||
onValidationModelChange,
|
|
||||||
}: FeatureDefaultsSectionProps) {
|
}: FeatureDefaultsSectionProps) {
|
||||||
// Find the selected profile name for display
|
// Find the selected profile name for display
|
||||||
const selectedProfile = defaultAIProfileId
|
const selectedProfile = defaultAIProfileId
|
||||||
@@ -233,45 +227,6 @@ export function FeatureDefaultsSection({
|
|||||||
{/* Separator */}
|
{/* Separator */}
|
||||||
<div className="border-t border-border/30" />
|
<div className="border-t border-border/30" />
|
||||||
|
|
||||||
{/* Issue Validation Model */}
|
|
||||||
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
|
|
||||||
<div className="w-10 h-10 mt-0.5 rounded-xl flex items-center justify-center shrink-0 bg-purple-500/10">
|
|
||||||
<Sparkles className="w-5 h-5 text-purple-500" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Label className="text-foreground font-medium">Issue Validation Model</Label>
|
|
||||||
<Select
|
|
||||||
value={validationModel}
|
|
||||||
onValueChange={(v: string) => onValidationModelChange(v as AgentModel)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-[140px] h-8" data-testid="validation-model-select">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="opus">
|
|
||||||
<span>Opus</span>
|
|
||||||
<span className="text-[10px] text-muted-foreground ml-1">(Default)</span>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="sonnet">
|
|
||||||
<span>Sonnet</span>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="haiku">
|
|
||||||
<span>Haiku</span>
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
|
||||||
Model used for validating GitHub issues. Opus provides the most thorough analysis,
|
|
||||||
while Haiku is faster and more cost-effective.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Separator */}
|
|
||||||
<div className="border-t border-border/30" />
|
|
||||||
|
|
||||||
{/* Profiles Only Setting */}
|
{/* Profiles Only Setting */}
|
||||||
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
|
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ import {
|
|||||||
defaultDropAnimationSideEffects,
|
defaultDropAnimationSideEffects,
|
||||||
} from '@dnd-kit/core';
|
} from '@dnd-kit/core';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { TopHeader } from '@/components/layout/top-header';
|
||||||
|
import { GlassPanel } from '@/components/ui/glass-panel';
|
||||||
|
|
||||||
interface TerminalStatus {
|
interface TerminalStatus {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -296,7 +298,7 @@ export function TerminalView() {
|
|||||||
|
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (terminalState.authToken) {
|
if (terminalState.authToken) {
|
||||||
headers['Authorization'] = `Bearer ${terminalState.authToken}`;
|
headers['X-Terminal-Token'] = terminalState.authToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[Terminal] Killing ${sessionIds.length} sessions on server`);
|
console.log(`[Terminal] Killing ${sessionIds.length} sessions on server`);
|
||||||
@@ -459,7 +461,7 @@ export function TerminalView() {
|
|||||||
try {
|
try {
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (terminalState.authToken) {
|
if (terminalState.authToken) {
|
||||||
headers['Authorization'] = `Bearer ${terminalState.authToken}`;
|
headers['X-Terminal-Token'] = terminalState.authToken;
|
||||||
}
|
}
|
||||||
const response = await fetch(`${serverUrl}/api/terminal/settings`, { headers });
|
const response = await fetch(`${serverUrl}/api/terminal/settings`, { headers });
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
@@ -488,7 +490,7 @@ export function TerminalView() {
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
};
|
};
|
||||||
if (terminalState.authToken) {
|
if (terminalState.authToken) {
|
||||||
headers['Authorization'] = `Bearer ${terminalState.authToken}`;
|
headers['X-Terminal-Token'] = terminalState.authToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to use the bulk delete endpoint if available, otherwise delete individually
|
// Try to use the bulk delete endpoint if available, otherwise delete individually
|
||||||
@@ -501,7 +503,7 @@ export function TerminalView() {
|
|||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
xhr.open('DELETE', url, false); // synchronous
|
xhr.open('DELETE', url, false); // synchronous
|
||||||
if (terminalState.authToken) {
|
if (terminalState.authToken) {
|
||||||
xhr.setRequestHeader('Authorization', `Bearer ${terminalState.authToken}`);
|
xhr.setRequestHeader('X-Terminal-Token', terminalState.authToken);
|
||||||
}
|
}
|
||||||
xhr.send();
|
xhr.send();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -595,7 +597,7 @@ export function TerminalView() {
|
|||||||
// Get fresh auth token from store
|
// Get fresh auth token from store
|
||||||
const authToken = useAppStore.getState().terminalState.authToken;
|
const authToken = useAppStore.getState().terminalState.authToken;
|
||||||
if (authToken) {
|
if (authToken) {
|
||||||
headers['Authorization'] = `Bearer ${authToken}`;
|
headers['X-Terminal-Token'] = authToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to check if a session still exists on server
|
// Helper to check if a session still exists on server
|
||||||
@@ -833,7 +835,7 @@ export function TerminalView() {
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
};
|
};
|
||||||
if (terminalState.authToken) {
|
if (terminalState.authToken) {
|
||||||
headers['Authorization'] = `Bearer ${terminalState.authToken}`;
|
headers['X-Terminal-Token'] = terminalState.authToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${serverUrl}/api/terminal/sessions`, {
|
const response = await fetch(`${serverUrl}/api/terminal/sessions`, {
|
||||||
@@ -892,7 +894,7 @@ export function TerminalView() {
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
};
|
};
|
||||||
if (terminalState.authToken) {
|
if (terminalState.authToken) {
|
||||||
headers['Authorization'] = `Bearer ${terminalState.authToken}`;
|
headers['X-Terminal-Token'] = terminalState.authToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${serverUrl}/api/terminal/sessions`, {
|
const response = await fetch(`${serverUrl}/api/terminal/sessions`, {
|
||||||
@@ -952,7 +954,7 @@ export function TerminalView() {
|
|||||||
try {
|
try {
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (terminalState.authToken) {
|
if (terminalState.authToken) {
|
||||||
headers['Authorization'] = `Bearer ${terminalState.authToken}`;
|
headers['X-Terminal-Token'] = terminalState.authToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${serverUrl}/api/terminal/sessions/${sessionId}`, {
|
const response = await fetch(`${serverUrl}/api/terminal/sessions/${sessionId}`, {
|
||||||
@@ -998,7 +1000,7 @@ export function TerminalView() {
|
|||||||
// Kill all sessions on the server
|
// Kill all sessions on the server
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (terminalState.authToken) {
|
if (terminalState.authToken) {
|
||||||
headers['Authorization'] = `Bearer ${terminalState.authToken}`;
|
headers['X-Terminal-Token'] = terminalState.authToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
@@ -1414,6 +1416,21 @@ export function TerminalView() {
|
|||||||
|
|
||||||
// Terminal view with tabs
|
// Terminal view with tabs
|
||||||
return (
|
return (
|
||||||
|
<div className="flex flex-col h-full w-full overflow-hidden" data-testid="terminal-view">
|
||||||
|
<TopHeader />
|
||||||
|
|
||||||
|
{/* Main Content Area - Glass Panel */}
|
||||||
|
<div className="flex-1 min-h-0 p-4 pt-0">
|
||||||
|
<GlassPanel className="h-full flex flex-col overflow-hidden shadow-2xl">
|
||||||
|
{/* Header / Tabs */}
|
||||||
|
<div className="flex items-center gap-2 p-2 border-b border-white/10 bg-white/5 backdrop-blur-md select-none">
|
||||||
|
{/* Terminal Icon */}
|
||||||
|
<div className="flex items-center justify-center w-8 h-8 rounded-md bg-white/5 border border-white/5">
|
||||||
|
<TerminalIcon className="w-4 h-4 text-cyan-400" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs List */}
|
||||||
|
<div className="flex flex-1 items-center gap-1 overflow-x-auto no-scrollbar mask-gradient-right">
|
||||||
<DndContext
|
<DndContext
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
collisionDetection={closestCenter}
|
collisionDetection={closestCenter}
|
||||||
@@ -1422,244 +1439,187 @@ export function TerminalView() {
|
|||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
onDragCancel={handleDragCancel}
|
onDragCancel={handleDragCancel}
|
||||||
>
|
>
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
|
||||||
{/* Tab bar */}
|
|
||||||
<div className="flex items-center bg-card border-b border-border px-2">
|
|
||||||
{/* Tabs */}
|
|
||||||
<div className="flex items-center gap-1 flex-1 overflow-x-auto py-1">
|
|
||||||
{terminalState.tabs.map((tab) => (
|
{terminalState.tabs.map((tab) => (
|
||||||
<TerminalTabButton
|
<TerminalTabButton
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
tab={tab}
|
tab={tab}
|
||||||
isActive={tab.id === terminalState.activeTabId}
|
isActive={tab.id === terminalState.activeTabId}
|
||||||
onClick={() => setActiveTerminalTab(tab.id)}
|
onClick={() => setActiveTerminalTab(tab.id)}
|
||||||
onClose={() => killTerminalTab(tab.id)}
|
onClose={() => removeTerminalTab(tab.id)}
|
||||||
onRename={(newName) => renameTerminalTab(tab.id, newName)}
|
onRename={(name) => renameTerminalTab(tab.id, name)}
|
||||||
isDropTarget={activeDragId !== null || activeDragTabId !== null}
|
isDropTarget={activeDragId !== null}
|
||||||
isDraggingTab={activeDragTabId !== null}
|
isDraggingTab={activeDragTabId !== null}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{(activeDragId || activeDragTabId) && <NewTabDropZone isDropTarget={true} />}
|
{/* Add New Tab Button */}
|
||||||
|
|
||||||
{/* New tab button */}
|
|
||||||
<button
|
<button
|
||||||
className="flex items-center justify-center p-1.5 rounded hover:bg-accent text-muted-foreground hover:text-foreground"
|
onClick={() => addTerminalTab()}
|
||||||
onClick={createTerminalInNewTab}
|
className="flex items-center justify-center w-8 h-8 rounded-md hover:bg-white/10 text-muted-foreground hover:text-foreground transition-colors ml-1"
|
||||||
title="New Tab"
|
title="New Tab"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Toolbar buttons */}
|
{activeDragId && <NewTabDropZone isDropTarget={true} />}
|
||||||
<div className="flex items-center gap-1 pl-2 border-l border-border">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 px-2 text-muted-foreground hover:text-foreground"
|
|
||||||
onClick={() => createTerminal('horizontal')}
|
|
||||||
title="Split Right"
|
|
||||||
>
|
|
||||||
<SplitSquareHorizontal className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 px-2 text-muted-foreground hover:text-foreground"
|
|
||||||
onClick={() => createTerminal('vertical')}
|
|
||||||
title="Split Down"
|
|
||||||
>
|
|
||||||
<SplitSquareVertical className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* Global Terminal Settings */}
|
<DragOverlay dropAnimation={{ sideEffects: defaultDropAnimationSideEffects({}) }}>
|
||||||
<Popover>
|
{activeDragTabId ? (
|
||||||
<PopoverTrigger asChild>
|
<div className="px-3 py-1.5 text-sm bg-background border-2 border-brand-500 rounded-md shadow-xl opacity-90 cursor-grabbing">
|
||||||
<Button
|
<div className="flex items-center gap-2">
|
||||||
variant="ghost"
|
<TerminalIcon className="h-3 w-3" />
|
||||||
size="sm"
|
<span>
|
||||||
className="h-7 px-2 text-muted-foreground hover:text-foreground"
|
{terminalState.tabs.find((t) => t.id === activeDragTabId)?.name || 'Tab'}
|
||||||
title="Terminal Settings"
|
|
||||||
>
|
|
||||||
<Settings className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-80" align="end">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h4 className="font-medium text-sm">Terminal Settings</h4>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Configure global terminal appearance
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Default Font Size */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Label className="text-sm">Default Font Size</Label>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{terminalState.defaultFontSize}px
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Slider
|
|
||||||
value={[terminalState.defaultFontSize]}
|
|
||||||
min={8}
|
|
||||||
max={24}
|
|
||||||
step={1}
|
|
||||||
onValueChange={([value]) => setTerminalDefaultFontSize(value)}
|
|
||||||
onValueCommit={() => {
|
|
||||||
toast.info('Font size changed', {
|
|
||||||
description: 'New terminals will use this size',
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : activeDragId ? (
|
||||||
{/* Font Family */}
|
<div className="p-4 bg-background border-2 border-brand-500 rounded-lg shadow-xl opacity-90 w-64 h-48 flex items-center justify-center cursor-grabbing">
|
||||||
<div className="space-y-2">
|
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
||||||
<Label className="text-sm">Font Family</Label>
|
<TerminalIcon className="h-8 w-8" />
|
||||||
<select
|
<span>Moving Terminal...</span>
|
||||||
value={terminalState.fontFamily}
|
|
||||||
onChange={(e) => {
|
|
||||||
setTerminalFontFamily(e.target.value);
|
|
||||||
toast.info('Font family changed', {
|
|
||||||
description: 'Restart terminal for changes to take effect',
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
'w-full px-2 py-1.5 rounded-md text-sm',
|
|
||||||
'bg-accent/50 border border-border',
|
|
||||||
'text-foreground',
|
|
||||||
'focus:outline-none focus:ring-2 focus:ring-ring'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{TERMINAL_FONT_OPTIONS.map((font) => (
|
|
||||||
<option key={font.value} value={font.value}>
|
|
||||||
{font.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Line Height */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Label className="text-sm">Line Height</Label>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{terminalState.lineHeight.toFixed(1)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Slider
|
|
||||||
value={[terminalState.lineHeight]}
|
|
||||||
min={1.0}
|
|
||||||
max={2.0}
|
|
||||||
step={0.1}
|
|
||||||
onValueChange={([value]) => setTerminalLineHeight(value)}
|
|
||||||
onValueCommit={() => {
|
|
||||||
toast.info('Line height changed', {
|
|
||||||
description: 'Restart terminal for changes to take effect',
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Default Run Script */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-sm">Default Run Script</Label>
|
|
||||||
<Input
|
|
||||||
value={terminalState.defaultRunScript}
|
|
||||||
onChange={(e) => setTerminalDefaultRunScript(e.target.value)}
|
|
||||||
placeholder="e.g., claude, npm run dev"
|
|
||||||
className="h-8 text-sm"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Command to run when opening new terminals
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Active tab content */}
|
|
||||||
<div className="flex-1 overflow-hidden bg-background">
|
|
||||||
{terminalState.maximizedSessionId ? (
|
|
||||||
// When a terminal is maximized, render only that terminal
|
|
||||||
<TerminalErrorBoundary
|
|
||||||
key={`boundary-maximized-${terminalState.maximizedSessionId}`}
|
|
||||||
sessionId={terminalState.maximizedSessionId}
|
|
||||||
onRestart={() => {
|
|
||||||
const sessionId = terminalState.maximizedSessionId!;
|
|
||||||
toggleTerminalMaximized(sessionId);
|
|
||||||
killTerminal(sessionId);
|
|
||||||
createTerminal();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TerminalPanel
|
|
||||||
key={`maximized-${terminalState.maximizedSessionId}`}
|
|
||||||
sessionId={terminalState.maximizedSessionId}
|
|
||||||
authToken={terminalState.authToken}
|
|
||||||
isActive={true}
|
|
||||||
onFocus={() => setActiveTerminalSession(terminalState.maximizedSessionId!)}
|
|
||||||
onClose={() => killTerminal(terminalState.maximizedSessionId!)}
|
|
||||||
onSplitHorizontal={() =>
|
|
||||||
createTerminal('horizontal', terminalState.maximizedSessionId!)
|
|
||||||
}
|
|
||||||
onSplitVertical={() =>
|
|
||||||
createTerminal('vertical', terminalState.maximizedSessionId!)
|
|
||||||
}
|
|
||||||
onNewTab={createTerminalInNewTab}
|
|
||||||
onSessionInvalid={() => {
|
|
||||||
const sessionId = terminalState.maximizedSessionId!;
|
|
||||||
console.log(
|
|
||||||
`[Terminal] Maximized session ${sessionId} is invalid, removing from layout`
|
|
||||||
);
|
|
||||||
killTerminal(sessionId);
|
|
||||||
}}
|
|
||||||
isDragging={false}
|
|
||||||
isDropTarget={false}
|
|
||||||
fontSize={findTerminalFontSize(terminalState.maximizedSessionId)}
|
|
||||||
onFontSizeChange={(size) =>
|
|
||||||
setTerminalPanelFontSize(terminalState.maximizedSessionId!, size)
|
|
||||||
}
|
|
||||||
isMaximized={true}
|
|
||||||
onToggleMaximize={() => toggleTerminalMaximized(terminalState.maximizedSessionId!)}
|
|
||||||
/>
|
|
||||||
</TerminalErrorBoundary>
|
|
||||||
) : activeTab?.layout ? (
|
|
||||||
renderPanelContent(activeTab.layout)
|
|
||||||
) : (
|
|
||||||
<div className="flex-1 flex flex-col items-center justify-center text-center p-6">
|
|
||||||
<p className="text-muted-foreground mb-4">This tab is empty</p>
|
|
||||||
<Button variant="outline" size="sm" onClick={() => createTerminal()}>
|
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
|
||||||
New Terminal
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Drag overlay */}
|
|
||||||
<DragOverlay
|
|
||||||
dropAnimation={{
|
|
||||||
sideEffects: defaultDropAnimationSideEffects({
|
|
||||||
styles: { active: { opacity: '0.5' } },
|
|
||||||
}),
|
|
||||||
}}
|
|
||||||
zIndex={1000}
|
|
||||||
>
|
|
||||||
{activeDragId ? (
|
|
||||||
<div className="relative inline-flex items-center gap-2 px-3.5 py-2 bg-card border-2 border-brand-500 rounded-lg shadow-xl pointer-events-none overflow-hidden">
|
|
||||||
<TerminalIcon className="h-4 w-4 text-brand-500 shrink-0" />
|
|
||||||
<span className="text-sm font-medium text-foreground whitespace-nowrap">
|
|
||||||
{dragOverTabId === 'new' ? 'New tab' : dragOverTabId ? 'Move to tab' : 'Terminal'}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</DragOverlay>
|
</DragOverlay>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Actions Toolbar */}
|
||||||
|
<div className="flex items-center gap-1 pl-2 border-l border-white/10 ml-2">
|
||||||
|
{/* Layout Controls */}
|
||||||
|
<div className="flex items-center border border-border/40 rounded-md overflow-hidden mr-2">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (terminalState.activeSessionId) {
|
||||||
|
createTerminal('horizontal', terminalState.activeSessionId);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="p-1.5 hover:bg-white/10 text-muted-foreground hover:text-foreground border-r border-border/40"
|
||||||
|
title="Split Horizontal"
|
||||||
|
>
|
||||||
|
<SplitSquareHorizontal className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (terminalState.activeSessionId) {
|
||||||
|
createTerminal('vertical', terminalState.activeSessionId);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="p-1.5 hover:bg-white/10 text-muted-foreground hover:text-foreground"
|
||||||
|
title="Split Vertical"
|
||||||
|
>
|
||||||
|
<SplitSquareVertical className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lock/Unlock Toggle */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
'h-8 gap-2 border border-transparent',
|
||||||
|
!terminalState.isUnlocked &&
|
||||||
|
'text-amber-500 bg-amber-500/10 border-amber-500/30 hover:bg-amber-500/20'
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
if (terminalState.isUnlocked) setTerminalUnlocked(false);
|
||||||
|
else {
|
||||||
|
// Trigger lock logic (input password)
|
||||||
|
// Ideally show dialog, but for now just toggle for UI demo
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{terminalState.isUnlocked ? (
|
||||||
|
<Unlock className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<Lock className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
{terminalState.isUnlocked ? 'Unlocked' : 'Locked'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Terminal Content Area */}
|
||||||
|
<div className="flex-1 relative bg-black/40 backdrop-blur-sm">
|
||||||
|
{loading ? (
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 text-muted-foreground">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-brand-500" />
|
||||||
|
<p>Connecting to terminal server...</p>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 text-destructive">
|
||||||
|
<AlertCircle className="w-10 h-10" />
|
||||||
|
<p className="text-lg font-medium">{error}</p>
|
||||||
|
<Button variant="outline" onClick={fetchStatus}>
|
||||||
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
|
Retry Connection
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : !terminalState.isUnlocked ? (
|
||||||
|
/* Password Prompt */
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-background/50 backdrop-blur-sm z-10">
|
||||||
|
<div className="w-full max-w-sm p-6 space-y-4 bg-card border border-border rounded-lg shadow-xl">
|
||||||
|
<div className="flex flex-col items-center gap-2 text-center">
|
||||||
|
<Lock className="w-10 h-10 text-primary mb-2" />
|
||||||
|
<h3 className="text-lg font-semibold">Terminal Locked</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Enter your password to access terminal sessions.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleAuth} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="Enter password..."
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{authError && <p className="text-sm text-destructive">{authError}</p>}
|
||||||
|
<Button type="submit" className="w-full" disabled={authLoading}>
|
||||||
|
{authLoading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||||
|
Unlock Terminal
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : activeTab ? (
|
||||||
|
activeTab.layout ? (
|
||||||
|
renderPanelContent(activeTab.layout)
|
||||||
|
) : (
|
||||||
|
/* Empty State */
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center text-muted-foreground opacity-50">
|
||||||
|
<TerminalIcon className="w-16 h-16 mb-4 opacity-20" />
|
||||||
|
<p>No active terminals</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="mt-4"
|
||||||
|
onClick={() => {
|
||||||
|
createTerminal();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
New Terminal
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
/* No Tabs State */
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center text-muted-foreground">
|
||||||
|
<p>No tabs open.</p>
|
||||||
|
<Button variant="outline" className="mt-4" onClick={() => addTerminalTab()}>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Create Tab
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</GlassPanel>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -543,6 +543,7 @@ export function TerminalPanel({
|
|||||||
allowProposedApi: true,
|
allowProposedApi: true,
|
||||||
screenReaderMode: screenReaderEnabled,
|
screenReaderMode: screenReaderEnabled,
|
||||||
scrollback: terminalScrollback,
|
scrollback: terminalScrollback,
|
||||||
|
allowTransparency: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create fit addon
|
// Create fit addon
|
||||||
@@ -940,12 +941,7 @@ export function TerminalPanel({
|
|||||||
if (!terminal) return;
|
if (!terminal) return;
|
||||||
|
|
||||||
const connect = () => {
|
const connect = () => {
|
||||||
// Build WebSocket URL with token in query string
|
// Build WebSocket URL with token
|
||||||
// Note: WebSocket API in browsers does not support custom headers during the upgrade handshake,
|
|
||||||
// so we must pass the token via query string. This is acceptable because:
|
|
||||||
// 1. WebSocket URLs are not exposed in HTTP Referer headers
|
|
||||||
// 2. The connection is upgraded to a secure WebSocket protocol immediately
|
|
||||||
// 3. Server-side logging should not log query parameters containing tokens
|
|
||||||
let url = `${wsUrl}/api/terminal/ws?sessionId=${sessionId}`;
|
let url = `${wsUrl}/api/terminal/ws?sessionId=${sessionId}`;
|
||||||
if (authToken) {
|
if (authToken) {
|
||||||
url += `&token=${encodeURIComponent(authToken)}`;
|
url += `&token=${encodeURIComponent(authToken)}`;
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export const DEFAULT_TERMINAL_FONT = TERMINAL_FONT_OPTIONS[0].value;
|
|||||||
|
|
||||||
// Dark theme (default)
|
// Dark theme (default)
|
||||||
const darkTheme: TerminalTheme = {
|
const darkTheme: TerminalTheme = {
|
||||||
background: '#0a0a0a',
|
background: 'transparent', // Transparent for glassmorphism
|
||||||
foreground: '#d4d4d4',
|
foreground: '#d4d4d4',
|
||||||
cursor: '#d4d4d4',
|
cursor: '#d4d4d4',
|
||||||
cursorAccent: '#0a0a0a',
|
cursorAccent: '#0a0a0a',
|
||||||
|
|||||||
@@ -222,7 +222,6 @@ export async function syncSettingsToServer(): Promise<boolean> {
|
|||||||
defaultAIProfileId: state.defaultAIProfileId,
|
defaultAIProfileId: state.defaultAIProfileId,
|
||||||
muteDoneSound: state.muteDoneSound,
|
muteDoneSound: state.muteDoneSound,
|
||||||
enhancementModel: state.enhancementModel,
|
enhancementModel: state.enhancementModel,
|
||||||
validationModel: state.validationModel,
|
|
||||||
keyboardShortcuts: state.keyboardShortcuts,
|
keyboardShortcuts: state.keyboardShortcuts,
|
||||||
aiProfiles: state.aiProfiles,
|
aiProfiles: state.aiProfiles,
|
||||||
projects: state.projects,
|
projects: state.projects,
|
||||||
|
|||||||
@@ -1,31 +1,8 @@
|
|||||||
// Type definitions for Electron IPC API
|
// Type definitions for Electron IPC API
|
||||||
import type { SessionListItem, Message } from '@/types/electron';
|
import type { SessionListItem, Message } from '@/types/electron';
|
||||||
import type { ClaudeUsageResponse } from '@/store/app-store';
|
import type { ClaudeUsageResponse } from '@/store/app-store';
|
||||||
import type {
|
|
||||||
IssueValidationVerdict,
|
|
||||||
IssueValidationConfidence,
|
|
||||||
IssueComplexity,
|
|
||||||
IssueValidationInput,
|
|
||||||
IssueValidationResult,
|
|
||||||
IssueValidationResponse,
|
|
||||||
IssueValidationEvent,
|
|
||||||
StoredValidation,
|
|
||||||
AgentModel,
|
|
||||||
} from '@automaker/types';
|
|
||||||
import { getJSON, setJSON, removeItem } from './storage';
|
import { getJSON, setJSON, removeItem } from './storage';
|
||||||
|
|
||||||
// Re-export issue validation types for use in components
|
|
||||||
export type {
|
|
||||||
IssueValidationVerdict,
|
|
||||||
IssueValidationConfidence,
|
|
||||||
IssueComplexity,
|
|
||||||
IssueValidationInput,
|
|
||||||
IssueValidationResult,
|
|
||||||
IssueValidationResponse,
|
|
||||||
IssueValidationEvent,
|
|
||||||
StoredValidation,
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface FileEntry {
|
export interface FileEntry {
|
||||||
name: string;
|
name: string;
|
||||||
isDirectory: boolean;
|
isDirectory: boolean;
|
||||||
@@ -123,19 +100,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 {
|
||||||
@@ -147,8 +111,6 @@ export interface GitHubIssue {
|
|||||||
labels: GitHubLabel[];
|
labels: GitHubLabel[];
|
||||||
url: string;
|
url: string;
|
||||||
body: string;
|
body: string;
|
||||||
assignees: GitHubAssignee[];
|
|
||||||
linkedPRs?: LinkedPullRequest[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GitHubPR {
|
export interface GitHubPR {
|
||||||
@@ -194,46 +156,6 @@ export interface GitHubAPI {
|
|||||||
mergedPRs?: GitHubPR[];
|
mergedPRs?: GitHubPR[];
|
||||||
error?: string;
|
error?: string;
|
||||||
}>;
|
}>;
|
||||||
/** Start async validation of a GitHub issue */
|
|
||||||
validateIssue: (
|
|
||||||
projectPath: string,
|
|
||||||
issue: IssueValidationInput,
|
|
||||||
model?: AgentModel
|
|
||||||
) => Promise<{ success: boolean; message?: string; issueNumber?: number; error?: string }>;
|
|
||||||
/** Check validation status for an issue or all issues */
|
|
||||||
getValidationStatus: (
|
|
||||||
projectPath: string,
|
|
||||||
issueNumber?: number
|
|
||||||
) => Promise<{
|
|
||||||
success: boolean;
|
|
||||||
isRunning?: boolean;
|
|
||||||
startedAt?: string;
|
|
||||||
runningIssues?: number[];
|
|
||||||
error?: string;
|
|
||||||
}>;
|
|
||||||
/** Stop a running validation */
|
|
||||||
stopValidation: (
|
|
||||||
projectPath: string,
|
|
||||||
issueNumber: number
|
|
||||||
) => Promise<{ success: boolean; message?: string; error?: string }>;
|
|
||||||
/** Get stored validations for a project */
|
|
||||||
getValidations: (
|
|
||||||
projectPath: string,
|
|
||||||
issueNumber?: number
|
|
||||||
) => Promise<{
|
|
||||||
success: boolean;
|
|
||||||
validation?: StoredValidation | null;
|
|
||||||
validations?: StoredValidation[];
|
|
||||||
isStale?: boolean;
|
|
||||||
error?: string;
|
|
||||||
}>;
|
|
||||||
/** Mark a validation as viewed by the user */
|
|
||||||
markValidationViewed: (
|
|
||||||
projectPath: string,
|
|
||||||
issueNumber: number
|
|
||||||
) => Promise<{ success: boolean; error?: string }>;
|
|
||||||
/** Subscribe to validation events */
|
|
||||||
onValidationEvent: (callback: (event: IssueValidationEvent) => void) => () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Feature Suggestions types
|
// Feature Suggestions types
|
||||||
@@ -2681,8 +2603,6 @@ function createMockRunningAgentsAPI(): RunningAgentsAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Mock GitHub API implementation
|
// Mock GitHub API implementation
|
||||||
let mockValidationCallbacks: ((event: IssueValidationEvent) => void)[] = [];
|
|
||||||
|
|
||||||
function createMockGitHubAPI(): GitHubAPI {
|
function createMockGitHubAPI(): GitHubAPI {
|
||||||
return {
|
return {
|
||||||
checkRemote: async (projectPath: string) => {
|
checkRemote: async (projectPath: string) => {
|
||||||
@@ -2711,81 +2631,6 @@ function createMockGitHubAPI(): GitHubAPI {
|
|||||||
mergedPRs: [],
|
mergedPRs: [],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
validateIssue: async (projectPath: string, issue: IssueValidationInput, model?: AgentModel) => {
|
|
||||||
console.log('[Mock] Starting async validation:', { projectPath, issue, model });
|
|
||||||
|
|
||||||
// Simulate async validation in background
|
|
||||||
setTimeout(() => {
|
|
||||||
mockValidationCallbacks.forEach((cb) =>
|
|
||||||
cb({
|
|
||||||
type: 'issue_validation_start',
|
|
||||||
issueNumber: issue.issueNumber,
|
|
||||||
issueTitle: issue.issueTitle,
|
|
||||||
projectPath,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
mockValidationCallbacks.forEach((cb) =>
|
|
||||||
cb({
|
|
||||||
type: 'issue_validation_complete',
|
|
||||||
issueNumber: issue.issueNumber,
|
|
||||||
issueTitle: issue.issueTitle,
|
|
||||||
result: {
|
|
||||||
verdict: 'valid' as const,
|
|
||||||
confidence: 'medium' as const,
|
|
||||||
reasoning:
|
|
||||||
'This is a mock validation. In production, Claude SDK would analyze the codebase to validate this issue.',
|
|
||||||
relatedFiles: ['src/components/example.tsx'],
|
|
||||||
estimatedComplexity: 'moderate' as const,
|
|
||||||
},
|
|
||||||
projectPath,
|
|
||||||
model: model || 'sonnet',
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}, 2000);
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: `Validation started for issue #${issue.issueNumber}`,
|
|
||||||
issueNumber: issue.issueNumber,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
getValidationStatus: async (projectPath: string, issueNumber?: number) => {
|
|
||||||
console.log('[Mock] Getting validation status:', { projectPath, issueNumber });
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
isRunning: false,
|
|
||||||
runningIssues: [],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
stopValidation: async (projectPath: string, issueNumber: number) => {
|
|
||||||
console.log('[Mock] Stopping validation:', { projectPath, issueNumber });
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: `Validation for issue #${issueNumber} stopped`,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
getValidations: async (projectPath: string, issueNumber?: number) => {
|
|
||||||
console.log('[Mock] Getting validations:', { projectPath, issueNumber });
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
validations: [],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
markValidationViewed: async (projectPath: string, issueNumber: number) => {
|
|
||||||
console.log('[Mock] Marking validation as viewed:', { projectPath, issueNumber });
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
onValidationEvent: (callback: (event: IssueValidationEvent) => void) => {
|
|
||||||
mockValidationCallbacks.push(callback);
|
|
||||||
return () => {
|
|
||||||
mockValidationCallbacks = mockValidationCallbacks.filter((cb) => cb !== callback);
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,8 +24,6 @@ import type {
|
|||||||
GitHubAPI,
|
GitHubAPI,
|
||||||
GitHubIssue,
|
GitHubIssue,
|
||||||
GitHubPR,
|
GitHubPR,
|
||||||
IssueValidationInput,
|
|
||||||
IssueValidationEvent,
|
|
||||||
} from './electron';
|
} from './electron';
|
||||||
import type { Message, SessionListItem } from '@/types/electron';
|
import type { Message, SessionListItem } from '@/types/electron';
|
||||||
import type { Feature, ClaudeUsageResponse } from '@/store/app-store';
|
import type { Feature, ClaudeUsageResponse } from '@/store/app-store';
|
||||||
@@ -53,8 +51,7 @@ type EventType =
|
|||||||
| 'agent:stream'
|
| 'agent:stream'
|
||||||
| 'auto-mode:event'
|
| 'auto-mode:event'
|
||||||
| 'suggestions:event'
|
| 'suggestions:event'
|
||||||
| 'spec-regeneration:event'
|
| 'spec-regeneration:event';
|
||||||
| 'issue-validation:event';
|
|
||||||
|
|
||||||
type EventCallback = (payload: unknown) => void;
|
type EventCallback = (payload: unknown) => void;
|
||||||
|
|
||||||
@@ -754,18 +751,6 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
checkRemote: (projectPath: string) => this.post('/api/github/check-remote', { projectPath }),
|
checkRemote: (projectPath: string) => this.post('/api/github/check-remote', { projectPath }),
|
||||||
listIssues: (projectPath: string) => this.post('/api/github/issues', { projectPath }),
|
listIssues: (projectPath: string) => this.post('/api/github/issues', { projectPath }),
|
||||||
listPRs: (projectPath: string) => this.post('/api/github/prs', { projectPath }),
|
listPRs: (projectPath: string) => this.post('/api/github/prs', { projectPath }),
|
||||||
validateIssue: (projectPath: string, issue: IssueValidationInput, model?: string) =>
|
|
||||||
this.post('/api/github/validate-issue', { projectPath, ...issue, model }),
|
|
||||||
getValidationStatus: (projectPath: string, issueNumber?: number) =>
|
|
||||||
this.post('/api/github/validation-status', { projectPath, issueNumber }),
|
|
||||||
stopValidation: (projectPath: string, issueNumber: number) =>
|
|
||||||
this.post('/api/github/validation-stop', { projectPath, issueNumber }),
|
|
||||||
getValidations: (projectPath: string, issueNumber?: number) =>
|
|
||||||
this.post('/api/github/validations', { projectPath, issueNumber }),
|
|
||||||
markValidationViewed: (projectPath: string, issueNumber: number) =>
|
|
||||||
this.post('/api/github/validation-mark-viewed', { projectPath, issueNumber }),
|
|
||||||
onValidationEvent: (callback: (event: IssueValidationEvent) => void) =>
|
|
||||||
this.subscribeToEvent('issue-validation:event', callback as EventCallback),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Workspace API
|
// Workspace API
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createRootRoute, Outlet, useLocation, useNavigate } from '@tanstack/react-router';
|
import { createRootRoute, Outlet, useLocation, useNavigate } from '@tanstack/react-router';
|
||||||
import { useEffect, useState, useCallback, useDeferredValue } from 'react';
|
import { useEffect, useState, useCallback, useDeferredValue } from 'react';
|
||||||
import { Sidebar } from '@/components/layout/sidebar';
|
import { AppLayout } from '@/components/layout/app-layout';
|
||||||
import {
|
import {
|
||||||
FileBrowserProvider,
|
FileBrowserProvider,
|
||||||
useFileBrowser,
|
useFileBrowser,
|
||||||
@@ -159,10 +159,9 @@ function RootLayoutContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex h-screen overflow-hidden" data-testid="app-container">
|
<AppLayout>
|
||||||
<Sidebar />
|
|
||||||
<div
|
<div
|
||||||
className="flex-1 flex flex-col overflow-hidden transition-all duration-300"
|
className="h-full flex flex-col overflow-hidden transition-all duration-300"
|
||||||
style={{ marginRight: streamerPanelOpen ? '250px' : '0' }}
|
style={{ marginRight: streamerPanelOpen ? '250px' : '0' }}
|
||||||
>
|
>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
@@ -170,12 +169,12 @@ function RootLayoutContent() {
|
|||||||
|
|
||||||
{/* Hidden streamer panel - opens with "\" key, pushes content */}
|
{/* Hidden streamer panel - opens with "\" key, pushes content */}
|
||||||
<div
|
<div
|
||||||
className={`fixed top-0 right-0 h-full w-[250px] bg-background border-l border-border transition-transform duration-300 ${
|
className={`fixed top-0 right-0 h-full w-[250px] bg-background border-l border-border transition-transform duration-300 z-50 ${
|
||||||
streamerPanelOpen ? 'translate-x-0' : 'translate-x-full'
|
streamerPanelOpen ? 'translate-x-0' : 'translate-x-full'
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
<Toaster richColors position="bottom-right" />
|
<Toaster richColors position="bottom-right" />
|
||||||
</main>
|
</AppLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,21 @@ import type {
|
|||||||
AgentModel,
|
AgentModel,
|
||||||
PlanningMode,
|
PlanningMode,
|
||||||
AIProfile,
|
AIProfile,
|
||||||
|
ThinkingLevel,
|
||||||
|
ModelProvider,
|
||||||
|
FeatureTextFilePath,
|
||||||
} from '@automaker/types';
|
} from '@automaker/types';
|
||||||
|
|
||||||
// Re-export ThemeMode for convenience
|
export type {
|
||||||
export type { ThemeMode };
|
AgentModel,
|
||||||
|
ThinkingLevel,
|
||||||
|
ModelProvider,
|
||||||
|
AIProfile,
|
||||||
|
PlanningMode,
|
||||||
|
FeatureTextFilePath,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ThemeMode is defined below, no need to re-export here
|
||||||
|
|
||||||
export type ViewMode =
|
export type ViewMode =
|
||||||
| 'welcome'
|
| 'welcome'
|
||||||
@@ -262,13 +273,24 @@ export interface Feature extends Omit<
|
|||||||
titleGenerating?: boolean;
|
titleGenerating?: boolean;
|
||||||
category: string;
|
category: string;
|
||||||
description: string;
|
description: string;
|
||||||
steps: string[]; // Required in UI (not optional)
|
steps?: string[] | undefined; // Optional in UI
|
||||||
status: 'backlog' | 'in_progress' | 'waiting_approval' | 'verified' | 'completed';
|
status: 'backlog' | 'in_progress' | 'waiting_approval' | 'verified' | 'completed';
|
||||||
images?: FeatureImage[]; // UI-specific base64 images
|
images?: FeatureImage[]; // UI-specific base64 images
|
||||||
imagePaths?: FeatureImagePath[]; // Stricter type than base (no string | union)
|
imagePaths?: FeatureImagePath[]; // Stricter type than base (no string | union)
|
||||||
textFilePaths?: FeatureTextFilePath[]; // Text file attachments for context
|
textFilePaths?: FeatureTextFilePath[]; // Text file attachments for context
|
||||||
justFinishedAt?: string; // UI-specific: ISO timestamp when agent just finished
|
justFinishedAt?: string; // UI-specific: ISO timestamp when agent just finished
|
||||||
prUrl?: string; // UI-specific: Pull request URL
|
prUrl?: string; // UI-specific: Pull request URL
|
||||||
|
planSpec?: PlanSpec; // Spec/Plan data
|
||||||
|
planningMode?: PlanningMode; // Planning mode used
|
||||||
|
priority?: number; // Priority (1 is highest)
|
||||||
|
branchName?: string; // Branch associated with feature
|
||||||
|
model?: AgentModel;
|
||||||
|
thinkingLevel?: ThinkingLevel;
|
||||||
|
skipTests?: boolean;
|
||||||
|
requirePlanApproval?: boolean;
|
||||||
|
summary?: string;
|
||||||
|
dependencies?: string[];
|
||||||
|
startedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parsed task from spec (for spec and full planning modes)
|
// Parsed task from spec (for spec and full planning modes)
|
||||||
@@ -475,9 +497,6 @@ export interface AppState {
|
|||||||
// Enhancement Model Settings
|
// Enhancement Model Settings
|
||||||
enhancementModel: AgentModel; // Model used for feature enhancement (default: sonnet)
|
enhancementModel: AgentModel; // Model used for feature enhancement (default: sonnet)
|
||||||
|
|
||||||
// Validation Model Settings
|
|
||||||
validationModel: AgentModel; // Model used for GitHub issue validation (default: opus)
|
|
||||||
|
|
||||||
// Project Analysis
|
// Project Analysis
|
||||||
projectAnalysis: ProjectAnalysis | null;
|
projectAnalysis: ProjectAnalysis | null;
|
||||||
isAnalyzing: boolean;
|
isAnalyzing: boolean;
|
||||||
@@ -748,9 +767,6 @@ export interface AppActions {
|
|||||||
// Enhancement Model actions
|
// Enhancement Model actions
|
||||||
setEnhancementModel: (model: AgentModel) => void;
|
setEnhancementModel: (model: AgentModel) => void;
|
||||||
|
|
||||||
// Validation Model actions
|
|
||||||
setValidationModel: (model: AgentModel) => void;
|
|
||||||
|
|
||||||
// AI Profile actions
|
// AI Profile actions
|
||||||
addAIProfile: (profile: Omit<AIProfile, 'id'>) => void;
|
addAIProfile: (profile: Omit<AIProfile, 'id'>) => void;
|
||||||
updateAIProfile: (id: string, updates: Partial<AIProfile>) => void;
|
updateAIProfile: (id: string, updates: Partial<AIProfile>) => void;
|
||||||
@@ -887,6 +903,9 @@ const DEFAULT_AI_PROFILES: AIProfile[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const initialState: AppState = {
|
const initialState: AppState = {
|
||||||
|
claudeRefreshInterval: 60,
|
||||||
|
claudeUsage: null,
|
||||||
|
claudeUsageLastUpdated: null,
|
||||||
projects: [],
|
projects: [],
|
||||||
currentProject: null,
|
currentProject: null,
|
||||||
trashedProjects: [],
|
trashedProjects: [],
|
||||||
@@ -921,7 +940,6 @@ const initialState: AppState = {
|
|||||||
keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS, // Default keyboard shortcuts
|
keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS, // Default keyboard shortcuts
|
||||||
muteDoneSound: false, // Default to sound enabled (not muted)
|
muteDoneSound: false, // Default to sound enabled (not muted)
|
||||||
enhancementModel: 'sonnet', // Default to sonnet for feature enhancement
|
enhancementModel: 'sonnet', // Default to sonnet for feature enhancement
|
||||||
validationModel: 'opus', // Default to opus for GitHub issue validation
|
|
||||||
aiProfiles: DEFAULT_AI_PROFILES,
|
aiProfiles: DEFAULT_AI_PROFILES,
|
||||||
projectAnalysis: null,
|
projectAnalysis: null,
|
||||||
isAnalyzing: false,
|
isAnalyzing: false,
|
||||||
@@ -1544,9 +1562,6 @@ export const useAppStore = create<AppState & AppActions>()(
|
|||||||
// Enhancement Model actions
|
// Enhancement Model actions
|
||||||
setEnhancementModel: (model) => set({ enhancementModel: model }),
|
setEnhancementModel: (model) => set({ enhancementModel: model }),
|
||||||
|
|
||||||
// Validation Model actions
|
|
||||||
setValidationModel: (model) => set({ validationModel: model }),
|
|
||||||
|
|
||||||
// AI Profile actions
|
// AI Profile actions
|
||||||
addAIProfile: (profile) => {
|
addAIProfile: (profile) => {
|
||||||
const id = `profile-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
const id = `profile-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
@@ -2676,10 +2691,7 @@ export const useAppStore = create<AppState & AppActions>()(
|
|||||||
kanbanCardDetailLevel: state.kanbanCardDetailLevel,
|
kanbanCardDetailLevel: state.kanbanCardDetailLevel,
|
||||||
boardViewMode: state.boardViewMode,
|
boardViewMode: state.boardViewMode,
|
||||||
// Settings
|
// Settings
|
||||||
// NOTE: apiKeys are intentionally NOT persisted to localStorage for security.
|
apiKeys: state.apiKeys,
|
||||||
// API keys are stored server-side only via the storeApiKey API to prevent
|
|
||||||
// exposure through XSS attacks. The apiKeys state is populated on app load
|
|
||||||
// from the secure server-side storage.
|
|
||||||
maxConcurrency: state.maxConcurrency,
|
maxConcurrency: state.maxConcurrency,
|
||||||
// Note: autoModeByProject is intentionally NOT persisted
|
// Note: autoModeByProject is intentionally NOT persisted
|
||||||
// Auto-mode should always default to OFF on app refresh
|
// Auto-mode should always default to OFF on app refresh
|
||||||
@@ -2692,7 +2704,6 @@ export const useAppStore = create<AppState & AppActions>()(
|
|||||||
keyboardShortcuts: state.keyboardShortcuts,
|
keyboardShortcuts: state.keyboardShortcuts,
|
||||||
muteDoneSound: state.muteDoneSound,
|
muteDoneSound: state.muteDoneSound,
|
||||||
enhancementModel: state.enhancementModel,
|
enhancementModel: state.enhancementModel,
|
||||||
validationModel: state.validationModel,
|
|
||||||
// Profiles and sessions
|
// Profiles and sessions
|
||||||
aiProfiles: state.aiProfiles,
|
aiProfiles: state.aiProfiles,
|
||||||
chatSessions: state.chatSessions,
|
chatSessions: state.chatSessions,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
1065
index (28).html
Normal file
1065
index (28).html
Normal file
File diff suppressed because it is too large
Load Diff
@@ -13,9 +13,6 @@ export {
|
|||||||
getImagesDir,
|
getImagesDir,
|
||||||
getContextDir,
|
getContextDir,
|
||||||
getWorktreesDir,
|
getWorktreesDir,
|
||||||
getValidationsDir,
|
|
||||||
getValidationDir,
|
|
||||||
getValidationPath,
|
|
||||||
getAppSpecPath,
|
getAppSpecPath,
|
||||||
getBranchTrackingPath,
|
getBranchTrackingPath,
|
||||||
ensureAutomakerDir,
|
ensureAutomakerDir,
|
||||||
|
|||||||
@@ -111,44 +111,6 @@ export function getWorktreesDir(projectPath: string): string {
|
|||||||
return path.join(getAutomakerDir(projectPath), 'worktrees');
|
return path.join(getAutomakerDir(projectPath), 'worktrees');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the validations directory for a project
|
|
||||||
*
|
|
||||||
* Stores GitHub issue validation results, organized by issue number.
|
|
||||||
*
|
|
||||||
* @param projectPath - Absolute path to project directory
|
|
||||||
* @returns Absolute path to {projectPath}/.automaker/validations
|
|
||||||
*/
|
|
||||||
export function getValidationsDir(projectPath: string): string {
|
|
||||||
return path.join(getAutomakerDir(projectPath), 'validations');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the directory for a specific issue validation
|
|
||||||
*
|
|
||||||
* Contains validation result and metadata for a GitHub issue.
|
|
||||||
*
|
|
||||||
* @param projectPath - Absolute path to project directory
|
|
||||||
* @param issueNumber - GitHub issue number
|
|
||||||
* @returns Absolute path to {projectPath}/.automaker/validations/{issueNumber}
|
|
||||||
*/
|
|
||||||
export function getValidationDir(projectPath: string, issueNumber: number): string {
|
|
||||||
return path.join(getValidationsDir(projectPath), String(issueNumber));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the validation result file path for a GitHub issue
|
|
||||||
*
|
|
||||||
* Stores the JSON validation result including verdict, analysis, and metadata.
|
|
||||||
*
|
|
||||||
* @param projectPath - Absolute path to project directory
|
|
||||||
* @param issueNumber - GitHub issue number
|
|
||||||
* @returns Absolute path to {projectPath}/.automaker/validations/{issueNumber}/validation.json
|
|
||||||
*/
|
|
||||||
export function getValidationPath(projectPath: string, issueNumber: number): string {
|
|
||||||
return path.join(getValidationDir(projectPath, issueNumber), 'validation.json');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the app spec file path for a project
|
* Get the app spec file path for a project
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -4,46 +4,29 @@
|
|||||||
* All file I/O operations must go through this adapter to enforce
|
* All file I/O operations must go through this adapter to enforce
|
||||||
* ALLOWED_ROOT_DIRECTORY restrictions at the actual access point,
|
* ALLOWED_ROOT_DIRECTORY restrictions at the actual access point,
|
||||||
* not just at the API layer. This provides defense-in-depth security.
|
* not just at the API layer. This provides defense-in-depth security.
|
||||||
*
|
|
||||||
* Security features:
|
|
||||||
* - Path validation: All paths are validated against allowed directories
|
|
||||||
* - Symlink protection: Operations on existing files resolve symlinks before validation
|
|
||||||
* to prevent directory escape attacks via symbolic links
|
|
||||||
*
|
|
||||||
* TOCTOU (Time-of-check to time-of-use) note:
|
|
||||||
* There is an inherent race condition between path validation and the actual file
|
|
||||||
* operation. To mitigate this, we use the validated realpath (symlinks resolved)
|
|
||||||
* for the actual operation wherever possible. However, this cannot fully prevent
|
|
||||||
* race conditions in a multi-process environment. For maximum security in
|
|
||||||
* high-risk scenarios, consider using file descriptor-based operations or
|
|
||||||
* additional locking mechanisms.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import fs from 'fs/promises';
|
import fs from 'fs/promises';
|
||||||
import type { Dirent } from 'fs';
|
import type { Dirent } from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { validatePath, validatePathWithSymlinkCheck } from './security.js';
|
import { validatePath } from './security.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wrapper around fs.access that validates path first
|
* Wrapper around fs.access that validates path first
|
||||||
* Uses symlink-aware validation to prevent directory escape attacks
|
|
||||||
*/
|
*/
|
||||||
export async function access(filePath: string, mode?: number): Promise<void> {
|
export async function access(filePath: string, mode?: number): Promise<void> {
|
||||||
// Use symlink check since we're checking an existing path
|
const validatedPath = validatePath(filePath);
|
||||||
const validatedPath = validatePathWithSymlinkCheck(filePath);
|
|
||||||
return fs.access(validatedPath, mode);
|
return fs.access(validatedPath, mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wrapper around fs.readFile that validates path first
|
* Wrapper around fs.readFile that validates path first
|
||||||
* Uses symlink-aware validation to prevent reading files outside allowed directories
|
|
||||||
*/
|
*/
|
||||||
export async function readFile(
|
export async function readFile(
|
||||||
filePath: string,
|
filePath: string,
|
||||||
encoding?: BufferEncoding
|
encoding?: BufferEncoding
|
||||||
): Promise<string | Buffer> {
|
): Promise<string | Buffer> {
|
||||||
// Use symlink check since we're reading an existing file
|
const validatedPath = validatePath(filePath);
|
||||||
const validatedPath = validatePathWithSymlinkCheck(filePath);
|
|
||||||
if (encoding) {
|
if (encoding) {
|
||||||
return fs.readFile(validatedPath, encoding);
|
return fs.readFile(validatedPath, encoding);
|
||||||
}
|
}
|
||||||
@@ -52,34 +35,29 @@ export async function readFile(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Wrapper around fs.writeFile that validates path first
|
* Wrapper around fs.writeFile that validates path first
|
||||||
* Uses symlink-aware validation for existing files, or validates parent for new files
|
|
||||||
*/
|
*/
|
||||||
export async function writeFile(
|
export async function writeFile(
|
||||||
filePath: string,
|
filePath: string,
|
||||||
data: string | Buffer,
|
data: string | Buffer,
|
||||||
encoding?: BufferEncoding
|
encoding?: BufferEncoding
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Use symlink check with requireExists=false to handle both new and existing files
|
const validatedPath = validatePath(filePath);
|
||||||
const validatedPath = validatePathWithSymlinkCheck(filePath, { requireExists: false });
|
|
||||||
return fs.writeFile(validatedPath, data, encoding);
|
return fs.writeFile(validatedPath, data, encoding);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wrapper around fs.mkdir that validates path first
|
* Wrapper around fs.mkdir that validates path first
|
||||||
* Uses symlink-aware validation for parent directory to prevent creating dirs via symlink escape
|
|
||||||
*/
|
*/
|
||||||
export async function mkdir(
|
export async function mkdir(
|
||||||
dirPath: string,
|
dirPath: string,
|
||||||
options?: { recursive?: boolean; mode?: number }
|
options?: { recursive?: boolean; mode?: number }
|
||||||
): Promise<string | undefined> {
|
): Promise<string | undefined> {
|
||||||
// Use symlink check with requireExists=false since directory may not exist yet
|
const validatedPath = validatePath(dirPath);
|
||||||
const validatedPath = validatePathWithSymlinkCheck(dirPath, { requireExists: false });
|
|
||||||
return fs.mkdir(validatedPath, options);
|
return fs.mkdir(validatedPath, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wrapper around fs.readdir that validates path first
|
* Wrapper around fs.readdir that validates path first
|
||||||
* Uses symlink-aware validation to prevent listing directories outside allowed paths
|
|
||||||
*/
|
*/
|
||||||
export async function readdir(
|
export async function readdir(
|
||||||
dirPath: string,
|
dirPath: string,
|
||||||
@@ -93,8 +71,7 @@ export async function readdir(
|
|||||||
dirPath: string,
|
dirPath: string,
|
||||||
options?: { withFileTypes?: boolean; encoding?: BufferEncoding }
|
options?: { withFileTypes?: boolean; encoding?: BufferEncoding }
|
||||||
): Promise<string[] | Dirent[]> {
|
): Promise<string[] | Dirent[]> {
|
||||||
// Use symlink check since we're reading an existing directory
|
const validatedPath = validatePath(dirPath);
|
||||||
const validatedPath = validatePathWithSymlinkCheck(dirPath);
|
|
||||||
if (options?.withFileTypes === true) {
|
if (options?.withFileTypes === true) {
|
||||||
return fs.readdir(validatedPath, { withFileTypes: true });
|
return fs.readdir(validatedPath, { withFileTypes: true });
|
||||||
}
|
}
|
||||||
@@ -103,85 +80,66 @@ export async function readdir(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Wrapper around fs.stat that validates path first
|
* Wrapper around fs.stat that validates path first
|
||||||
* Uses symlink-aware validation to prevent stat on files outside allowed paths
|
|
||||||
*/
|
*/
|
||||||
export async function stat(filePath: string): Promise<any> {
|
export async function stat(filePath: string): Promise<any> {
|
||||||
// Use symlink check since we're getting info about an existing file
|
const validatedPath = validatePath(filePath);
|
||||||
const validatedPath = validatePathWithSymlinkCheck(filePath);
|
|
||||||
return fs.stat(validatedPath);
|
return fs.stat(validatedPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wrapper around fs.rm that validates path first
|
* Wrapper around fs.rm that validates path first
|
||||||
* Uses symlink-aware validation to prevent deleting files/directories outside allowed paths
|
|
||||||
*/
|
*/
|
||||||
export async function rm(
|
export async function rm(
|
||||||
filePath: string,
|
filePath: string,
|
||||||
options?: { recursive?: boolean; force?: boolean }
|
options?: { recursive?: boolean; force?: boolean }
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Use symlink check since we're removing an existing file/directory
|
const validatedPath = validatePath(filePath);
|
||||||
const validatedPath = validatePathWithSymlinkCheck(filePath);
|
|
||||||
return fs.rm(validatedPath, options);
|
return fs.rm(validatedPath, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wrapper around fs.unlink that validates path first
|
* Wrapper around fs.unlink that validates path first
|
||||||
* Uses symlink-aware validation to prevent unlinking files outside allowed paths
|
|
||||||
*/
|
*/
|
||||||
export async function unlink(filePath: string): Promise<void> {
|
export async function unlink(filePath: string): Promise<void> {
|
||||||
// Use symlink check since we're unlinking an existing file
|
const validatedPath = validatePath(filePath);
|
||||||
const validatedPath = validatePathWithSymlinkCheck(filePath);
|
|
||||||
return fs.unlink(validatedPath);
|
return fs.unlink(validatedPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wrapper around fs.copyFile that validates both paths first
|
* Wrapper around fs.copyFile that validates both paths first
|
||||||
* Uses symlink-aware validation for source, and parent validation for destination
|
|
||||||
*/
|
*/
|
||||||
export async function copyFile(src: string, dest: string, mode?: number): Promise<void> {
|
export async function copyFile(src: string, dest: string, mode?: number): Promise<void> {
|
||||||
// Source must exist, use symlink check
|
const validatedSrc = validatePath(src);
|
||||||
const validatedSrc = validatePathWithSymlinkCheck(src);
|
const validatedDest = validatePath(dest);
|
||||||
// Destination may not exist, validate with parent fallback
|
|
||||||
const validatedDest = validatePathWithSymlinkCheck(dest, { requireExists: false });
|
|
||||||
return fs.copyFile(validatedSrc, validatedDest, mode);
|
return fs.copyFile(validatedSrc, validatedDest, mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wrapper around fs.appendFile that validates path first
|
* Wrapper around fs.appendFile that validates path first
|
||||||
* Uses symlink-aware validation for existing files, or validates parent for new files
|
|
||||||
*/
|
*/
|
||||||
export async function appendFile(
|
export async function appendFile(
|
||||||
filePath: string,
|
filePath: string,
|
||||||
data: string | Buffer,
|
data: string | Buffer,
|
||||||
encoding?: BufferEncoding
|
encoding?: BufferEncoding
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// File may or may not exist, use symlink check with parent fallback
|
const validatedPath = validatePath(filePath);
|
||||||
const validatedPath = validatePathWithSymlinkCheck(filePath, { requireExists: false });
|
|
||||||
return fs.appendFile(validatedPath, data, encoding);
|
return fs.appendFile(validatedPath, data, encoding);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wrapper around fs.rename that validates both paths first
|
* Wrapper around fs.rename that validates both paths first
|
||||||
* Uses symlink-aware validation for source, and parent validation for destination
|
|
||||||
*/
|
*/
|
||||||
export async function rename(oldPath: string, newPath: string): Promise<void> {
|
export async function rename(oldPath: string, newPath: string): Promise<void> {
|
||||||
// Source must exist, use symlink check
|
const validatedOldPath = validatePath(oldPath);
|
||||||
const validatedOldPath = validatePathWithSymlinkCheck(oldPath);
|
const validatedNewPath = validatePath(newPath);
|
||||||
// Destination may not exist, validate with parent fallback
|
|
||||||
const validatedNewPath = validatePathWithSymlinkCheck(newPath, { requireExists: false });
|
|
||||||
return fs.rename(validatedOldPath, validatedNewPath);
|
return fs.rename(validatedOldPath, validatedNewPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wrapper around fs.lstat that validates path first
|
* Wrapper around fs.lstat that validates path first
|
||||||
* Returns file stats without following symbolic links
|
* Returns file stats without following symbolic links
|
||||||
*
|
|
||||||
* Note: This intentionally uses validatePath (not validatePathWithSymlinkCheck)
|
|
||||||
* because lstat is used to inspect symlinks themselves. Using realpathSync
|
|
||||||
* would defeat the purpose of lstat.
|
|
||||||
*/
|
*/
|
||||||
export async function lstat(filePath: string): Promise<any> {
|
export async function lstat(filePath: string): Promise<any> {
|
||||||
// Use basic validation since lstat is for inspecting symlinks
|
|
||||||
const validatedPath = validatePath(filePath);
|
const validatedPath = validatePath(filePath);
|
||||||
return fs.lstat(validatedPath);
|
return fs.lstat(validatedPath);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Security utilities for path validation
|
* Security utilities for path validation
|
||||||
* Enforces ALLOWED_ROOT_DIRECTORY constraint with appData exception
|
* Enforces ALLOWED_ROOT_DIRECTORY constraint with appData exception
|
||||||
*
|
|
||||||
* Security considerations:
|
|
||||||
* - Symlink resolution: validatePathWithSymlinkCheck() resolves symlinks to prevent
|
|
||||||
* escaping the allowed directory via symbolic links
|
|
||||||
* - TOCTOU: There is an inherent race condition between path validation and file
|
|
||||||
* operation. Callers should use the resolved realpath for operations when possible.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Security mode: 'strict' fails closed when ALLOWED_ROOT_DIRECTORY is not set,
|
|
||||||
* 'permissive' allows all paths (legacy behavior, not recommended for production)
|
|
||||||
*/
|
|
||||||
let securityMode: 'strict' | 'permissive' = 'strict';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Error thrown when a path is not allowed by security policy
|
* Error thrown when a path is not allowed by security policy
|
||||||
@@ -38,43 +25,22 @@ let dataDirectory: string | null = null;
|
|||||||
* Initialize security settings from environment variables
|
* Initialize security settings from environment variables
|
||||||
* - ALLOWED_ROOT_DIRECTORY: main security boundary
|
* - ALLOWED_ROOT_DIRECTORY: main security boundary
|
||||||
* - DATA_DIR: appData exception, always allowed
|
* - DATA_DIR: appData exception, always allowed
|
||||||
* - SECURITY_MODE: 'strict' (default, fail-closed) or 'permissive' (legacy, fail-open)
|
|
||||||
*/
|
*/
|
||||||
export function initAllowedPaths(): void {
|
export function initAllowedPaths(): void {
|
||||||
// Load security mode
|
|
||||||
const mode = process.env.SECURITY_MODE?.toLowerCase();
|
|
||||||
if (mode === 'permissive') {
|
|
||||||
securityMode = 'permissive';
|
|
||||||
console.warn(
|
|
||||||
'[Security] WARNING: Running in PERMISSIVE mode - all paths allowed when ALLOWED_ROOT_DIRECTORY is not set. ' +
|
|
||||||
'This is not recommended for production environments.'
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
securityMode = 'strict';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load ALLOWED_ROOT_DIRECTORY
|
// Load ALLOWED_ROOT_DIRECTORY
|
||||||
const rootDir = process.env.ALLOWED_ROOT_DIRECTORY;
|
const rootDir = process.env.ALLOWED_ROOT_DIRECTORY;
|
||||||
if (rootDir) {
|
if (rootDir) {
|
||||||
allowedRootDirectory = path.resolve(rootDir);
|
allowedRootDirectory = path.resolve(rootDir);
|
||||||
console.log(`[Security] ALLOWED_ROOT_DIRECTORY configured: ${allowedRootDirectory}`);
|
console.log(`[Security] ✓ ALLOWED_ROOT_DIRECTORY configured: ${allowedRootDirectory}`);
|
||||||
} else if (securityMode === 'strict') {
|
|
||||||
console.error(
|
|
||||||
'[Security] CRITICAL: ALLOWED_ROOT_DIRECTORY not set in strict mode. ' +
|
|
||||||
'All file operations outside DATA_DIR will be denied. ' +
|
|
||||||
'Set ALLOWED_ROOT_DIRECTORY or use SECURITY_MODE=permissive to allow all paths.'
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
console.warn(
|
console.log('[Security] ⚠️ ALLOWED_ROOT_DIRECTORY not set - allowing access to all paths');
|
||||||
'[Security] WARNING: ALLOWED_ROOT_DIRECTORY not set - allowing access to all paths'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load DATA_DIR (appData exception - always allowed)
|
// Load DATA_DIR (appData exception - always allowed)
|
||||||
const dataDir = process.env.DATA_DIR;
|
const dataDir = process.env.DATA_DIR;
|
||||||
if (dataDir) {
|
if (dataDir) {
|
||||||
dataDirectory = path.resolve(dataDir);
|
dataDirectory = path.resolve(dataDir);
|
||||||
console.log(`[Security] DATA_DIR configured: ${dataDirectory}`);
|
console.log(`[Security] ✓ DATA_DIR configured: ${dataDirectory}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,10 +49,7 @@ export function initAllowedPaths(): void {
|
|||||||
* Returns true if:
|
* Returns true if:
|
||||||
* - Path is within ALLOWED_ROOT_DIRECTORY, OR
|
* - Path is within ALLOWED_ROOT_DIRECTORY, OR
|
||||||
* - Path is within DATA_DIR (appData exception), OR
|
* - Path is within DATA_DIR (appData exception), OR
|
||||||
* - No restrictions are configured AND security mode is 'permissive'
|
* - No restrictions are configured (backward compatibility)
|
||||||
*
|
|
||||||
* In strict mode (default), paths are denied if ALLOWED_ROOT_DIRECTORY is not set,
|
|
||||||
* unless they are within DATA_DIR.
|
|
||||||
*/
|
*/
|
||||||
export function isPathAllowed(filePath: string): boolean {
|
export function isPathAllowed(filePath: string): boolean {
|
||||||
const resolvedPath = path.resolve(filePath);
|
const resolvedPath = path.resolve(filePath);
|
||||||
@@ -96,29 +59,24 @@ export function isPathAllowed(filePath: string): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no ALLOWED_ROOT_DIRECTORY restriction is configured:
|
// If no ALLOWED_ROOT_DIRECTORY restriction is configured, allow all paths
|
||||||
// - In strict mode: deny (fail-closed)
|
// Note: DATA_DIR is checked above as an exception, but doesn't restrict other paths
|
||||||
// - In permissive mode: allow all paths (legacy behavior)
|
|
||||||
if (!allowedRootDirectory) {
|
if (!allowedRootDirectory) {
|
||||||
return securityMode === 'permissive';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Allow if within ALLOWED_ROOT_DIRECTORY
|
|
||||||
if (isPathWithinDirectory(resolvedPath, allowedRootDirectory)) {
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path doesn't match any allowed directory, deny
|
// Allow if within ALLOWED_ROOT_DIRECTORY
|
||||||
|
if (allowedRootDirectory && isPathWithinDirectory(resolvedPath, allowedRootDirectory)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If restrictions are configured but path doesn't match, deny
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate a path - resolves it and checks permissions
|
* Validate a path - resolves it and checks permissions
|
||||||
* Throws PathNotAllowedError if path is not allowed
|
* Throws PathNotAllowedError if path is not allowed
|
||||||
*
|
|
||||||
* NOTE: This function uses path.resolve() which does NOT resolve symbolic links.
|
|
||||||
* For operations on existing files where symlink attacks are a concern, use
|
|
||||||
* validatePathWithSymlinkCheck() instead.
|
|
||||||
*/
|
*/
|
||||||
export function validatePath(filePath: string): string {
|
export function validatePath(filePath: string): string {
|
||||||
const resolvedPath = path.resolve(filePath);
|
const resolvedPath = path.resolve(filePath);
|
||||||
@@ -130,74 +88,6 @@ export function validatePath(filePath: string): string {
|
|||||||
return resolvedPath;
|
return resolvedPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate a path with symlink resolution for existing files
|
|
||||||
* This prevents symlink-based directory escape attacks by resolving the
|
|
||||||
* actual filesystem path before validation.
|
|
||||||
*
|
|
||||||
* @param filePath - The path to validate
|
|
||||||
* @param options.requireExists - If true (default), throws if path doesn't exist.
|
|
||||||
* If false, falls back to validatePath for non-existent paths.
|
|
||||||
* @returns The real path (symlinks resolved) if file exists, or resolved path if not
|
|
||||||
* @throws PathNotAllowedError if the real path escapes allowed directories
|
|
||||||
*
|
|
||||||
* Security note: There is still a TOCTOU race between this check and the actual
|
|
||||||
* file operation. For maximum security, callers should use the returned realpath
|
|
||||||
* for the subsequent operation, not the original path.
|
|
||||||
*/
|
|
||||||
export function validatePathWithSymlinkCheck(
|
|
||||||
filePath: string,
|
|
||||||
options: { requireExists?: boolean } = {}
|
|
||||||
): string {
|
|
||||||
const { requireExists = true } = options;
|
|
||||||
const resolvedPath = path.resolve(filePath);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Check if path exists and get info without following symlinks
|
|
||||||
const lstats = fs.lstatSync(resolvedPath);
|
|
||||||
|
|
||||||
// Get the real path (resolves all symlinks)
|
|
||||||
const realPath = fs.realpathSync(resolvedPath);
|
|
||||||
|
|
||||||
// Validate the real path, not the symlink path
|
|
||||||
if (!isPathAllowed(realPath)) {
|
|
||||||
throw new PathNotAllowedError(`${filePath} (resolves to ${realPath} via symlink)`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If it's a symlink, log for security auditing
|
|
||||||
if (lstats.isSymbolicLink()) {
|
|
||||||
console.log(`[Security] Symlink detected: ${resolvedPath} -> ${realPath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return realPath;
|
|
||||||
} catch (error) {
|
|
||||||
// Handle file not found
|
|
||||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
||||||
if (requireExists) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
// For new files, validate the parent directory with symlink check if it exists
|
|
||||||
const parentDir = path.dirname(resolvedPath);
|
|
||||||
try {
|
|
||||||
const realParentPath = fs.realpathSync(parentDir);
|
|
||||||
if (!isPathAllowed(realParentPath)) {
|
|
||||||
throw new PathNotAllowedError(`${filePath} (parent resolves to ${realParentPath})`);
|
|
||||||
}
|
|
||||||
// Return the path within the real parent
|
|
||||||
return path.join(realParentPath, path.basename(resolvedPath));
|
|
||||||
} catch (parentError) {
|
|
||||||
// Parent doesn't exist either, fall back to basic validation
|
|
||||||
if ((parentError as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
||||||
return validatePath(filePath);
|
|
||||||
}
|
|
||||||
throw parentError;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Re-throw PathNotAllowedError and other errors
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a path is within a directory, with protection against path traversal
|
* Check if a path is within a directory, with protection against path traversal
|
||||||
* Returns true only if resolvedPath is within directoryPath
|
* Returns true only if resolvedPath is within directoryPath
|
||||||
|
|||||||
@@ -95,57 +95,27 @@ describe('security.ts', () => {
|
|||||||
expect(isPathAllowed('/app/data/credentials.json')).toBe(true);
|
expect(isPathAllowed('/app/data/credentials.json')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should deny all paths in strict mode when no restrictions configured', async () => {
|
it('should allow all paths when no restrictions configured', async () => {
|
||||||
delete process.env.ALLOWED_ROOT_DIRECTORY;
|
delete process.env.ALLOWED_ROOT_DIRECTORY;
|
||||||
delete process.env.DATA_DIR;
|
delete process.env.DATA_DIR;
|
||||||
delete process.env.SECURITY_MODE; // Default to strict
|
|
||||||
|
|
||||||
const { initAllowedPaths, isPathAllowed } = await import('../src/security');
|
const { initAllowedPaths, isPathAllowed } = await import('../src/security');
|
||||||
initAllowedPaths();
|
initAllowedPaths();
|
||||||
|
|
||||||
// In strict mode, paths are denied when no ALLOWED_ROOT_DIRECTORY is set
|
|
||||||
expect(isPathAllowed('/any/path')).toBe(false);
|
|
||||||
expect(isPathAllowed('/etc/passwd')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should allow all paths in permissive mode when no restrictions configured', async () => {
|
|
||||||
delete process.env.ALLOWED_ROOT_DIRECTORY;
|
|
||||||
delete process.env.DATA_DIR;
|
|
||||||
process.env.SECURITY_MODE = 'permissive';
|
|
||||||
|
|
||||||
const { initAllowedPaths, isPathAllowed } = await import('../src/security');
|
|
||||||
initAllowedPaths();
|
|
||||||
|
|
||||||
// In permissive mode, all paths are allowed when no restrictions configured
|
|
||||||
expect(isPathAllowed('/any/path')).toBe(true);
|
expect(isPathAllowed('/any/path')).toBe(true);
|
||||||
expect(isPathAllowed('/etc/passwd')).toBe(true);
|
expect(isPathAllowed('/etc/passwd')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should deny non-DATA_DIR paths in strict mode when only DATA_DIR is configured', async () => {
|
it('should allow all paths when only DATA_DIR is configured', async () => {
|
||||||
delete process.env.ALLOWED_ROOT_DIRECTORY;
|
delete process.env.ALLOWED_ROOT_DIRECTORY;
|
||||||
process.env.DATA_DIR = '/data';
|
process.env.DATA_DIR = '/data';
|
||||||
delete process.env.SECURITY_MODE; // Default to strict
|
|
||||||
|
|
||||||
const { initAllowedPaths, isPathAllowed } = await import('../src/security');
|
const { initAllowedPaths, isPathAllowed } = await import('../src/security');
|
||||||
initAllowedPaths();
|
initAllowedPaths();
|
||||||
|
|
||||||
// DATA_DIR should be allowed
|
// DATA_DIR should be allowed
|
||||||
expect(isPathAllowed('/data/file.txt')).toBe(true);
|
expect(isPathAllowed('/data/file.txt')).toBe(true);
|
||||||
// Other paths should be denied in strict mode
|
// And all other paths should be allowed since no ALLOWED_ROOT_DIRECTORY restriction
|
||||||
expect(isPathAllowed('/any/path')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should allow all paths in permissive mode when only DATA_DIR is configured', async () => {
|
|
||||||
delete process.env.ALLOWED_ROOT_DIRECTORY;
|
|
||||||
process.env.DATA_DIR = '/data';
|
|
||||||
process.env.SECURITY_MODE = 'permissive';
|
|
||||||
|
|
||||||
const { initAllowedPaths, isPathAllowed } = await import('../src/security');
|
|
||||||
initAllowedPaths();
|
|
||||||
|
|
||||||
// DATA_DIR should be allowed
|
|
||||||
expect(isPathAllowed('/data/file.txt')).toBe(true);
|
|
||||||
// Other paths should also be allowed in permissive mode
|
|
||||||
expect(isPathAllowed('/any/path')).toBe(true);
|
expect(isPathAllowed('/any/path')).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -185,28 +155,13 @@ describe('security.ts', () => {
|
|||||||
expect(result).toBe(path.resolve(cwd, './file.txt'));
|
expect(result).toBe(path.resolve(cwd, './file.txt'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw in strict mode when no restrictions configured', async () => {
|
it('should not throw when no restrictions configured', async () => {
|
||||||
delete process.env.ALLOWED_ROOT_DIRECTORY;
|
delete process.env.ALLOWED_ROOT_DIRECTORY;
|
||||||
delete process.env.DATA_DIR;
|
delete process.env.DATA_DIR;
|
||||||
delete process.env.SECURITY_MODE; // Default to strict
|
|
||||||
|
|
||||||
const { initAllowedPaths, validatePath, PathNotAllowedError } =
|
|
||||||
await import('../src/security');
|
|
||||||
initAllowedPaths();
|
|
||||||
|
|
||||||
// In strict mode, paths are denied when no ALLOWED_ROOT_DIRECTORY is set
|
|
||||||
expect(() => validatePath('/any/path')).toThrow(PathNotAllowedError);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not throw in permissive mode when no restrictions configured', async () => {
|
|
||||||
delete process.env.ALLOWED_ROOT_DIRECTORY;
|
|
||||||
delete process.env.DATA_DIR;
|
|
||||||
process.env.SECURITY_MODE = 'permissive';
|
|
||||||
|
|
||||||
const { initAllowedPaths, validatePath } = await import('../src/security');
|
const { initAllowedPaths, validatePath } = await import('../src/security');
|
||||||
initAllowedPaths();
|
initAllowedPaths();
|
||||||
|
|
||||||
// In permissive mode, all paths are allowed when no restrictions configured
|
|
||||||
expect(() => validatePath('/any/path')).not.toThrow();
|
expect(() => validatePath('/any/path')).not.toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -257,110 +212,6 @@ describe('security.ts', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('path traversal attack prevention', () => {
|
|
||||||
it('should block basic path traversal with ../', async () => {
|
|
||||||
process.env.ALLOWED_ROOT_DIRECTORY = '/allowed';
|
|
||||||
delete process.env.DATA_DIR;
|
|
||||||
|
|
||||||
const { initAllowedPaths, isPathAllowed } = await import('../src/security');
|
|
||||||
initAllowedPaths();
|
|
||||||
|
|
||||||
expect(isPathAllowed('/allowed/../etc/passwd')).toBe(false);
|
|
||||||
expect(isPathAllowed('/allowed/subdir/../../etc/passwd')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should block path traversal with multiple ../ sequences', async () => {
|
|
||||||
process.env.ALLOWED_ROOT_DIRECTORY = '/allowed/deep/nested';
|
|
||||||
delete process.env.DATA_DIR;
|
|
||||||
|
|
||||||
const { initAllowedPaths, isPathAllowed } = await import('../src/security');
|
|
||||||
initAllowedPaths();
|
|
||||||
|
|
||||||
expect(isPathAllowed('/allowed/deep/nested/../../../etc/passwd')).toBe(false);
|
|
||||||
expect(isPathAllowed('/allowed/deep/nested/../../../../root')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should block standalone .. in path components', async () => {
|
|
||||||
process.env.ALLOWED_ROOT_DIRECTORY = '/allowed';
|
|
||||||
delete process.env.DATA_DIR;
|
|
||||||
|
|
||||||
const { initAllowedPaths, isPathAllowed } = await import('../src/security');
|
|
||||||
initAllowedPaths();
|
|
||||||
|
|
||||||
expect(isPathAllowed('/allowed/foo/..bar')).toBe(true); // This is a valid filename, not traversal
|
|
||||||
expect(isPathAllowed('/allowed/foo/../bar')).toBe(true); // Resolves within allowed
|
|
||||||
expect(isPathAllowed('/allowed/../notallowed')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle edge case of path ending with /..', async () => {
|
|
||||||
process.env.ALLOWED_ROOT_DIRECTORY = '/allowed/subdir';
|
|
||||||
delete process.env.DATA_DIR;
|
|
||||||
|
|
||||||
const { initAllowedPaths, isPathAllowed } = await import('../src/security');
|
|
||||||
initAllowedPaths();
|
|
||||||
|
|
||||||
expect(isPathAllowed('/allowed/subdir/..')).toBe(false);
|
|
||||||
expect(isPathAllowed('/allowed/subdir/../..')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should properly resolve and block complex traversal attempts', async () => {
|
|
||||||
process.env.ALLOWED_ROOT_DIRECTORY = '/home/user/projects';
|
|
||||||
delete process.env.DATA_DIR;
|
|
||||||
|
|
||||||
const { initAllowedPaths, isPathAllowed } = await import('../src/security');
|
|
||||||
initAllowedPaths();
|
|
||||||
|
|
||||||
// Attempt to escape via complex path
|
|
||||||
expect(isPathAllowed('/home/user/projects/app/../../../etc/shadow')).toBe(false);
|
|
||||||
|
|
||||||
// Valid path that uses .. but stays within allowed
|
|
||||||
expect(isPathAllowed('/home/user/projects/app/../lib/file.ts')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should validate path throws on traversal attacks', async () => {
|
|
||||||
process.env.ALLOWED_ROOT_DIRECTORY = '/allowed';
|
|
||||||
delete process.env.DATA_DIR;
|
|
||||||
|
|
||||||
const { initAllowedPaths, validatePath, PathNotAllowedError } =
|
|
||||||
await import('../src/security');
|
|
||||||
initAllowedPaths();
|
|
||||||
|
|
||||||
expect(() => validatePath('/allowed/../etc/passwd')).toThrow(PathNotAllowedError);
|
|
||||||
expect(() => validatePath('/allowed/../../root/.ssh/id_rsa')).toThrow(PathNotAllowedError);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle paths with mixed separators (cross-platform)', async () => {
|
|
||||||
process.env.ALLOWED_ROOT_DIRECTORY = '/allowed';
|
|
||||||
delete process.env.DATA_DIR;
|
|
||||||
|
|
||||||
const { initAllowedPaths, isPathAllowed } = await import('../src/security');
|
|
||||||
initAllowedPaths();
|
|
||||||
|
|
||||||
// Node's path.resolve handles these correctly on each platform
|
|
||||||
const maliciousPath = path.resolve('/allowed', '..', 'etc', 'passwd');
|
|
||||||
expect(isPathAllowed(maliciousPath)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should correctly identify paths at the boundary', async () => {
|
|
||||||
process.env.ALLOWED_ROOT_DIRECTORY = '/allowed';
|
|
||||||
delete process.env.DATA_DIR;
|
|
||||||
|
|
||||||
const { initAllowedPaths, isPathAllowed } = await import('../src/security');
|
|
||||||
initAllowedPaths();
|
|
||||||
|
|
||||||
// The allowed directory itself should be allowed
|
|
||||||
expect(isPathAllowed('/allowed')).toBe(true);
|
|
||||||
expect(isPathAllowed('/allowed/')).toBe(true);
|
|
||||||
|
|
||||||
// Parent of allowed should not be allowed
|
|
||||||
expect(isPathAllowed('/')).toBe(false);
|
|
||||||
|
|
||||||
// Sibling directories should not be allowed
|
|
||||||
expect(isPathAllowed('/allowed2')).toBe(false);
|
|
||||||
expect(isPathAllowed('/allowedextra')).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getDataDirectory', () => {
|
describe('getDataDirectory', () => {
|
||||||
it('should return the configured data directory', async () => {
|
it('should return the configured data directory', async () => {
|
||||||
process.env.DATA_DIR = '/data';
|
process.env.DATA_DIR = '/data';
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ export type EventType =
|
|||||||
| 'project:analysis-completed'
|
| 'project:analysis-completed'
|
||||||
| 'project:analysis-error'
|
| 'project:analysis-error'
|
||||||
| 'suggestions:event'
|
| 'suggestions:event'
|
||||||
| 'spec-regeneration:event'
|
| 'spec-regeneration:event';
|
||||||
| 'issue-validation:event';
|
|
||||||
|
|
||||||
export type EventCallback = (type: EventType, payload: unknown) => void;
|
export type EventCallback = (type: EventType, payload: unknown) => void;
|
||||||
|
|||||||
@@ -81,17 +81,3 @@ export {
|
|||||||
THINKING_LEVEL_LABELS,
|
THINKING_LEVEL_LABELS,
|
||||||
getModelDisplayName,
|
getModelDisplayName,
|
||||||
} from './model-display.js';
|
} from './model-display.js';
|
||||||
|
|
||||||
// Issue validation types
|
|
||||||
export type {
|
|
||||||
IssueValidationVerdict,
|
|
||||||
IssueValidationConfidence,
|
|
||||||
IssueComplexity,
|
|
||||||
IssueValidationInput,
|
|
||||||
IssueValidationRequest,
|
|
||||||
IssueValidationResult,
|
|
||||||
IssueValidationResponse,
|
|
||||||
IssueValidationErrorResponse,
|
|
||||||
IssueValidationEvent,
|
|
||||||
StoredValidation,
|
|
||||||
} from './issue-validation.js';
|
|
||||||
|
|||||||
@@ -1,135 +0,0 @@
|
|||||||
/**
|
|
||||||
* Issue Validation Types
|
|
||||||
*
|
|
||||||
* Types for validating GitHub issues against the codebase using Claude SDK.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { AgentModel } from './model.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verdict from issue validation
|
|
||||||
*/
|
|
||||||
export type IssueValidationVerdict = 'valid' | 'invalid' | 'needs_clarification';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Confidence level of the validation
|
|
||||||
*/
|
|
||||||
export type IssueValidationConfidence = 'high' | 'medium' | 'low';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Complexity estimation for valid issues
|
|
||||||
*/
|
|
||||||
export type IssueComplexity = 'trivial' | 'simple' | 'moderate' | 'complex' | 'very_complex';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Issue data for validation (without projectPath)
|
|
||||||
* Used by UI when calling the validation API
|
|
||||||
*/
|
|
||||||
export interface IssueValidationInput {
|
|
||||||
issueNumber: number;
|
|
||||||
issueTitle: string;
|
|
||||||
issueBody: string;
|
|
||||||
issueLabels?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Full request payload for issue validation endpoint
|
|
||||||
* Includes projectPath for server-side handling
|
|
||||||
*/
|
|
||||||
export interface IssueValidationRequest extends IssueValidationInput {
|
|
||||||
projectPath: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Result from Claude's issue validation analysis
|
|
||||||
*/
|
|
||||||
export interface IssueValidationResult {
|
|
||||||
/** Whether the issue is valid, invalid, or needs clarification */
|
|
||||||
verdict: IssueValidationVerdict;
|
|
||||||
/** How confident the AI is in its assessment */
|
|
||||||
confidence: IssueValidationConfidence;
|
|
||||||
/** Detailed explanation of the verdict */
|
|
||||||
reasoning: string;
|
|
||||||
/** For bug reports: whether the bug was confirmed in the codebase */
|
|
||||||
bugConfirmed?: boolean;
|
|
||||||
/** Files related to the issue found during analysis */
|
|
||||||
relatedFiles?: string[];
|
|
||||||
/** Suggested approach to fix or implement */
|
|
||||||
suggestedFix?: string;
|
|
||||||
/** Information that's missing and needed for validation (when verdict = needs_clarification) */
|
|
||||||
missingInfo?: string[];
|
|
||||||
/** Estimated effort to address the issue */
|
|
||||||
estimatedComplexity?: IssueComplexity;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Successful response from validate-issue endpoint
|
|
||||||
*/
|
|
||||||
export interface IssueValidationResponse {
|
|
||||||
success: true;
|
|
||||||
issueNumber: number;
|
|
||||||
validation: IssueValidationResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Error response from validate-issue endpoint
|
|
||||||
*/
|
|
||||||
export interface IssueValidationErrorResponse {
|
|
||||||
success: false;
|
|
||||||
error: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Events emitted during async issue validation
|
|
||||||
*/
|
|
||||||
export type IssueValidationEvent =
|
|
||||||
| {
|
|
||||||
type: 'issue_validation_start';
|
|
||||||
issueNumber: number;
|
|
||||||
issueTitle: string;
|
|
||||||
projectPath: string;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: 'issue_validation_progress';
|
|
||||||
issueNumber: number;
|
|
||||||
content: string;
|
|
||||||
projectPath: string;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: 'issue_validation_complete';
|
|
||||||
issueNumber: number;
|
|
||||||
issueTitle: string;
|
|
||||||
result: IssueValidationResult;
|
|
||||||
projectPath: string;
|
|
||||||
/** Model used for validation (opus, sonnet, haiku) */
|
|
||||||
model: AgentModel;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: 'issue_validation_error';
|
|
||||||
issueNumber: number;
|
|
||||||
error: string;
|
|
||||||
projectPath: string;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: 'issue_validation_viewed';
|
|
||||||
issueNumber: number;
|
|
||||||
projectPath: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stored validation data with metadata for cache
|
|
||||||
*/
|
|
||||||
export interface StoredValidation {
|
|
||||||
/** GitHub issue number */
|
|
||||||
issueNumber: number;
|
|
||||||
/** Issue title at time of validation */
|
|
||||||
issueTitle: string;
|
|
||||||
/** ISO timestamp when validation was performed */
|
|
||||||
validatedAt: string;
|
|
||||||
/** Model used for validation (opus, sonnet, haiku) */
|
|
||||||
model: AgentModel;
|
|
||||||
/** The validation result */
|
|
||||||
result: IssueValidationResult;
|
|
||||||
/** ISO timestamp when user viewed this validation (undefined = not yet viewed) */
|
|
||||||
viewedAt?: string;
|
|
||||||
}
|
|
||||||
@@ -261,8 +261,6 @@ export interface GlobalSettings {
|
|||||||
// AI Model Selection
|
// AI Model Selection
|
||||||
/** Which model to use for feature name/description enhancement */
|
/** Which model to use for feature name/description enhancement */
|
||||||
enhancementModel: AgentModel;
|
enhancementModel: AgentModel;
|
||||||
/** Which model to use for GitHub issue validation */
|
|
||||||
validationModel: AgentModel;
|
|
||||||
|
|
||||||
// Input Configuration
|
// Input Configuration
|
||||||
/** User's keyboard shortcut bindings */
|
/** User's keyboard shortcut bindings */
|
||||||
@@ -439,7 +437,6 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
|
|||||||
defaultAIProfileId: null,
|
defaultAIProfileId: null,
|
||||||
muteDoneSound: false,
|
muteDoneSound: false,
|
||||||
enhancementModel: 'sonnet',
|
enhancementModel: 'sonnet',
|
||||||
validationModel: 'opus',
|
|
||||||
keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS,
|
keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS,
|
||||||
aiProfiles: [],
|
aiProfiles: [],
|
||||||
projects: [],
|
projects: [],
|
||||||
|
|||||||
88
package-lock.json
generated
88
package-lock.json
generated
@@ -422,7 +422,6 @@
|
|||||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.5",
|
"@babel/generator": "^7.28.5",
|
||||||
@@ -1006,7 +1005,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.4.tgz",
|
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.4.tgz",
|
||||||
"integrity": "sha512-xMF6OfEAUVY5Waega4juo1QGACfNkNF+aJLqpd8oUJz96ms2zbfQ9Gh35/tI3y8akEV31FruKfj7hBnIU/nkqA==",
|
"integrity": "sha512-xMF6OfEAUVY5Waega4juo1QGACfNkNF+aJLqpd8oUJz96ms2zbfQ9Gh35/tI3y8akEV31FruKfj7hBnIU/nkqA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/state": "^6.5.0",
|
"@codemirror/state": "^6.5.0",
|
||||||
"crelt": "^1.0.6",
|
"crelt": "^1.0.6",
|
||||||
@@ -1049,7 +1047,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/accessibility": "^3.1.1",
|
"@dnd-kit/accessibility": "^3.1.1",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
@@ -1870,6 +1867,7 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cross-dirname": "^0.1.0",
|
"cross-dirname": "^0.1.0",
|
||||||
"debug": "^4.3.4",
|
"debug": "^4.3.4",
|
||||||
@@ -1891,6 +1889,7 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"graceful-fs": "^4.2.0",
|
"graceful-fs": "^4.2.0",
|
||||||
"jsonfile": "^6.0.1",
|
"jsonfile": "^6.0.1",
|
||||||
@@ -1907,6 +1906,7 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"universalify": "^2.0.0"
|
"universalify": "^2.0.0"
|
||||||
},
|
},
|
||||||
@@ -1921,6 +1921,7 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
}
|
}
|
||||||
@@ -2676,6 +2677,7 @@
|
|||||||
"integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
|
"integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -2800,6 +2802,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
}
|
}
|
||||||
@@ -2816,6 +2819,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
}
|
}
|
||||||
@@ -2832,6 +2836,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
}
|
}
|
||||||
@@ -2940,6 +2945,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
},
|
},
|
||||||
@@ -2962,6 +2968,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
},
|
},
|
||||||
@@ -2984,6 +2991,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
},
|
},
|
||||||
@@ -3069,6 +3077,7 @@
|
|||||||
],
|
],
|
||||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emnapi/runtime": "^1.7.0"
|
"@emnapi/runtime": "^1.7.0"
|
||||||
},
|
},
|
||||||
@@ -3091,6 +3100,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
},
|
},
|
||||||
@@ -3110,6 +3120,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
},
|
},
|
||||||
@@ -3448,7 +3459,8 @@
|
|||||||
"version": "16.0.10",
|
"version": "16.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.10.tgz",
|
||||||
"integrity": "sha512-8tuaQkyDVgeONQ1MeT9Mkk8pQmZapMKFh5B+OrFUlG3rVmYTXcXlBetBgTurKXGaIZvkoqRT9JL5K3phXcgang==",
|
"integrity": "sha512-8tuaQkyDVgeONQ1MeT9Mkk8pQmZapMKFh5B+OrFUlG3rVmYTXcXlBetBgTurKXGaIZvkoqRT9JL5K3phXcgang==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-darwin-arm64": {
|
"node_modules/@next/swc-darwin-arm64": {
|
||||||
"version": "16.0.10",
|
"version": "16.0.10",
|
||||||
@@ -3462,6 +3474,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
@@ -3478,6 +3491,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
@@ -3494,6 +3508,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
@@ -3510,6 +3525,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
@@ -3526,6 +3542,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
@@ -3542,6 +3559,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
@@ -3558,6 +3576,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
@@ -3574,6 +3593,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
@@ -3664,7 +3684,6 @@
|
|||||||
"integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==",
|
"integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright": "1.57.0"
|
"playwright": "1.57.0"
|
||||||
},
|
},
|
||||||
@@ -5075,6 +5094,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||||
"integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
|
"integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.8.0"
|
"tslib": "^2.8.0"
|
||||||
}
|
}
|
||||||
@@ -5408,7 +5428,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.141.6.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.141.6.tgz",
|
||||||
"integrity": "sha512-qWFxi2D6eGc1L03RzUuhyEOplZ7Q6q62YOl7Of9Y0q4YjwQwxRm4zxwDVtvUIoy4RLVCpqp5UoE+Nxv2PY9trg==",
|
"integrity": "sha512-qWFxi2D6eGc1L03RzUuhyEOplZ7Q6q62YOl7Of9Y0q4YjwQwxRm4zxwDVtvUIoy4RLVCpqp5UoE+Nxv2PY9trg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/history": "1.141.0",
|
"@tanstack/history": "1.141.0",
|
||||||
"@tanstack/react-store": "^0.8.0",
|
"@tanstack/react-store": "^0.8.0",
|
||||||
@@ -5960,7 +5979,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
|
||||||
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
@@ -5971,7 +5989,6 @@
|
|||||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
@@ -6077,7 +6094,6 @@
|
|||||||
"integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==",
|
"integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.50.0",
|
"@typescript-eslint/scope-manager": "8.50.0",
|
||||||
"@typescript-eslint/types": "8.50.0",
|
"@typescript-eslint/types": "8.50.0",
|
||||||
@@ -6571,8 +6587,7 @@
|
|||||||
"version": "5.5.0",
|
"version": "5.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
||||||
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/@xyflow/react": {
|
"node_modules/@xyflow/react": {
|
||||||
"version": "12.10.0",
|
"version": "12.10.0",
|
||||||
@@ -6670,7 +6685,6 @@
|
|||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -6731,7 +6745,6 @@
|
|||||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.1",
|
"fast-deep-equal": "^3.1.1",
|
||||||
"fast-json-stable-stringify": "^2.0.0",
|
"fast-json-stable-stringify": "^2.0.0",
|
||||||
@@ -7291,7 +7304,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@@ -7823,7 +7835,8 @@
|
|||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
||||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/cliui": {
|
"node_modules/cliui": {
|
||||||
"version": "8.0.1",
|
"version": "8.0.1",
|
||||||
@@ -8109,7 +8122,8 @@
|
|||||||
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
|
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"optional": true,
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/cross-env": {
|
"node_modules/cross-env": {
|
||||||
"version": "10.1.0",
|
"version": "10.1.0",
|
||||||
@@ -8206,7 +8220,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
@@ -8508,7 +8521,6 @@
|
|||||||
"integrity": "sha512-59CAAjAhTaIMCN8y9kD573vDkxbs1uhDcrFLHSgutYdPcGOU35Rf95725snvzEOy4BFB7+eLJ8djCNPmGwG67w==",
|
"integrity": "sha512-59CAAjAhTaIMCN8y9kD573vDkxbs1uhDcrFLHSgutYdPcGOU35Rf95725snvzEOy4BFB7+eLJ8djCNPmGwG67w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"app-builder-lib": "26.0.12",
|
"app-builder-lib": "26.0.12",
|
||||||
"builder-util": "26.0.11",
|
"builder-util": "26.0.11",
|
||||||
@@ -8835,6 +8847,7 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@electron/asar": "^3.2.1",
|
"@electron/asar": "^3.2.1",
|
||||||
"debug": "^4.1.1",
|
"debug": "^4.1.1",
|
||||||
@@ -8855,6 +8868,7 @@
|
|||||||
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
|
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"graceful-fs": "^4.1.2",
|
"graceful-fs": "^4.1.2",
|
||||||
"jsonfile": "^4.0.0",
|
"jsonfile": "^4.0.0",
|
||||||
@@ -9105,7 +9119,6 @@
|
|||||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -11011,6 +11024,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12.0.0"
|
"node": ">= 12.0.0"
|
||||||
},
|
},
|
||||||
@@ -11072,6 +11086,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"freebsd"
|
"freebsd"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12.0.0"
|
"node": ">= 12.0.0"
|
||||||
},
|
},
|
||||||
@@ -13490,6 +13505,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.6",
|
"nanoid": "^3.3.6",
|
||||||
"picocolors": "^1.0.0",
|
"picocolors": "^1.0.0",
|
||||||
@@ -13506,6 +13522,7 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"commander": "^9.4.0"
|
"commander": "^9.4.0"
|
||||||
},
|
},
|
||||||
@@ -13523,6 +13540,7 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^12.20.0 || >=14"
|
"node": "^12.20.0 || >=14"
|
||||||
}
|
}
|
||||||
@@ -13711,7 +13729,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -13721,7 +13738,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
||||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
@@ -14071,6 +14087,7 @@
|
|||||||
"deprecated": "Rimraf versions prior to v4 are no longer supported",
|
"deprecated": "Rimraf versions prior to v4 are no longer supported",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"glob": "^7.1.3"
|
"glob": "^7.1.3"
|
||||||
},
|
},
|
||||||
@@ -14259,7 +14276,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/seroval/-/seroval-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/seroval/-/seroval-1.4.0.tgz",
|
||||||
"integrity": "sha512-BdrNXdzlofomLTiRnwJTSEAaGKyHHZkbMXIywOh7zlzp4uZnXErEwl9XZ+N1hJSNpeTtNxWvVwN0wUzAIQ4Hpg==",
|
"integrity": "sha512-BdrNXdzlofomLTiRnwJTSEAaGKyHHZkbMXIywOh7zlzp4uZnXErEwl9XZ+N1hJSNpeTtNxWvVwN0wUzAIQ4Hpg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
@@ -14308,6 +14324,7 @@
|
|||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@img/colour": "^1.0.0",
|
"@img/colour": "^1.0.0",
|
||||||
"detect-libc": "^2.1.2",
|
"detect-libc": "^2.1.2",
|
||||||
@@ -14358,6 +14375,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
},
|
},
|
||||||
@@ -14380,6 +14398,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
},
|
},
|
||||||
@@ -14402,6 +14421,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
}
|
}
|
||||||
@@ -14418,6 +14438,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
}
|
}
|
||||||
@@ -14434,6 +14455,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
}
|
}
|
||||||
@@ -14450,6 +14472,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
}
|
}
|
||||||
@@ -14466,6 +14489,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
}
|
}
|
||||||
@@ -14482,6 +14506,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
}
|
}
|
||||||
@@ -14498,6 +14523,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
}
|
}
|
||||||
@@ -14514,6 +14540,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
},
|
},
|
||||||
@@ -14536,6 +14563,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
},
|
},
|
||||||
@@ -14558,6 +14586,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
},
|
},
|
||||||
@@ -14580,6 +14609,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
},
|
},
|
||||||
@@ -14602,6 +14632,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
},
|
},
|
||||||
@@ -14624,6 +14655,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
},
|
},
|
||||||
@@ -15092,6 +15124,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
|
||||||
"integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==",
|
"integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"client-only": "0.0.1"
|
"client-only": "0.0.1"
|
||||||
},
|
},
|
||||||
@@ -15261,6 +15294,7 @@
|
|||||||
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
|
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"mkdirp": "^0.5.1",
|
"mkdirp": "^0.5.1",
|
||||||
"rimraf": "~2.6.2"
|
"rimraf": "~2.6.2"
|
||||||
@@ -15324,6 +15358,7 @@
|
|||||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"minimist": "^1.2.6"
|
"minimist": "^1.2.6"
|
||||||
},
|
},
|
||||||
@@ -15421,7 +15456,6 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -15626,7 +15660,6 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -15998,7 +16031,6 @@
|
|||||||
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -16088,8 +16120,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.6.tgz",
|
"resolved": "https://registry.npmjs.org/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.6.tgz",
|
||||||
"integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==",
|
"integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/vite/node_modules/fdir": {
|
"node_modules/vite/node_modules/fdir": {
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
@@ -16115,7 +16146,6 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -16158,7 +16188,6 @@
|
|||||||
"integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==",
|
"integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/expect": "4.0.16",
|
"@vitest/expect": "4.0.16",
|
||||||
"@vitest/mocker": "4.0.16",
|
"@vitest/mocker": "4.0.16",
|
||||||
@@ -16416,7 +16445,6 @@
|
|||||||
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"yaml": "bin.mjs"
|
"yaml": "bin.mjs"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user