mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 14:22:02 +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 { createGitHubRoutes } from './routes/github/index.js';
|
||||
import { createContextRoutes } from './routes/context/index.js';
|
||||
import { cleanupStaleValidations } from './routes/github/routes/validation-common.js';
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
@@ -124,15 +123,6 @@ const claudeUsageService = new ClaudeUsageService();
|
||||
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
|
||||
app.use('/api/health', createHealthRoutes());
|
||||
|
||||
@@ -157,7 +147,7 @@ app.use('/api/templates', createTemplatesRoutes());
|
||||
app.use('/api/terminal', createTerminalRoutes());
|
||||
app.use('/api/settings', createSettingsRoutes(settingsService));
|
||||
app.use('/api/claude', createClaudeRoutes(claudeUsageService));
|
||||
app.use('/api/github', createGitHubRoutes(events));
|
||||
app.use('/api/github', createGitHubRoutes());
|
||||
app.use('/api/context', createContextRoutes());
|
||||
|
||||
// Create HTTP server
|
||||
|
||||
@@ -2,30 +2,9 @@
|
||||
* Authentication middleware for API security
|
||||
*
|
||||
* 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 { 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)
|
||||
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 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 {
|
||||
// If no API key is configured, allow all requests
|
||||
@@ -44,22 +22,6 @@ export function authMiddleware(req: Request, res: Response, next: NextFunction):
|
||||
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
|
||||
const providedKey = req.headers['x-api-key'] as string | undefined;
|
||||
|
||||
@@ -71,10 +33,7 @@ export function authMiddleware(req: Request, res: Response, next: NextFunction):
|
||||
return;
|
||||
}
|
||||
|
||||
if (!secureCompare(providedKey, API_KEY)) {
|
||||
// Record failed attempt
|
||||
apiKeyRateLimiter.recordFailure(clientIp);
|
||||
|
||||
if (providedKey !== API_KEY) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error: 'Invalid API key.',
|
||||
@@ -82,9 +41,6 @@ export function authMiddleware(req: Request, res: Response, next: NextFunction):
|
||||
return;
|
||||
}
|
||||
|
||||
// Successful authentication - reset rate limiter for this IP
|
||||
apiKeyRateLimiter.reset(clientIp);
|
||||
|
||||
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 { 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
|
||||
* @param paramNames - Names of parameters to validate (e.g., 'projectPath', 'worktreePath')
|
||||
@@ -50,8 +27,7 @@ export function validatePathParams(...paramNames: string[]) {
|
||||
if (paramName.endsWith('?')) {
|
||||
const actualName = paramName.slice(0, -1);
|
||||
const value = req.body[actualName];
|
||||
if (value !== undefined && value !== null) {
|
||||
assertValidPathString(value, actualName);
|
||||
if (value) {
|
||||
validatePath(value);
|
||||
}
|
||||
continue;
|
||||
@@ -61,30 +37,17 @@ export function validatePathParams(...paramNames: string[]) {
|
||||
if (paramName.endsWith('[]')) {
|
||||
const actualName = paramName.slice(0, -2);
|
||||
const values = req.body[actualName];
|
||||
|
||||
// Skip if not provided or empty
|
||||
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);
|
||||
if (Array.isArray(values) && values.length > 0) {
|
||||
for (const value of values) {
|
||||
validatePath(value);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle regular parameters
|
||||
const value = req.body[paramName];
|
||||
if (value !== undefined && value !== null) {
|
||||
assertValidPathString(value, paramName);
|
||||
if (value) {
|
||||
validatePath(value);
|
||||
}
|
||||
}
|
||||
@@ -99,14 +62,6 @@ export function validatePathParams(...paramNames: string[]) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof InvalidPathTypeError) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Re-throw unexpected errors
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -3,50 +3,16 @@
|
||||
*/
|
||||
|
||||
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 { createListIssuesHandler } from './routes/list-issues.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();
|
||||
|
||||
router.post('/check-remote', validatePathParams('projectPath'), createCheckGitHubRemoteHandler());
|
||||
router.post('/issues', validatePathParams('projectPath'), createListIssuesHandler());
|
||||
router.post('/prs', validatePathParams('projectPath'), 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)
|
||||
);
|
||||
router.post('/check-remote', createCheckGitHubRemoteHandler());
|
||||
router.post('/issues', createListIssuesHandler());
|
||||
router.post('/prs', createListPRsHandler());
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
* POST /list-issues endpoint - List GitHub issues for a project
|
||||
*/
|
||||
|
||||
import { spawn } from 'child_process';
|
||||
import type { Request, Response } from 'express';
|
||||
import { execAsync, execEnv, getErrorMessage, logError } from './common.js';
|
||||
import { checkGitHubRemote } from './check-github-remote.js';
|
||||
@@ -14,19 +13,6 @@ export interface GitHubLabel {
|
||||
|
||||
export interface GitHubAuthor {
|
||||
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 {
|
||||
@@ -38,8 +24,6 @@ export interface GitHubIssue {
|
||||
labels: GitHubLabel[];
|
||||
url: string;
|
||||
body: string;
|
||||
assignees: GitHubAssignee[];
|
||||
linkedPRs?: LinkedPullRequest[];
|
||||
}
|
||||
|
||||
export interface ListIssuesResult {
|
||||
@@ -49,146 +33,6 @@ export interface ListIssuesResult {
|
||||
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() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
@@ -209,17 +53,17 @@ export function createListIssuesHandler() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch open and closed issues in parallel (now including assignees)
|
||||
// Fetch open and closed issues in parallel
|
||||
const [openResult, closedResult] = await Promise.all([
|
||||
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,
|
||||
env: execEnv,
|
||||
}
|
||||
),
|
||||
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,
|
||||
env: execEnv,
|
||||
@@ -233,24 +77,6 @@ export function createListIssuesHandler() {
|
||||
const openIssues: GitHubIssue[] = JSON.parse(openStdout || '[]');
|
||||
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({
|
||||
success: true,
|
||||
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 { 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
|
||||
@@ -29,7 +29,7 @@ export function createGetCredentialsHandler(settingsService: SettingsService) {
|
||||
});
|
||||
} catch (error) {
|
||||
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 { SettingsService } from '../../../services/settings-service.js';
|
||||
import type { Credentials } from '../../../types/settings.js';
|
||||
import { 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;
|
||||
}
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
/**
|
||||
* Create handler factory for PUT /api/settings/credentials
|
||||
@@ -86,19 +22,16 @@ function validateCredentialsUpdate(updates: unknown): string | null {
|
||||
export function createUpdateCredentialsHandler(settingsService: SettingsService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// Validate the request body before type assertion
|
||||
const validationError = validateCredentialsUpdate(req.body);
|
||||
if (validationError) {
|
||||
const updates = req.body as Partial<Credentials>;
|
||||
|
||||
if (!updates || typeof updates !== 'object') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: validationError,
|
||||
error: 'Invalid request body - expected credentials object',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Safe to cast after validation
|
||||
const updates = req.body as Partial<Credentials>;
|
||||
|
||||
await settingsService.updateCredentials(updates);
|
||||
|
||||
// Return masked credentials for confirmation
|
||||
@@ -110,7 +43,7 @@ export function createUpdateCredentialsHandler(settingsService: SettingsService)
|
||||
});
|
||||
} catch (error) {
|
||||
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');
|
||||
|
||||
/**
|
||||
* 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
|
||||
const apiKeys: Record<string, string> = {};
|
||||
|
||||
@@ -44,32 +33,6 @@ export function getAllApiKeys(): Record<string, string> {
|
||||
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
|
||||
*/
|
||||
@@ -84,24 +47,21 @@ export async function persistApiKeyToEnv(key: string, value: string): Promise<vo
|
||||
// .env file doesn't exist, we'll create it
|
||||
}
|
||||
|
||||
// Escape the value for safe .env file storage
|
||||
const escapedValue = escapeEnvValue(value);
|
||||
|
||||
// Parse existing env content - match key with optional quoted values
|
||||
// Parse existing env content
|
||||
const lines = envContent.split('\n');
|
||||
const keyRegex = new RegExp(`^${escapeRegExp(key)}=`);
|
||||
const keyRegex = new RegExp(`^${key}=`);
|
||||
let found = false;
|
||||
const newLines = lines.map((line) => {
|
||||
if (keyRegex.test(line)) {
|
||||
found = true;
|
||||
return `${key}=${escapedValue}`;
|
||||
return `${key}=${value}`;
|
||||
}
|
||||
return line;
|
||||
});
|
||||
|
||||
if (!found) {
|
||||
// Add the key at the end
|
||||
newLines.push(`${key}=${escapedValue}`);
|
||||
newLines.push(`${key}=${value}`);
|
||||
}
|
||||
|
||||
await fs.writeFile(envPath, newLines.join('\n'));
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { getApiKey, logError } from '../common.js';
|
||||
import { getApiKey, getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createApiKeysHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
@@ -14,7 +14,7 @@ export function createApiKeysHandler() {
|
||||
});
|
||||
} catch (error) {
|
||||
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)
|
||||
// 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
|
||||
@@ -30,7 +30,7 @@ async function removeApiKeyFromEnv(key: string): Promise<void> {
|
||||
|
||||
// Parse existing env content and remove the key
|
||||
const lines = envContent.split('\n');
|
||||
const keyRegex = new RegExp(`^${escapeRegExp(key)}=`);
|
||||
const keyRegex = new RegExp(`^${key}=`);
|
||||
const newLines = lines.filter((line) => !keyRegex.test(line));
|
||||
|
||||
// Remove empty lines at the end
|
||||
@@ -68,10 +68,9 @@ export function createDeleteApiKeyHandler() {
|
||||
|
||||
const envKey = envKeyMap[provider];
|
||||
if (!envKey) {
|
||||
logger.warn(`[Setup] Unknown provider requested for deletion: ${provider}`);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Unknown provider. Only anthropic is supported.',
|
||||
error: `Unknown provider: ${provider}. Only anthropic is supported.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -95,7 +94,7 @@ export function createDeleteApiKeyHandler() {
|
||||
logger.error('[Setup] Delete API key error:', error);
|
||||
res.status(500).json({
|
||||
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 { setApiKey, persistApiKeyToEnv, logError } from '../common.js';
|
||||
import { setApiKey, persistApiKeyToEnv, getErrorMessage, logError } from '../common.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('Setup');
|
||||
@@ -30,10 +30,9 @@ export function createStoreApiKeyHandler() {
|
||||
await persistApiKeyToEnv('ANTHROPIC_API_KEY', apiKey);
|
||||
logger.info('[Setup] Stored API key as ANTHROPIC_API_KEY');
|
||||
} else {
|
||||
logger.warn(`[Setup] Unsupported provider requested: ${provider}`);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Unsupported provider. Only anthropic is supported.',
|
||||
error: `Unsupported provider: ${provider}. Only anthropic is supported.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -41,7 +40,7 @@ export function createStoreApiKeyHandler() {
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
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');
|
||||
|
||||
/**
|
||||
* 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
|
||||
const AUTH_ERROR_PATTERNS = [
|
||||
'OAuth token revoked',
|
||||
@@ -103,79 +68,14 @@ function containsAuthError(text: string): boolean {
|
||||
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() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// Validate and extract the auth method from the request body
|
||||
let authMethod: AuthMethod | undefined;
|
||||
try {
|
||||
authMethod = validateAuthMethod(req.body);
|
||||
} catch (validationError) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
authenticated: false,
|
||||
error: validationError instanceof Error ? validationError.message : 'Invalid request',
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Get the auth method from the request body
|
||||
const { authMethod } = req.body as { authMethod?: 'cli' | 'api_key' };
|
||||
|
||||
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
|
||||
const abortController = new AbortController();
|
||||
const timeoutId = setTimeout(() => abortController.abort(), 30000);
|
||||
@@ -184,7 +84,7 @@ export function createVerifyClaudeAuthHandler() {
|
||||
let errorMessage = '';
|
||||
let receivedAnyContent = false;
|
||||
|
||||
// Save original env values (inside mutex to ensure consistency)
|
||||
// Save original env values
|
||||
const originalAnthropicKey = process.env.ANTHROPIC_API_KEY;
|
||||
|
||||
try {
|
||||
@@ -199,8 +99,17 @@ export function createVerifyClaudeAuthHandler() {
|
||||
if (storedApiKey) {
|
||||
process.env.ANTHROPIC_API_KEY = storedApiKey;
|
||||
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
|
||||
@@ -220,8 +129,7 @@ export function createVerifyClaudeAuthHandler() {
|
||||
for await (const msg of stream) {
|
||||
const msgStr = JSON.stringify(msg);
|
||||
allMessages.push(msgStr);
|
||||
// Debug log only message type to avoid leaking sensitive data
|
||||
logger.debug('[Setup] Stream message type:', msg.type);
|
||||
logger.info('[Setup] Stream message:', msgStr.substring(0, 500));
|
||||
|
||||
// Check for billing errors FIRST - these should fail verification
|
||||
if (isBillingError(msgStr)) {
|
||||
@@ -313,8 +221,7 @@ export function createVerifyClaudeAuthHandler() {
|
||||
} else {
|
||||
// No content received - might be an issue
|
||||
logger.warn('[Setup] No content received from stream');
|
||||
// Log only message count to avoid leaking sensitive data
|
||||
logger.warn('[Setup] Total messages received:', allMessages.length);
|
||||
logger.warn('[Setup] All messages:', allMessages.join('\n'));
|
||||
errorMessage = 'No response received from Claude. Please check your authentication.';
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
@@ -370,8 +277,6 @@ export function createVerifyClaudeAuthHandler() {
|
||||
// If we cleared it and there was no original, keep it cleared
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
}
|
||||
// Release the mutex so other verification requests can proceed
|
||||
verificationMutex.release();
|
||||
}
|
||||
|
||||
logger.info('[Setup] Verification result:', {
|
||||
|
||||
@@ -63,8 +63,10 @@ For each suggestion, provide:
|
||||
|
||||
The response will be automatically formatted as structured JSON.`;
|
||||
|
||||
// Don't send initial message - let the agent output speak for itself
|
||||
// The first agent message will be captured as an info entry
|
||||
events.emit('suggestions:event', {
|
||||
type: 'suggestions_progress',
|
||||
content: `Starting ${suggestionType} analysis...\n`,
|
||||
});
|
||||
|
||||
const options = createSuggestionsOptions({
|
||||
cwd: projectPath,
|
||||
|
||||
@@ -65,18 +65,6 @@ export function cleanupExpiredTokens(): void {
|
||||
// Clean up expired tokens every 5 minutes
|
||||
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
|
||||
*/
|
||||
@@ -128,9 +116,8 @@ export function terminalAuthMiddleware(req: Request, res: Response, next: NextFu
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract token from Authorization header only (Bearer token format)
|
||||
// Query string tokens are not supported due to security risks (URL logging, referrer leakage)
|
||||
const token = extractBearerToken(req);
|
||||
// Check for session token
|
||||
const token = (req.headers['x-terminal-token'] as string) || (req.query.token as string);
|
||||
|
||||
if (!validateTerminalToken(token)) {
|
||||
res.status(401).json({
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
/**
|
||||
* 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 {
|
||||
getTerminalEnabledConfigValue,
|
||||
@@ -13,25 +11,6 @@ import {
|
||||
getTokenExpiryMs,
|
||||
getErrorMessage,
|
||||
} 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() {
|
||||
return (req: Request, res: Response): void => {
|
||||
@@ -57,28 +36,9 @@ export function createAuthHandler() {
|
||||
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;
|
||||
|
||||
if (!password || !secureCompare(password, terminalPassword)) {
|
||||
// Record failed attempt
|
||||
terminalAuthRateLimiter.recordFailure(clientIp);
|
||||
|
||||
if (!password || password !== terminalPassword) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: 'Invalid password',
|
||||
@@ -86,9 +46,6 @@ export function createAuthHandler() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Successful authentication - reset rate limiter for this IP
|
||||
terminalAuthRateLimiter.reset(clientIp);
|
||||
|
||||
// Generate session token
|
||||
const token = generateToken();
|
||||
const now = new Date();
|
||||
|
||||
@@ -1,36 +1,18 @@
|
||||
/**
|
||||
* 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 { deleteToken, extractBearerToken, validateTerminalToken } from '../common.js';
|
||||
import { deleteToken } from '../common.js';
|
||||
|
||||
export function createLogoutHandler() {
|
||||
return (req: Request, res: Response): void => {
|
||||
const token = extractBearerToken(req);
|
||||
const token = (req.headers['x-terminal-token'] as string) || req.body.token;
|
||||
|
||||
if (!token) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: 'Authorization header with Bearer token is required',
|
||||
});
|
||||
return;
|
||||
if (token) {
|
||||
deleteToken(token);
|
||||
}
|
||||
|
||||
if (!validateTerminalToken(token)) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: 'Invalid or expired token',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Token is valid and belongs to the requester - safe to invalidate
|
||||
deleteToken(token);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
});
|
||||
|
||||
@@ -1,16 +1,6 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
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.
|
||||
* 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';
|
||||
|
||||
const { authMiddleware } = await import('@/lib/auth.js');
|
||||
const { req, res, next } = createMockExpressContextWithSocket();
|
||||
const { req, res, next } = createMockExpressContext();
|
||||
|
||||
authMiddleware(req, res, next);
|
||||
|
||||
@@ -55,7 +45,7 @@ describe('auth.ts', () => {
|
||||
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
|
||||
|
||||
const { authMiddleware } = await import('@/lib/auth.js');
|
||||
const { req, res, next } = createMockExpressContextWithSocket();
|
||||
const { req, res, next } = createMockExpressContext();
|
||||
req.headers['x-api-key'] = 'wrong-key';
|
||||
|
||||
authMiddleware(req, res, next);
|
||||
@@ -72,7 +62,7 @@ describe('auth.ts', () => {
|
||||
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
|
||||
|
||||
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';
|
||||
|
||||
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 { useNavigate, useLocation } from '@tanstack/react-router';
|
||||
import React from 'react';
|
||||
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 { useAppStore, type ThemeMode } from '@/store/app-store';
|
||||
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';
|
||||
import { Link, useLocation } from '@tanstack/react-router';
|
||||
|
||||
export function Sidebar() {
|
||||
const navigate = useNavigate();
|
||||
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 (
|
||||
<aside
|
||||
className={cn(
|
||||
'flex-shrink-0 flex flex-col z-30 relative',
|
||||
// Glass morphism background with gradient
|
||||
'bg-gradient-to-b from-sidebar/95 via-sidebar/85 to-sidebar/90 backdrop-blur-2xl',
|
||||
// Premium border with subtle glow
|
||||
'border-r border-border/60 shadow-[1px_0_20px_-5px_rgba(0,0,0,0.1)]',
|
||||
// Smooth width transition
|
||||
'transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]',
|
||||
sidebarOpen ? 'w-16 lg:w-72' : 'w-16'
|
||||
)}
|
||||
data-testid="sidebar"
|
||||
>
|
||||
<CollapseToggleButton
|
||||
sidebarOpen={sidebarOpen}
|
||||
toggleSidebar={toggleSidebar}
|
||||
shortcut={shortcuts.toggleSidebar}
|
||||
/>
|
||||
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<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}
|
||||
/>
|
||||
<aside className="w-[260px] flex-shrink-0 flex flex-col glass-sidebar z-30 relative h-full">
|
||||
{/* Logo */}
|
||||
<div className="h-16 flex items-center px-6 gap-3 flex-shrink-0">
|
||||
<div className="text-brand-cyan relative flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-brand-cyan blur-md opacity-30"></div>
|
||||
<Code2 className="w-6 h-6 relative z-10" />
|
||||
</div>
|
||||
<span className="text-white font-bold text-lg tracking-tight">automaker.</span>
|
||||
<button className="ml-auto text-slate-600 hover:text-white transition">
|
||||
<PanelLeft className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<SidebarFooter
|
||||
sidebarOpen={sidebarOpen}
|
||||
isActiveRoute={isActiveRoute}
|
||||
navigate={navigate}
|
||||
hideWiki={hideWiki}
|
||||
hideRunningAgents={hideRunningAgents}
|
||||
runningAgentsCount={runningAgentsCount}
|
||||
shortcuts={{ settings: shortcuts.settings }}
|
||||
/>
|
||||
<TrashDialog
|
||||
open={showTrashDialog}
|
||||
onOpenChange={setShowTrashDialog}
|
||||
trashedProjects={trashedProjects}
|
||||
activeTrashId={activeTrashId}
|
||||
handleRestoreProject={handleRestoreProject}
|
||||
handleDeleteProjectFromDisk={handleDeleteProjectFromDisk}
|
||||
deleteTrashedProject={deleteTrashedProject}
|
||||
handleEmptyTrash={handleEmptyTrash}
|
||||
isEmptyingTrash={isEmptyingTrash}
|
||||
/>
|
||||
{/* Top Actions */}
|
||||
<div className="px-5 pb-6 space-y-4 flex-shrink-0">
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<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">
|
||||
<Plus className="w-3.5 h-3.5 group-hover:text-brand-cyan transition-colors" /> New
|
||||
</button>
|
||||
<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">
|
||||
<Folder className="w-3.5 h-3.5" />
|
||||
<span className="ml-1 text-[10px]">0</span>
|
||||
</button>
|
||||
<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">
|
||||
<Bell className="w-3.5 h-3.5" />
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* New Project Setup Dialog */}
|
||||
<CreateSpecDialog
|
||||
open={showSetupDialog}
|
||||
onOpenChange={setShowSetupDialog}
|
||||
projectOverview={projectOverview}
|
||||
onProjectOverviewChange={setProjectOverview}
|
||||
generateFeatures={generateFeatures}
|
||||
onGenerateFeaturesChange={setGenerateFeatures}
|
||||
analyzeProject={analyzeProject}
|
||||
onAnalyzeProjectChange={setAnalyzeProject}
|
||||
featureCount={featureCount}
|
||||
onFeatureCountChange={setFeatureCount}
|
||||
onCreateSpec={handleCreateInitialSpec}
|
||||
onSkip={handleSkipSetup}
|
||||
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."
|
||||
/>
|
||||
{/* Project Selector */}
|
||||
<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">
|
||||
<div className="flex items-center gap-3 px-2 py-1.5">
|
||||
<FolderOpen className="w-4 h-4 text-brand-cyan group-hover:text-cyan-300 transition" />
|
||||
<span className="text-white font-medium text-sm">test case 1</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 pr-1">
|
||||
<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">
|
||||
P
|
||||
</span>
|
||||
<MoreVertical className="w-4 h-4 text-slate-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<OnboardingDialog
|
||||
open={showOnboardingDialog}
|
||||
onOpenChange={setShowOnboardingDialog}
|
||||
newProjectName={newProjectName}
|
||||
onSkip={handleOnboardingSkip}
|
||||
onGenerateSpec={handleOnboardingGenerateSpec}
|
||||
/>
|
||||
{/* Navigation */}
|
||||
<div className="flex-1 overflow-y-auto px-0 space-y-6 custom-scrollbar">
|
||||
{/* Project Section */}
|
||||
<div>
|
||||
<h3 className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-2 px-6 font-mono">
|
||||
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 */}
|
||||
<DeleteProjectDialog
|
||||
open={showDeleteProjectDialog}
|
||||
onOpenChange={setShowDeleteProjectDialog}
|
||||
project={currentProject}
|
||||
onConfirm={moveProjectToTrash}
|
||||
/>
|
||||
{/* Tools Section */}
|
||||
<div>
|
||||
<h3 className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-2 px-6 font-mono">
|
||||
Tools
|
||||
</h3>
|
||||
<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 */}
|
||||
<NewProjectModal
|
||||
open={showNewProjectModal}
|
||||
onOpenChange={setShowNewProjectModal}
|
||||
onCreateBlankProject={handleCreateBlankProject}
|
||||
onCreateFromTemplate={handleCreateFromTemplate}
|
||||
onCreateFromCustomUrl={handleCreateFromCustomUrl}
|
||||
isCreating={isCreatingProject}
|
||||
/>
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t border-white/5 space-y-1 bg-dark-900/30 flex-shrink-0 backdrop-blur-sm">
|
||||
<Link
|
||||
to="/wiki"
|
||||
className="flex items-center gap-3 px-3 py-2 rounded-lg text-slate-400 hover:text-white hover:bg-white/5 transition"
|
||||
>
|
||||
<Book className="w-4 h-4" />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
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,29 +78,14 @@ export function SidebarNavigation({
|
||||
title={!sidebarOpen ? item.label : undefined}
|
||||
data-testid={`nav-${item.id}`}
|
||||
>
|
||||
<div className="relative">
|
||||
<Icon
|
||||
className={cn(
|
||||
'w-[18px] h-[18px] shrink-0 transition-all duration-200',
|
||||
isActive
|
||||
? 'text-brand-500 drop-shadow-sm'
|
||||
: '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>
|
||||
<Icon
|
||||
className={cn(
|
||||
'w-[18px] h-[18px] shrink-0 transition-all duration-200',
|
||||
isActive
|
||||
? 'text-brand-500 drop-shadow-sm'
|
||||
: 'group-hover:text-brand-400 group-hover:scale-110'
|
||||
)}
|
||||
</div>
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'ml-3 font-medium text-sm flex-1 text-left',
|
||||
@@ -109,21 +94,7 @@ export function SidebarNavigation({
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
{/* Count badge */}
|
||||
{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 && (
|
||||
{item.shortcut && sidebarOpen && (
|
||||
<span
|
||||
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',
|
||||
|
||||
@@ -10,4 +10,3 @@ export { useProjectCreation } from './use-project-creation';
|
||||
export { useSetupDialog } from './use-setup-dialog';
|
||||
export { useTrashDialog } from './use-trash-dialog';
|
||||
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;
|
||||
cyclePrevProject: () => void;
|
||||
cycleNextProject: () => void;
|
||||
/** Count of unviewed validations to show on GitHub Issues nav item */
|
||||
unviewedValidationsCount?: number;
|
||||
}
|
||||
|
||||
export function useNavigation({
|
||||
@@ -63,7 +61,6 @@ export function useNavigation({
|
||||
setIsProjectPickerOpen,
|
||||
cyclePrevProject,
|
||||
cycleNextProject,
|
||||
unviewedValidationsCount,
|
||||
}: UseNavigationProps) {
|
||||
// Track if current project has a GitHub remote
|
||||
const [hasGitHubRemote, setHasGitHubRemote] = useState(false);
|
||||
@@ -172,7 +169,6 @@ export function useNavigation({
|
||||
id: 'github-issues',
|
||||
label: 'Issues',
|
||||
icon: CircleDot,
|
||||
count: unviewedValidationsCount,
|
||||
},
|
||||
{
|
||||
id: 'github-prs',
|
||||
@@ -184,15 +180,7 @@ export function useNavigation({
|
||||
}
|
||||
|
||||
return sections;
|
||||
}, [
|
||||
shortcuts,
|
||||
hideSpecEditor,
|
||||
hideContext,
|
||||
hideTerminal,
|
||||
hideAiProfiles,
|
||||
hasGitHubRemote,
|
||||
unviewedValidationsCount,
|
||||
]);
|
||||
}, [shortcuts, hideSpecEditor, hideContext, hideTerminal, hideAiProfiles, hasGitHubRemote]);
|
||||
|
||||
// Build keyboard shortcuts for navigation
|
||||
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;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
shortcut?: string;
|
||||
/** Optional count badge to display next to the nav item */
|
||||
count?: number;
|
||||
}
|
||||
|
||||
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 { useAppStore } from '@/store/app-store';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -18,6 +18,9 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
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 {
|
||||
success: boolean;
|
||||
@@ -190,258 +193,285 @@ export function AgentToolsView() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden content-bg" data-testid="agent-tools-view">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 p-4 border-b border-border bg-glass backdrop-blur-md">
|
||||
<Wrench className="w-5 h-5 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Agent Tools</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Test file system and terminal tools for {currentProject.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tools Grid */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="grid gap-6 md:grid-cols-1 lg:grid-cols-2 xl:grid-cols-3">
|
||||
{/* Read File Tool */}
|
||||
<Card data-testid="read-file-tool">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<File className="w-5 h-5 text-blue-500" />
|
||||
<CardTitle className="text-lg">Read File</CardTitle>
|
||||
<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 */}
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<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>
|
||||
<CardDescription>Agent requests to read a file from the filesystem</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="read-file-path">File Path</Label>
|
||||
<Input
|
||||
id="read-file-path"
|
||||
placeholder="/path/to/file.txt"
|
||||
value={readFilePath}
|
||||
onChange={(e) => setReadFilePath(e.target.value)}
|
||||
data-testid="read-file-path-input"
|
||||
/>
|
||||
<div>
|
||||
<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">
|
||||
Test file system and terminal tools for {currentProject.name}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleReadFile}
|
||||
disabled={isReadingFile || !readFilePath.trim()}
|
||||
className="w-full"
|
||||
data-testid="read-file-button"
|
||||
>
|
||||
{isReadingFile ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Reading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
Execute Read
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Result */}
|
||||
{readFileResult && (
|
||||
<div
|
||||
className={cn(
|
||||
'p-3 rounded-md border',
|
||||
readFileResult.success
|
||||
? 'bg-green-500/10 border-green-500/20'
|
||||
: 'bg-red-500/10 border-red-500/20'
|
||||
)}
|
||||
data-testid="read-file-result"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{readFileResult.success ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<XCircle className="w-4 h-4 text-red-500" />
|
||||
)}
|
||||
<span className="text-sm font-medium">
|
||||
{readFileResult.success ? 'Success' : 'Failed'}
|
||||
</span>
|
||||
</div>
|
||||
<pre className="text-xs overflow-auto max-h-40 whitespace-pre-wrap">
|
||||
{readFileResult.success ? readFileResult.output : readFileResult.error}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Write File Tool */}
|
||||
<Card data-testid="write-file-tool">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Pencil className="w-5 h-5 text-green-500" />
|
||||
<CardTitle className="text-lg">Write File</CardTitle>
|
||||
</div>
|
||||
<CardDescription>Agent requests to write content to a file</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="write-file-path">File Path</Label>
|
||||
<Input
|
||||
id="write-file-path"
|
||||
placeholder="/path/to/file.txt"
|
||||
value={writeFilePath}
|
||||
onChange={(e) => setWriteFilePath(e.target.value)}
|
||||
data-testid="write-file-path-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="write-file-content">Content</Label>
|
||||
<textarea
|
||||
id="write-file-content"
|
||||
placeholder="File content..."
|
||||
value={writeFileContent}
|
||||
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"
|
||||
data-testid="write-file-content-input"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleWriteFile}
|
||||
disabled={isWritingFile || !writeFilePath.trim() || !writeFileContent.trim()}
|
||||
className="w-full"
|
||||
data-testid="write-file-button"
|
||||
>
|
||||
{isWritingFile ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Writing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
Execute Write
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Result */}
|
||||
{writeFileResult && (
|
||||
<div
|
||||
className={cn(
|
||||
'p-3 rounded-md border',
|
||||
writeFileResult.success
|
||||
? 'bg-green-500/10 border-green-500/20'
|
||||
: 'bg-red-500/10 border-red-500/20'
|
||||
)}
|
||||
data-testid="write-file-result"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{writeFileResult.success ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<XCircle className="w-4 h-4 text-red-500" />
|
||||
)}
|
||||
<span className="text-sm font-medium">
|
||||
{writeFileResult.success ? 'Success' : 'Failed'}
|
||||
</span>
|
||||
</div>
|
||||
<pre className="text-xs overflow-auto max-h-40 whitespace-pre-wrap">
|
||||
{writeFileResult.success ? writeFileResult.output : writeFileResult.error}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Terminal Tool */}
|
||||
<Card data-testid="terminal-tool">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="w-5 h-5 text-purple-500" />
|
||||
<CardTitle className="text-lg">Run Terminal</CardTitle>
|
||||
</div>
|
||||
<CardDescription>Agent requests to execute a terminal command</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="terminal-command">Command</Label>
|
||||
<Input
|
||||
id="terminal-command"
|
||||
placeholder="ls -la"
|
||||
value={terminalCommand}
|
||||
onChange={(e) => setTerminalCommand(e.target.value)}
|
||||
data-testid="terminal-command-input"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleRunCommand}
|
||||
disabled={isRunningCommand || !terminalCommand.trim()}
|
||||
className="w-full"
|
||||
data-testid="run-terminal-button"
|
||||
>
|
||||
{isRunningCommand ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Running...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
Execute Command
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Result */}
|
||||
{terminalResult && (
|
||||
<div
|
||||
className={cn(
|
||||
'p-3 rounded-md border',
|
||||
terminalResult.success
|
||||
? 'bg-green-500/10 border-green-500/20'
|
||||
: 'bg-red-500/10 border-red-500/20'
|
||||
)}
|
||||
data-testid="terminal-result"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{terminalResult.success ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<XCircle className="w-4 h-4 text-red-500" />
|
||||
)}
|
||||
<span className="text-sm font-medium">
|
||||
{terminalResult.success ? 'Success' : 'Failed'}
|
||||
</span>
|
||||
</div>
|
||||
<pre className="text-xs overflow-auto max-h-40 whitespace-pre-wrap font-mono bg-black/50 text-green-400 p-2 rounded">
|
||||
$ {terminalCommand}
|
||||
{'\n'}
|
||||
{terminalResult.success ? terminalResult.output : terminalResult.error}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tool Log Section */}
|
||||
<Card className="mt-6" data-testid="tool-log">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Tool Execution Log</CardTitle>
|
||||
<CardDescription>View agent tool requests and responses</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2 text-sm">
|
||||
<p className="text-muted-foreground">
|
||||
Open your browser's developer console to see detailed agent tool logs.
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
|
||||
<li>Read File - Agent requests file content from filesystem</li>
|
||||
<li>Write File - Agent writes content to specified path</li>
|
||||
<li>Run Terminal - Agent executes shell commands</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tools Grid */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="grid gap-6 md:grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 max-w-7xl">
|
||||
{/* Read File Tool */}
|
||||
<GlassCard
|
||||
className="flex flex-col gap-4 bg-white/5 border-white/10"
|
||||
data-testid="read-file-tool"
|
||||
>
|
||||
<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>
|
||||
<h3 className="font-semibold text-foreground">Read File</h3>
|
||||
<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">
|
||||
<Label htmlFor="read-file-path">File Path</Label>
|
||||
<Input
|
||||
id="read-file-path"
|
||||
placeholder="/path/to/file.txt"
|
||||
value={readFilePath}
|
||||
onChange={(e) => setReadFilePath(e.target.value)}
|
||||
data-testid="read-file-path-input"
|
||||
className="bg-black/20 border-white/10 focus:border-blue-500/50"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleReadFile}
|
||||
disabled={isReadingFile || !readFilePath.trim()}
|
||||
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"
|
||||
>
|
||||
{isReadingFile ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Reading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
Execute Read
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Result */}
|
||||
{readFileResult && (
|
||||
<div
|
||||
className={cn(
|
||||
'p-3 rounded-lg border text-xs',
|
||||
readFileResult.success
|
||||
? 'bg-green-500/10 border-green-500/20 text-green-300'
|
||||
: 'bg-red-500/10 border-red-500/20 text-red-300'
|
||||
)}
|
||||
data-testid="read-file-result"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2 font-medium">
|
||||
{readFileResult.success ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-400" />
|
||||
) : (
|
||||
<XCircle className="w-4 h-4 text-red-400" />
|
||||
)}
|
||||
<span>{readFileResult.success ? 'Success' : 'Failed'}</span>
|
||||
</div>
|
||||
<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}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
{/* Write File Tool */}
|
||||
<GlassCard
|
||||
className="flex flex-col gap-4 bg-white/5 border-white/10"
|
||||
data-testid="write-file-tool"
|
||||
>
|
||||
<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>
|
||||
<h3 className="font-semibold text-foreground">Write File</h3>
|
||||
<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">
|
||||
<Label htmlFor="write-file-path">File Path</Label>
|
||||
<Input
|
||||
id="write-file-path"
|
||||
placeholder="/path/to/file.txt"
|
||||
value={writeFilePath}
|
||||
onChange={(e) => setWriteFilePath(e.target.value)}
|
||||
data-testid="write-file-path-input"
|
||||
className="bg-black/20 border-white/10 focus:border-green-500/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="write-file-content">Content</Label>
|
||||
<textarea
|
||||
id="write-file-content"
|
||||
placeholder="File content..."
|
||||
value={writeFileContent}
|
||||
onChange={(e) => setWriteFileContent(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleWriteFile}
|
||||
disabled={isWritingFile || !writeFilePath.trim() || !writeFileContent.trim()}
|
||||
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"
|
||||
>
|
||||
{isWritingFile ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Writing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
Execute Write
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Result */}
|
||||
{writeFileResult && (
|
||||
<div
|
||||
className={cn(
|
||||
'p-3 rounded-lg border text-xs',
|
||||
writeFileResult.success
|
||||
? 'bg-green-500/10 border-green-500/20 text-green-300'
|
||||
: 'bg-red-500/10 border-red-500/20 text-red-300'
|
||||
)}
|
||||
data-testid="write-file-result"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2 font-medium">
|
||||
{writeFileResult.success ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-400" />
|
||||
) : (
|
||||
<XCircle className="w-4 h-4 text-red-400" />
|
||||
)}
|
||||
<span>{writeFileResult.success ? 'Success' : 'Failed'}</span>
|
||||
</div>
|
||||
<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}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
{/* Terminal Tool */}
|
||||
<GlassCard
|
||||
className="flex flex-col gap-4 bg-white/5 border-white/10"
|
||||
data-testid="terminal-tool"
|
||||
>
|
||||
<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>
|
||||
<h3 className="font-semibold text-foreground">Run Terminal</h3>
|
||||
<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">
|
||||
<Label htmlFor="terminal-command">Command</Label>
|
||||
<Input
|
||||
id="terminal-command"
|
||||
placeholder="ls -la"
|
||||
value={terminalCommand}
|
||||
onChange={(e) => setTerminalCommand(e.target.value)}
|
||||
data-testid="terminal-command-input"
|
||||
className="bg-black/20 border-white/10 focus:border-purple-500/50 font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleRunCommand}
|
||||
disabled={isRunningCommand || !terminalCommand.trim()}
|
||||
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"
|
||||
>
|
||||
{isRunningCommand ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Running...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
Execute Command
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Result */}
|
||||
{terminalResult && (
|
||||
<div
|
||||
className={cn(
|
||||
'p-3 rounded-lg border text-xs',
|
||||
terminalResult.success
|
||||
? 'bg-green-500/10 border-green-500/20 text-green-300'
|
||||
: 'bg-red-500/10 border-red-500/20 text-red-300'
|
||||
)}
|
||||
data-testid="terminal-result"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2 font-medium">
|
||||
{terminalResult.success ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-400" />
|
||||
) : (
|
||||
<XCircle className="w-4 h-4 text-red-400" />
|
||||
)}
|
||||
<span>{terminalResult.success ? 'Success' : 'Failed'}</span>
|
||||
</div>
|
||||
<pre className="overflow-auto max-h-40 whitespace-pre-wrap font-mono bg-black/30 p-2 rounded border border-white/5">
|
||||
$ {terminalCommand}
|
||||
{'\n'}
|
||||
{terminalResult.success ? terminalResult.output : terminalResult.error}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
|
||||
{/* Tool Log Section */}
|
||||
<GlassCard className="mt-6 bg-white/5 border-white/10" data-testid="tool-log">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="font-semibold text-foreground">Tool Execution Log</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
View agent tool requests and responses
|
||||
</p>
|
||||
|
||||
<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">
|
||||
Open your browser's developer console to see detailed agent tool logs.
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
|
||||
<li>Read File - Agent requests file content from filesystem</li>
|
||||
<li>Write File - Agent writes content to specified path</li>
|
||||
<li>Run Terminal - Agent executes shell commands</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
</div>
|
||||
</GlassPanel>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -50,6 +50,8 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
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() {
|
||||
const { currentProject, setLastSelectedSession, getLastSelectedSession } = useAppStore();
|
||||
@@ -491,468 +493,501 @@ export function AgentView() {
|
||||
: messages;
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex overflow-hidden bg-background" data-testid="agent-view">
|
||||
{/* Session Manager Sidebar */}
|
||||
{showSessionManager && currentProject && (
|
||||
<div className="w-80 border-r border-border flex-shrink-0 bg-card/50">
|
||||
<SessionManager
|
||||
currentSessionId={currentSessionId}
|
||||
onSelectSession={handleSelectSession}
|
||||
projectPath={currentProject.path}
|
||||
isCurrentSessionThinking={isProcessing}
|
||||
onQuickCreateRef={quickCreateSessionRef}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col h-full w-full overflow-hidden" data-testid="agent-view">
|
||||
<TopHeader />
|
||||
|
||||
{/* Chat Area */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* 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 gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowSessionManager(!showSessionManager)}
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showSessionManager ? (
|
||||
<PanelLeftClose className="w-4 h-4" />
|
||||
) : (
|
||||
<PanelLeft className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
<div className="w-9 h-9 rounded-xl bg-primary/10 flex items-center justify-center">
|
||||
<Bot className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-foreground">AI Agent</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{currentProject.name}
|
||||
{currentSessionId && !isConnected && ' - Connecting...'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 flex overflow-hidden p-4 pt-0 gap-4">
|
||||
{/* Session Manager Sidebar */}
|
||||
{showSessionManager && currentProject && (
|
||||
<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
|
||||
currentSessionId={currentSessionId}
|
||||
onSelectSession={handleSelectSession}
|
||||
projectPath={currentProject.path}
|
||||
isCurrentSessionThinking={isProcessing}
|
||||
onQuickCreateRef={quickCreateSessionRef}
|
||||
/>
|
||||
</GlassPanel>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status indicators & actions */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Model Selector */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
{/* Chat Area */}
|
||||
<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 */}
|
||||
<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-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 text-xs font-medium"
|
||||
disabled={isProcessing}
|
||||
data-testid="model-selector"
|
||||
onClick={() => setShowSessionManager(!showSessionManager)}
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground hover:bg-white/10"
|
||||
>
|
||||
<Bot className="w-3.5 h-3.5" />
|
||||
{CLAUDE_MODELS.find((m) => m.id === selectedModel)?.label.replace(
|
||||
'Claude ',
|
||||
''
|
||||
) || 'Sonnet'}
|
||||
<ChevronDown className="w-3 h-3 opacity-50" />
|
||||
{showSessionManager ? (
|
||||
<PanelLeftClose className="w-4 h-4" />
|
||||
) : (
|
||||
<PanelLeft className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
{CLAUDE_MODELS.map((model) => (
|
||||
<DropdownMenuItem
|
||||
key={model.id}
|
||||
onClick={() => setSelectedModel(model.id)}
|
||||
className={cn('cursor-pointer', selectedModel === model.id && 'bg-accent')}
|
||||
data-testid={`model-option-${model.id}`}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{model.label}</span>
|
||||
<span className="text-xs text-muted-foreground">{model.description}</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{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">
|
||||
<Wrench className="w-3 h-3 text-primary" />
|
||||
<span className="font-medium">{currentTool}</span>
|
||||
</div>
|
||||
)}
|
||||
{agentError && (
|
||||
<span className="text-xs text-destructive font-medium">{agentError}</span>
|
||||
)}
|
||||
{currentSessionId && messages.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClearChat}
|
||||
disabled={isProcessing}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
{!currentSessionId ? (
|
||||
<div
|
||||
className="flex-1 flex items-center justify-center bg-background"
|
||||
data-testid="no-session-placeholder"
|
||||
>
|
||||
<div className="text-center max-w-md">
|
||||
<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" />
|
||||
</div>
|
||||
<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">
|
||||
Create or select a session to start chatting with the AI agent
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => setShowSessionManager(true)}
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
>
|
||||
<PanelLeft className="w-4 h-4" />
|
||||
{showSessionManager ? 'View' : 'Show'} Sessions
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
ref={messagesContainerRef}
|
||||
className="flex-1 overflow-y-auto px-6 py-6 space-y-6 scroll-smooth"
|
||||
data-testid="message-list"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{displayMessages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={cn(
|
||||
'flex gap-4 max-w-4xl',
|
||||
message.role === 'user' ? 'flex-row-reverse ml-auto' : ''
|
||||
)}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div
|
||||
className={cn(
|
||||
'w-9 h-9 rounded-xl flex items-center justify-center shrink-0 shadow-sm',
|
||||
message.role === 'assistant'
|
||||
? 'bg-primary/10 ring-1 ring-primary/20'
|
||||
: 'bg-muted ring-1 ring-border'
|
||||
)}
|
||||
>
|
||||
{message.role === 'assistant' ? (
|
||||
<Bot className="w-4 h-4 text-primary" />
|
||||
) : (
|
||||
<User className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
<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-4 h-4 text-cyan-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-sm font-semibold text-foreground flex items-center gap-2">
|
||||
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}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message Bubble */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex-1 max-w-[85%] rounded-2xl px-4 py-3 shadow-sm',
|
||||
message.role === 'user'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-card border border-border'
|
||||
)}
|
||||
>
|
||||
{message.role === 'assistant' ? (
|
||||
<Markdown className="text-sm text-foreground prose-p:leading-relaxed prose-headings:text-foreground prose-strong:text-foreground prose-code:text-primary prose-code:bg-muted prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded">
|
||||
{message.content}
|
||||
</Markdown>
|
||||
) : (
|
||||
<p className="text-sm whitespace-pre-wrap leading-relaxed">{message.content}</p>
|
||||
)}
|
||||
{/* Status indicators & actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Model Selector */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
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}
|
||||
data-testid="model-selector"
|
||||
>
|
||||
<Bot className="w-3.5 h-3.5" />
|
||||
{CLAUDE_MODELS.find((m) => m.id === selectedModel)?.label.replace(
|
||||
'Claude ',
|
||||
''
|
||||
) || 'Sonnet'}
|
||||
<ChevronDown className="w-3 h-3 opacity-50" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="w-56 bg-zinc-950/95 border-white/10 backdrop-blur-xl"
|
||||
>
|
||||
{CLAUDE_MODELS.map((model) => (
|
||||
<DropdownMenuItem
|
||||
key={model.id}
|
||||
onClick={() => setSelectedModel(model.id)}
|
||||
className={cn(
|
||||
'cursor-pointer focus:bg-white/10',
|
||||
selectedModel === model.id && 'bg-cyan-500/10 text-cyan-400'
|
||||
)}
|
||||
data-testid={`model-option-${model.id}`}
|
||||
>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-medium text-xs">{model.label}</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{model.description}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Display attached images for user messages */}
|
||||
{message.role === 'user' && message.images && message.images.length > 0 && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="flex items-center gap-1.5 text-xs text-primary-foreground/80">
|
||||
<ImageIcon className="w-3 h-3" />
|
||||
<span>
|
||||
{message.images.length} image
|
||||
{message.images.length > 1 ? 's' : ''} attached
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{message.images.map((image, index) => {
|
||||
// Construct proper data URL from base64 data and mime type
|
||||
const dataUrl = image.data.startsWith('data:')
|
||||
? image.data
|
||||
: `data:${image.mimeType || 'image/png'};base64,${image.data}`;
|
||||
return (
|
||||
<div
|
||||
key={image.id || `img-${index}`}
|
||||
className="relative group rounded-lg overflow-hidden border border-primary-foreground/20 bg-primary-foreground/10"
|
||||
>
|
||||
<img
|
||||
src={dataUrl}
|
||||
alt={image.filename || `Attached image ${index + 1}`}
|
||||
className="w-20 h-20 object-cover hover:opacity-90 transition-opacity"
|
||||
/>
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-black/50 px-1.5 py-0.5 text-[9px] text-white truncate">
|
||||
{image.filename || `Image ${index + 1}`}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{currentTool && (
|
||||
<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" />
|
||||
<span className="font-medium">{currentTool}</span>
|
||||
</div>
|
||||
)}
|
||||
{agentError && (
|
||||
<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 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleClearChat}
|
||||
disabled={isProcessing}
|
||||
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" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
{/* Messages */}
|
||||
{!currentSessionId ? (
|
||||
<div
|
||||
className="flex-1 flex items-center justify-center bg-background"
|
||||
data-testid="no-session-placeholder"
|
||||
>
|
||||
<div className="text-center max-w-md">
|
||||
<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" />
|
||||
</div>
|
||||
<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">
|
||||
Create or select a session to start chatting with the AI agent
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => setShowSessionManager(true)}
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
>
|
||||
<PanelLeft className="w-4 h-4" />
|
||||
{showSessionManager ? 'View' : 'Show'} Sessions
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
ref={messagesContainerRef}
|
||||
className="flex-1 overflow-y-auto px-6 py-6 space-y-6 scroll-smooth"
|
||||
data-testid="message-list"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{displayMessages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={cn(
|
||||
'text-[11px] mt-2 font-medium',
|
||||
message.role === 'user'
|
||||
? 'text-primary-foreground/70'
|
||||
: 'text-muted-foreground'
|
||||
'flex gap-4 max-w-4xl',
|
||||
message.role === 'user' ? 'flex-row-reverse ml-auto' : ''
|
||||
)}
|
||||
>
|
||||
{new Date(message.timestamp).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Thinking Indicator */}
|
||||
{isProcessing && (
|
||||
<div className="flex gap-4 max-w-4xl">
|
||||
<div className="w-9 h-9 rounded-xl bg-primary/10 ring-1 ring-primary/20 flex items-center justify-center shrink-0 shadow-sm">
|
||||
<Bot className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<div className="bg-card border border-border rounded-2xl px-4 py-3 shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full bg-primary animate-pulse"
|
||||
style={{ animationDelay: '0ms' }}
|
||||
/>
|
||||
<span
|
||||
className="w-2 h-2 rounded-full bg-primary animate-pulse"
|
||||
style={{ animationDelay: '150ms' }}
|
||||
/>
|
||||
<span
|
||||
className="w-2 h-2 rounded-full bg-primary animate-pulse"
|
||||
style={{ animationDelay: '300ms' }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">Thinking...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input Area */}
|
||||
{currentSessionId && (
|
||||
<div className="border-t border-border p-4 bg-card/50 backdrop-blur-sm">
|
||||
{/* Image Drop Zone (when visible) */}
|
||||
{showImageDropZone && (
|
||||
<ImageDropZone
|
||||
onImagesSelected={handleImagesSelected}
|
||||
images={selectedImages}
|
||||
maxFiles={5}
|
||||
className="mb-4"
|
||||
disabled={isProcessing || !isConnected}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Selected Files Preview - only show when ImageDropZone is hidden to avoid duplicate display */}
|
||||
{(selectedImages.length > 0 || selectedTextFiles.length > 0) && !showImageDropZone && (
|
||||
<div className="mb-4 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-medium text-foreground">
|
||||
{selectedImages.length + selectedTextFiles.length} file
|
||||
{selectedImages.length + selectedTextFiles.length > 1 ? 's' : ''} attached
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedImages([]);
|
||||
setSelectedTextFiles([]);
|
||||
}}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{/* Image attachments */}
|
||||
{selectedImages.map((image) => (
|
||||
{/* Avatar */}
|
||||
<div
|
||||
key={image.id}
|
||||
className="group relative rounded-lg border border-border bg-muted/30 p-2 flex items-center gap-2 hover:border-primary/30 transition-colors"
|
||||
className={cn(
|
||||
'w-9 h-9 rounded-xl flex items-center justify-center shrink-0 shadow-sm',
|
||||
message.role === 'assistant'
|
||||
? 'bg-primary/10 ring-1 ring-primary/20'
|
||||
: 'bg-muted ring-1 ring-border'
|
||||
)}
|
||||
>
|
||||
{/* Image thumbnail */}
|
||||
<div className="w-8 h-8 rounded-md overflow-hidden bg-muted flex-shrink-0">
|
||||
<img
|
||||
src={image.data}
|
||||
alt={image.filename}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
{/* Image info */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium text-foreground truncate max-w-24">
|
||||
{image.filename}
|
||||
</p>
|
||||
{image.size !== undefined && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{formatFileSize(image.size)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Remove button */}
|
||||
{image.id && (
|
||||
<button
|
||||
onClick={() => removeImage(image.id!)}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded-full hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
|
||||
disabled={isProcessing}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
{message.role === 'assistant' ? (
|
||||
<Bot className="w-4 h-4 text-primary" />
|
||||
) : (
|
||||
<User className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{/* Text file attachments */}
|
||||
{selectedTextFiles.map((file) => (
|
||||
|
||||
{/* Message Bubble */}
|
||||
<div
|
||||
key={file.id}
|
||||
className="group relative rounded-lg border border-border bg-muted/30 p-2 flex items-center gap-2 hover:border-primary/30 transition-colors"
|
||||
className={cn(
|
||||
'flex-1 max-w-[85%] rounded-2xl px-4 py-3 shadow-sm',
|
||||
message.role === 'user'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-card border border-border'
|
||||
)}
|
||||
>
|
||||
{/* File icon */}
|
||||
<div className="w-8 h-8 rounded-md bg-muted flex-shrink-0 flex items-center justify-center">
|
||||
<FileText className="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
{/* File info */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium text-foreground truncate max-w-24">
|
||||
{file.filename}
|
||||
{message.role === 'assistant' ? (
|
||||
<Markdown className="text-sm text-foreground prose-p:leading-relaxed prose-headings:text-foreground prose-strong:text-foreground prose-code:text-primary prose-code:bg-muted prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded">
|
||||
{message.content}
|
||||
</Markdown>
|
||||
) : (
|
||||
<p className="text-sm whitespace-pre-wrap leading-relaxed">
|
||||
{message.content}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{formatFileSize(file.size)}
|
||||
</p>
|
||||
</div>
|
||||
{/* Remove button */}
|
||||
<button
|
||||
onClick={() => removeTextFile(file.id)}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded-full hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
|
||||
disabled={isProcessing}
|
||||
)}
|
||||
|
||||
{/* Display attached images for user messages */}
|
||||
{message.role === 'user' && message.images && message.images.length > 0 && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="flex items-center gap-1.5 text-xs text-primary-foreground/80">
|
||||
<ImageIcon className="w-3 h-3" />
|
||||
<span>
|
||||
{message.images.length} image
|
||||
{message.images.length > 1 ? 's' : ''} attached
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{message.images.map((image, index) => {
|
||||
// Construct proper data URL from base64 data and mime type
|
||||
const dataUrl = image.data.startsWith('data:')
|
||||
? image.data
|
||||
: `data:${image.mimeType || 'image/png'};base64,${image.data}`;
|
||||
return (
|
||||
<div
|
||||
key={image.id || `img-${index}`}
|
||||
className="relative group rounded-lg overflow-hidden border border-primary-foreground/20 bg-primary-foreground/10"
|
||||
>
|
||||
<img
|
||||
src={dataUrl}
|
||||
alt={image.filename || `Attached image ${index + 1}`}
|
||||
className="w-20 h-20 object-cover hover:opacity-90 transition-opacity"
|
||||
/>
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-black/50 px-1.5 py-0.5 text-[9px] text-white truncate">
|
||||
{image.filename || `Image ${index + 1}`}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p
|
||||
className={cn(
|
||||
'text-[11px] mt-2 font-medium',
|
||||
message.role === 'user'
|
||||
? 'text-primary-foreground/70'
|
||||
: 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
{new Date(message.timestamp).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Thinking Indicator */}
|
||||
{isProcessing && (
|
||||
<div className="flex gap-4 max-w-4xl">
|
||||
<div className="w-9 h-9 rounded-xl bg-primary/10 ring-1 ring-primary/20 flex items-center justify-center shrink-0 shadow-sm">
|
||||
<Bot className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<div className="bg-card border border-border rounded-2xl px-4 py-3 shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full bg-primary animate-pulse"
|
||||
style={{ animationDelay: '0ms' }}
|
||||
/>
|
||||
<span
|
||||
className="w-2 h-2 rounded-full bg-primary animate-pulse"
|
||||
style={{ animationDelay: '150ms' }}
|
||||
/>
|
||||
<span
|
||||
className="w-2 h-2 rounded-full bg-primary animate-pulse"
|
||||
style={{ animationDelay: '300ms' }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">Thinking...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Text Input and Controls */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex gap-2 transition-all duration-200 rounded-xl p-1',
|
||||
isDragOver && 'bg-primary/5 ring-2 ring-primary/30'
|
||||
)}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div className="flex-1 relative">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
placeholder={
|
||||
isDragOver ? 'Drop your files here...' : 'Describe what you want to build...'
|
||||
}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
onPaste={handlePaste}
|
||||
disabled={isProcessing || !isConnected}
|
||||
data-testid="agent-input"
|
||||
className={cn(
|
||||
'h-11 bg-background border-border rounded-xl pl-4 pr-20 text-sm transition-all',
|
||||
'focus:ring-2 focus:ring-primary/20 focus:border-primary/50',
|
||||
(selectedImages.length > 0 || selectedTextFiles.length > 0) &&
|
||||
'border-primary/30',
|
||||
isDragOver && 'border-primary bg-primary/5'
|
||||
{/* Input Area */}
|
||||
{currentSessionId && (
|
||||
<div className="border-t border-border p-4 bg-card/50 backdrop-blur-sm">
|
||||
{/* Image Drop Zone (when visible) */}
|
||||
{showImageDropZone && (
|
||||
<ImageDropZone
|
||||
onImagesSelected={handleImagesSelected}
|
||||
images={selectedImages}
|
||||
maxFiles={5}
|
||||
className="mb-4"
|
||||
disabled={isProcessing || !isConnected}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Selected Files Preview - only show when ImageDropZone is hidden to avoid duplicate display */}
|
||||
{(selectedImages.length > 0 || selectedTextFiles.length > 0) &&
|
||||
!showImageDropZone && (
|
||||
<div className="mb-4 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-medium text-foreground">
|
||||
{selectedImages.length + selectedTextFiles.length} file
|
||||
{selectedImages.length + selectedTextFiles.length > 1 ? 's' : ''} attached
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedImages([]);
|
||||
setSelectedTextFiles([]);
|
||||
}}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{/* Image attachments */}
|
||||
{selectedImages.map((image) => (
|
||||
<div
|
||||
key={image.id}
|
||||
className="group relative rounded-lg border border-border bg-muted/30 p-2 flex items-center gap-2 hover:border-primary/30 transition-colors"
|
||||
>
|
||||
{/* Image thumbnail */}
|
||||
<div className="w-8 h-8 rounded-md overflow-hidden bg-muted flex-shrink-0">
|
||||
<img
|
||||
src={image.data}
|
||||
alt={image.filename}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
{/* Image info */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium text-foreground truncate max-w-24">
|
||||
{image.filename}
|
||||
</p>
|
||||
{image.size !== undefined && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{formatFileSize(image.size)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Remove button */}
|
||||
{image.id && (
|
||||
<button
|
||||
onClick={() => removeImage(image.id!)}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded-full hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
|
||||
disabled={isProcessing}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{/* Text file attachments */}
|
||||
{selectedTextFiles.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="group relative rounded-lg border border-border bg-muted/30 p-2 flex items-center gap-2 hover:border-primary/30 transition-colors"
|
||||
>
|
||||
{/* File icon */}
|
||||
<div className="w-8 h-8 rounded-md bg-muted flex-shrink-0 flex items-center justify-center">
|
||||
<FileText className="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
{/* File info */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium text-foreground truncate max-w-24">
|
||||
{file.filename}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{formatFileSize(file.size)}
|
||||
</p>
|
||||
</div>
|
||||
{/* Remove button */}
|
||||
<button
|
||||
onClick={() => removeTextFile(file.id)}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded-full hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
|
||||
disabled={isProcessing}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{(selectedImages.length > 0 || selectedTextFiles.length > 0) && !isDragOver && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-xs bg-primary text-primary-foreground px-2 py-0.5 rounded-full font-medium">
|
||||
{selectedImages.length + selectedTextFiles.length} file
|
||||
{selectedImages.length + selectedTextFiles.length > 1 ? 's' : ''}
|
||||
|
||||
{/* Text Input and Controls */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex gap-2 transition-all duration-200 rounded-xl p-1',
|
||||
isDragOver && 'bg-primary/5 ring-2 ring-primary/30'
|
||||
)}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div className="flex-1 relative">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
placeholder={
|
||||
isDragOver
|
||||
? 'Drop your files here...'
|
||||
: 'Describe what you want to build...'
|
||||
}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
onPaste={handlePaste}
|
||||
disabled={isProcessing || !isConnected}
|
||||
data-testid="agent-input"
|
||||
className={cn(
|
||||
'h-11 bg-background border-border rounded-xl pl-4 pr-20 text-sm transition-all',
|
||||
'focus:ring-2 focus:ring-primary/20 focus:border-primary/50',
|
||||
(selectedImages.length > 0 || selectedTextFiles.length > 0) &&
|
||||
'border-primary/30',
|
||||
isDragOver && 'border-primary bg-primary/5'
|
||||
)}
|
||||
/>
|
||||
{(selectedImages.length > 0 || selectedTextFiles.length > 0) && !isDragOver && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-xs bg-primary text-primary-foreground px-2 py-0.5 rounded-full font-medium">
|
||||
{selectedImages.length + selectedTextFiles.length} file
|
||||
{selectedImages.length + selectedTextFiles.length > 1 ? 's' : ''}
|
||||
</div>
|
||||
)}
|
||||
{isDragOver && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-1.5 text-xs text-primary font-medium">
|
||||
<Paperclip className="w-3 h-3" />
|
||||
Drop here
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isDragOver && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-1.5 text-xs text-primary font-medium">
|
||||
<Paperclip className="w-3 h-3" />
|
||||
Drop here
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File Attachment Button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={toggleImageDropZone}
|
||||
disabled={isProcessing || !isConnected}
|
||||
className={cn(
|
||||
'h-11 w-11 rounded-xl border-border',
|
||||
showImageDropZone && 'bg-primary/10 text-primary border-primary/30',
|
||||
(selectedImages.length > 0 || selectedTextFiles.length > 0) &&
|
||||
'border-primary/30 text-primary'
|
||||
)}
|
||||
title="Attach files (images, .txt, .md)"
|
||||
>
|
||||
<Paperclip className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
{/* Send / Stop Button */}
|
||||
{isProcessing ? (
|
||||
<Button
|
||||
onClick={stopExecution}
|
||||
disabled={!isConnected}
|
||||
className="h-11 px-4 rounded-xl"
|
||||
variant="destructive"
|
||||
data-testid="stop-agent"
|
||||
title="Stop generation"
|
||||
>
|
||||
<Square className="w-4 h-4 fill-current" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
disabled={
|
||||
(!input.trim() &&
|
||||
selectedImages.length === 0 &&
|
||||
selectedTextFiles.length === 0) ||
|
||||
!isConnected
|
||||
}
|
||||
className="h-11 px-4 rounded-xl"
|
||||
data-testid="send-message"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Keyboard hint */}
|
||||
<p className="text-[11px] text-muted-foreground mt-2 text-center">
|
||||
Press{' '}
|
||||
<kbd className="px-1.5 py-0.5 bg-muted rounded text-[10px] font-medium">
|
||||
Enter
|
||||
</kbd>{' '}
|
||||
to send
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* File Attachment Button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={toggleImageDropZone}
|
||||
disabled={isProcessing || !isConnected}
|
||||
className={cn(
|
||||
'h-11 w-11 rounded-xl border-border',
|
||||
showImageDropZone && 'bg-primary/10 text-primary border-primary/30',
|
||||
(selectedImages.length > 0 || selectedTextFiles.length > 0) &&
|
||||
'border-primary/30 text-primary'
|
||||
)}
|
||||
title="Attach files (images, .txt, .md)"
|
||||
>
|
||||
<Paperclip className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
{/* Send / Stop Button */}
|
||||
{isProcessing ? (
|
||||
<Button
|
||||
onClick={stopExecution}
|
||||
disabled={!isConnected}
|
||||
className="h-11 px-4 rounded-xl"
|
||||
variant="destructive"
|
||||
data-testid="stop-agent"
|
||||
title="Stop generation"
|
||||
>
|
||||
<Square className="w-4 h-4 fill-current" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
disabled={
|
||||
(!input.trim() &&
|
||||
selectedImages.length === 0 &&
|
||||
selectedTextFiles.length === 0) ||
|
||||
!isConnected
|
||||
}
|
||||
className="h-11 px-4 rounded-xl"
|
||||
data-testid="send-message"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Keyboard hint */}
|
||||
<p className="text-[11px] text-muted-foreground mt-2 text-center">
|
||||
Press{' '}
|
||||
<kbd className="px-1.5 py-0.5 bg-muted rounded text-[10px] font-medium">Enter</kbd> to
|
||||
send
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</GlassPanel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -17,7 +17,8 @@ import { useAutoMode } from '@/hooks/use-auto-mode';
|
||||
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
|
||||
import { useWindowState } from '@/hooks/use-window-state';
|
||||
// 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 { BoardControls } from './board-view/board-controls';
|
||||
import { KanbanBoard } from './board-view/kanban-board';
|
||||
@@ -263,9 +264,9 @@ export function BoardView() {
|
||||
// Calculate unarchived card counts per branch
|
||||
const branchCardCounts = useMemo(() => {
|
||||
return hookFeatures.reduce(
|
||||
(counts, feature) => {
|
||||
(counts: Record<string, number>, feature) => {
|
||||
if (feature.status !== 'completed') {
|
||||
const branch = feature.branchName ?? 'main';
|
||||
const branch = (feature.branchName as string) ?? 'main';
|
||||
counts[branch] = (counts[branch] || 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
@@ -918,27 +919,8 @@ export function BoardView() {
|
||||
data-testid="board-view"
|
||||
>
|
||||
{/* Header */}
|
||||
<BoardHeader
|
||||
projectName={currentProject.name}
|
||||
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}
|
||||
/>
|
||||
{/* Top Header */}
|
||||
<TopHeader />
|
||||
|
||||
{/* Worktree Panel */}
|
||||
<WorktreePanel
|
||||
|
||||
@@ -31,6 +31,7 @@ interface CardHeaderProps {
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onViewOutput?: () => void;
|
||||
hideActions?: boolean;
|
||||
}
|
||||
|
||||
export function CardHeaderSection({
|
||||
@@ -40,6 +41,7 @@ export function CardHeaderSection({
|
||||
onEdit,
|
||||
onDelete,
|
||||
onViewOutput,
|
||||
hideActions,
|
||||
}: CardHeaderProps) {
|
||||
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { memo } from 'react';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
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 { CardBadges, PriorityBadges } from './card-badges';
|
||||
import { CardHeaderSection } from './card-header';
|
||||
@@ -56,10 +56,6 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
shortcutKey,
|
||||
contextContent,
|
||||
summary,
|
||||
opacity = 100,
|
||||
glassmorphism = true,
|
||||
cardBorderEnabled = true,
|
||||
cardBorderOpacity = 100,
|
||||
}: KanbanCardProps) {
|
||||
const { useWorktrees } = useAppStore();
|
||||
|
||||
@@ -68,6 +64,7 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
feature.status === 'waiting_approval' ||
|
||||
feature.status === 'verified' ||
|
||||
(feature.status === 'in_progress' && !isCurrentAutoTask);
|
||||
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: feature.id,
|
||||
disabled: !isDraggable,
|
||||
@@ -79,36 +76,15 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
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 = (
|
||||
<Card
|
||||
<GlassCard
|
||||
ref={setNodeRef}
|
||||
style={isCurrentAutoTask ? style : borderStyle}
|
||||
variant={isCurrentAutoTask ? 'active-blue' : 'default'}
|
||||
style={style}
|
||||
className={cn(
|
||||
'cursor-grab active:cursor-grabbing relative kanban-card-content select-none',
|
||||
'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]!',
|
||||
'group relative min-h-[140px] flex flex-col',
|
||||
isDragging && 'scale-105 shadow-xl shadow-black/20 rotate-1',
|
||||
// Error state - using CSS variable
|
||||
feature.error &&
|
||||
!isCurrentAutoTask &&
|
||||
'border-[var(--status-error)] border-2 shadow-[var(--status-error-bg)] shadow-lg',
|
||||
feature.error && 'border-brand-red border-2 shadow-glow-red',
|
||||
!isDraggable && 'cursor-default'
|
||||
)}
|
||||
data-testid={`kanban-card-${feature.id}`}
|
||||
@@ -116,51 +92,79 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
{...attributes}
|
||||
{...(isDraggable ? listeners : {})}
|
||||
>
|
||||
{/* Background overlay with opacity */}
|
||||
{!isDragging && (
|
||||
<div
|
||||
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} />
|
||||
|
||||
{/* Category row */}
|
||||
<div className="px-3 pt-4">
|
||||
<span className="text-[11px] text-muted-foreground/70 font-medium">{feature.category}</span>
|
||||
{/* Top Row: Empty space + Delete (on hover) */}
|
||||
<div className="flex justify-between items-start mb-2 h-5">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<CardBadges feature={feature} />
|
||||
</div>
|
||||
{/* Delete/Actions on hover */}
|
||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-2">
|
||||
<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>
|
||||
|
||||
{/* Priority and Manual Verification badges */}
|
||||
<PriorityBadges feature={feature} />
|
||||
{/* Description */}
|
||||
<div className="mb-4">
|
||||
<CardHeaderSection
|
||||
feature={feature}
|
||||
isDraggable={isDraggable}
|
||||
isCurrentAutoTask={!!isCurrentAutoTask}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onViewOutput={onViewOutput}
|
||||
hideActions={true} // We handle actions via hover/bottom bar
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Card Header */}
|
||||
<CardHeaderSection
|
||||
feature={feature}
|
||||
isDraggable={isDraggable}
|
||||
isCurrentAutoTask={!!isCurrentAutoTask}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onViewOutput={onViewOutput}
|
||||
/>
|
||||
{/* Middle Grid: Priority, etc */}
|
||||
<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>
|
||||
|
||||
<CardContent className="px-3 pt-0 pb-0">
|
||||
{/* Content Sections */}
|
||||
{/* Content & Agent Info */}
|
||||
<div className="mb-2">
|
||||
<CardContentSections feature={feature} useWorktrees={useWorktrees} />
|
||||
|
||||
{/* Agent Info Panel */}
|
||||
<AgentInfoPanel
|
||||
feature={feature}
|
||||
contextContent={contextContent}
|
||||
summary={summary}
|
||||
isCurrentAutoTask={isCurrentAutoTask}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{/* Buttons Grid */}
|
||||
<div className="mt-auto pt-2">
|
||||
<CardActions
|
||||
feature={feature}
|
||||
isCurrentAutoTask={!!isCurrentAutoTask}
|
||||
@@ -178,14 +182,9 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
onViewPlan={onViewPlan}
|
||||
onApprovePlan={onApprovePlan}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</GlassCard>
|
||||
);
|
||||
|
||||
// Wrap with animated border when in progress
|
||||
if (isCurrentAutoTask) {
|
||||
return <div className="animated-border-wrapper">{cardElement}</div>;
|
||||
}
|
||||
|
||||
return cardElement;
|
||||
});
|
||||
|
||||
@@ -2,31 +2,30 @@ import { memo } from 'react';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ReactNode } from 'react';
|
||||
import { GlassPanel } from '@/components/ui/glass-panel';
|
||||
|
||||
interface KanbanColumnProps {
|
||||
id: string;
|
||||
title: string;
|
||||
colorClass: string;
|
||||
accent: 'cyan' | 'blue' | 'orange' | 'green' | 'none';
|
||||
count: number;
|
||||
children: ReactNode;
|
||||
headerAction?: ReactNode;
|
||||
width?: number;
|
||||
// Legacy props ignored or used for compatibility
|
||||
colorClass?: string;
|
||||
opacity?: number;
|
||||
showBorder?: boolean;
|
||||
hideScrollbar?: boolean;
|
||||
/** Custom width in pixels. If not provided, defaults to 288px (w-72) */
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export const KanbanColumn = memo(function KanbanColumn({
|
||||
id,
|
||||
title,
|
||||
colorClass,
|
||||
accent,
|
||||
count,
|
||||
children,
|
||||
headerAction,
|
||||
opacity = 100,
|
||||
showBorder = true,
|
||||
hideScrollbar = false,
|
||||
width,
|
||||
}: KanbanColumnProps) {
|
||||
const { setNodeRef, isOver } = useDroppable({ id });
|
||||
@@ -35,60 +34,63 @@ export const KanbanColumn = memo(function KanbanColumn({
|
||||
const widthStyle = width ? { width: `${width}px`, flexShrink: 0 } : undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
<GlassPanel
|
||||
ref={setNodeRef}
|
||||
accent={accent}
|
||||
className={cn(
|
||||
'relative flex flex-col h-full rounded-xl',
|
||||
// Only transition ring/shadow for drag-over effect, not width
|
||||
'transition-[box-shadow,ring] duration-200',
|
||||
!width && 'w-72', // Only apply w-72 if no custom width
|
||||
showBorder && 'border border-border/60',
|
||||
'relative flex flex-col h-full min-w-[300px] transition-[box-shadow,ring] duration-200',
|
||||
!width && 'w-72',
|
||||
isOver && 'ring-2 ring-primary/30 ring-offset-1 ring-offset-background'
|
||||
)}
|
||||
style={widthStyle}
|
||||
data-testid={`kanban-column-${id}`}
|
||||
>
|
||||
{/* Background layer with opacity */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 rounded-xl backdrop-blur-sm transition-colors duration-200',
|
||||
isOver ? 'bg-accent/80' : 'bg-card/80'
|
||||
)}
|
||||
style={{ opacity: opacity / 100 }}
|
||||
/>
|
||||
{/* Subtle Glow Top (Only for Blue/Orange/Green to match design, could make generic) */}
|
||||
{(accent === 'blue' || accent === 'orange' || accent === 'green') && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute top-0 left-0 w-full h-32 bg-gradient-to-b pointer-events-none rounded-t-2xl',
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* Column Header */}
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-10 flex items-center gap-3 px-3 py-2.5',
|
||||
showBorder && 'border-b border-border/40'
|
||||
)}
|
||||
>
|
||||
<div className={cn('w-2.5 h-2.5 rounded-full shrink-0', colorClass)} />
|
||||
<h3 className="font-semibold text-sm text-foreground/90 flex-1 tracking-tight">{title}</h3>
|
||||
{headerAction}
|
||||
<span className="text-xs font-medium text-muted-foreground/80 bg-muted/50 px-2 py-0.5 rounded-md tabular-nums">
|
||||
<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
|
||||
className={cn(
|
||||
'w-2 h-2 rounded-full',
|
||||
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>
|
||||
<span className="font-bold text-slate-200 text-sm">{title}</span>
|
||||
|
||||
{/* Action container (like "Make") */}
|
||||
{headerAction}
|
||||
</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}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Column Content */}
|
||||
<div
|
||||
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'
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-3 custom-scrollbar relative z-10">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Drop zone indicator when dragging over */}
|
||||
{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 const COLUMNS: { id: ColumnId; title: string; colorClass: string }[] = [
|
||||
{ id: 'backlog', title: 'Backlog', colorClass: 'bg-[var(--status-backlog)]' },
|
||||
export const COLUMNS: {
|
||||
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',
|
||||
title: 'In Progress',
|
||||
colorClass: 'bg-[var(--status-in-progress)]',
|
||||
accent: 'blue',
|
||||
},
|
||||
{
|
||||
id: 'waiting_approval',
|
||||
title: 'Waiting Approval',
|
||||
colorClass: 'bg-[var(--status-waiting)]',
|
||||
accent: 'orange',
|
||||
},
|
||||
{
|
||||
id: 'verified',
|
||||
title: 'Verified',
|
||||
colorClass: 'bg-[var(--status-success)]',
|
||||
accent: 'green',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -21,8 +21,6 @@ import {
|
||||
RefreshCw,
|
||||
Shield,
|
||||
Zap,
|
||||
List,
|
||||
FileText,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
getElectronAPI,
|
||||
@@ -32,7 +30,6 @@ import {
|
||||
} from '@/lib/electron';
|
||||
import { useAppStore, Feature } from '@/store/app-store';
|
||||
import { toast } from 'sonner';
|
||||
import { LogViewer } from '@/components/ui/log-viewer';
|
||||
|
||||
interface FeatureSuggestionsDialogProps {
|
||||
open: boolean;
|
||||
@@ -95,7 +92,6 @@ export function FeatureSuggestionsDialog({
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [currentSuggestionType, setCurrentSuggestionType] = useState<SuggestionType | null>(null);
|
||||
const [viewMode, setViewMode] = useState<'parsed' | 'raw'>('parsed');
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const autoScrollRef = useRef(true);
|
||||
|
||||
@@ -127,9 +123,7 @@ export function FeatureSuggestionsDialog({
|
||||
setProgress((prev) => [...prev, event.content || '']);
|
||||
} else if (event.type === 'suggestions_tool') {
|
||||
const toolName = event.tool || 'Unknown Tool';
|
||||
const toolInput = event.input ? JSON.stringify(event.input, null, 2) : '';
|
||||
const formattedTool = `\n🔧 Tool: ${toolName}\n${toolInput ? `Input: ${toolInput}\n` : ''}`;
|
||||
setProgress((prev) => [...prev, formattedTool]);
|
||||
setProgress((prev) => [...prev, `Using tool: ${toolName}\n`]);
|
||||
} else if (event.type === 'suggestions_complete') {
|
||||
setIsGenerating(false);
|
||||
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)}`,
|
||||
category: s.category,
|
||||
description: s.description,
|
||||
steps: [], // Required empty steps array for new features
|
||||
status: 'backlog' as const,
|
||||
skipTests: true, // As specified, testing mode true
|
||||
priority: s.priority, // Preserve priority from suggestion
|
||||
@@ -304,7 +297,7 @@ export function FeatureSuggestionsDialog({
|
||||
setCurrentSuggestionType(null);
|
||||
}, [setSuggestions]);
|
||||
|
||||
const hasStarted = isGenerating || progress.length > 0 || suggestions.length > 0;
|
||||
const hasStarted = progress.length > 0 || suggestions.length > 0;
|
||||
const hasSuggestions = suggestions.length > 0;
|
||||
const currentConfig = currentSuggestionType ? suggestionTypeConfig[currentSuggestionType] : null;
|
||||
|
||||
@@ -378,56 +371,19 @@ export function FeatureSuggestionsDialog({
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Analyzing project...
|
||||
</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}>
|
||||
<StopCircle className="w-4 h-4 mr-2" />
|
||||
Stop
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="destructive" size="sm" onClick={handleStop}>
|
||||
<StopCircle className="w-4 h-4 mr-2" />
|
||||
Stop
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
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]"
|
||||
>
|
||||
{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">
|
||||
{progress.join('')}
|
||||
</div>
|
||||
)}
|
||||
<div className="whitespace-pre-wrap break-words text-zinc-300">
|
||||
{progress.join('')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : hasSuggestions ? (
|
||||
|
||||
@@ -109,7 +109,9 @@ export function useBoardColumnFeatures({
|
||||
// This ensures features appear in dependency order (dependencies before dependents)
|
||||
// Within the same dependency level, features are sorted by priority
|
||||
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
|
||||
const allFeatures = features;
|
||||
@@ -122,7 +124,7 @@ export function useBoardColumnFeatures({
|
||||
const blocked: Feature[] = [];
|
||||
|
||||
for (const f of orderedFeatures) {
|
||||
if (getBlockingDependencies(f, allFeatures).length > 0) {
|
||||
if (getBlockingDependencies(f as any, allFeatures as any).length > 0) {
|
||||
blocked.push(f);
|
||||
} else {
|
||||
unblocked.push(f);
|
||||
|
||||
@@ -101,6 +101,7 @@ export function KanbanBoard({
|
||||
key={column.id}
|
||||
id={column.id}
|
||||
title={column.title}
|
||||
accent={column.accent}
|
||||
colorClass={column.colorClass}
|
||||
count={columnFeatures.length}
|
||||
width={columnWidth}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RefreshCw, Globe, Loader2, CircleDot, GitPullRequest } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import type { WorktreeInfo, BranchInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types';
|
||||
@@ -87,7 +88,7 @@ export function WorktreeTab({
|
||||
onStopDevServer,
|
||||
onOpenDevServerUrl,
|
||||
}: WorktreeTabProps) {
|
||||
let prBadge: JSX.Element | null = null;
|
||||
let prBadge: React.ReactNode | null = null;
|
||||
if (worktree.pr) {
|
||||
const prState = worktree.pr.state?.toLowerCase() ?? 'open';
|
||||
const prStateClasses = (() => {
|
||||
|
||||
@@ -1,126 +1,93 @@
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { CircleDot, RefreshCw } from 'lucide-react';
|
||||
import { getElectronAPI, GitHubIssue, IssueValidationResult } from '@/lib/electron';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { CircleDot, Loader2, RefreshCw, ExternalLink, CheckCircle2, Circle, X } from 'lucide-react';
|
||||
import { getElectronAPI, GitHubIssue } from '@/lib/electron';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { LoadingState } from '@/components/ui/loading-state';
|
||||
import { ErrorState } from '@/components/ui/error-state';
|
||||
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';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Markdown } from '@/components/ui/markdown';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
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 [validationResult, setValidationResult] = useState<IssueValidationResult | null>(null);
|
||||
const [showValidationDialog, setShowValidationDialog] = useState(false);
|
||||
const [showRevalidateConfirm, setShowRevalidateConfirm] = useState(false);
|
||||
const { currentProject } = useAppStore();
|
||||
|
||||
const { currentProject, defaultAIProfileId, aiProfiles, getCurrentWorktree, worktreesByProject } =
|
||||
useAppStore();
|
||||
const fetchIssues = useCallback(async () => {
|
||||
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 } =
|
||||
useIssueValidation({
|
||||
selectedIssue,
|
||||
showValidationDialog,
|
||||
onValidationResultChange: setValidationResult,
|
||||
onShowValidationDialogChange: setShowValidationDialog,
|
||||
});
|
||||
useEffect(() => {
|
||||
fetchIssues();
|
||||
}, [fetchIssues]);
|
||||
|
||||
// Get default AI profile for task creation
|
||||
const defaultProfile = useMemo(() => {
|
||||
if (!defaultAIProfileId) return null;
|
||||
return aiProfiles.find((p) => p.id === defaultAIProfileId) ?? null;
|
||||
}, [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 handleRefresh = useCallback(() => {
|
||||
setRefreshing(true);
|
||||
fetchIssues();
|
||||
}, [fetchIssues]);
|
||||
|
||||
const handleOpenInGitHub = useCallback((url: string) => {
|
||||
const api = getElectronAPI();
|
||||
api.openExternalLink(url);
|
||||
}, []);
|
||||
|
||||
const handleConvertToTask = useCallback(
|
||||
async (issue: GitHubIssue, validation: IssueValidationResult) => {
|
||||
if (!currentProject?.path) {
|
||||
toast.error('No project selected');
|
||||
return;
|
||||
}
|
||||
|
||||
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]
|
||||
);
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
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) {
|
||||
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;
|
||||
@@ -135,12 +102,24 @@ export function GitHubIssuesView() {
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<IssuesListHeader
|
||||
openCount={openIssues.length}
|
||||
closedCount={closedIssues.length}
|
||||
refreshing={refreshing}
|
||||
onRefresh={refresh}
|
||||
/>
|
||||
<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'
|
||||
: `${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 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
@@ -163,8 +142,6 @@ export function GitHubIssuesView() {
|
||||
onClick={() => setSelectedIssue(issue)}
|
||||
onOpenExternal={() => handleOpenInGitHub(issue.url)}
|
||||
formatDate={formatDate}
|
||||
cachedValidation={cachedValidations.get(issue.number)}
|
||||
isValidating={validatingIssues.has(issue.number)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -182,8 +159,6 @@ export function GitHubIssuesView() {
|
||||
onClick={() => setSelectedIssue(issue)}
|
||||
onOpenExternal={() => handleOpenInGitHub(issue.url)}
|
||||
formatDate={formatDate}
|
||||
cachedValidation={cachedValidations.get(issue.number)}
|
||||
isValidating={validatingIssues.has(issue.number)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
@@ -195,43 +170,164 @@ export function GitHubIssuesView() {
|
||||
|
||||
{/* Issue Detail Panel */}
|
||||
{selectedIssue && (
|
||||
<IssueDetailPanel
|
||||
issue={selectedIssue}
|
||||
validatingIssues={validatingIssues}
|
||||
cachedValidations={cachedValidations}
|
||||
onValidateIssue={handleValidateIssue}
|
||||
onViewCachedValidation={handleViewCachedValidation}
|
||||
onOpenInGitHub={handleOpenInGitHub}
|
||||
onClose={() => setSelectedIssue(null)}
|
||||
onShowRevalidateConfirm={() => setShowRevalidateConfirm(true)}
|
||||
formatDate={formatDate}
|
||||
/>
|
||||
<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">
|
||||
{selectedIssue.state === 'OPEN' ? (
|
||||
<Circle className="h-4 w-4 text-green-500 flex-shrink-0" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-4 w-4 text-purple-500 flex-shrink-0" />
|
||||
)}
|
||||
<span className="text-sm font-medium truncate">
|
||||
#{selectedIssue.number} {selectedIssue.title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleOpenInGitHub(selectedIssue.url)}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4 mr-1" />
|
||||
Open in GitHub
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setSelectedIssue(null)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Issue Detail Content */}
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
{/* Title */}
|
||||
<h1 className="text-xl font-bold mb-2">{selectedIssue.title}</h1>
|
||||
|
||||
{/* Meta info */}
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground mb-4">
|
||||
<span
|
||||
className={cn(
|
||||
'px-2 py-0.5 rounded-full text-xs font-medium',
|
||||
selectedIssue.state === 'OPEN'
|
||||
? 'bg-green-500/10 text-green-500'
|
||||
: 'bg-purple-500/10 text-purple-500'
|
||||
)}
|
||||
>
|
||||
{selectedIssue.state === 'OPEN' ? 'Open' : 'Closed'}
|
||||
</span>
|
||||
<span>
|
||||
#{selectedIssue.number} opened {formatDate(selectedIssue.createdAt)} by{' '}
|
||||
<span className="font-medium text-foreground">{selectedIssue.author.login}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Labels */}
|
||||
{selectedIssue.labels.length > 0 && (
|
||||
<div className="flex items-center gap-2 mb-6 flex-wrap">
|
||||
{selectedIssue.labels.map((label) => (
|
||||
<span
|
||||
key={label.name}
|
||||
className="px-2 py-0.5 text-xs font-medium rounded-full"
|
||||
style={{
|
||||
backgroundColor: `#${label.color}20`,
|
||||
color: `#${label.color}`,
|
||||
border: `1px solid #${label.color}40`,
|
||||
}}
|
||||
>
|
||||
{label.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Body */}
|
||||
{selectedIssue.body ? (
|
||||
<Markdown className="text-sm">{selectedIssue.body}</Markdown>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground italic">No description provided.</p>
|
||||
)}
|
||||
|
||||
{/* Open in GitHub CTA */}
|
||||
<div className="mt-8 p-4 rounded-lg bg-muted/50 border border-border">
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
View comments, add reactions, and more on GitHub.
|
||||
</p>
|
||||
<Button onClick={() => handleOpenInGitHub(selectedIssue.url)}>
|
||||
<ExternalLink className="h-4 w-4 mr-2" />
|
||||
View Full Issue on GitHub
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Validation Dialog */}
|
||||
<ValidationDialog
|
||||
open={showValidationDialog}
|
||||
onOpenChange={setShowValidationDialog}
|
||||
issue={selectedIssue}
|
||||
validationResult={validationResult}
|
||||
onConvertToTask={handleConvertToTask}
|
||||
/>
|
||||
|
||||
{/* Revalidate Confirmation Dialog */}
|
||||
<ConfirmDialog
|
||||
open={showRevalidateConfirm}
|
||||
onOpenChange={setShowRevalidateConfirm}
|
||||
title="Re-validate Issue"
|
||||
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.`}
|
||||
icon={RefreshCw}
|
||||
iconClassName="text-primary"
|
||||
confirmText="Re-validate"
|
||||
onConfirm={() => {
|
||||
if (selectedIssue) {
|
||||
handleValidateIssue(selectedIssue, { forceRevalidate: true });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 { 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 { useAppStore } from '@/store/app-store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -103,102 +105,121 @@ export function RunningAgentsView() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-brand-500/10">
|
||||
<Activity className="h-6 w-6 text-brand-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Running Agents</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{runningAgents.length === 0
|
||||
? 'No agents currently running'
|
||||
: `${runningAgents.length} agent${runningAgents.length === 1 ? '' : 's'} running across all projects`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={refreshing}>
|
||||
<RefreshCw className={cn('h-4 w-4 mr-2', refreshing && 'animate-spin')} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col overflow-hidden bg-background">
|
||||
<TopHeader />
|
||||
|
||||
{/* Content */}
|
||||
{runningAgents.length === 0 ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-center">
|
||||
<div className="p-4 rounded-full bg-muted/50 mb-4">
|
||||
<Bot className="h-12 w-12 text-muted-foreground" />
|
||||
</div>
|
||||
<h2 className="text-lg font-medium mb-2">No Running Agents</h2>
|
||||
<p className="text-muted-foreground max-w-md">
|
||||
Agents will appear here when they are actively working on features. Start an agent from
|
||||
the Kanban board by dragging a feature to "In Progress".
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="space-y-3">
|
||||
{runningAgents.map((agent) => (
|
||||
<div
|
||||
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"
|
||||
>
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
{/* Status indicator */}
|
||||
<div className="relative">
|
||||
<Bot className="h-8 w-8 text-brand-500" />
|
||||
<span className="absolute -top-1 -right-1 flex h-3 w-3">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* Agent info */}
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium truncate">{agent.featureId}</span>
|
||||
{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">
|
||||
AUTO
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleNavigateToProject(agent)}
|
||||
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Folder className="h-3.5 w-3.5" />
|
||||
<span className="truncate">{agent.projectName}</span>
|
||||
</button>
|
||||
</div>
|
||||
<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 */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<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-5 w-5 text-brand-400" />
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleNavigateToProject(agent)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
View Project
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => handleStopAgent(agent.featureId)}
|
||||
>
|
||||
<Square className="h-3.5 w-3.5 mr-1.5" />
|
||||
Stop
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-white to-white/70">
|
||||
Running Agents
|
||||
</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{runningAgents.length === 0
|
||||
? 'No agents currently running'
|
||||
: `${runningAgents.length} agent${runningAgents.length === 1 ? '' : 's'} running across all projects`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
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
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{runningAgents.length === 0 ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-center">
|
||||
<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-8 w-8 text-muted-foreground/50" />
|
||||
</div>
|
||||
<h2 className="text-lg font-medium mb-2 text-foreground">No Running Agents</h2>
|
||||
<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 the Kanban board.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-auto pr-2">
|
||||
<div className="grid gap-3 grid-cols-1 md:grid-cols-2 xl:grid-cols-3">
|
||||
{runningAgents.map((agent) => (
|
||||
<div
|
||||
key={`${agent.projectPath}-${agent.featureId}`}
|
||||
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-start justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Status indicator */}
|
||||
<div className="relative">
|
||||
<div className="w-10 h-10 rounded-lg bg-brand-500/10 flex items-center justify-center">
|
||||
<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="relative inline-flex rounded-full h-2.5 w-2.5 bg-green-500" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<span className="font-semibold text-sm truncate text-foreground">
|
||||
{agent.featureId}
|
||||
</span>
|
||||
{agent.isAutoMode && (
|
||||
<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
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Folder className="h-3 w-3" />
|
||||
<span className="truncate max-w-[120px]">{agent.projectName}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="mt-auto pt-3 flex items-center gap-2 border-t border-white/5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleNavigateToProject(agent)}
|
||||
className="flex-1 h-8 text-xs hover:bg-white/10"
|
||||
>
|
||||
View Project
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
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 fill-current" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</GlassPanel>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,8 +45,6 @@ export function SettingsView() {
|
||||
setDefaultAIProfileId,
|
||||
aiProfiles,
|
||||
apiKeys,
|
||||
validationModel,
|
||||
setValidationModel,
|
||||
} = useAppStore();
|
||||
|
||||
// Hide usage tracking when using API key (only show for Claude Code CLI users)
|
||||
@@ -112,7 +110,7 @@ export function SettingsView() {
|
||||
<AppearanceSection
|
||||
effectiveTheme={effectiveTheme}
|
||||
currentProject={settingsProject}
|
||||
onThemeChange={handleSetTheme}
|
||||
onThemeChange={handleSetTheme as (theme: Theme) => void}
|
||||
/>
|
||||
);
|
||||
case 'terminal':
|
||||
@@ -136,7 +134,6 @@ export function SettingsView() {
|
||||
defaultRequirePlanApproval={defaultRequirePlanApproval}
|
||||
defaultAIProfileId={defaultAIProfileId}
|
||||
aiProfiles={aiProfiles}
|
||||
validationModel={validationModel}
|
||||
onShowProfilesOnlyChange={setShowProfilesOnly}
|
||||
onDefaultSkipTestsChange={setDefaultSkipTests}
|
||||
onEnableDependencyBlockingChange={setEnableDependencyBlocking}
|
||||
@@ -144,7 +141,6 @@ export function SettingsView() {
|
||||
onDefaultPlanningModeChange={setDefaultPlanningMode}
|
||||
onDefaultRequirePlanApprovalChange={setDefaultRequirePlanApproval}
|
||||
onDefaultAIProfileIdChange={setDefaultAIProfileId}
|
||||
onValidationModelChange={setValidationModel}
|
||||
/>
|
||||
);
|
||||
case 'danger':
|
||||
|
||||
@@ -59,9 +59,8 @@ export function useApiKeyManagement() {
|
||||
hasGoogleKey: status.hasGoogleKey,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Silently handle API key status check failures to avoid exposing
|
||||
// sensitive error details in the console
|
||||
} catch (error) {
|
||||
console.error('Failed to check API key status:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -99,29 +98,26 @@ export function useApiKeyManagement() {
|
||||
};
|
||||
|
||||
// Test Google/Gemini connection
|
||||
// NOTE: Full API key validation requires a backend call to verify the key
|
||||
// against Google's API. The current client-side validation only checks
|
||||
// basic format requirements and cannot confirm the key is actually valid.
|
||||
// TODO: Add backend endpoint for Gemini API key verification
|
||||
const handleTestGeminiConnection = async () => {
|
||||
setTestingGeminiConnection(true);
|
||||
setGeminiTestResult(null);
|
||||
|
||||
// Basic client-side format validation only
|
||||
// This does NOT verify the key is valid with Google's API
|
||||
// Basic validation - check key format
|
||||
if (!googleKey || googleKey.trim().length < 10) {
|
||||
setGeminiTestResult({
|
||||
success: false,
|
||||
message: 'Please enter an API key with at least 10 characters.',
|
||||
message: 'Please enter a valid API key.',
|
||||
});
|
||||
setTestingGeminiConnection(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Client-side validation cannot confirm key validity.
|
||||
// The key will be verified when first used with the Gemini API.
|
||||
// For now, just validate the key format (starts with expected prefix)
|
||||
// Full verification requires a backend endpoint
|
||||
setGeminiTestResult({
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
ScrollText,
|
||||
ShieldCheck,
|
||||
User,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
@@ -23,7 +22,6 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { AIProfile } from '@/store/app-store';
|
||||
import type { AgentModel } from '@automaker/types';
|
||||
|
||||
type PlanningMode = 'skip' | 'lite' | 'spec' | 'full';
|
||||
|
||||
@@ -36,7 +34,6 @@ interface FeatureDefaultsSectionProps {
|
||||
defaultRequirePlanApproval: boolean;
|
||||
defaultAIProfileId: string | null;
|
||||
aiProfiles: AIProfile[];
|
||||
validationModel: AgentModel;
|
||||
onShowProfilesOnlyChange: (value: boolean) => void;
|
||||
onDefaultSkipTestsChange: (value: boolean) => void;
|
||||
onEnableDependencyBlockingChange: (value: boolean) => void;
|
||||
@@ -44,7 +41,6 @@ interface FeatureDefaultsSectionProps {
|
||||
onDefaultPlanningModeChange: (value: PlanningMode) => void;
|
||||
onDefaultRequirePlanApprovalChange: (value: boolean) => void;
|
||||
onDefaultAIProfileIdChange: (value: string | null) => void;
|
||||
onValidationModelChange: (value: AgentModel) => void;
|
||||
}
|
||||
|
||||
export function FeatureDefaultsSection({
|
||||
@@ -56,7 +52,6 @@ export function FeatureDefaultsSection({
|
||||
defaultRequirePlanApproval,
|
||||
defaultAIProfileId,
|
||||
aiProfiles,
|
||||
validationModel,
|
||||
onShowProfilesOnlyChange,
|
||||
onDefaultSkipTestsChange,
|
||||
onEnableDependencyBlockingChange,
|
||||
@@ -64,7 +59,6 @@ export function FeatureDefaultsSection({
|
||||
onDefaultPlanningModeChange,
|
||||
onDefaultRequirePlanApprovalChange,
|
||||
onDefaultAIProfileIdChange,
|
||||
onValidationModelChange,
|
||||
}: FeatureDefaultsSectionProps) {
|
||||
// Find the selected profile name for display
|
||||
const selectedProfile = defaultAIProfileId
|
||||
@@ -233,45 +227,6 @@ export function FeatureDefaultsSection({
|
||||
{/* Separator */}
|
||||
<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 */}
|
||||
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
|
||||
<Checkbox
|
||||
|
||||
@@ -46,6 +46,8 @@ import {
|
||||
defaultDropAnimationSideEffects,
|
||||
} from '@dnd-kit/core';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { TopHeader } from '@/components/layout/top-header';
|
||||
import { GlassPanel } from '@/components/ui/glass-panel';
|
||||
|
||||
interface TerminalStatus {
|
||||
enabled: boolean;
|
||||
@@ -296,7 +298,7 @@ export function TerminalView() {
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (terminalState.authToken) {
|
||||
headers['Authorization'] = `Bearer ${terminalState.authToken}`;
|
||||
headers['X-Terminal-Token'] = terminalState.authToken;
|
||||
}
|
||||
|
||||
console.log(`[Terminal] Killing ${sessionIds.length} sessions on server`);
|
||||
@@ -459,7 +461,7 @@ export function TerminalView() {
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
if (terminalState.authToken) {
|
||||
headers['Authorization'] = `Bearer ${terminalState.authToken}`;
|
||||
headers['X-Terminal-Token'] = terminalState.authToken;
|
||||
}
|
||||
const response = await fetch(`${serverUrl}/api/terminal/settings`, { headers });
|
||||
const data = await response.json();
|
||||
@@ -488,7 +490,7 @@ export function TerminalView() {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
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
|
||||
@@ -501,7 +503,7 @@ export function TerminalView() {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('DELETE', url, false); // synchronous
|
||||
if (terminalState.authToken) {
|
||||
xhr.setRequestHeader('Authorization', `Bearer ${terminalState.authToken}`);
|
||||
xhr.setRequestHeader('X-Terminal-Token', terminalState.authToken);
|
||||
}
|
||||
xhr.send();
|
||||
} catch {
|
||||
@@ -595,7 +597,7 @@ export function TerminalView() {
|
||||
// Get fresh auth token from store
|
||||
const authToken = useAppStore.getState().terminalState.authToken;
|
||||
if (authToken) {
|
||||
headers['Authorization'] = `Bearer ${authToken}`;
|
||||
headers['X-Terminal-Token'] = authToken;
|
||||
}
|
||||
|
||||
// Helper to check if a session still exists on server
|
||||
@@ -833,7 +835,7 @@ export function TerminalView() {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (terminalState.authToken) {
|
||||
headers['Authorization'] = `Bearer ${terminalState.authToken}`;
|
||||
headers['X-Terminal-Token'] = terminalState.authToken;
|
||||
}
|
||||
|
||||
const response = await fetch(`${serverUrl}/api/terminal/sessions`, {
|
||||
@@ -892,7 +894,7 @@ export function TerminalView() {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (terminalState.authToken) {
|
||||
headers['Authorization'] = `Bearer ${terminalState.authToken}`;
|
||||
headers['X-Terminal-Token'] = terminalState.authToken;
|
||||
}
|
||||
|
||||
const response = await fetch(`${serverUrl}/api/terminal/sessions`, {
|
||||
@@ -952,7 +954,7 @@ export function TerminalView() {
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
if (terminalState.authToken) {
|
||||
headers['Authorization'] = `Bearer ${terminalState.authToken}`;
|
||||
headers['X-Terminal-Token'] = terminalState.authToken;
|
||||
}
|
||||
|
||||
const response = await fetch(`${serverUrl}/api/terminal/sessions/${sessionId}`, {
|
||||
@@ -998,7 +1000,7 @@ export function TerminalView() {
|
||||
// Kill all sessions on the server
|
||||
const headers: Record<string, string> = {};
|
||||
if (terminalState.authToken) {
|
||||
headers['Authorization'] = `Bearer ${terminalState.authToken}`;
|
||||
headers['X-Terminal-Token'] = terminalState.authToken;
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
@@ -1414,252 +1416,210 @@ export function TerminalView() {
|
||||
|
||||
// Terminal view with tabs
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
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) => (
|
||||
<TerminalTabButton
|
||||
key={tab.id}
|
||||
tab={tab}
|
||||
isActive={tab.id === terminalState.activeTabId}
|
||||
onClick={() => setActiveTerminalTab(tab.id)}
|
||||
onClose={() => killTerminalTab(tab.id)}
|
||||
onRename={(newName) => renameTerminalTab(tab.id, newName)}
|
||||
isDropTarget={activeDragId !== null || activeDragTabId !== null}
|
||||
isDraggingTab={activeDragTabId !== null}
|
||||
/>
|
||||
))}
|
||||
<div className="flex flex-col h-full w-full overflow-hidden" data-testid="terminal-view">
|
||||
<TopHeader />
|
||||
|
||||
{(activeDragId || activeDragTabId) && <NewTabDropZone isDropTarget={true} />}
|
||||
{/* 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>
|
||||
|
||||
{/* New tab button */}
|
||||
<button
|
||||
className="flex items-center justify-center p-1.5 rounded hover:bg-accent text-muted-foreground hover:text-foreground"
|
||||
onClick={createTerminalInNewTab}
|
||||
title="New Tab"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{/* Tabs List */}
|
||||
<div className="flex flex-1 items-center gap-1 overflow-x-auto no-scrollbar mask-gradient-right">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
>
|
||||
{terminalState.tabs.map((tab) => (
|
||||
<TerminalTabButton
|
||||
key={tab.id}
|
||||
tab={tab}
|
||||
isActive={tab.id === terminalState.activeTabId}
|
||||
onClick={() => setActiveTerminalTab(tab.id)}
|
||||
onClose={() => removeTerminalTab(tab.id)}
|
||||
onRename={(name) => renameTerminalTab(tab.id, name)}
|
||||
isDropTarget={activeDragId !== null}
|
||||
isDraggingTab={activeDragTabId !== null}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Toolbar buttons */}
|
||||
<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 */}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-muted-foreground hover:text-foreground"
|
||||
title="Terminal Settings"
|
||||
{/* Add New Tab Button */}
|
||||
<button
|
||||
onClick={() => addTerminalTab()}
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* 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>
|
||||
{activeDragId && <NewTabDropZone isDropTarget={true} />}
|
||||
|
||||
<DragOverlay dropAnimation={{ sideEffects: defaultDropAnimationSideEffects({}) }}>
|
||||
{activeDragTabId ? (
|
||||
<div className="px-3 py-1.5 text-sm bg-background border-2 border-brand-500 rounded-md shadow-xl opacity-90 cursor-grabbing">
|
||||
<div className="flex items-center gap-2">
|
||||
<TerminalIcon className="h-3 w-3" />
|
||||
<span>
|
||||
{terminalState.tabs.find((t) => t.id === activeDragTabId)?.name || 'Tab'}
|
||||
</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>
|
||||
|
||||
{/* Font Family */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Font Family</Label>
|
||||
<select
|
||||
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>
|
||||
|
||||
{/* 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>
|
||||
) : activeDragId ? (
|
||||
<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="flex flex-col items-center gap-2 text-muted-foreground">
|
||||
<TerminalIcon className="h-8 w-8" />
|
||||
<span>Moving Terminal...</span>
|
||||
</div>
|
||||
</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>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</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>
|
||||
{/* 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>
|
||||
|
||||
{/* 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);
|
||||
{/* 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
|
||||
}
|
||||
}}
|
||||
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
|
||||
>
|
||||
{terminalState.isUnlocked ? (
|
||||
<Unlock className="w-4 h-4" />
|
||||
) : (
|
||||
<Lock className="w-4 h-4" />
|
||||
)}
|
||||
{terminalState.isUnlocked ? 'Unlocked' : 'Locked'}
|
||||
</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>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
|
||||
{/* 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,
|
||||
screenReaderMode: screenReaderEnabled,
|
||||
scrollback: terminalScrollback,
|
||||
allowTransparency: true,
|
||||
});
|
||||
|
||||
// Create fit addon
|
||||
@@ -940,12 +941,7 @@ export function TerminalPanel({
|
||||
if (!terminal) return;
|
||||
|
||||
const connect = () => {
|
||||
// Build WebSocket URL with token in query string
|
||||
// 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
|
||||
// Build WebSocket URL with token
|
||||
let url = `${wsUrl}/api/terminal/ws?sessionId=${sessionId}`;
|
||||
if (authToken) {
|
||||
url += `&token=${encodeURIComponent(authToken)}`;
|
||||
|
||||
@@ -61,7 +61,7 @@ export const DEFAULT_TERMINAL_FONT = TERMINAL_FONT_OPTIONS[0].value;
|
||||
|
||||
// Dark theme (default)
|
||||
const darkTheme: TerminalTheme = {
|
||||
background: '#0a0a0a',
|
||||
background: 'transparent', // Transparent for glassmorphism
|
||||
foreground: '#d4d4d4',
|
||||
cursor: '#d4d4d4',
|
||||
cursorAccent: '#0a0a0a',
|
||||
|
||||
@@ -222,7 +222,6 @@ export async function syncSettingsToServer(): Promise<boolean> {
|
||||
defaultAIProfileId: state.defaultAIProfileId,
|
||||
muteDoneSound: state.muteDoneSound,
|
||||
enhancementModel: state.enhancementModel,
|
||||
validationModel: state.validationModel,
|
||||
keyboardShortcuts: state.keyboardShortcuts,
|
||||
aiProfiles: state.aiProfiles,
|
||||
projects: state.projects,
|
||||
|
||||
@@ -1,31 +1,8 @@
|
||||
// Type definitions for Electron IPC API
|
||||
import type { SessionListItem, Message } from '@/types/electron';
|
||||
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';
|
||||
|
||||
// Re-export issue validation types for use in components
|
||||
export type {
|
||||
IssueValidationVerdict,
|
||||
IssueValidationConfidence,
|
||||
IssueComplexity,
|
||||
IssueValidationInput,
|
||||
IssueValidationResult,
|
||||
IssueValidationResponse,
|
||||
IssueValidationEvent,
|
||||
StoredValidation,
|
||||
};
|
||||
|
||||
export interface FileEntry {
|
||||
name: string;
|
||||
isDirectory: boolean;
|
||||
@@ -123,19 +100,6 @@ export interface GitHubLabel {
|
||||
|
||||
export interface GitHubAuthor {
|
||||
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 {
|
||||
@@ -147,8 +111,6 @@ export interface GitHubIssue {
|
||||
labels: GitHubLabel[];
|
||||
url: string;
|
||||
body: string;
|
||||
assignees: GitHubAssignee[];
|
||||
linkedPRs?: LinkedPullRequest[];
|
||||
}
|
||||
|
||||
export interface GitHubPR {
|
||||
@@ -194,46 +156,6 @@ export interface GitHubAPI {
|
||||
mergedPRs?: GitHubPR[];
|
||||
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
|
||||
@@ -2681,8 +2603,6 @@ function createMockRunningAgentsAPI(): RunningAgentsAPI {
|
||||
}
|
||||
|
||||
// Mock GitHub API implementation
|
||||
let mockValidationCallbacks: ((event: IssueValidationEvent) => void)[] = [];
|
||||
|
||||
function createMockGitHubAPI(): GitHubAPI {
|
||||
return {
|
||||
checkRemote: async (projectPath: string) => {
|
||||
@@ -2711,81 +2631,6 @@ function createMockGitHubAPI(): GitHubAPI {
|
||||
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,
|
||||
GitHubIssue,
|
||||
GitHubPR,
|
||||
IssueValidationInput,
|
||||
IssueValidationEvent,
|
||||
} from './electron';
|
||||
import type { Message, SessionListItem } from '@/types/electron';
|
||||
import type { Feature, ClaudeUsageResponse } from '@/store/app-store';
|
||||
@@ -53,8 +51,7 @@ type EventType =
|
||||
| 'agent:stream'
|
||||
| 'auto-mode:event'
|
||||
| 'suggestions:event'
|
||||
| 'spec-regeneration:event'
|
||||
| 'issue-validation:event';
|
||||
| 'spec-regeneration:event';
|
||||
|
||||
type EventCallback = (payload: unknown) => void;
|
||||
|
||||
@@ -754,18 +751,6 @@ export class HttpApiClient implements ElectronAPI {
|
||||
checkRemote: (projectPath: string) => this.post('/api/github/check-remote', { projectPath }),
|
||||
listIssues: (projectPath: string) => this.post('/api/github/issues', { 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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createRootRoute, Outlet, useLocation, useNavigate } from '@tanstack/react-router';
|
||||
import { useEffect, useState, useCallback, useDeferredValue } from 'react';
|
||||
import { Sidebar } from '@/components/layout/sidebar';
|
||||
import { AppLayout } from '@/components/layout/app-layout';
|
||||
import {
|
||||
FileBrowserProvider,
|
||||
useFileBrowser,
|
||||
@@ -159,10 +159,9 @@ function RootLayoutContent() {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex h-screen overflow-hidden" data-testid="app-container">
|
||||
<Sidebar />
|
||||
<AppLayout>
|
||||
<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' }}
|
||||
>
|
||||
<Outlet />
|
||||
@@ -170,12 +169,12 @@ function RootLayoutContent() {
|
||||
|
||||
{/* Hidden streamer panel - opens with "\" key, pushes content */}
|
||||
<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'
|
||||
}`}
|
||||
/>
|
||||
<Toaster richColors position="bottom-right" />
|
||||
</main>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,10 +7,21 @@ import type {
|
||||
AgentModel,
|
||||
PlanningMode,
|
||||
AIProfile,
|
||||
ThinkingLevel,
|
||||
ModelProvider,
|
||||
FeatureTextFilePath,
|
||||
} from '@automaker/types';
|
||||
|
||||
// Re-export ThemeMode for convenience
|
||||
export type { ThemeMode };
|
||||
export type {
|
||||
AgentModel,
|
||||
ThinkingLevel,
|
||||
ModelProvider,
|
||||
AIProfile,
|
||||
PlanningMode,
|
||||
FeatureTextFilePath,
|
||||
};
|
||||
|
||||
// ThemeMode is defined below, no need to re-export here
|
||||
|
||||
export type ViewMode =
|
||||
| 'welcome'
|
||||
@@ -262,13 +273,24 @@ export interface Feature extends Omit<
|
||||
titleGenerating?: boolean;
|
||||
category: string;
|
||||
description: string;
|
||||
steps: string[]; // Required in UI (not optional)
|
||||
steps?: string[] | undefined; // Optional in UI
|
||||
status: 'backlog' | 'in_progress' | 'waiting_approval' | 'verified' | 'completed';
|
||||
images?: FeatureImage[]; // UI-specific base64 images
|
||||
imagePaths?: FeatureImagePath[]; // Stricter type than base (no string | union)
|
||||
textFilePaths?: FeatureTextFilePath[]; // Text file attachments for context
|
||||
justFinishedAt?: string; // UI-specific: ISO timestamp when agent just finished
|
||||
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)
|
||||
@@ -475,9 +497,6 @@ export interface AppState {
|
||||
// Enhancement Model Settings
|
||||
enhancementModel: AgentModel; // Model used for feature enhancement (default: sonnet)
|
||||
|
||||
// Validation Model Settings
|
||||
validationModel: AgentModel; // Model used for GitHub issue validation (default: opus)
|
||||
|
||||
// Project Analysis
|
||||
projectAnalysis: ProjectAnalysis | null;
|
||||
isAnalyzing: boolean;
|
||||
@@ -748,9 +767,6 @@ export interface AppActions {
|
||||
// Enhancement Model actions
|
||||
setEnhancementModel: (model: AgentModel) => void;
|
||||
|
||||
// Validation Model actions
|
||||
setValidationModel: (model: AgentModel) => void;
|
||||
|
||||
// AI Profile actions
|
||||
addAIProfile: (profile: Omit<AIProfile, 'id'>) => void;
|
||||
updateAIProfile: (id: string, updates: Partial<AIProfile>) => void;
|
||||
@@ -887,6 +903,9 @@ const DEFAULT_AI_PROFILES: AIProfile[] = [
|
||||
];
|
||||
|
||||
const initialState: AppState = {
|
||||
claudeRefreshInterval: 60,
|
||||
claudeUsage: null,
|
||||
claudeUsageLastUpdated: null,
|
||||
projects: [],
|
||||
currentProject: null,
|
||||
trashedProjects: [],
|
||||
@@ -921,7 +940,6 @@ const initialState: AppState = {
|
||||
keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS, // Default keyboard shortcuts
|
||||
muteDoneSound: false, // Default to sound enabled (not muted)
|
||||
enhancementModel: 'sonnet', // Default to sonnet for feature enhancement
|
||||
validationModel: 'opus', // Default to opus for GitHub issue validation
|
||||
aiProfiles: DEFAULT_AI_PROFILES,
|
||||
projectAnalysis: null,
|
||||
isAnalyzing: false,
|
||||
@@ -1544,9 +1562,6 @@ export const useAppStore = create<AppState & AppActions>()(
|
||||
// Enhancement Model actions
|
||||
setEnhancementModel: (model) => set({ enhancementModel: model }),
|
||||
|
||||
// Validation Model actions
|
||||
setValidationModel: (model) => set({ validationModel: model }),
|
||||
|
||||
// AI Profile actions
|
||||
addAIProfile: (profile) => {
|
||||
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,
|
||||
boardViewMode: state.boardViewMode,
|
||||
// Settings
|
||||
// NOTE: apiKeys are intentionally NOT persisted to localStorage for security.
|
||||
// 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.
|
||||
apiKeys: state.apiKeys,
|
||||
maxConcurrency: state.maxConcurrency,
|
||||
// Note: autoModeByProject is intentionally NOT persisted
|
||||
// Auto-mode should always default to OFF on app refresh
|
||||
@@ -2692,7 +2704,6 @@ export const useAppStore = create<AppState & AppActions>()(
|
||||
keyboardShortcuts: state.keyboardShortcuts,
|
||||
muteDoneSound: state.muteDoneSound,
|
||||
enhancementModel: state.enhancementModel,
|
||||
validationModel: state.validationModel,
|
||||
// Profiles and sessions
|
||||
aiProfiles: state.aiProfiles,
|
||||
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,
|
||||
getContextDir,
|
||||
getWorktreesDir,
|
||||
getValidationsDir,
|
||||
getValidationDir,
|
||||
getValidationPath,
|
||||
getAppSpecPath,
|
||||
getBranchTrackingPath,
|
||||
ensureAutomakerDir,
|
||||
|
||||
@@ -111,44 +111,6 @@ export function getWorktreesDir(projectPath: string): string {
|
||||
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
|
||||
*
|
||||
|
||||
@@ -4,46 +4,29 @@
|
||||
* All file I/O operations must go through this adapter to enforce
|
||||
* ALLOWED_ROOT_DIRECTORY restrictions at the actual access point,
|
||||
* 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 type { Dirent } from 'fs';
|
||||
import path from 'path';
|
||||
import { validatePath, validatePathWithSymlinkCheck } from './security.js';
|
||||
import { validatePath } from './security.js';
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
// Use symlink check since we're checking an existing path
|
||||
const validatedPath = validatePathWithSymlinkCheck(filePath);
|
||||
const validatedPath = validatePath(filePath);
|
||||
return fs.access(validatedPath, mode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper around fs.readFile that validates path first
|
||||
* Uses symlink-aware validation to prevent reading files outside allowed directories
|
||||
*/
|
||||
export async function readFile(
|
||||
filePath: string,
|
||||
encoding?: BufferEncoding
|
||||
): Promise<string | Buffer> {
|
||||
// Use symlink check since we're reading an existing file
|
||||
const validatedPath = validatePathWithSymlinkCheck(filePath);
|
||||
const validatedPath = validatePath(filePath);
|
||||
if (encoding) {
|
||||
return fs.readFile(validatedPath, encoding);
|
||||
}
|
||||
@@ -52,34 +35,29 @@ export async function readFile(
|
||||
|
||||
/**
|
||||
* 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(
|
||||
filePath: string,
|
||||
data: string | Buffer,
|
||||
encoding?: BufferEncoding
|
||||
): Promise<void> {
|
||||
// Use symlink check with requireExists=false to handle both new and existing files
|
||||
const validatedPath = validatePathWithSymlinkCheck(filePath, { requireExists: false });
|
||||
const validatedPath = validatePath(filePath);
|
||||
return fs.writeFile(validatedPath, data, encoding);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
dirPath: string,
|
||||
options?: { recursive?: boolean; mode?: number }
|
||||
): Promise<string | undefined> {
|
||||
// Use symlink check with requireExists=false since directory may not exist yet
|
||||
const validatedPath = validatePathWithSymlinkCheck(dirPath, { requireExists: false });
|
||||
const validatedPath = validatePath(dirPath);
|
||||
return fs.mkdir(validatedPath, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper around fs.readdir that validates path first
|
||||
* Uses symlink-aware validation to prevent listing directories outside allowed paths
|
||||
*/
|
||||
export async function readdir(
|
||||
dirPath: string,
|
||||
@@ -93,8 +71,7 @@ export async function readdir(
|
||||
dirPath: string,
|
||||
options?: { withFileTypes?: boolean; encoding?: BufferEncoding }
|
||||
): Promise<string[] | Dirent[]> {
|
||||
// Use symlink check since we're reading an existing directory
|
||||
const validatedPath = validatePathWithSymlinkCheck(dirPath);
|
||||
const validatedPath = validatePath(dirPath);
|
||||
if (options?.withFileTypes === true) {
|
||||
return fs.readdir(validatedPath, { withFileTypes: true });
|
||||
}
|
||||
@@ -103,85 +80,66 @@ export async function readdir(
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
// Use symlink check since we're getting info about an existing file
|
||||
const validatedPath = validatePathWithSymlinkCheck(filePath);
|
||||
const validatedPath = validatePath(filePath);
|
||||
return fs.stat(validatedPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper around fs.rm that validates path first
|
||||
* Uses symlink-aware validation to prevent deleting files/directories outside allowed paths
|
||||
*/
|
||||
export async function rm(
|
||||
filePath: string,
|
||||
options?: { recursive?: boolean; force?: boolean }
|
||||
): Promise<void> {
|
||||
// Use symlink check since we're removing an existing file/directory
|
||||
const validatedPath = validatePathWithSymlinkCheck(filePath);
|
||||
const validatedPath = validatePath(filePath);
|
||||
return fs.rm(validatedPath, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
// Use symlink check since we're unlinking an existing file
|
||||
const validatedPath = validatePathWithSymlinkCheck(filePath);
|
||||
const validatedPath = validatePath(filePath);
|
||||
return fs.unlink(validatedPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
// Source must exist, use symlink check
|
||||
const validatedSrc = validatePathWithSymlinkCheck(src);
|
||||
// Destination may not exist, validate with parent fallback
|
||||
const validatedDest = validatePathWithSymlinkCheck(dest, { requireExists: false });
|
||||
const validatedSrc = validatePath(src);
|
||||
const validatedDest = validatePath(dest);
|
||||
return fs.copyFile(validatedSrc, validatedDest, mode);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
filePath: string,
|
||||
data: string | Buffer,
|
||||
encoding?: BufferEncoding
|
||||
): Promise<void> {
|
||||
// File may or may not exist, use symlink check with parent fallback
|
||||
const validatedPath = validatePathWithSymlinkCheck(filePath, { requireExists: false });
|
||||
const validatedPath = validatePath(filePath);
|
||||
return fs.appendFile(validatedPath, data, encoding);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
// Source must exist, use symlink check
|
||||
const validatedOldPath = validatePathWithSymlinkCheck(oldPath);
|
||||
// Destination may not exist, validate with parent fallback
|
||||
const validatedNewPath = validatePathWithSymlinkCheck(newPath, { requireExists: false });
|
||||
const validatedOldPath = validatePath(oldPath);
|
||||
const validatedNewPath = validatePath(newPath);
|
||||
return fs.rename(validatedOldPath, validatedNewPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper around fs.lstat that validates path first
|
||||
* 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> {
|
||||
// Use basic validation since lstat is for inspecting symlinks
|
||||
const validatedPath = validatePath(filePath);
|
||||
return fs.lstat(validatedPath);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,9 @@
|
||||
/**
|
||||
* Security utilities for path validation
|
||||
* 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 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
|
||||
@@ -38,43 +25,22 @@ let dataDirectory: string | null = null;
|
||||
* Initialize security settings from environment variables
|
||||
* - ALLOWED_ROOT_DIRECTORY: main security boundary
|
||||
* - DATA_DIR: appData exception, always allowed
|
||||
* - SECURITY_MODE: 'strict' (default, fail-closed) or 'permissive' (legacy, fail-open)
|
||||
*/
|
||||
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
|
||||
const rootDir = process.env.ALLOWED_ROOT_DIRECTORY;
|
||||
if (rootDir) {
|
||||
allowedRootDirectory = path.resolve(rootDir);
|
||||
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.'
|
||||
);
|
||||
console.log(`[Security] ✓ ALLOWED_ROOT_DIRECTORY configured: ${allowedRootDirectory}`);
|
||||
} else {
|
||||
console.warn(
|
||||
'[Security] WARNING: ALLOWED_ROOT_DIRECTORY not set - allowing access to all paths'
|
||||
);
|
||||
console.log('[Security] ⚠️ ALLOWED_ROOT_DIRECTORY not set - allowing access to all paths');
|
||||
}
|
||||
|
||||
// Load DATA_DIR (appData exception - always allowed)
|
||||
const dataDir = process.env.DATA_DIR;
|
||||
if (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:
|
||||
* - Path is within ALLOWED_ROOT_DIRECTORY, OR
|
||||
* - Path is within DATA_DIR (appData exception), OR
|
||||
* - No restrictions are configured AND security mode is 'permissive'
|
||||
*
|
||||
* In strict mode (default), paths are denied if ALLOWED_ROOT_DIRECTORY is not set,
|
||||
* unless they are within DATA_DIR.
|
||||
* - No restrictions are configured (backward compatibility)
|
||||
*/
|
||||
export function isPathAllowed(filePath: string): boolean {
|
||||
const resolvedPath = path.resolve(filePath);
|
||||
@@ -96,29 +59,24 @@ export function isPathAllowed(filePath: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If no ALLOWED_ROOT_DIRECTORY restriction is configured:
|
||||
// - In strict mode: deny (fail-closed)
|
||||
// - In permissive mode: allow all paths (legacy behavior)
|
||||
// If no ALLOWED_ROOT_DIRECTORY restriction is configured, allow all paths
|
||||
// Note: DATA_DIR is checked above as an exception, but doesn't restrict other paths
|
||||
if (!allowedRootDirectory) {
|
||||
return securityMode === 'permissive';
|
||||
}
|
||||
|
||||
// Allow if within ALLOWED_ROOT_DIRECTORY
|
||||
if (isPathWithinDirectory(resolvedPath, allowedRootDirectory)) {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a path - resolves it and checks permissions
|
||||
* 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 {
|
||||
const resolvedPath = path.resolve(filePath);
|
||||
@@ -130,74 +88,6 @@ export function validatePath(filePath: string): string {
|
||||
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
|
||||
* Returns true only if resolvedPath is within directoryPath
|
||||
|
||||
@@ -95,57 +95,27 @@ describe('security.ts', () => {
|
||||
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.DATA_DIR;
|
||||
delete process.env.SECURITY_MODE; // Default to strict
|
||||
|
||||
const { initAllowedPaths, isPathAllowed } = await import('../src/security');
|
||||
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('/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;
|
||||
process.env.DATA_DIR = '/data';
|
||||
delete process.env.SECURITY_MODE; // Default to strict
|
||||
|
||||
const { initAllowedPaths, isPathAllowed } = await import('../src/security');
|
||||
initAllowedPaths();
|
||||
|
||||
// DATA_DIR should be allowed
|
||||
expect(isPathAllowed('/data/file.txt')).toBe(true);
|
||||
// Other paths should be denied in strict mode
|
||||
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
|
||||
// And all other paths should be allowed since no ALLOWED_ROOT_DIRECTORY restriction
|
||||
expect(isPathAllowed('/any/path')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -185,28 +155,13 @@ describe('security.ts', () => {
|
||||
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.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');
|
||||
initAllowedPaths();
|
||||
|
||||
// In permissive mode, all paths are allowed when no restrictions configured
|
||||
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', () => {
|
||||
it('should return the configured data directory', async () => {
|
||||
process.env.DATA_DIR = '/data';
|
||||
|
||||
@@ -24,7 +24,6 @@ export type EventType =
|
||||
| 'project:analysis-completed'
|
||||
| 'project:analysis-error'
|
||||
| 'suggestions:event'
|
||||
| 'spec-regeneration:event'
|
||||
| 'issue-validation:event';
|
||||
| 'spec-regeneration:event';
|
||||
|
||||
export type EventCallback = (type: EventType, payload: unknown) => void;
|
||||
|
||||
@@ -81,17 +81,3 @@ export {
|
||||
THINKING_LEVEL_LABELS,
|
||||
getModelDisplayName,
|
||||
} 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
|
||||
/** Which model to use for feature name/description enhancement */
|
||||
enhancementModel: AgentModel;
|
||||
/** Which model to use for GitHub issue validation */
|
||||
validationModel: AgentModel;
|
||||
|
||||
// Input Configuration
|
||||
/** User's keyboard shortcut bindings */
|
||||
@@ -439,7 +437,6 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
|
||||
defaultAIProfileId: null,
|
||||
muteDoneSound: false,
|
||||
enhancementModel: 'sonnet',
|
||||
validationModel: 'opus',
|
||||
keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS,
|
||||
aiProfiles: [],
|
||||
projects: [],
|
||||
|
||||
88
package-lock.json
generated
88
package-lock.json
generated
@@ -422,7 +422,6 @@
|
||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.5",
|
||||
@@ -1006,7 +1005,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.4.tgz",
|
||||
"integrity": "sha512-xMF6OfEAUVY5Waega4juo1QGACfNkNF+aJLqpd8oUJz96ms2zbfQ9Gh35/tI3y8akEV31FruKfj7hBnIU/nkqA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.5.0",
|
||||
"crelt": "^1.0.6",
|
||||
@@ -1049,7 +1047,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
@@ -1870,6 +1867,7 @@
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cross-dirname": "^0.1.0",
|
||||
"debug": "^4.3.4",
|
||||
@@ -1891,6 +1889,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^6.0.1",
|
||||
@@ -1907,6 +1906,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
@@ -1921,6 +1921,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
@@ -2676,6 +2677,7 @@
|
||||
"integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -2800,6 +2802,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
@@ -2816,6 +2819,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
@@ -2832,6 +2836,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
@@ -2940,6 +2945,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
@@ -2962,6 +2968,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
@@ -2984,6 +2991,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"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",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@emnapi/runtime": "^1.7.0"
|
||||
},
|
||||
@@ -3091,6 +3100,7 @@
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
@@ -3110,6 +3120,7 @@
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
@@ -3448,7 +3459,8 @@
|
||||
"version": "16.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.10.tgz",
|
||||
"integrity": "sha512-8tuaQkyDVgeONQ1MeT9Mkk8pQmZapMKFh5B+OrFUlG3rVmYTXcXlBetBgTurKXGaIZvkoqRT9JL5K3phXcgang==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@next/swc-darwin-arm64": {
|
||||
"version": "16.0.10",
|
||||
@@ -3462,6 +3474,7 @@
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
@@ -3478,6 +3491,7 @@
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
@@ -3494,6 +3508,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
@@ -3510,6 +3525,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
@@ -3526,6 +3542,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
@@ -3542,6 +3559,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
@@ -3558,6 +3576,7 @@
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
@@ -3574,6 +3593,7 @@
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
@@ -3664,7 +3684,6 @@
|
||||
"integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"playwright": "1.57.0"
|
||||
},
|
||||
@@ -5075,6 +5094,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||
"integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
@@ -5408,7 +5428,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.141.6.tgz",
|
||||
"integrity": "sha512-qWFxi2D6eGc1L03RzUuhyEOplZ7Q6q62YOl7Of9Y0q4YjwQwxRm4zxwDVtvUIoy4RLVCpqp5UoE+Nxv2PY9trg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@tanstack/history": "1.141.0",
|
||||
"@tanstack/react-store": "^0.8.0",
|
||||
@@ -5960,7 +5979,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
|
||||
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -5971,7 +5989,6 @@
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
@@ -6077,7 +6094,6 @@
|
||||
"integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.50.0",
|
||||
"@typescript-eslint/types": "8.50.0",
|
||||
@@ -6571,8 +6587,7 @@
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
||||
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xyflow/react": {
|
||||
"version": "12.10.0",
|
||||
@@ -6670,7 +6685,6 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -6731,7 +6745,6 @@
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
@@ -7291,7 +7304,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -7823,7 +7835,8 @@
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "8.0.1",
|
||||
@@ -8109,7 +8122,8 @@
|
||||
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/cross-env": {
|
||||
"version": "10.1.0",
|
||||
@@ -8206,7 +8220,6 @@
|
||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
@@ -8508,7 +8521,6 @@
|
||||
"integrity": "sha512-59CAAjAhTaIMCN8y9kD573vDkxbs1uhDcrFLHSgutYdPcGOU35Rf95725snvzEOy4BFB7+eLJ8djCNPmGwG67w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"app-builder-lib": "26.0.12",
|
||||
"builder-util": "26.0.11",
|
||||
@@ -8835,6 +8847,7 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@electron/asar": "^3.2.1",
|
||||
"debug": "^4.1.1",
|
||||
@@ -8855,6 +8868,7 @@
|
||||
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.1.2",
|
||||
"jsonfile": "^4.0.0",
|
||||
@@ -9105,7 +9119,6 @@
|
||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -11011,6 +11024,7 @@
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
@@ -11072,6 +11086,7 @@
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
@@ -13490,6 +13505,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.6",
|
||||
"picocolors": "^1.0.0",
|
||||
@@ -13506,6 +13522,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"commander": "^9.4.0"
|
||||
},
|
||||
@@ -13523,6 +13540,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^12.20.0 || >=14"
|
||||
}
|
||||
@@ -13711,7 +13729,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -13721,7 +13738,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -14071,6 +14087,7 @@
|
||||
"deprecated": "Rimraf versions prior to v4 are no longer supported",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"glob": "^7.1.3"
|
||||
},
|
||||
@@ -14259,7 +14276,6 @@
|
||||
"resolved": "https://registry.npmjs.org/seroval/-/seroval-1.4.0.tgz",
|
||||
"integrity": "sha512-BdrNXdzlofomLTiRnwJTSEAaGKyHHZkbMXIywOh7zlzp4uZnXErEwl9XZ+N1hJSNpeTtNxWvVwN0wUzAIQ4Hpg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
@@ -14308,6 +14324,7 @@
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@img/colour": "^1.0.0",
|
||||
"detect-libc": "^2.1.2",
|
||||
@@ -14358,6 +14375,7 @@
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
@@ -14380,6 +14398,7 @@
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
@@ -14402,6 +14421,7 @@
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
@@ -14418,6 +14438,7 @@
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
@@ -14434,6 +14455,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
@@ -14450,6 +14472,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
@@ -14466,6 +14489,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
@@ -14482,6 +14506,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
@@ -14498,6 +14523,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
@@ -14514,6 +14540,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
@@ -14536,6 +14563,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
@@ -14558,6 +14586,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
@@ -14580,6 +14609,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
@@ -14602,6 +14632,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
@@ -14624,6 +14655,7 @@
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"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",
|
||||
"integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"client-only": "0.0.1"
|
||||
},
|
||||
@@ -15261,6 +15294,7 @@
|
||||
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"mkdirp": "^0.5.1",
|
||||
"rimraf": "~2.6.2"
|
||||
@@ -15324,6 +15358,7 @@
|
||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.6"
|
||||
},
|
||||
@@ -15421,7 +15456,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -15626,7 +15660,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -15998,7 +16031,6 @@
|
||||
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.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",
|
||||
"integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vite/node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
@@ -16115,7 +16146,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -16158,7 +16188,6 @@
|
||||
"integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.0.16",
|
||||
"@vitest/mocker": "4.0.16",
|
||||
@@ -16416,7 +16445,6 @@
|
||||
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user