Compare commits

..

1 Commits

Author SHA1 Message Date
SuperComboGamer
379976aba7 feat: implement new layout components and enhance UI with glassmorphism
This commit introduces a new app layout structure with an AppLayout component, a TopHeader for improved navigation, and a Sidebar for project management. Additionally, it adds GlassPanel and GlassCard components to enhance the UI with a glassmorphism effect. The Kanban board and agent views have been updated to utilize these new components, improving the overall user experience and visual consistency across the application.
2025-12-23 16:38:49 -05:00
88 changed files with 4500 additions and 7654 deletions

1282
apps/app/server-bundle/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View 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"
}
}

View File

@@ -48,7 +48,6 @@ import { createClaudeRoutes } from './routes/claude/index.js';
import { ClaudeUsageService } from './services/claude-usage-service.js'; import { ClaudeUsageService } from './services/claude-usage-service.js';
import { createGitHubRoutes } from './routes/github/index.js'; import { createGitHubRoutes } from './routes/github/index.js';
import { createContextRoutes } from './routes/context/index.js'; import { createContextRoutes } from './routes/context/index.js';
import { cleanupStaleValidations } from './routes/github/routes/validation-common.js';
// Load environment variables // Load environment variables
dotenv.config(); dotenv.config();
@@ -124,15 +123,6 @@ const claudeUsageService = new ClaudeUsageService();
console.log('[Server] Agent service initialized'); console.log('[Server] Agent service initialized');
})(); })();
// Run stale validation cleanup every hour to prevent memory leaks from crashed validations
const VALIDATION_CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
setInterval(() => {
const cleaned = cleanupStaleValidations();
if (cleaned > 0) {
console.log(`[Server] Cleaned up ${cleaned} stale validation entries`);
}
}, VALIDATION_CLEANUP_INTERVAL_MS);
// Mount API routes - health is unauthenticated for monitoring // Mount API routes - health is unauthenticated for monitoring
app.use('/api/health', createHealthRoutes()); app.use('/api/health', createHealthRoutes());
@@ -157,7 +147,7 @@ app.use('/api/templates', createTemplatesRoutes());
app.use('/api/terminal', createTerminalRoutes()); app.use('/api/terminal', createTerminalRoutes());
app.use('/api/settings', createSettingsRoutes(settingsService)); app.use('/api/settings', createSettingsRoutes(settingsService));
app.use('/api/claude', createClaudeRoutes(claudeUsageService)); app.use('/api/claude', createClaudeRoutes(claudeUsageService));
app.use('/api/github', createGitHubRoutes(events)); app.use('/api/github', createGitHubRoutes());
app.use('/api/context', createContextRoutes()); app.use('/api/context', createContextRoutes());
// Create HTTP server // Create HTTP server

View File

@@ -2,30 +2,9 @@
* Authentication middleware for API security * Authentication middleware for API security
* *
* Supports API key authentication via header or environment variable. * Supports API key authentication via header or environment variable.
* Includes rate limiting to prevent brute-force attacks.
*/ */
import * as crypto from 'crypto';
import type { Request, Response, NextFunction } from 'express'; import type { Request, Response, NextFunction } from 'express';
import { apiKeyRateLimiter } from './rate-limiter.js';
/**
* Performs a constant-time string comparison to prevent timing attacks.
* Uses crypto.timingSafeEqual with proper buffer handling.
*/
function secureCompare(a: string, b: string): boolean {
const bufferA = Buffer.from(a, 'utf8');
const bufferB = Buffer.from(b, 'utf8');
// If lengths differ, we still need to do a constant-time comparison
// to avoid leaking length information. We compare against bufferA twice.
if (bufferA.length !== bufferB.length) {
crypto.timingSafeEqual(bufferA, bufferA);
return false;
}
return crypto.timingSafeEqual(bufferA, bufferB);
}
// API key from environment (optional - if not set, auth is disabled) // API key from environment (optional - if not set, auth is disabled)
const API_KEY = process.env.AUTOMAKER_API_KEY; const API_KEY = process.env.AUTOMAKER_API_KEY;
@@ -35,7 +14,6 @@ const API_KEY = process.env.AUTOMAKER_API_KEY;
* *
* If AUTOMAKER_API_KEY is set, requires matching key in X-API-Key header. * If AUTOMAKER_API_KEY is set, requires matching key in X-API-Key header.
* If not set, allows all requests (development mode). * If not set, allows all requests (development mode).
* Includes rate limiting to prevent brute-force attacks.
*/ */
export function authMiddleware(req: Request, res: Response, next: NextFunction): void { export function authMiddleware(req: Request, res: Response, next: NextFunction): void {
// If no API key is configured, allow all requests // If no API key is configured, allow all requests
@@ -44,22 +22,6 @@ export function authMiddleware(req: Request, res: Response, next: NextFunction):
return; return;
} }
const clientIp = apiKeyRateLimiter.getClientIp(req);
// Check if client is rate limited
if (apiKeyRateLimiter.isBlocked(clientIp)) {
const retryAfterMs = apiKeyRateLimiter.getBlockTimeRemaining(clientIp);
const retryAfterSeconds = Math.ceil(retryAfterMs / 1000);
res.setHeader('Retry-After', retryAfterSeconds.toString());
res.status(429).json({
success: false,
error: 'Too many failed authentication attempts. Please try again later.',
retryAfter: retryAfterSeconds,
});
return;
}
// Check for API key in header // Check for API key in header
const providedKey = req.headers['x-api-key'] as string | undefined; const providedKey = req.headers['x-api-key'] as string | undefined;
@@ -71,10 +33,7 @@ export function authMiddleware(req: Request, res: Response, next: NextFunction):
return; return;
} }
if (!secureCompare(providedKey, API_KEY)) { if (providedKey !== API_KEY) {
// Record failed attempt
apiKeyRateLimiter.recordFailure(clientIp);
res.status(403).json({ res.status(403).json({
success: false, success: false,
error: 'Invalid API key.', error: 'Invalid API key.',
@@ -82,9 +41,6 @@ export function authMiddleware(req: Request, res: Response, next: NextFunction):
return; return;
} }
// Successful authentication - reset rate limiter for this IP
apiKeyRateLimiter.reset(clientIp);
next(); next();
} }

View File

@@ -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();
};
}

View File

@@ -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;
}

View File

@@ -7,29 +7,6 @@
import type { Request, Response, NextFunction } from 'express'; import type { Request, Response, NextFunction } from 'express';
import { validatePath, PathNotAllowedError } from '@automaker/platform'; import { validatePath, PathNotAllowedError } from '@automaker/platform';
/**
* Custom error for invalid path type
*/
class InvalidPathTypeError extends Error {
constructor(paramName: string, expectedType: string, actualType: string) {
super(`Invalid type for '${paramName}': expected ${expectedType}, got ${actualType}`);
this.name = 'InvalidPathTypeError';
}
}
/**
* Validates that a value is a non-empty string suitable for path validation
*
* @param value - The value to check
* @param paramName - The parameter name for error messages
* @throws InvalidPathTypeError if value is not a valid string
*/
function assertValidPathString(value: unknown, paramName: string): asserts value is string {
if (typeof value !== 'string') {
throw new InvalidPathTypeError(paramName, 'string', typeof value);
}
}
/** /**
* Creates a middleware that validates specified path parameters in req.body * Creates a middleware that validates specified path parameters in req.body
* @param paramNames - Names of parameters to validate (e.g., 'projectPath', 'worktreePath') * @param paramNames - Names of parameters to validate (e.g., 'projectPath', 'worktreePath')
@@ -50,8 +27,7 @@ export function validatePathParams(...paramNames: string[]) {
if (paramName.endsWith('?')) { if (paramName.endsWith('?')) {
const actualName = paramName.slice(0, -1); const actualName = paramName.slice(0, -1);
const value = req.body[actualName]; const value = req.body[actualName];
if (value !== undefined && value !== null) { if (value) {
assertValidPathString(value, actualName);
validatePath(value); validatePath(value);
} }
continue; continue;
@@ -61,30 +37,17 @@ export function validatePathParams(...paramNames: string[]) {
if (paramName.endsWith('[]')) { if (paramName.endsWith('[]')) {
const actualName = paramName.slice(0, -2); const actualName = paramName.slice(0, -2);
const values = req.body[actualName]; const values = req.body[actualName];
if (Array.isArray(values) && values.length > 0) {
// Skip if not provided or empty for (const value of values) {
if (values === undefined || values === null) {
continue;
}
// Validate that it's actually an array
if (!Array.isArray(values)) {
throw new InvalidPathTypeError(actualName, 'array', typeof values);
}
// Validate each element in the array
for (let i = 0; i < values.length; i++) {
const value = values[i];
assertValidPathString(value, `${actualName}[${i}]`);
validatePath(value); validatePath(value);
} }
}
continue; continue;
} }
// Handle regular parameters // Handle regular parameters
const value = req.body[paramName]; const value = req.body[paramName];
if (value !== undefined && value !== null) { if (value) {
assertValidPathString(value, paramName);
validatePath(value); validatePath(value);
} }
} }
@@ -99,14 +62,6 @@ export function validatePathParams(...paramNames: string[]) {
return; return;
} }
if (error instanceof InvalidPathTypeError) {
res.status(400).json({
success: false,
error: error.message,
});
return;
}
// Re-throw unexpected errors // Re-throw unexpected errors
throw error; throw error;
} }

View File

@@ -3,50 +3,16 @@
*/ */
import { Router } from 'express'; import { Router } from 'express';
import type { EventEmitter } from '../../lib/events.js';
import { validatePathParams } from '../../middleware/validate-paths.js';
import { createCheckGitHubRemoteHandler } from './routes/check-github-remote.js'; import { createCheckGitHubRemoteHandler } from './routes/check-github-remote.js';
import { createListIssuesHandler } from './routes/list-issues.js'; import { createListIssuesHandler } from './routes/list-issues.js';
import { createListPRsHandler } from './routes/list-prs.js'; import { createListPRsHandler } from './routes/list-prs.js';
import { createValidateIssueHandler } from './routes/validate-issue.js';
import {
createValidationStatusHandler,
createValidationStopHandler,
createGetValidationsHandler,
createDeleteValidationHandler,
createMarkViewedHandler,
} from './routes/validation-endpoints.js';
export function createGitHubRoutes(events: EventEmitter): Router { export function createGitHubRoutes(): Router {
const router = Router(); const router = Router();
router.post('/check-remote', validatePathParams('projectPath'), createCheckGitHubRemoteHandler()); router.post('/check-remote', createCheckGitHubRemoteHandler());
router.post('/issues', validatePathParams('projectPath'), createListIssuesHandler()); router.post('/issues', createListIssuesHandler());
router.post('/prs', validatePathParams('projectPath'), createListPRsHandler()); router.post('/prs', createListPRsHandler());
router.post(
'/validate-issue',
validatePathParams('projectPath'),
createValidateIssueHandler(events)
);
// Validation management endpoints
router.post(
'/validation-status',
validatePathParams('projectPath'),
createValidationStatusHandler()
);
router.post('/validation-stop', validatePathParams('projectPath'), createValidationStopHandler());
router.post('/validations', validatePathParams('projectPath'), createGetValidationsHandler());
router.post(
'/validation-delete',
validatePathParams('projectPath'),
createDeleteValidationHandler()
);
router.post(
'/validation-mark-viewed',
validatePathParams('projectPath'),
createMarkViewedHandler(events)
);
return router; return router;
} }

View File

@@ -2,7 +2,6 @@
* POST /list-issues endpoint - List GitHub issues for a project * POST /list-issues endpoint - List GitHub issues for a project
*/ */
import { spawn } from 'child_process';
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { execAsync, execEnv, getErrorMessage, logError } from './common.js'; import { execAsync, execEnv, getErrorMessage, logError } from './common.js';
import { checkGitHubRemote } from './check-github-remote.js'; import { checkGitHubRemote } from './check-github-remote.js';
@@ -14,19 +13,6 @@ export interface GitHubLabel {
export interface GitHubAuthor { export interface GitHubAuthor {
login: string; login: string;
avatarUrl?: string;
}
export interface GitHubAssignee {
login: string;
avatarUrl?: string;
}
export interface LinkedPullRequest {
number: number;
title: string;
state: string;
url: string;
} }
export interface GitHubIssue { export interface GitHubIssue {
@@ -38,8 +24,6 @@ export interface GitHubIssue {
labels: GitHubLabel[]; labels: GitHubLabel[];
url: string; url: string;
body: string; body: string;
assignees: GitHubAssignee[];
linkedPRs?: LinkedPullRequest[];
} }
export interface ListIssuesResult { export interface ListIssuesResult {
@@ -49,146 +33,6 @@ export interface ListIssuesResult {
error?: string; error?: string;
} }
/**
* Fetch linked PRs for a list of issues using GitHub GraphQL API
*/
async function fetchLinkedPRs(
projectPath: string,
owner: string,
repo: string,
issueNumbers: number[]
): Promise<Map<number, LinkedPullRequest[]>> {
const linkedPRsMap = new Map<number, LinkedPullRequest[]>();
if (issueNumbers.length === 0) {
return linkedPRsMap;
}
// Build GraphQL query for batch fetching linked PRs
// We fetch up to 20 issues at a time to avoid query limits
const batchSize = 20;
for (let i = 0; i < issueNumbers.length; i += batchSize) {
const batch = issueNumbers.slice(i, i + batchSize);
const issueQueries = batch
.map(
(num, idx) => `
issue${idx}: issue(number: ${num}) {
number
timelineItems(first: 10, itemTypes: [CROSS_REFERENCED_EVENT, CONNECTED_EVENT]) {
nodes {
... on CrossReferencedEvent {
source {
... on PullRequest {
number
title
state
url
}
}
}
... on ConnectedEvent {
subject {
... on PullRequest {
number
title
state
url
}
}
}
}
}
}`
)
.join('\n');
const query = `{
repository(owner: "${owner}", name: "${repo}") {
${issueQueries}
}
}`;
try {
// Use spawn with stdin to avoid shell injection vulnerabilities
// --input - reads the JSON request body from stdin
const requestBody = JSON.stringify({ query });
const response = await new Promise<Record<string, unknown>>((resolve, reject) => {
const gh = spawn('gh', ['api', 'graphql', '--input', '-'], {
cwd: projectPath,
env: execEnv,
});
let stdout = '';
let stderr = '';
gh.stdout.on('data', (data: Buffer) => (stdout += data.toString()));
gh.stderr.on('data', (data: Buffer) => (stderr += data.toString()));
gh.on('close', (code) => {
if (code !== 0) {
return reject(new Error(`gh process exited with code ${code}: ${stderr}`));
}
try {
resolve(JSON.parse(stdout));
} catch (e) {
reject(e);
}
});
gh.stdin.write(requestBody);
gh.stdin.end();
});
const repoData = (response?.data as Record<string, unknown>)?.repository as Record<
string,
unknown
> | null;
if (repoData) {
batch.forEach((issueNum, idx) => {
const issueData = repoData[`issue${idx}`] as {
timelineItems?: {
nodes?: Array<{
source?: { number?: number; title?: string; state?: string; url?: string };
subject?: { number?: number; title?: string; state?: string; url?: string };
}>;
};
} | null;
if (issueData?.timelineItems?.nodes) {
const linkedPRs: LinkedPullRequest[] = [];
const seenPRs = new Set<number>();
for (const node of issueData.timelineItems.nodes) {
const pr = node?.source || node?.subject;
if (pr?.number && !seenPRs.has(pr.number)) {
seenPRs.add(pr.number);
linkedPRs.push({
number: pr.number,
title: pr.title || '',
state: (pr.state || '').toLowerCase(),
url: pr.url || '',
});
}
}
if (linkedPRs.length > 0) {
linkedPRsMap.set(issueNum, linkedPRs);
}
}
});
}
} catch (error) {
// If GraphQL fails, continue without linked PRs
console.warn(
'Failed to fetch linked PRs via GraphQL:',
error instanceof Error ? error.message : error
);
}
}
return linkedPRsMap;
}
export function createListIssuesHandler() { export function createListIssuesHandler() {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
try { try {
@@ -209,17 +53,17 @@ export function createListIssuesHandler() {
return; return;
} }
// Fetch open and closed issues in parallel (now including assignees) // Fetch open and closed issues in parallel
const [openResult, closedResult] = await Promise.all([ const [openResult, closedResult] = await Promise.all([
execAsync( execAsync(
'gh issue list --state open --json number,title,state,author,createdAt,labels,url,body,assignees --limit 100', 'gh issue list --state open --json number,title,state,author,createdAt,labels,url,body --limit 100',
{ {
cwd: projectPath, cwd: projectPath,
env: execEnv, env: execEnv,
} }
), ),
execAsync( execAsync(
'gh issue list --state closed --json number,title,state,author,createdAt,labels,url,body,assignees --limit 50', 'gh issue list --state closed --json number,title,state,author,createdAt,labels,url,body --limit 50',
{ {
cwd: projectPath, cwd: projectPath,
env: execEnv, env: execEnv,
@@ -233,24 +77,6 @@ export function createListIssuesHandler() {
const openIssues: GitHubIssue[] = JSON.parse(openStdout || '[]'); const openIssues: GitHubIssue[] = JSON.parse(openStdout || '[]');
const closedIssues: GitHubIssue[] = JSON.parse(closedStdout || '[]'); const closedIssues: GitHubIssue[] = JSON.parse(closedStdout || '[]');
// Fetch linked PRs for open issues (more relevant for active work)
if (remoteStatus.owner && remoteStatus.repo && openIssues.length > 0) {
const linkedPRsMap = await fetchLinkedPRs(
projectPath,
remoteStatus.owner,
remoteStatus.repo,
openIssues.map((i) => i.number)
);
// Attach linked PRs to issues
for (const issue of openIssues) {
const linkedPRs = linkedPRsMap.get(issue.number);
if (linkedPRs) {
issue.linkedPRs = linkedPRs;
}
}
}
res.json({ res.json({
success: true, success: true,
openIssues, openIssues,

View File

@@ -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),
});
}
}
};
}

View File

@@ -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 };

View File

@@ -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) });
}
};
}

View File

@@ -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.`;
}

View File

@@ -10,7 +10,7 @@
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import type { SettingsService } from '../../../services/settings-service.js'; import type { SettingsService } from '../../../services/settings-service.js';
import { logError } from '../common.js'; import { getErrorMessage, logError } from '../common.js';
/** /**
* Create handler factory for GET /api/settings/credentials * Create handler factory for GET /api/settings/credentials
@@ -29,7 +29,7 @@ export function createGetCredentialsHandler(settingsService: SettingsService) {
}); });
} catch (error) { } catch (error) {
logError(error, 'Get credentials failed'); logError(error, 'Get credentials failed');
res.status(500).json({ success: false, error: 'Failed to retrieve credentials' }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }
}; };
} }

View File

@@ -11,71 +11,7 @@
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import type { SettingsService } from '../../../services/settings-service.js'; import type { SettingsService } from '../../../services/settings-service.js';
import type { Credentials } from '../../../types/settings.js'; import type { Credentials } from '../../../types/settings.js';
import { logError } from '../common.js'; import { getErrorMessage, logError } from '../common.js';
/** Maximum allowed length for API keys to prevent abuse */
const MAX_API_KEY_LENGTH = 512;
/** Known API key provider names that are valid */
const VALID_API_KEY_PROVIDERS = ['anthropic', 'google', 'openai'] as const;
/**
* Validates that the provided updates object has the correct structure
* and all apiKeys values are strings within acceptable length limits.
*
* @param updates - The partial credentials update object to validate
* @returns An error message if validation fails, or null if valid
*/
function validateCredentialsUpdate(updates: unknown): string | null {
if (!updates || typeof updates !== 'object' || Array.isArray(updates)) {
return 'Invalid request body - expected credentials object';
}
const obj = updates as Record<string, unknown>;
// If apiKeys is provided, validate its structure
if ('apiKeys' in obj) {
const apiKeys = obj.apiKeys;
if (apiKeys === null || apiKeys === undefined) {
// Allow null/undefined to clear
return null;
}
if (typeof apiKeys !== 'object' || Array.isArray(apiKeys)) {
return 'Invalid apiKeys - expected object';
}
const keysObj = apiKeys as Record<string, unknown>;
// Validate each provided API key
for (const [provider, value] of Object.entries(keysObj)) {
// Check provider name is valid
if (!VALID_API_KEY_PROVIDERS.includes(provider as (typeof VALID_API_KEY_PROVIDERS)[number])) {
return `Invalid API key provider: ${provider}. Valid providers: ${VALID_API_KEY_PROVIDERS.join(', ')}`;
}
// Check value is a string
if (typeof value !== 'string') {
return `Invalid API key for ${provider} - expected string`;
}
// Check length limit
if (value.length > MAX_API_KEY_LENGTH) {
return `API key for ${provider} exceeds maximum length of ${MAX_API_KEY_LENGTH} characters`;
}
}
}
// Validate version if provided
if ('version' in obj && obj.version !== undefined) {
if (typeof obj.version !== 'number' || !Number.isInteger(obj.version) || obj.version < 0) {
return 'Invalid version - expected non-negative integer';
}
}
return null;
}
/** /**
* Create handler factory for PUT /api/settings/credentials * Create handler factory for PUT /api/settings/credentials
@@ -86,19 +22,16 @@ function validateCredentialsUpdate(updates: unknown): string | null {
export function createUpdateCredentialsHandler(settingsService: SettingsService) { export function createUpdateCredentialsHandler(settingsService: SettingsService) {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
try { try {
// Validate the request body before type assertion const updates = req.body as Partial<Credentials>;
const validationError = validateCredentialsUpdate(req.body);
if (validationError) { if (!updates || typeof updates !== 'object') {
res.status(400).json({ res.status(400).json({
success: false, success: false,
error: validationError, error: 'Invalid request body - expected credentials object',
}); });
return; return;
} }
// Safe to cast after validation
const updates = req.body as Partial<Credentials>;
await settingsService.updateCredentials(updates); await settingsService.updateCredentials(updates);
// Return masked credentials for confirmation // Return masked credentials for confirmation
@@ -110,7 +43,7 @@ export function createUpdateCredentialsHandler(settingsService: SettingsService)
}); });
} catch (error) { } catch (error) {
logError(error, 'Update credentials failed'); logError(error, 'Update credentials failed');
res.status(500).json({ success: false, error: 'Failed to update credentials' }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }
}; };
} }

View File

@@ -9,17 +9,6 @@ import { getErrorMessage as getErrorMessageShared, createLogError } from '../com
const logger = createLogger('Setup'); const logger = createLogger('Setup');
/**
* Escapes special regex characters in a string to prevent regex injection.
* This ensures user input can be safely used in RegExp constructors.
*
* @param str - The string to escape
* @returns The escaped string safe for use in RegExp
*/
export function escapeRegExp(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// Storage for API keys (in-memory cache) - private // Storage for API keys (in-memory cache) - private
const apiKeys: Record<string, string> = {}; const apiKeys: Record<string, string> = {};
@@ -44,32 +33,6 @@ export function getAllApiKeys(): Record<string, string> {
return { ...apiKeys }; return { ...apiKeys };
} }
/**
* Escape a value for safe inclusion in a .env file.
* Handles special characters like quotes, newlines, dollar signs, and backslashes.
* Returns a properly quoted string if needed.
*/
function escapeEnvValue(value: string): string {
// Check if the value contains any characters that require quoting
const requiresQuoting = /[\s"'$`\\#\n\r]/.test(value) || value.includes('=');
if (!requiresQuoting) {
return value;
}
// Use double quotes and escape special characters within
// Escape backslashes first to avoid double-escaping
let escaped = value
.replace(/\\/g, '\\\\') // Escape backslashes
.replace(/"/g, '\\"') // Escape double quotes
.replace(/\$/g, '\\$') // Escape dollar signs (prevents variable expansion)
.replace(/`/g, '\\`') // Escape backticks
.replace(/\n/g, '\\n') // Escape newlines
.replace(/\r/g, '\\r'); // Escape carriage returns
return `"${escaped}"`;
}
/** /**
* Helper to persist API keys to .env file * Helper to persist API keys to .env file
*/ */
@@ -84,24 +47,21 @@ export async function persistApiKeyToEnv(key: string, value: string): Promise<vo
// .env file doesn't exist, we'll create it // .env file doesn't exist, we'll create it
} }
// Escape the value for safe .env file storage // Parse existing env content
const escapedValue = escapeEnvValue(value);
// Parse existing env content - match key with optional quoted values
const lines = envContent.split('\n'); const lines = envContent.split('\n');
const keyRegex = new RegExp(`^${escapeRegExp(key)}=`); const keyRegex = new RegExp(`^${key}=`);
let found = false; let found = false;
const newLines = lines.map((line) => { const newLines = lines.map((line) => {
if (keyRegex.test(line)) { if (keyRegex.test(line)) {
found = true; found = true;
return `${key}=${escapedValue}`; return `${key}=${value}`;
} }
return line; return line;
}); });
if (!found) { if (!found) {
// Add the key at the end // Add the key at the end
newLines.push(`${key}=${escapedValue}`); newLines.push(`${key}=${value}`);
} }
await fs.writeFile(envPath, newLines.join('\n')); await fs.writeFile(envPath, newLines.join('\n'));

View File

@@ -3,7 +3,7 @@
*/ */
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { getApiKey, logError } from '../common.js'; import { getApiKey, getErrorMessage, logError } from '../common.js';
export function createApiKeysHandler() { export function createApiKeysHandler() {
return async (_req: Request, res: Response): Promise<void> => { return async (_req: Request, res: Response): Promise<void> => {
@@ -14,7 +14,7 @@ export function createApiKeysHandler() {
}); });
} catch (error) { } catch (error) {
logError(error, 'Get API keys failed'); logError(error, 'Get API keys failed');
res.status(500).json({ success: false, error: 'Failed to retrieve API keys status' }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }
}; };
} }

View File

@@ -11,7 +11,7 @@ const logger = createLogger('Setup');
// In-memory storage reference (imported from common.ts pattern) // In-memory storage reference (imported from common.ts pattern)
// We need to modify common.ts to export a deleteApiKey function // We need to modify common.ts to export a deleteApiKey function
import { setApiKey, escapeRegExp } from '../common.js'; import { setApiKey } from '../common.js';
/** /**
* Remove an API key from the .env file * Remove an API key from the .env file
@@ -30,7 +30,7 @@ async function removeApiKeyFromEnv(key: string): Promise<void> {
// Parse existing env content and remove the key // Parse existing env content and remove the key
const lines = envContent.split('\n'); const lines = envContent.split('\n');
const keyRegex = new RegExp(`^${escapeRegExp(key)}=`); const keyRegex = new RegExp(`^${key}=`);
const newLines = lines.filter((line) => !keyRegex.test(line)); const newLines = lines.filter((line) => !keyRegex.test(line));
// Remove empty lines at the end // Remove empty lines at the end
@@ -68,10 +68,9 @@ export function createDeleteApiKeyHandler() {
const envKey = envKeyMap[provider]; const envKey = envKeyMap[provider];
if (!envKey) { if (!envKey) {
logger.warn(`[Setup] Unknown provider requested for deletion: ${provider}`);
res.status(400).json({ res.status(400).json({
success: false, success: false,
error: 'Unknown provider. Only anthropic is supported.', error: `Unknown provider: ${provider}. Only anthropic is supported.`,
}); });
return; return;
} }
@@ -95,7 +94,7 @@ export function createDeleteApiKeyHandler() {
logger.error('[Setup] Delete API key error:', error); logger.error('[Setup] Delete API key error:', error);
res.status(500).json({ res.status(500).json({
success: false, success: false,
error: 'Failed to delete API key', error: error instanceof Error ? error.message : 'Failed to delete API key',
}); });
} }
}; };

View File

@@ -3,7 +3,7 @@
*/ */
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { setApiKey, persistApiKeyToEnv, logError } from '../common.js'; import { setApiKey, persistApiKeyToEnv, getErrorMessage, logError } from '../common.js';
import { createLogger } from '@automaker/utils'; import { createLogger } from '@automaker/utils';
const logger = createLogger('Setup'); const logger = createLogger('Setup');
@@ -30,10 +30,9 @@ export function createStoreApiKeyHandler() {
await persistApiKeyToEnv('ANTHROPIC_API_KEY', apiKey); await persistApiKeyToEnv('ANTHROPIC_API_KEY', apiKey);
logger.info('[Setup] Stored API key as ANTHROPIC_API_KEY'); logger.info('[Setup] Stored API key as ANTHROPIC_API_KEY');
} else { } else {
logger.warn(`[Setup] Unsupported provider requested: ${provider}`);
res.status(400).json({ res.status(400).json({
success: false, success: false,
error: 'Unsupported provider. Only anthropic is supported.', error: `Unsupported provider: ${provider}. Only anthropic is supported.`,
}); });
return; return;
} }
@@ -41,7 +40,7 @@ export function createStoreApiKeyHandler() {
res.json({ success: true }); res.json({ success: true });
} catch (error) { } catch (error) {
logError(error, 'Store API key failed'); logError(error, 'Store API key failed');
res.status(500).json({ success: false, error: 'Failed to store API key' }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }
}; };
} }

View File

@@ -10,41 +10,6 @@ import { getApiKey } from '../common.js';
const logger = createLogger('Setup'); const logger = createLogger('Setup');
/**
* Simple mutex implementation to prevent race conditions when
* modifying process.env during concurrent verification requests.
*
* The Claude Agent SDK reads ANTHROPIC_API_KEY from process.env,
* so we must temporarily modify it for verification. This mutex
* ensures only one verification runs at a time.
*/
class VerificationMutex {
private locked = false;
private queue: Array<() => void> = [];
async acquire(): Promise<void> {
return new Promise((resolve) => {
if (!this.locked) {
this.locked = true;
resolve();
} else {
this.queue.push(resolve);
}
});
}
release(): void {
if (this.queue.length > 0) {
const next = this.queue.shift();
if (next) next();
} else {
this.locked = false;
}
}
}
const verificationMutex = new VerificationMutex();
// Known error patterns that indicate auth failure // Known error patterns that indicate auth failure
const AUTH_ERROR_PATTERNS = [ const AUTH_ERROR_PATTERNS = [
'OAuth token revoked', 'OAuth token revoked',
@@ -103,79 +68,14 @@ function containsAuthError(text: string): boolean {
return AUTH_ERROR_PATTERNS.some((pattern) => lowerText.includes(pattern.toLowerCase())); return AUTH_ERROR_PATTERNS.some((pattern) => lowerText.includes(pattern.toLowerCase()));
} }
/** Valid authentication method values */
const VALID_AUTH_METHODS = ['cli', 'api_key'] as const;
type AuthMethod = (typeof VALID_AUTH_METHODS)[number];
/**
* Validates and extracts the authMethod from the request body.
*
* @param body - The request body to validate
* @returns The validated authMethod or undefined if not provided
* @throws Error if authMethod is provided but invalid
*/
function validateAuthMethod(body: unknown): AuthMethod | undefined {
if (!body || typeof body !== 'object') {
return undefined;
}
const obj = body as Record<string, unknown>;
if (!('authMethod' in obj) || obj.authMethod === undefined || obj.authMethod === null) {
return undefined;
}
const authMethod = obj.authMethod;
if (typeof authMethod !== 'string') {
throw new Error(`Invalid authMethod type: expected string, got ${typeof authMethod}`);
}
if (!VALID_AUTH_METHODS.includes(authMethod as AuthMethod)) {
throw new Error(
`Invalid authMethod value: '${authMethod}'. Valid values: ${VALID_AUTH_METHODS.join(', ')}`
);
}
return authMethod as AuthMethod;
}
export function createVerifyClaudeAuthHandler() { export function createVerifyClaudeAuthHandler() {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
try { try {
// Validate and extract the auth method from the request body // Get the auth method from the request body
let authMethod: AuthMethod | undefined; const { authMethod } = req.body as { authMethod?: 'cli' | 'api_key' };
try {
authMethod = validateAuthMethod(req.body);
} catch (validationError) {
res.status(400).json({
success: false,
authenticated: false,
error: validationError instanceof Error ? validationError.message : 'Invalid request',
});
return;
}
logger.info(`[Setup] Verifying Claude authentication using method: ${authMethod || 'auto'}`); logger.info(`[Setup] Verifying Claude authentication using method: ${authMethod || 'auto'}`);
// Early validation before acquiring mutex - check if API key is needed but missing
if (authMethod === 'api_key') {
const storedApiKey = getApiKey('anthropic');
if (!storedApiKey && !process.env.ANTHROPIC_API_KEY) {
res.json({
success: true,
authenticated: false,
error: 'No API key configured. Please enter an API key first.',
});
return;
}
}
// Acquire mutex to prevent race conditions when modifying process.env
// The SDK reads ANTHROPIC_API_KEY from environment, so concurrent requests
// could interfere with each other without this lock
await verificationMutex.acquire();
// Create an AbortController with a 30-second timeout // Create an AbortController with a 30-second timeout
const abortController = new AbortController(); const abortController = new AbortController();
const timeoutId = setTimeout(() => abortController.abort(), 30000); const timeoutId = setTimeout(() => abortController.abort(), 30000);
@@ -184,7 +84,7 @@ export function createVerifyClaudeAuthHandler() {
let errorMessage = ''; let errorMessage = '';
let receivedAnyContent = false; let receivedAnyContent = false;
// Save original env values (inside mutex to ensure consistency) // Save original env values
const originalAnthropicKey = process.env.ANTHROPIC_API_KEY; const originalAnthropicKey = process.env.ANTHROPIC_API_KEY;
try { try {
@@ -199,8 +99,17 @@ export function createVerifyClaudeAuthHandler() {
if (storedApiKey) { if (storedApiKey) {
process.env.ANTHROPIC_API_KEY = storedApiKey; process.env.ANTHROPIC_API_KEY = storedApiKey;
logger.info('[Setup] Using stored API key for verification'); logger.info('[Setup] Using stored API key for verification');
} else {
// Check env var
if (!process.env.ANTHROPIC_API_KEY) {
res.json({
success: true,
authenticated: false,
error: 'No API key configured. Please enter an API key first.',
});
return;
}
} }
// Note: if no stored key, we use the existing env var (already validated above)
} }
// Run a minimal query to verify authentication // Run a minimal query to verify authentication
@@ -220,8 +129,7 @@ export function createVerifyClaudeAuthHandler() {
for await (const msg of stream) { for await (const msg of stream) {
const msgStr = JSON.stringify(msg); const msgStr = JSON.stringify(msg);
allMessages.push(msgStr); allMessages.push(msgStr);
// Debug log only message type to avoid leaking sensitive data logger.info('[Setup] Stream message:', msgStr.substring(0, 500));
logger.debug('[Setup] Stream message type:', msg.type);
// Check for billing errors FIRST - these should fail verification // Check for billing errors FIRST - these should fail verification
if (isBillingError(msgStr)) { if (isBillingError(msgStr)) {
@@ -313,8 +221,7 @@ export function createVerifyClaudeAuthHandler() {
} else { } else {
// No content received - might be an issue // No content received - might be an issue
logger.warn('[Setup] No content received from stream'); logger.warn('[Setup] No content received from stream');
// Log only message count to avoid leaking sensitive data logger.warn('[Setup] All messages:', allMessages.join('\n'));
logger.warn('[Setup] Total messages received:', allMessages.length);
errorMessage = 'No response received from Claude. Please check your authentication.'; errorMessage = 'No response received from Claude. Please check your authentication.';
} }
} catch (error: unknown) { } catch (error: unknown) {
@@ -370,8 +277,6 @@ export function createVerifyClaudeAuthHandler() {
// If we cleared it and there was no original, keep it cleared // If we cleared it and there was no original, keep it cleared
delete process.env.ANTHROPIC_API_KEY; delete process.env.ANTHROPIC_API_KEY;
} }
// Release the mutex so other verification requests can proceed
verificationMutex.release();
} }
logger.info('[Setup] Verification result:', { logger.info('[Setup] Verification result:', {

View File

@@ -63,8 +63,10 @@ For each suggestion, provide:
The response will be automatically formatted as structured JSON.`; The response will be automatically formatted as structured JSON.`;
// Don't send initial message - let the agent output speak for itself events.emit('suggestions:event', {
// The first agent message will be captured as an info entry type: 'suggestions_progress',
content: `Starting ${suggestionType} analysis...\n`,
});
const options = createSuggestionsOptions({ const options = createSuggestionsOptions({
cwd: projectPath, cwd: projectPath,

View File

@@ -65,18 +65,6 @@ export function cleanupExpiredTokens(): void {
// Clean up expired tokens every 5 minutes // Clean up expired tokens every 5 minutes
setInterval(cleanupExpiredTokens, 5 * 60 * 1000); setInterval(cleanupExpiredTokens, 5 * 60 * 1000);
/**
* Extract Bearer token from Authorization header
* Returns undefined if header is missing or malformed
*/
export function extractBearerToken(req: Request): string | undefined {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return undefined;
}
return authHeader.slice(7); // Remove 'Bearer ' prefix
}
/** /**
* Validate a terminal session token * Validate a terminal session token
*/ */
@@ -128,9 +116,8 @@ export function terminalAuthMiddleware(req: Request, res: Response, next: NextFu
return; return;
} }
// Extract token from Authorization header only (Bearer token format) // Check for session token
// Query string tokens are not supported due to security risks (URL logging, referrer leakage) const token = (req.headers['x-terminal-token'] as string) || (req.query.token as string);
const token = extractBearerToken(req);
if (!validateTerminalToken(token)) { if (!validateTerminalToken(token)) {
res.status(401).json({ res.status(401).json({

View File

@@ -1,9 +1,7 @@
/** /**
* POST /auth endpoint - Authenticate with password to get a session token * POST /auth endpoint - Authenticate with password to get a session token
* Includes rate limiting to prevent brute-force attacks.
*/ */
import * as crypto from 'crypto';
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { import {
getTerminalEnabledConfigValue, getTerminalEnabledConfigValue,
@@ -13,25 +11,6 @@ import {
getTokenExpiryMs, getTokenExpiryMs,
getErrorMessage, getErrorMessage,
} from '../common.js'; } from '../common.js';
import { terminalAuthRateLimiter } from '../../../lib/rate-limiter.js';
/**
* Performs a constant-time string comparison to prevent timing attacks.
* Uses crypto.timingSafeEqual with proper buffer handling.
*/
function secureCompare(a: string, b: string): boolean {
const bufferA = Buffer.from(a, 'utf8');
const bufferB = Buffer.from(b, 'utf8');
// If lengths differ, we still need to do a constant-time comparison
// to avoid leaking length information. We compare against bufferA twice.
if (bufferA.length !== bufferB.length) {
crypto.timingSafeEqual(bufferA, bufferA);
return false;
}
return crypto.timingSafeEqual(bufferA, bufferB);
}
export function createAuthHandler() { export function createAuthHandler() {
return (req: Request, res: Response): void => { return (req: Request, res: Response): void => {
@@ -57,28 +36,9 @@ export function createAuthHandler() {
return; return;
} }
const clientIp = terminalAuthRateLimiter.getClientIp(req);
// Check if client is rate limited
if (terminalAuthRateLimiter.isBlocked(clientIp)) {
const retryAfterMs = terminalAuthRateLimiter.getBlockTimeRemaining(clientIp);
const retryAfterSeconds = Math.ceil(retryAfterMs / 1000);
res.setHeader('Retry-After', retryAfterSeconds.toString());
res.status(429).json({
success: false,
error: 'Too many failed authentication attempts. Please try again later.',
retryAfter: retryAfterSeconds,
});
return;
}
const { password } = req.body; const { password } = req.body;
if (!password || !secureCompare(password, terminalPassword)) { if (!password || password !== terminalPassword) {
// Record failed attempt
terminalAuthRateLimiter.recordFailure(clientIp);
res.status(401).json({ res.status(401).json({
success: false, success: false,
error: 'Invalid password', error: 'Invalid password',
@@ -86,9 +46,6 @@ export function createAuthHandler() {
return; return;
} }
// Successful authentication - reset rate limiter for this IP
terminalAuthRateLimiter.reset(clientIp);
// Generate session token // Generate session token
const token = generateToken(); const token = generateToken();
const now = new Date(); const now = new Date();

View File

@@ -1,35 +1,17 @@
/** /**
* POST /logout endpoint - Invalidate a session token * POST /logout endpoint - Invalidate a session token
*
* Security: Only allows invalidating the token used for authentication.
* This ensures users can only log out their own sessions.
*/ */
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { deleteToken, extractBearerToken, validateTerminalToken } from '../common.js'; import { deleteToken } from '../common.js';
export function createLogoutHandler() { export function createLogoutHandler() {
return (req: Request, res: Response): void => { return (req: Request, res: Response): void => {
const token = extractBearerToken(req); const token = (req.headers['x-terminal-token'] as string) || req.body.token;
if (!token) { if (token) {
res.status(401).json({
success: false,
error: 'Authorization header with Bearer token is required',
});
return;
}
if (!validateTerminalToken(token)) {
res.status(401).json({
success: false,
error: 'Invalid or expired token',
});
return;
}
// Token is valid and belongs to the requester - safe to invalidate
deleteToken(token); deleteToken(token);
}
res.json({ res.json({
success: true, success: true,

View File

@@ -1,16 +1,6 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'; import { describe, it, expect, beforeEach, vi } from 'vitest';
import { createMockExpressContext } from '../../utils/mocks.js'; import { createMockExpressContext } from '../../utils/mocks.js';
/**
* Creates a mock Express context with socket properties for rate limiter support
*/
function createMockExpressContextWithSocket() {
const ctx = createMockExpressContext();
ctx.req.socket = { remoteAddress: '127.0.0.1' } as any;
ctx.res.setHeader = vi.fn().mockReturnThis();
return ctx;
}
/** /**
* Note: auth.ts reads AUTOMAKER_API_KEY at module load time. * Note: auth.ts reads AUTOMAKER_API_KEY at module load time.
* We need to reset modules and reimport for each test to get fresh state. * We need to reset modules and reimport for each test to get fresh state.
@@ -39,7 +29,7 @@ describe('auth.ts', () => {
process.env.AUTOMAKER_API_KEY = 'test-secret-key'; process.env.AUTOMAKER_API_KEY = 'test-secret-key';
const { authMiddleware } = await import('@/lib/auth.js'); const { authMiddleware } = await import('@/lib/auth.js');
const { req, res, next } = createMockExpressContextWithSocket(); const { req, res, next } = createMockExpressContext();
authMiddleware(req, res, next); authMiddleware(req, res, next);
@@ -55,7 +45,7 @@ describe('auth.ts', () => {
process.env.AUTOMAKER_API_KEY = 'test-secret-key'; process.env.AUTOMAKER_API_KEY = 'test-secret-key';
const { authMiddleware } = await import('@/lib/auth.js'); const { authMiddleware } = await import('@/lib/auth.js');
const { req, res, next } = createMockExpressContextWithSocket(); const { req, res, next } = createMockExpressContext();
req.headers['x-api-key'] = 'wrong-key'; req.headers['x-api-key'] = 'wrong-key';
authMiddleware(req, res, next); authMiddleware(req, res, next);
@@ -72,7 +62,7 @@ describe('auth.ts', () => {
process.env.AUTOMAKER_API_KEY = 'test-secret-key'; process.env.AUTOMAKER_API_KEY = 'test-secret-key';
const { authMiddleware } = await import('@/lib/auth.js'); const { authMiddleware } = await import('@/lib/auth.js');
const { req, res, next } = createMockExpressContextWithSocket(); const { req, res, next } = createMockExpressContext();
req.headers['x-api-key'] = 'test-secret-key'; req.headers['x-api-key'] = 'test-secret-key';
authMiddleware(req, res, next); authMiddleware(req, res, next);
@@ -123,197 +113,4 @@ describe('auth.ts', () => {
}); });
}); });
}); });
describe('security - AUTOMAKER_API_KEY not set', () => {
it('should allow requests without any authentication when API key is not configured', async () => {
delete process.env.AUTOMAKER_API_KEY;
const { authMiddleware } = await import('@/lib/auth.js');
const { req, res, next } = createMockExpressContext();
authMiddleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
expect(res.json).not.toHaveBeenCalled();
});
it('should allow requests even with invalid key header when API key is not configured', async () => {
delete process.env.AUTOMAKER_API_KEY;
const { authMiddleware } = await import('@/lib/auth.js');
const { req, res, next } = createMockExpressContext();
req.headers['x-api-key'] = 'some-random-key';
authMiddleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should report auth as disabled when no API key is configured', async () => {
delete process.env.AUTOMAKER_API_KEY;
const { isAuthEnabled, getAuthStatus } = await import('@/lib/auth.js');
expect(isAuthEnabled()).toBe(false);
expect(getAuthStatus()).toEqual({
enabled: false,
method: 'none',
});
});
});
describe('security - authentication correctness', () => {
it('should correctly authenticate with matching API key', async () => {
const testKey = 'correct-secret-key-12345';
process.env.AUTOMAKER_API_KEY = testKey;
const { authMiddleware } = await import('@/lib/auth.js');
const { req, res, next } = createMockExpressContextWithSocket();
req.headers['x-api-key'] = testKey;
authMiddleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should reject keys that differ by a single character', async () => {
process.env.AUTOMAKER_API_KEY = 'correct-secret-key';
const { authMiddleware } = await import('@/lib/auth.js');
const { req, res, next } = createMockExpressContextWithSocket();
req.headers['x-api-key'] = 'correct-secret-keY'; // Last char uppercase
authMiddleware(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(next).not.toHaveBeenCalled();
});
it('should reject keys with extra characters', async () => {
process.env.AUTOMAKER_API_KEY = 'secret-key';
const { authMiddleware } = await import('@/lib/auth.js');
const { req, res, next } = createMockExpressContextWithSocket();
req.headers['x-api-key'] = 'secret-key-extra';
authMiddleware(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(next).not.toHaveBeenCalled();
});
it('should reject keys that are a prefix of the actual key', async () => {
process.env.AUTOMAKER_API_KEY = 'full-secret-key';
const { authMiddleware } = await import('@/lib/auth.js');
const { req, res, next } = createMockExpressContextWithSocket();
req.headers['x-api-key'] = 'full-secret';
authMiddleware(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(next).not.toHaveBeenCalled();
});
it('should reject empty string API key header', async () => {
process.env.AUTOMAKER_API_KEY = 'secret-key';
const { authMiddleware } = await import('@/lib/auth.js');
const { req, res, next } = createMockExpressContextWithSocket();
req.headers['x-api-key'] = '';
authMiddleware(req, res, next);
// Empty string is falsy, so should get 401 (no key provided)
expect(res.status).toHaveBeenCalledWith(401);
expect(next).not.toHaveBeenCalled();
});
it('should handle keys with special characters correctly', async () => {
const specialKey = 'key-with-$pecial!@#chars_123';
process.env.AUTOMAKER_API_KEY = specialKey;
const { authMiddleware } = await import('@/lib/auth.js');
const { req, res, next } = createMockExpressContextWithSocket();
req.headers['x-api-key'] = specialKey;
authMiddleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
});
describe('security - rate limiting', () => {
it('should block requests after multiple failed attempts', async () => {
process.env.AUTOMAKER_API_KEY = 'correct-key';
const { authMiddleware } = await import('@/lib/auth.js');
const { apiKeyRateLimiter } = await import('@/lib/rate-limiter.js');
// Reset the rate limiter for this test
apiKeyRateLimiter.reset('192.168.1.100');
// Simulate multiple failed attempts
for (let i = 0; i < 5; i++) {
const { req, res, next } = createMockExpressContextWithSocket();
req.socket.remoteAddress = '192.168.1.100';
req.headers['x-api-key'] = 'wrong-key';
authMiddleware(req, res, next);
}
// Next request should be rate limited
const { req, res, next } = createMockExpressContextWithSocket();
req.socket.remoteAddress = '192.168.1.100';
req.headers['x-api-key'] = 'correct-key'; // Even with correct key
authMiddleware(req, res, next);
expect(res.status).toHaveBeenCalledWith(429);
expect(next).not.toHaveBeenCalled();
// Cleanup
apiKeyRateLimiter.reset('192.168.1.100');
});
it('should reset rate limit on successful authentication', async () => {
process.env.AUTOMAKER_API_KEY = 'correct-key';
const { authMiddleware } = await import('@/lib/auth.js');
const { apiKeyRateLimiter } = await import('@/lib/rate-limiter.js');
// Reset the rate limiter for this test
apiKeyRateLimiter.reset('192.168.1.101');
// Simulate a few failed attempts (not enough to trigger block)
for (let i = 0; i < 3; i++) {
const { req, res, next } = createMockExpressContextWithSocket();
req.socket.remoteAddress = '192.168.1.101';
req.headers['x-api-key'] = 'wrong-key';
authMiddleware(req, res, next);
}
// Successful authentication should reset the counter
const {
req: successReq,
res: successRes,
next: successNext,
} = createMockExpressContextWithSocket();
successReq.socket.remoteAddress = '192.168.1.101';
successReq.headers['x-api-key'] = 'correct-key';
authMiddleware(successReq, successRes, successNext);
expect(successNext).toHaveBeenCalled();
// After reset, we should have full attempts available again
expect(apiKeyRateLimiter.getAttemptsRemaining('192.168.1.101')).toBe(5);
// Cleanup
apiKeyRateLimiter.reset('192.168.1.101');
});
});
}); });

View File

@@ -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);
});
});
});

View File

@@ -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);
});
});
});

View 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>
);
}

View File

@@ -1,372 +1,213 @@
import { useState, useCallback } from 'react'; import React from 'react';
import { useNavigate, useLocation } from '@tanstack/react-router'; import {
Code2,
PanelLeft,
Plus,
Folder,
Bell,
FolderOpen,
MoreVertical,
LayoutGrid,
Bot,
FileJson,
BookOpen,
UserCircle,
TerminalSquare,
Book,
Activity,
Settings,
} from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useAppStore, type ThemeMode } from '@/store/app-store'; import { Link, useLocation } from '@tanstack/react-router';
import { useKeyboardShortcuts, useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
import { getElectronAPI } from '@/lib/electron';
import { initializeProject, hasAppSpec, hasAutomakerDir } from '@/lib/project-init';
import { toast } from 'sonner';
import { DeleteProjectDialog } from '@/components/views/settings-view/components/delete-project-dialog';
import { NewProjectModal } from '@/components/dialogs/new-project-modal';
import { CreateSpecDialog } from '@/components/views/spec-view/dialogs';
// Local imports from subfolder
import {
CollapseToggleButton,
SidebarHeader,
ProjectActions,
SidebarNavigation,
ProjectSelectorWithOptions,
SidebarFooter,
} from './sidebar/components';
import { TrashDialog, OnboardingDialog } from './sidebar/dialogs';
import { SIDEBAR_FEATURE_FLAGS } from './sidebar/constants';
import {
useSidebarAutoCollapse,
useRunningAgents,
useSpecRegeneration,
useNavigation,
useProjectCreation,
useSetupDialog,
useTrashDialog,
useProjectTheme,
useUnviewedValidations,
} from './sidebar/hooks';
export function Sidebar() { export function Sidebar() {
const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const {
projects,
trashedProjects,
currentProject,
sidebarOpen,
projectHistory,
upsertAndSetCurrentProject,
toggleSidebar,
restoreTrashedProject,
deleteTrashedProject,
emptyTrash,
cyclePrevProject,
cycleNextProject,
moveProjectToTrash,
specCreatingForProject,
setSpecCreatingForProject,
} = useAppStore();
// Environment variable flags for hiding sidebar items
const { hideTerminal, hideWiki, hideRunningAgents, hideContext, hideSpecEditor, hideAiProfiles } =
SIDEBAR_FEATURE_FLAGS;
// Get customizable keyboard shortcuts
const shortcuts = useKeyboardShortcutsConfig();
// State for project picker (needed for keyboard shortcuts)
const [isProjectPickerOpen, setIsProjectPickerOpen] = useState(false);
// State for delete project confirmation dialog
const [showDeleteProjectDialog, setShowDeleteProjectDialog] = useState(false);
// Project theme management (must come before useProjectCreation which uses globalTheme)
const { globalTheme } = useProjectTheme();
// Project creation state and handlers
const {
showNewProjectModal,
setShowNewProjectModal,
isCreatingProject,
showOnboardingDialog,
setShowOnboardingDialog,
newProjectName,
setNewProjectName,
newProjectPath,
setNewProjectPath,
handleCreateBlankProject,
handleCreateFromTemplate,
handleCreateFromCustomUrl,
} = useProjectCreation({
trashedProjects,
currentProject,
globalTheme,
upsertAndSetCurrentProject,
});
// Setup dialog state and handlers
const {
showSetupDialog,
setShowSetupDialog,
setupProjectPath,
setSetupProjectPath,
projectOverview,
setProjectOverview,
generateFeatures,
setGenerateFeatures,
analyzeProject,
setAnalyzeProject,
featureCount,
setFeatureCount,
handleCreateInitialSpec,
handleSkipSetup,
handleOnboardingGenerateSpec,
handleOnboardingSkip,
} = useSetupDialog({
setSpecCreatingForProject,
newProjectPath,
setNewProjectName,
setNewProjectPath,
setShowOnboardingDialog,
});
// Derive isCreatingSpec from store state
const isCreatingSpec = specCreatingForProject !== null;
const creatingSpecProjectPath = specCreatingForProject;
// Auto-collapse sidebar on small screens and update Electron window minWidth
useSidebarAutoCollapse({ sidebarOpen, toggleSidebar });
// Running agents count
const { runningAgentsCount } = useRunningAgents();
// Unviewed validations count
const { count: unviewedValidationsCount } = useUnviewedValidations(currentProject);
// Trash dialog and operations
const {
showTrashDialog,
setShowTrashDialog,
activeTrashId,
isEmptyingTrash,
handleRestoreProject,
handleDeleteProjectFromDisk,
handleEmptyTrash,
} = useTrashDialog({
restoreTrashedProject,
deleteTrashedProject,
emptyTrash,
trashedProjects,
});
// Spec regeneration events
useSpecRegeneration({
creatingSpecProjectPath,
setupProjectPath,
setSpecCreatingForProject,
setShowSetupDialog,
setProjectOverview,
setSetupProjectPath,
setNewProjectName,
setNewProjectPath,
});
/**
* Opens the system folder selection dialog and initializes the selected project.
* Used by both the 'O' keyboard shortcut and the folder icon button.
*/
const handleOpenFolder = useCallback(async () => {
const api = getElectronAPI();
const result = await api.openDirectory();
if (!result.canceled && result.filePaths[0]) {
const path = result.filePaths[0];
// Extract folder name from path (works on both Windows and Mac/Linux)
const name = path.split(/[/\\]/).filter(Boolean).pop() || 'Untitled Project';
try {
// Check if this is a brand new project (no .automaker directory)
const hadAutomakerDir = await hasAutomakerDir(path);
// Initialize the .automaker directory structure
const initResult = await initializeProject(path);
if (!initResult.success) {
toast.error('Failed to initialize project', {
description: initResult.error || 'Unknown error occurred',
});
return;
}
// Upsert project and set as current (handles both create and update cases)
// Theme preservation is handled by the store action
const trashedProject = trashedProjects.find((p) => p.path === path);
const effectiveTheme =
(trashedProject?.theme as ThemeMode | undefined) ||
(currentProject?.theme as ThemeMode | undefined) ||
globalTheme;
upsertAndSetCurrentProject(path, name, effectiveTheme);
// Check if app_spec.txt exists
const specExists = await hasAppSpec(path);
if (!hadAutomakerDir && !specExists) {
// This is a brand new project - show setup dialog
setSetupProjectPath(path);
setShowSetupDialog(true);
toast.success('Project opened', {
description: `Opened ${name}. Let's set up your app specification!`,
});
} else if (initResult.createdFiles && initResult.createdFiles.length > 0) {
toast.success(initResult.isNewProject ? 'Project initialized' : 'Project updated', {
description: `Set up ${initResult.createdFiles.length} file(s) in .automaker`,
});
} else {
toast.success('Project opened', {
description: `Opened ${name}`,
});
}
} catch (error) {
console.error('[Sidebar] Failed to open project:', error);
toast.error('Failed to open project', {
description: error instanceof Error ? error.message : 'Unknown error',
});
}
}
}, [trashedProjects, upsertAndSetCurrentProject, currentProject, globalTheme]);
// Navigation sections and keyboard shortcuts (defined after handlers)
const { navSections, navigationShortcuts } = useNavigation({
shortcuts,
hideSpecEditor,
hideContext,
hideTerminal,
hideAiProfiles,
currentProject,
projects,
projectHistory,
navigate,
toggleSidebar,
handleOpenFolder,
setIsProjectPickerOpen,
cyclePrevProject,
cycleNextProject,
unviewedValidationsCount,
});
// Register keyboard shortcuts
useKeyboardShortcuts(navigationShortcuts);
const isActiveRoute = (id: string) => {
// Map view IDs to route paths
const routePath = id === 'welcome' ? '/' : `/${id}`;
return location.pathname === routePath;
};
return ( return (
<aside <aside className="w-[260px] flex-shrink-0 flex flex-col glass-sidebar z-30 relative h-full">
className={cn( {/* Logo */}
'flex-shrink-0 flex flex-col z-30 relative', <div className="h-16 flex items-center px-6 gap-3 flex-shrink-0">
// Glass morphism background with gradient <div className="text-brand-cyan relative flex items-center justify-center">
'bg-gradient-to-b from-sidebar/95 via-sidebar/85 to-sidebar/90 backdrop-blur-2xl', <div className="absolute inset-0 bg-brand-cyan blur-md opacity-30"></div>
// Premium border with subtle glow <Code2 className="w-6 h-6 relative z-10" />
'border-r border-border/60 shadow-[1px_0_20px_-5px_rgba(0,0,0,0.1)]', </div>
// Smooth width transition <span className="text-white font-bold text-lg tracking-tight">automaker.</span>
'transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]', <button className="ml-auto text-slate-600 hover:text-white transition">
sidebarOpen ? 'w-16 lg:w-72' : 'w-16' <PanelLeft className="w-4 h-4" />
)} </button>
data-testid="sidebar"
>
<CollapseToggleButton
sidebarOpen={sidebarOpen}
toggleSidebar={toggleSidebar}
shortcut={shortcuts.toggleSidebar}
/>
<div className="flex-1 flex flex-col overflow-hidden">
<SidebarHeader sidebarOpen={sidebarOpen} navigate={navigate} />
{/* Project Actions - Moved above project selector */}
{sidebarOpen && (
<ProjectActions
setShowNewProjectModal={setShowNewProjectModal}
handleOpenFolder={handleOpenFolder}
setShowTrashDialog={setShowTrashDialog}
trashedProjects={trashedProjects}
shortcuts={{ openProject: shortcuts.openProject }}
/>
)}
<ProjectSelectorWithOptions
sidebarOpen={sidebarOpen}
isProjectPickerOpen={isProjectPickerOpen}
setIsProjectPickerOpen={setIsProjectPickerOpen}
setShowDeleteProjectDialog={setShowDeleteProjectDialog}
/>
<SidebarNavigation
currentProject={currentProject}
sidebarOpen={sidebarOpen}
navSections={navSections}
isActiveRoute={isActiveRoute}
navigate={navigate}
/>
</div> </div>
<SidebarFooter {/* Top Actions */}
sidebarOpen={sidebarOpen} <div className="px-5 pb-6 space-y-4 flex-shrink-0">
isActiveRoute={isActiveRoute} <div className="grid grid-cols-4 gap-2">
navigate={navigate} <button className="col-span-2 bg-dark-850/60 hover:bg-dark-700 text-slate-200 py-2 px-3 rounded-lg border border-white/5 flex items-center justify-center gap-2 transition text-xs font-medium shadow-lg shadow-black/20 group">
hideWiki={hideWiki} <Plus className="w-3.5 h-3.5 group-hover:text-brand-cyan transition-colors" /> New
hideRunningAgents={hideRunningAgents} </button>
runningAgentsCount={runningAgentsCount} <button className="col-span-1 bg-dark-850/60 hover:bg-dark-700 text-slate-400 hover:text-white py-2 rounded-lg border border-white/5 flex items-center justify-center transition">
shortcuts={{ settings: shortcuts.settings }} <Folder className="w-3.5 h-3.5" />
/> <span className="ml-1 text-[10px]">0</span>
<TrashDialog </button>
open={showTrashDialog} <button className="col-span-1 bg-dark-850/60 hover:bg-dark-700 text-slate-400 hover:text-white py-2 rounded-lg border border-white/5 flex items-center justify-center transition relative">
onOpenChange={setShowTrashDialog} <Bell className="w-3.5 h-3.5" />
trashedProjects={trashedProjects} <span className="absolute top-2 right-2.5 w-1.5 h-1.5 bg-brand-red rounded-full ring-2 ring-dark-850"></span>
activeTrashId={activeTrashId} </button>
handleRestoreProject={handleRestoreProject} </div>
handleDeleteProjectFromDisk={handleDeleteProjectFromDisk}
deleteTrashedProject={deleteTrashedProject}
handleEmptyTrash={handleEmptyTrash}
isEmptyingTrash={isEmptyingTrash}
/>
{/* New Project Setup Dialog */} {/* Project Selector */}
<CreateSpecDialog <div className="bg-dark-850/40 border border-white/5 rounded-xl p-1 flex items-center justify-between cursor-pointer hover:border-white/10 hover:bg-dark-850/60 transition group">
open={showSetupDialog} <div className="flex items-center gap-3 px-2 py-1.5">
onOpenChange={setShowSetupDialog} <FolderOpen className="w-4 h-4 text-brand-cyan group-hover:text-cyan-300 transition" />
projectOverview={projectOverview} <span className="text-white font-medium text-sm">test case 1</span>
onProjectOverviewChange={setProjectOverview} </div>
generateFeatures={generateFeatures} <div className="flex items-center gap-1 pr-1">
onGenerateFeaturesChange={setGenerateFeatures} <span className="w-5 h-5 rounded bg-dark-700 flex items-center justify-center text-[10px] text-slate-400 font-bold border border-white/5">
analyzeProject={analyzeProject} P
onAnalyzeProjectChange={setAnalyzeProject} </span>
featureCount={featureCount} <MoreVertical className="w-4 h-4 text-slate-500" />
onFeatureCountChange={setFeatureCount} </div>
onCreateSpec={handleCreateInitialSpec} </div>
onSkip={handleSkipSetup} </div>
isCreatingSpec={isCreatingSpec}
showSkipButton={true}
title="Set Up Your Project"
description="We didn't find an app_spec.txt file. Let us help you generate your app_spec.txt to help describe your project for our system. We'll analyze your project's tech stack and create a comprehensive specification."
/>
<OnboardingDialog {/* Navigation */}
open={showOnboardingDialog} <div className="flex-1 overflow-y-auto px-0 space-y-6 custom-scrollbar">
onOpenChange={setShowOnboardingDialog} {/* Project Section */}
newProjectName={newProjectName} <div>
onSkip={handleOnboardingSkip} <h3 className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-2 px-6 font-mono">
onGenerateSpec={handleOnboardingGenerateSpec} Project
</h3>
<nav className="space-y-0.5">
<NavItem
to="/"
icon={<LayoutGrid className="w-4 h-4" />}
label="Kanban Board"
shortcut="L"
isActive={location.pathname === '/' || location.pathname === '/board'}
/> />
<NavItem
to="/agents"
icon={<Bot className="w-4 h-4" />}
label="Agent Runner"
shortcut="A"
isActive={location.pathname.startsWith('/agents')}
/>
</nav>
</div>
{/* Delete Project Confirmation Dialog */} {/* Tools Section */}
<DeleteProjectDialog <div>
open={showDeleteProjectDialog} <h3 className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-2 px-6 font-mono">
onOpenChange={setShowDeleteProjectDialog} Tools
project={currentProject} </h3>
onConfirm={moveProjectToTrash} <nav className="space-y-0.5">
<NavItem
to="/spec"
icon={<FileJson className="w-4 h-4" />}
label="Spec Editor"
shortcut="D"
isActive={location.pathname.startsWith('/spec')}
/> />
<NavItem
to="/context"
icon={<BookOpen className="w-4 h-4" />}
label="Context"
shortcut="C"
isActive={location.pathname.startsWith('/context')}
/>
<NavItem
to="/profiles"
icon={<UserCircle className="w-4 h-4" />}
label="AI Profiles"
shortcut="H"
isActive={location.pathname.startsWith('/profiles')}
/>
<NavItem
to="/terminal"
icon={<TerminalSquare className="w-4 h-4" />}
label="Terminal"
shortcut="T"
isActive={location.pathname.startsWith('/terminal')}
/>
</nav>
</div>
</div>
{/* New Project Modal */} {/* Footer */}
<NewProjectModal <div className="p-4 border-t border-white/5 space-y-1 bg-dark-900/30 flex-shrink-0 backdrop-blur-sm">
open={showNewProjectModal} <Link
onOpenChange={setShowNewProjectModal} to="/wiki"
onCreateBlankProject={handleCreateBlankProject} className="flex items-center gap-3 px-3 py-2 rounded-lg text-slate-400 hover:text-white hover:bg-white/5 transition"
onCreateFromTemplate={handleCreateFromTemplate} >
onCreateFromCustomUrl={handleCreateFromCustomUrl} <Book className="w-4 h-4" />
isCreating={isCreatingProject} <span className="text-sm">Wiki</span>
/> </Link>
<Link
to="/running-agents"
className="flex items-center justify-between px-3 py-2 rounded-lg text-slate-400 hover:text-white hover:bg-white/5 transition"
>
<div className="flex items-center gap-3">
<Activity className="w-4 h-4 text-brand-cyan" />
<span className="text-sm">Running Agents</span>
</div>
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-brand-cyan text-[10px] text-black font-bold shadow-glow-cyan">
3
</span>
</Link>
<Link
to="/settings"
className="flex items-center justify-between px-3 py-2 rounded-lg text-slate-400 hover:text-white hover:bg-white/5 transition"
>
<div className="flex items-center gap-3">
<Settings className="w-4 h-4" />
<span className="text-sm">Settings</span>
</div>
<span className="text-[10px] bg-dark-700 text-slate-500 px-1.5 py-0.5 rounded font-mono border border-white/5">
S
</span>
</Link>
</div>
</aside> </aside>
); );
} }
function NavItem({
to,
icon,
label,
shortcut,
isActive,
}: {
to: string;
icon: React.ReactNode;
label: string;
shortcut: string;
isActive: boolean;
}) {
return (
<Link
to={to}
className={cn(
'flex items-center justify-between px-6 py-2.5 transition group border-l-[2px]',
isActive
? 'nav-item-active bg-gradient-to-r from-brand-cyan/10 to-transparent border-brand-cyan text-brand-cyan-hover'
: 'text-slate-400 hover:text-white hover:bg-white/5 border-transparent'
)}
>
<div className="flex items-center gap-3">
<span className={cn(isActive ? 'text-brand-cyan' : 'group-hover:text-slate-300')}>
{icon}
</span>
<span className="text-sm font-medium">{label}</span>
</div>
<span
className={cn(
'text-[10px] px-1.5 py-0.5 rounded font-mono border',
isActive
? 'bg-brand-cyan/10 text-brand-cyan border-brand-cyan/20'
: 'bg-dark-700 text-slate-500 border-white/5 group-hover:text-slate-300'
)}
>
{shortcut}
</span>
</Link>
);
}

View File

@@ -78,7 +78,6 @@ export function SidebarNavigation({
title={!sidebarOpen ? item.label : undefined} title={!sidebarOpen ? item.label : undefined}
data-testid={`nav-${item.id}`} data-testid={`nav-${item.id}`}
> >
<div className="relative">
<Icon <Icon
className={cn( className={cn(
'w-[18px] h-[18px] shrink-0 transition-all duration-200', 'w-[18px] h-[18px] shrink-0 transition-all duration-200',
@@ -87,20 +86,6 @@ export function SidebarNavigation({
: 'group-hover:text-brand-400 group-hover:scale-110' : 'group-hover:text-brand-400 group-hover:scale-110'
)} )}
/> />
{/* Count badge for collapsed state */}
{!sidebarOpen && item.count !== undefined && item.count > 0 && (
<span
className={cn(
'absolute -top-1.5 -right-1.5 flex items-center justify-center',
'min-w-4 h-4 px-1 text-[9px] font-bold rounded-full',
'bg-primary text-primary-foreground shadow-sm',
'animate-in fade-in zoom-in duration-200'
)}
>
{item.count > 99 ? '99' : item.count}
</span>
)}
</div>
<span <span
className={cn( className={cn(
'ml-3 font-medium text-sm flex-1 text-left', 'ml-3 font-medium text-sm flex-1 text-left',
@@ -109,21 +94,7 @@ export function SidebarNavigation({
> >
{item.label} {item.label}
</span> </span>
{/* Count badge */} {item.shortcut && sidebarOpen && (
{item.count !== undefined && item.count > 0 && sidebarOpen && (
<span
className={cn(
'hidden lg:flex items-center justify-center',
'min-w-5 h-5 px-1.5 text-[10px] font-bold rounded-full',
'bg-primary text-primary-foreground shadow-sm',
'animate-in fade-in zoom-in duration-200'
)}
data-testid={`count-${item.id}`}
>
{item.count > 99 ? '99+' : item.count}
</span>
)}
{item.shortcut && sidebarOpen && !item.count && (
<span <span
className={cn( className={cn(
'hidden lg:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200', 'hidden lg:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200',

View File

@@ -10,4 +10,3 @@ export { useProjectCreation } from './use-project-creation';
export { useSetupDialog } from './use-setup-dialog'; export { useSetupDialog } from './use-setup-dialog';
export { useTrashDialog } from './use-trash-dialog'; export { useTrashDialog } from './use-trash-dialog';
export { useProjectTheme } from './use-project-theme'; export { useProjectTheme } from './use-project-theme';
export { useUnviewedValidations } from './use-unviewed-validations';

View File

@@ -44,8 +44,6 @@ interface UseNavigationProps {
setIsProjectPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void; setIsProjectPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
cyclePrevProject: () => void; cyclePrevProject: () => void;
cycleNextProject: () => void; cycleNextProject: () => void;
/** Count of unviewed validations to show on GitHub Issues nav item */
unviewedValidationsCount?: number;
} }
export function useNavigation({ export function useNavigation({
@@ -63,7 +61,6 @@ export function useNavigation({
setIsProjectPickerOpen, setIsProjectPickerOpen,
cyclePrevProject, cyclePrevProject,
cycleNextProject, cycleNextProject,
unviewedValidationsCount,
}: UseNavigationProps) { }: UseNavigationProps) {
// Track if current project has a GitHub remote // Track if current project has a GitHub remote
const [hasGitHubRemote, setHasGitHubRemote] = useState(false); const [hasGitHubRemote, setHasGitHubRemote] = useState(false);
@@ -172,7 +169,6 @@ export function useNavigation({
id: 'github-issues', id: 'github-issues',
label: 'Issues', label: 'Issues',
icon: CircleDot, icon: CircleDot,
count: unviewedValidationsCount,
}, },
{ {
id: 'github-prs', id: 'github-prs',
@@ -184,15 +180,7 @@ export function useNavigation({
} }
return sections; return sections;
}, [ }, [shortcuts, hideSpecEditor, hideContext, hideTerminal, hideAiProfiles, hasGitHubRemote]);
shortcuts,
hideSpecEditor,
hideContext,
hideTerminal,
hideAiProfiles,
hasGitHubRemote,
unviewedValidationsCount,
]);
// Build keyboard shortcuts for navigation // Build keyboard shortcuts for navigation
const navigationShortcuts: KeyboardShortcut[] = useMemo(() => { const navigationShortcuts: KeyboardShortcut[] = useMemo(() => {

View File

@@ -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 };
}

View File

@@ -11,8 +11,6 @@ export interface NavItem {
label: string; label: string;
icon: React.ComponentType<{ className?: string }>; icon: React.ComponentType<{ className?: string }>;
shortcut?: string; shortcut?: string;
/** Optional count badge to display next to the nav item */
count?: number;
} }
export interface SortableProjectItemProps { export interface SortableProjectItemProps {

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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';

View 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';

View File

@@ -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>
);
}

View File

@@ -1,6 +1,6 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { useAppStore } from '@/store/app-store'; import { useAppStore } from '@/store/app-store';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
@@ -18,6 +18,9 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { getElectronAPI } from '@/lib/electron'; import { getElectronAPI } from '@/lib/electron';
import { TopHeader } from '@/components/layout/top-header';
import { GlassPanel } from '@/components/ui/glass-panel';
import { GlassCard } from '@/components/ui/glass-card';
interface ToolResult { interface ToolResult {
success: boolean; success: boolean;
@@ -190,12 +193,20 @@ export function AgentToolsView() {
} }
return ( return (
<div className="flex-1 flex flex-col overflow-hidden content-bg" data-testid="agent-tools-view"> <div className="flex-1 flex flex-col overflow-hidden bg-background">
<TopHeader />
<div className="flex-1 flex flex-col overflow-hidden p-6 pt-0">
<GlassPanel className="flex-1 flex flex-col overflow-hidden relative shadow-2xl bg-black/40 backdrop-blur-xl border-white/5">
<div className="flex-1 flex flex-col overflow-hidden p-6">
{/* Header */} {/* Header */}
<div className="flex items-center gap-3 p-4 border-b border-border bg-glass backdrop-blur-md"> <div className="flex items-center gap-4 mb-6">
<Wrench className="w-5 h-5 text-primary" /> <div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-purple-500/20 to-blue-600/20 border border-purple-500/30 flex items-center justify-center shadow-inner shadow-purple-500/20">
<Wrench className="w-6 h-6 text-purple-400" />
</div>
<div> <div>
<h1 className="text-xl font-bold">Agent Tools</h1> <h1 className="text-2xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-white to-white/70">
Agent Tools
</h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Test file system and terminal tools for {currentProject.name} Test file system and terminal tools for {currentProject.name}
</p> </p>
@@ -203,18 +214,24 @@ export function AgentToolsView() {
</div> </div>
{/* Tools Grid */} {/* Tools Grid */}
<div className="flex-1 overflow-y-auto p-4"> <div className="flex-1 overflow-y-auto">
<div className="grid gap-6 md:grid-cols-1 lg:grid-cols-2 xl:grid-cols-3"> <div className="grid gap-6 md:grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 max-w-7xl">
{/* Read File Tool */} {/* Read File Tool */}
<Card data-testid="read-file-tool"> <GlassCard
<CardHeader> className="flex flex-col gap-4 bg-white/5 border-white/10"
<div className="flex items-center gap-2"> data-testid="read-file-tool"
<File className="w-5 h-5 text-blue-500" /> >
<CardTitle className="text-lg">Read File</CardTitle> <div className="flex items-center gap-3 pb-2 border-b border-white/5">
<div className="p-2 rounded-lg bg-blue-500/10">
<File className="w-5 h-5 text-blue-400" />
</div> </div>
<CardDescription>Agent requests to read a file from the filesystem</CardDescription> <div>
</CardHeader> <h3 className="font-semibold text-foreground">Read File</h3>
<CardContent className="space-y-4"> <p className="text-xs text-muted-foreground">Read from filesystem</p>
</div>
</div>
<div className="space-y-4 flex-1">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="read-file-path">File Path</Label> <Label htmlFor="read-file-path">File Path</Label>
<Input <Input
@@ -223,12 +240,13 @@ export function AgentToolsView() {
value={readFilePath} value={readFilePath}
onChange={(e) => setReadFilePath(e.target.value)} onChange={(e) => setReadFilePath(e.target.value)}
data-testid="read-file-path-input" data-testid="read-file-path-input"
className="bg-black/20 border-white/10 focus:border-blue-500/50"
/> />
</div> </div>
<Button <Button
onClick={handleReadFile} onClick={handleReadFile}
disabled={isReadingFile || !readFilePath.trim()} disabled={isReadingFile || !readFilePath.trim()}
className="w-full" className="w-full bg-blue-600/20 hover:bg-blue-600/30 text-blue-400 border border-blue-500/30"
data-testid="read-file-button" data-testid="read-file-button"
> >
{isReadingFile ? ( {isReadingFile ? (
@@ -248,41 +266,45 @@ export function AgentToolsView() {
{readFileResult && ( {readFileResult && (
<div <div
className={cn( className={cn(
'p-3 rounded-md border', 'p-3 rounded-lg border text-xs',
readFileResult.success readFileResult.success
? 'bg-green-500/10 border-green-500/20' ? 'bg-green-500/10 border-green-500/20 text-green-300'
: 'bg-red-500/10 border-red-500/20' : 'bg-red-500/10 border-red-500/20 text-red-300'
)} )}
data-testid="read-file-result" data-testid="read-file-result"
> >
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2 font-medium">
{readFileResult.success ? ( {readFileResult.success ? (
<CheckCircle className="w-4 h-4 text-green-500" /> <CheckCircle className="w-4 h-4 text-green-400" />
) : ( ) : (
<XCircle className="w-4 h-4 text-red-500" /> <XCircle className="w-4 h-4 text-red-400" />
)} )}
<span className="text-sm font-medium"> <span>{readFileResult.success ? 'Success' : 'Failed'}</span>
{readFileResult.success ? 'Success' : 'Failed'}
</span>
</div> </div>
<pre className="text-xs overflow-auto max-h-40 whitespace-pre-wrap"> <pre className="overflow-auto max-h-40 whitespace-pre-wrap font-mono bg-black/30 p-2 rounded border border-white/5">
{readFileResult.success ? readFileResult.output : readFileResult.error} {readFileResult.success ? readFileResult.output : readFileResult.error}
</pre> </pre>
</div> </div>
)} )}
</CardContent> </div>
</Card> </GlassCard>
{/* Write File Tool */} {/* Write File Tool */}
<Card data-testid="write-file-tool"> <GlassCard
<CardHeader> className="flex flex-col gap-4 bg-white/5 border-white/10"
<div className="flex items-center gap-2"> data-testid="write-file-tool"
<Pencil className="w-5 h-5 text-green-500" /> >
<CardTitle className="text-lg">Write File</CardTitle> <div className="flex items-center gap-3 pb-2 border-b border-white/5">
<div className="p-2 rounded-lg bg-green-500/10">
<Pencil className="w-5 h-5 text-green-400" />
</div> </div>
<CardDescription>Agent requests to write content to a file</CardDescription> <div>
</CardHeader> <h3 className="font-semibold text-foreground">Write File</h3>
<CardContent className="space-y-4"> <p className="text-xs text-muted-foreground">Write to filesystem</p>
</div>
</div>
<div className="space-y-4 flex-1">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="write-file-path">File Path</Label> <Label htmlFor="write-file-path">File Path</Label>
<Input <Input
@@ -291,6 +313,7 @@ export function AgentToolsView() {
value={writeFilePath} value={writeFilePath}
onChange={(e) => setWriteFilePath(e.target.value)} onChange={(e) => setWriteFilePath(e.target.value)}
data-testid="write-file-path-input" data-testid="write-file-path-input"
className="bg-black/20 border-white/10 focus:border-green-500/50"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -300,14 +323,14 @@ export function AgentToolsView() {
placeholder="File content..." placeholder="File content..."
value={writeFileContent} value={writeFileContent}
onChange={(e) => setWriteFileContent(e.target.value)} onChange={(e) => setWriteFileContent(e.target.value)}
className="w-full min-h-[100px] px-3 py-2 text-sm rounded-md border border-input bg-background resize-y" className="w-full min-h-[100px] px-3 py-2 text-sm rounded-md border border-white/10 bg-black/20 resize-y focus:outline-none focus:ring-1 focus:ring-green-500/50"
data-testid="write-file-content-input" data-testid="write-file-content-input"
/> />
</div> </div>
<Button <Button
onClick={handleWriteFile} onClick={handleWriteFile}
disabled={isWritingFile || !writeFilePath.trim() || !writeFileContent.trim()} disabled={isWritingFile || !writeFilePath.trim() || !writeFileContent.trim()}
className="w-full" className="w-full bg-green-600/20 hover:bg-green-600/30 text-green-400 border border-green-500/30"
data-testid="write-file-button" data-testid="write-file-button"
> >
{isWritingFile ? ( {isWritingFile ? (
@@ -327,41 +350,45 @@ export function AgentToolsView() {
{writeFileResult && ( {writeFileResult && (
<div <div
className={cn( className={cn(
'p-3 rounded-md border', 'p-3 rounded-lg border text-xs',
writeFileResult.success writeFileResult.success
? 'bg-green-500/10 border-green-500/20' ? 'bg-green-500/10 border-green-500/20 text-green-300'
: 'bg-red-500/10 border-red-500/20' : 'bg-red-500/10 border-red-500/20 text-red-300'
)} )}
data-testid="write-file-result" data-testid="write-file-result"
> >
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2 font-medium">
{writeFileResult.success ? ( {writeFileResult.success ? (
<CheckCircle className="w-4 h-4 text-green-500" /> <CheckCircle className="w-4 h-4 text-green-400" />
) : ( ) : (
<XCircle className="w-4 h-4 text-red-500" /> <XCircle className="w-4 h-4 text-red-400" />
)} )}
<span className="text-sm font-medium"> <span>{writeFileResult.success ? 'Success' : 'Failed'}</span>
{writeFileResult.success ? 'Success' : 'Failed'}
</span>
</div> </div>
<pre className="text-xs overflow-auto max-h-40 whitespace-pre-wrap"> <pre className="overflow-auto max-h-40 whitespace-pre-wrap font-mono bg-black/30 p-2 rounded border border-white/5">
{writeFileResult.success ? writeFileResult.output : writeFileResult.error} {writeFileResult.success ? writeFileResult.output : writeFileResult.error}
</pre> </pre>
</div> </div>
)} )}
</CardContent> </div>
</Card> </GlassCard>
{/* Terminal Tool */} {/* Terminal Tool */}
<Card data-testid="terminal-tool"> <GlassCard
<CardHeader> className="flex flex-col gap-4 bg-white/5 border-white/10"
<div className="flex items-center gap-2"> data-testid="terminal-tool"
<Terminal className="w-5 h-5 text-purple-500" /> >
<CardTitle className="text-lg">Run Terminal</CardTitle> <div className="flex items-center gap-3 pb-2 border-b border-white/5">
<div className="p-2 rounded-lg bg-purple-500/10">
<Terminal className="w-5 h-5 text-purple-400" />
</div> </div>
<CardDescription>Agent requests to execute a terminal command</CardDescription> <div>
</CardHeader> <h3 className="font-semibold text-foreground">Run Terminal</h3>
<CardContent className="space-y-4"> <p className="text-xs text-muted-foreground">Execute shell commands</p>
</div>
</div>
<div className="space-y-4 flex-1">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="terminal-command">Command</Label> <Label htmlFor="terminal-command">Command</Label>
<Input <Input
@@ -370,12 +397,13 @@ export function AgentToolsView() {
value={terminalCommand} value={terminalCommand}
onChange={(e) => setTerminalCommand(e.target.value)} onChange={(e) => setTerminalCommand(e.target.value)}
data-testid="terminal-command-input" data-testid="terminal-command-input"
className="bg-black/20 border-white/10 focus:border-purple-500/50 font-mono text-sm"
/> />
</div> </div>
<Button <Button
onClick={handleRunCommand} onClick={handleRunCommand}
disabled={isRunningCommand || !terminalCommand.trim()} disabled={isRunningCommand || !terminalCommand.trim()}
className="w-full" className="w-full bg-purple-600/20 hover:bg-purple-600/30 text-purple-400 border border-purple-500/30"
data-testid="run-terminal-button" data-testid="run-terminal-button"
> >
{isRunningCommand ? ( {isRunningCommand ? (
@@ -395,42 +423,41 @@ export function AgentToolsView() {
{terminalResult && ( {terminalResult && (
<div <div
className={cn( className={cn(
'p-3 rounded-md border', 'p-3 rounded-lg border text-xs',
terminalResult.success terminalResult.success
? 'bg-green-500/10 border-green-500/20' ? 'bg-green-500/10 border-green-500/20 text-green-300'
: 'bg-red-500/10 border-red-500/20' : 'bg-red-500/10 border-red-500/20 text-red-300'
)} )}
data-testid="terminal-result" data-testid="terminal-result"
> >
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2 font-medium">
{terminalResult.success ? ( {terminalResult.success ? (
<CheckCircle className="w-4 h-4 text-green-500" /> <CheckCircle className="w-4 h-4 text-green-400" />
) : ( ) : (
<XCircle className="w-4 h-4 text-red-500" /> <XCircle className="w-4 h-4 text-red-400" />
)} )}
<span className="text-sm font-medium"> <span>{terminalResult.success ? 'Success' : 'Failed'}</span>
{terminalResult.success ? 'Success' : 'Failed'}
</span>
</div> </div>
<pre className="text-xs overflow-auto max-h-40 whitespace-pre-wrap font-mono bg-black/50 text-green-400 p-2 rounded"> <pre className="overflow-auto max-h-40 whitespace-pre-wrap font-mono bg-black/30 p-2 rounded border border-white/5">
$ {terminalCommand} $ {terminalCommand}
{'\n'} {'\n'}
{terminalResult.success ? terminalResult.output : terminalResult.error} {terminalResult.success ? terminalResult.output : terminalResult.error}
</pre> </pre>
</div> </div>
)} )}
</CardContent> </div>
</Card> </GlassCard>
</div> </div>
{/* Tool Log Section */} {/* Tool Log Section */}
<Card className="mt-6" data-testid="tool-log"> <GlassCard className="mt-6 bg-white/5 border-white/10" data-testid="tool-log">
<CardHeader> <div className="flex flex-col gap-2">
<CardTitle className="text-lg">Tool Execution Log</CardTitle> <h3 className="font-semibold text-foreground">Tool Execution Log</h3>
<CardDescription>View agent tool requests and responses</CardDescription> <p className="text-sm text-muted-foreground">
</CardHeader> View agent tool requests and responses
<CardContent> </p>
<div className="space-y-2 text-sm">
<div className="mt-4 space-y-2 text-sm bg-black/20 p-4 rounded-lg border border-white/5">
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Open your browser&apos;s developer console to see detailed agent tool logs. Open your browser&apos;s developer console to see detailed agent tool logs.
</p> </p>
@@ -440,8 +467,11 @@ export function AgentToolsView() {
<li>Run Terminal - Agent executes shell commands</li> <li>Run Terminal - Agent executes shell commands</li>
</ul> </ul>
</div> </div>
</CardContent> </div>
</Card> </GlassCard>
</div>
</div>
</GlassPanel>
</div> </div>
</div> </div>
); );

View File

@@ -50,6 +50,8 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { CLAUDE_MODELS } from '@/components/views/board-view/shared/model-constants'; import { CLAUDE_MODELS } from '@/components/views/board-view/shared/model-constants';
import { TopHeader } from '@/components/layout/top-header';
import { GlassPanel } from '@/components/ui/glass-panel';
export function AgentView() { export function AgentView() {
const { currentProject, setLastSelectedSession, getLastSelectedSession } = useAppStore(); const { currentProject, setLastSelectedSession, getLastSelectedSession } = useAppStore();
@@ -491,10 +493,14 @@ export function AgentView() {
: messages; : messages;
return ( return (
<div className="flex-1 flex overflow-hidden bg-background" data-testid="agent-view"> <div className="flex flex-col h-full w-full overflow-hidden" data-testid="agent-view">
<TopHeader />
<div className="flex-1 flex overflow-hidden p-4 pt-0 gap-4">
{/* Session Manager Sidebar */} {/* Session Manager Sidebar */}
{showSessionManager && currentProject && ( {showSessionManager && currentProject && (
<div className="w-80 border-r border-border flex-shrink-0 bg-card/50"> <div className="w-80 flex-shrink-0">
<GlassPanel className="h-full flex flex-col overflow-hidden bg-black/40 backdrop-blur-xl border-white/5">
<SessionManager <SessionManager
currentSessionId={currentSessionId} currentSessionId={currentSessionId}
onSelectSession={handleSelectSession} onSelectSession={handleSelectSession}
@@ -502,19 +508,21 @@ export function AgentView() {
isCurrentSessionThinking={isProcessing} isCurrentSessionThinking={isProcessing}
onQuickCreateRef={quickCreateSessionRef} onQuickCreateRef={quickCreateSessionRef}
/> />
</GlassPanel>
</div> </div>
)} )}
{/* Chat Area */} {/* Chat Area */}
<div className="flex-1 flex flex-col overflow-hidden"> <div className="flex-1 flex flex-col overflow-hidden min-w-0">
<GlassPanel className="h-full flex flex-col overflow-hidden shadow-2xl relative">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border bg-card/50 backdrop-blur-sm"> <div className="flex items-center justify-between px-4 py-3 border-b border-white/10 bg-white/5 backdrop-blur-md z-20">
<div className="flex items-center gap-4"> <div className="flex items-center gap-3">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => setShowSessionManager(!showSessionManager)} onClick={() => setShowSessionManager(!showSessionManager)}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground" className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground hover:bg-white/10"
> >
{showSessionManager ? ( {showSessionManager ? (
<PanelLeftClose className="w-4 h-4" /> <PanelLeftClose className="w-4 h-4" />
@@ -522,27 +530,33 @@ export function AgentView() {
<PanelLeft className="w-4 h-4" /> <PanelLeft className="w-4 h-4" />
)} )}
</Button> </Button>
<div className="w-9 h-9 rounded-xl bg-primary/10 flex items-center justify-center"> <div className="w-8 h-8 rounded-lg bg-gradient-to-br from-cyan-500/20 to-blue-600/20 border border-cyan-500/30 flex items-center justify-center shadow-inner shadow-cyan-500/20">
<Bot className="w-5 h-5 text-primary" /> <Bot className="w-4 h-4 text-cyan-400" />
</div> </div>
<div> <div>
<h1 className="text-lg font-semibold text-foreground">AI Agent</h1> <h1 className="text-sm font-semibold text-foreground flex items-center gap-2">
<p className="text-sm text-muted-foreground"> AI Agent
{currentSessionId && !isConnected && (
<span className="text-[10px] bg-yellow-500/20 text-yellow-500 px-1.5 py-0.5 rounded-full animate-pulse">
Connecting...
</span>
)}
</h1>
<p className="text-xs text-muted-foreground truncate max-w-[200px]">
{currentProject.name} {currentProject.name}
{currentSessionId && !isConnected && ' - Connecting...'}
</p> </p>
</div> </div>
</div> </div>
{/* Status indicators & actions */} {/* Status indicators & actions */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-2">
{/* Model Selector */} {/* Model Selector */}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="h-8 gap-1.5 text-xs font-medium" className="h-7 gap-1.5 text-xs font-medium bg-black/20 border-white/10 hover:bg-white/5 hover:text-cyan-400"
disabled={isProcessing} disabled={isProcessing}
data-testid="model-selector" data-testid="model-selector"
> >
@@ -554,17 +568,25 @@ export function AgentView() {
<ChevronDown className="w-3 h-3 opacity-50" /> <ChevronDown className="w-3 h-3 opacity-50" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48"> <DropdownMenuContent
align="end"
className="w-56 bg-zinc-950/95 border-white/10 backdrop-blur-xl"
>
{CLAUDE_MODELS.map((model) => ( {CLAUDE_MODELS.map((model) => (
<DropdownMenuItem <DropdownMenuItem
key={model.id} key={model.id}
onClick={() => setSelectedModel(model.id)} onClick={() => setSelectedModel(model.id)}
className={cn('cursor-pointer', selectedModel === model.id && 'bg-accent')} className={cn(
'cursor-pointer focus:bg-white/10',
selectedModel === model.id && 'bg-cyan-500/10 text-cyan-400'
)}
data-testid={`model-option-${model.id}`} data-testid={`model-option-${model.id}`}
> >
<div className="flex flex-col"> <div className="flex flex-col gap-0.5">
<span className="font-medium">{model.label}</span> <span className="font-medium text-xs">{model.label}</span>
<span className="text-xs text-muted-foreground">{model.description}</span> <span className="text-[10px] text-muted-foreground">
{model.description}
</span>
</div> </div>
</DropdownMenuItem> </DropdownMenuItem>
))} ))}
@@ -572,24 +594,26 @@ export function AgentView() {
</DropdownMenu> </DropdownMenu>
{currentTool && ( {currentTool && (
<div className="flex items-center gap-2 text-xs text-muted-foreground bg-muted/50 px-3 py-1.5 rounded-full border border-border"> <div className="flex items-center gap-1.5 text-[10px] text-cyan-400 bg-cyan-950/40 px-2.5 py-1 rounded-full border border-cyan-500/30 shadow-sm shadow-cyan-900/20 animate-in fade-in zoom-in-95 duration-300">
<Wrench className="w-3 h-3 text-primary" /> <Wrench className="w-3 h-3" />
<span className="font-medium">{currentTool}</span> <span className="font-medium">{currentTool}</span>
</div> </div>
)} )}
{agentError && ( {agentError && (
<span className="text-xs text-destructive font-medium">{agentError}</span> <span className="text-xs text-red-400 font-medium bg-red-950/30 px-2 py-0.5 rounded border border-red-500/30">
{agentError}
</span>
)} )}
{currentSessionId && messages.length > 0 && ( {currentSessionId && messages.length > 0 && (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="icon"
onClick={handleClearChat} onClick={handleClearChat}
disabled={isProcessing} disabled={isProcessing}
className="text-muted-foreground hover:text-foreground" className="h-7 w-7 text-muted-foreground hover:text-red-400 hover:bg-red-500/10"
title="Clear Chat"
> >
<Trash2 className="w-4 h-4 mr-2" /> <Trash2 className="w-4 h-4" />
Clear
</Button> </Button>
)} )}
</div> </div>
@@ -605,7 +629,9 @@ export function AgentView() {
<div className="w-16 h-16 rounded-2xl bg-muted/50 flex items-center justify-center mx-auto mb-6"> <div className="w-16 h-16 rounded-2xl bg-muted/50 flex items-center justify-center mx-auto mb-6">
<Bot className="w-8 h-8 text-muted-foreground" /> <Bot className="w-8 h-8 text-muted-foreground" />
</div> </div>
<h2 className="text-lg font-semibold mb-3 text-foreground">No Session Selected</h2> <h2 className="text-lg font-semibold mb-3 text-foreground">
No Session Selected
</h2>
<p className="text-sm text-muted-foreground mb-6 leading-relaxed"> <p className="text-sm text-muted-foreground mb-6 leading-relaxed">
Create or select a session to start chatting with the AI agent Create or select a session to start chatting with the AI agent
</p> </p>
@@ -664,7 +690,9 @@ export function AgentView() {
{message.content} {message.content}
</Markdown> </Markdown>
) : ( ) : (
<p className="text-sm whitespace-pre-wrap leading-relaxed">{message.content}</p> <p className="text-sm whitespace-pre-wrap leading-relaxed">
{message.content}
</p>
)} )}
{/* Display attached images for user messages */} {/* Display attached images for user messages */}
@@ -765,7 +793,8 @@ export function AgentView() {
)} )}
{/* Selected Files Preview - only show when ImageDropZone is hidden to avoid duplicate display */} {/* Selected Files Preview - only show when ImageDropZone is hidden to avoid duplicate display */}
{(selectedImages.length > 0 || selectedTextFiles.length > 0) && !showImageDropZone && ( {(selectedImages.length > 0 || selectedTextFiles.length > 0) &&
!showImageDropZone && (
<div className="mb-4 space-y-2"> <div className="mb-4 space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-xs font-medium text-foreground"> <p className="text-xs font-medium text-foreground">
@@ -869,7 +898,9 @@ export function AgentView() {
<Input <Input
ref={inputRef} ref={inputRef}
placeholder={ placeholder={
isDragOver ? 'Drop your files here...' : 'Describe what you want to build...' isDragOver
? 'Drop your files here...'
: 'Describe what you want to build...'
} }
value={input} value={input}
onChange={(e) => setInput(e.target.value)} onChange={(e) => setInput(e.target.value)}
@@ -948,11 +979,15 @@ export function AgentView() {
{/* Keyboard hint */} {/* Keyboard hint */}
<p className="text-[11px] text-muted-foreground mt-2 text-center"> <p className="text-[11px] text-muted-foreground mt-2 text-center">
Press{' '} Press{' '}
<kbd className="px-1.5 py-0.5 bg-muted rounded text-[10px] font-medium">Enter</kbd> to <kbd className="px-1.5 py-0.5 bg-muted rounded text-[10px] font-medium">
send Enter
</kbd>{' '}
to send
</p> </p>
</div> </div>
)} )}
</GlassPanel>
</div>
</div> </div>
</div> </div>
); );

View File

@@ -17,7 +17,8 @@ import { useAutoMode } from '@/hooks/use-auto-mode';
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts'; import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
import { useWindowState } from '@/hooks/use-window-state'; import { useWindowState } from '@/hooks/use-window-state';
// Board-view specific imports // Board-view specific imports
import { BoardHeader } from './board-view/board-header'; import { TopHeader } from '@/components/layout/top-header';
// BoardHeader removed
import { BoardSearchBar } from './board-view/board-search-bar'; import { BoardSearchBar } from './board-view/board-search-bar';
import { BoardControls } from './board-view/board-controls'; import { BoardControls } from './board-view/board-controls';
import { KanbanBoard } from './board-view/kanban-board'; import { KanbanBoard } from './board-view/kanban-board';
@@ -263,9 +264,9 @@ export function BoardView() {
// Calculate unarchived card counts per branch // Calculate unarchived card counts per branch
const branchCardCounts = useMemo(() => { const branchCardCounts = useMemo(() => {
return hookFeatures.reduce( return hookFeatures.reduce(
(counts, feature) => { (counts: Record<string, number>, feature) => {
if (feature.status !== 'completed') { if (feature.status !== 'completed') {
const branch = feature.branchName ?? 'main'; const branch = (feature.branchName as string) ?? 'main';
counts[branch] = (counts[branch] || 0) + 1; counts[branch] = (counts[branch] || 0) + 1;
} }
return counts; return counts;
@@ -918,27 +919,8 @@ export function BoardView() {
data-testid="board-view" data-testid="board-view"
> >
{/* Header */} {/* Header */}
<BoardHeader {/* Top Header */}
projectName={currentProject.name} <TopHeader />
maxConcurrency={maxConcurrency}
runningAgentsCount={runningAutoTasks.length}
onConcurrencyChange={setMaxConcurrency}
isAutoModeRunning={autoMode.isRunning}
onAutoModeToggle={(enabled) => {
if (enabled) {
autoMode.start();
} else {
autoMode.stop();
}
}}
onAddFeature={() => setShowAddDialog(true)}
addFeatureShortcut={{
key: shortcuts.addFeature,
action: () => setShowAddDialog(true),
description: 'Add new feature',
}}
isMounted={isMounted}
/>
{/* Worktree Panel */} {/* Worktree Panel */}
<WorktreePanel <WorktreePanel

View File

@@ -31,6 +31,7 @@ interface CardHeaderProps {
onEdit: () => void; onEdit: () => void;
onDelete: () => void; onDelete: () => void;
onViewOutput?: () => void; onViewOutput?: () => void;
hideActions?: boolean;
} }
export function CardHeaderSection({ export function CardHeaderSection({
@@ -40,6 +41,7 @@ export function CardHeaderSection({
onEdit, onEdit,
onDelete, onDelete,
onViewOutput, onViewOutput,
hideActions,
}: CardHeaderProps) { }: CardHeaderProps) {
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false); const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);

View File

@@ -2,7 +2,7 @@ import React, { memo } from 'react';
import { useSortable } from '@dnd-kit/sortable'; import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Card, CardContent } from '@/components/ui/card'; import { GlassCard } from '@/components/ui/glass-card';
import { Feature, useAppStore } from '@/store/app-store'; import { Feature, useAppStore } from '@/store/app-store';
import { CardBadges, PriorityBadges } from './card-badges'; import { CardBadges, PriorityBadges } from './card-badges';
import { CardHeaderSection } from './card-header'; import { CardHeaderSection } from './card-header';
@@ -56,10 +56,6 @@ export const KanbanCard = memo(function KanbanCard({
shortcutKey, shortcutKey,
contextContent, contextContent,
summary, summary,
opacity = 100,
glassmorphism = true,
cardBorderEnabled = true,
cardBorderOpacity = 100,
}: KanbanCardProps) { }: KanbanCardProps) {
const { useWorktrees } = useAppStore(); const { useWorktrees } = useAppStore();
@@ -68,6 +64,7 @@ export const KanbanCard = memo(function KanbanCard({
feature.status === 'waiting_approval' || feature.status === 'waiting_approval' ||
feature.status === 'verified' || feature.status === 'verified' ||
(feature.status === 'in_progress' && !isCurrentAutoTask); (feature.status === 'in_progress' && !isCurrentAutoTask);
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: feature.id, id: feature.id,
disabled: !isDraggable, disabled: !isDraggable,
@@ -79,36 +76,15 @@ export const KanbanCard = memo(function KanbanCard({
opacity: isDragging ? 0.5 : undefined, opacity: isDragging ? 0.5 : undefined,
}; };
const borderStyle: React.CSSProperties = { ...style };
if (!cardBorderEnabled) {
(borderStyle as Record<string, string>).borderWidth = '0px';
(borderStyle as Record<string, string>).borderColor = 'transparent';
} else if (cardBorderOpacity !== 100) {
(borderStyle as Record<string, string>).borderWidth = '1px';
(borderStyle as Record<string, string>).borderColor =
`color-mix(in oklch, var(--border) ${cardBorderOpacity}%, transparent)`;
}
const cardElement = ( const cardElement = (
<Card <GlassCard
ref={setNodeRef} ref={setNodeRef}
style={isCurrentAutoTask ? style : borderStyle} variant={isCurrentAutoTask ? 'active-blue' : 'default'}
style={style}
className={cn( className={cn(
'cursor-grab active:cursor-grabbing relative kanban-card-content select-none', 'group relative min-h-[140px] flex flex-col',
'transition-all duration-200 ease-out',
// Premium shadow system
'shadow-sm hover:shadow-md hover:shadow-black/10',
// Subtle lift on hover
'hover:-translate-y-0.5',
!isCurrentAutoTask && cardBorderEnabled && cardBorderOpacity === 100 && 'border-border/50',
!isCurrentAutoTask && cardBorderEnabled && cardBorderOpacity !== 100 && 'border',
!isDragging && 'bg-transparent',
!glassmorphism && 'backdrop-blur-[0px]!',
isDragging && 'scale-105 shadow-xl shadow-black/20 rotate-1', isDragging && 'scale-105 shadow-xl shadow-black/20 rotate-1',
// Error state - using CSS variable feature.error && 'border-brand-red border-2 shadow-glow-red',
feature.error &&
!isCurrentAutoTask &&
'border-[var(--status-error)] border-2 shadow-[var(--status-error-bg)] shadow-lg',
!isDraggable && 'cursor-default' !isDraggable && 'cursor-default'
)} )}
data-testid={`kanban-card-${feature.id}`} data-testid={`kanban-card-${feature.id}`}
@@ -116,29 +92,44 @@ export const KanbanCard = memo(function KanbanCard({
{...attributes} {...attributes}
{...(isDraggable ? listeners : {})} {...(isDraggable ? listeners : {})}
> >
{/* Background overlay with opacity */} {/* Top Row: Empty space + Delete (on hover) */}
{!isDragging && ( <div className="flex justify-between items-start mb-2 h-5">
<div <div className="flex flex-wrap gap-1">
className={cn(
'absolute inset-0 rounded-xl bg-card -z-10',
glassmorphism && 'backdrop-blur-sm'
)}
style={{ opacity: opacity / 100 }}
/>
)}
{/* Status Badges Row */}
<CardBadges feature={feature} /> <CardBadges feature={feature} />
</div>
{/* Category row */} {/* Delete/Actions on hover */}
<div className="px-3 pt-4"> <div className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-2">
<span className="text-[11px] text-muted-foreground/70 font-medium">{feature.category}</span> <button
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className="text-slate-600 hover:text-brand-red transition"
>
<i data-lucide="trash" className="w-3.5 h-3.5"></i>
{/* Fallback to SVG if i tag fails */}
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="lucide lucide-trash w-3.5 h-3.5"
>
<path d="M3 6h18" />
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
</svg>
</button>
</div>
</div> </div>
{/* Priority and Manual Verification badges */} {/* Description */}
<PriorityBadges feature={feature} /> <div className="mb-4">
{/* Card Header */}
<CardHeaderSection <CardHeaderSection
feature={feature} feature={feature}
isDraggable={isDraggable} isDraggable={isDraggable}
@@ -146,21 +137,34 @@ export const KanbanCard = memo(function KanbanCard({
onEdit={onEdit} onEdit={onEdit}
onDelete={onDelete} onDelete={onDelete}
onViewOutput={onViewOutput} onViewOutput={onViewOutput}
hideActions={true} // We handle actions via hover/bottom bar
/> />
</div>
<CardContent className="px-3 pt-0 pb-0"> {/* Middle Grid: Priority, etc */}
{/* Content Sections */} <div className="flex items-center justify-between mb-4">
<PriorityBadges feature={feature} />
<div className="flex items-center gap-2">
{/* Category / Model info */}
<span className="text-[10px] text-brand-cyan font-mono">
{feature.model || 'Opus 4.2'}
</span>
</div>
</div>
{/* Content & Agent Info */}
<div className="mb-2">
<CardContentSections feature={feature} useWorktrees={useWorktrees} /> <CardContentSections feature={feature} useWorktrees={useWorktrees} />
{/* Agent Info Panel */}
<AgentInfoPanel <AgentInfoPanel
feature={feature} feature={feature}
contextContent={contextContent} contextContent={contextContent}
summary={summary} summary={summary}
isCurrentAutoTask={isCurrentAutoTask} isCurrentAutoTask={isCurrentAutoTask}
/> />
</div>
{/* Actions */} {/* Buttons Grid */}
<div className="mt-auto pt-2">
<CardActions <CardActions
feature={feature} feature={feature}
isCurrentAutoTask={!!isCurrentAutoTask} isCurrentAutoTask={!!isCurrentAutoTask}
@@ -178,14 +182,9 @@ export const KanbanCard = memo(function KanbanCard({
onViewPlan={onViewPlan} onViewPlan={onViewPlan}
onApprovePlan={onApprovePlan} onApprovePlan={onApprovePlan}
/> />
</CardContent> </div>
</Card> </GlassCard>
); );
// Wrap with animated border when in progress
if (isCurrentAutoTask) {
return <div className="animated-border-wrapper">{cardElement}</div>;
}
return cardElement; return cardElement;
}); });

View File

@@ -2,31 +2,30 @@ import { memo } from 'react';
import { useDroppable } from '@dnd-kit/core'; import { useDroppable } from '@dnd-kit/core';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { GlassPanel } from '@/components/ui/glass-panel';
interface KanbanColumnProps { interface KanbanColumnProps {
id: string; id: string;
title: string; title: string;
colorClass: string; accent: 'cyan' | 'blue' | 'orange' | 'green' | 'none';
count: number; count: number;
children: ReactNode; children: ReactNode;
headerAction?: ReactNode; headerAction?: ReactNode;
width?: number;
// Legacy props ignored or used for compatibility
colorClass?: string;
opacity?: number; opacity?: number;
showBorder?: boolean; showBorder?: boolean;
hideScrollbar?: boolean; hideScrollbar?: boolean;
/** Custom width in pixels. If not provided, defaults to 288px (w-72) */
width?: number;
} }
export const KanbanColumn = memo(function KanbanColumn({ export const KanbanColumn = memo(function KanbanColumn({
id, id,
title, title,
colorClass, accent,
count, count,
children, children,
headerAction, headerAction,
opacity = 100,
showBorder = true,
hideScrollbar = false,
width, width,
}: KanbanColumnProps) { }: KanbanColumnProps) {
const { setNodeRef, isOver } = useDroppable({ id }); const { setNodeRef, isOver } = useDroppable({ id });
@@ -35,60 +34,63 @@ export const KanbanColumn = memo(function KanbanColumn({
const widthStyle = width ? { width: `${width}px`, flexShrink: 0 } : undefined; const widthStyle = width ? { width: `${width}px`, flexShrink: 0 } : undefined;
return ( return (
<div <GlassPanel
ref={setNodeRef} ref={setNodeRef}
accent={accent}
className={cn( className={cn(
'relative flex flex-col h-full rounded-xl', 'relative flex flex-col h-full min-w-[300px] transition-[box-shadow,ring] duration-200',
// Only transition ring/shadow for drag-over effect, not width !width && 'w-72',
'transition-[box-shadow,ring] duration-200',
!width && 'w-72', // Only apply w-72 if no custom width
showBorder && 'border border-border/60',
isOver && 'ring-2 ring-primary/30 ring-offset-1 ring-offset-background' isOver && 'ring-2 ring-primary/30 ring-offset-1 ring-offset-background'
)} )}
style={widthStyle} style={widthStyle}
data-testid={`kanban-column-${id}`} data-testid={`kanban-column-${id}`}
> >
{/* Background layer with opacity */} {/* Subtle Glow Top (Only for Blue/Orange/Green to match design, could make generic) */}
{(accent === 'blue' || accent === 'orange' || accent === 'green') && (
<div <div
className={cn( className={cn(
'absolute inset-0 rounded-xl backdrop-blur-sm transition-colors duration-200', 'absolute top-0 left-0 w-full h-32 bg-gradient-to-b pointer-events-none rounded-t-2xl',
isOver ? 'bg-accent/80' : 'bg-card/80' accent === 'blue' && 'from-brand-blue/10 to-transparent',
accent === 'orange' && 'from-brand-orange/10 to-transparent',
accent === 'green' && 'from-brand-green/10 to-transparent'
)}
></div>
)} )}
style={{ opacity: opacity / 100 }}
/>
{/* Column Header */} {/* Column Header */}
<div className="flex items-center justify-between p-4 border-b border-white/5 relative z-10">
<div className="flex items-center gap-2">
{/* Status Dot */}
<div <div
className={cn( className={cn(
'relative z-10 flex items-center gap-3 px-3 py-2.5', 'w-2 h-2 rounded-full',
showBorder && 'border-b border-border/40' accent === 'cyan' && 'bg-slate-400', // Backlog is neutral in design
accent === 'blue' && 'bg-brand-orange shadow-glow-orange', // In Progress has orange dot in design
accent === 'orange' && 'bg-brand-orange shadow-glow-orange',
accent === 'green' && 'bg-brand-green shadow-glow-green'
)} )}
> ></div>
<div className={cn('w-2.5 h-2.5 rounded-full shrink-0', colorClass)} /> <span className="font-bold text-slate-200 text-sm">{title}</span>
<h3 className="font-semibold text-sm text-foreground/90 flex-1 tracking-tight">{title}</h3>
{/* Action container (like "Make") */}
{headerAction} {headerAction}
<span className="text-xs font-medium text-muted-foreground/80 bg-muted/50 px-2 py-0.5 rounded-md tabular-nums"> </div>
{/* Count Badge */}
<span className="text-[10px] bg-dark-700 text-slate-400 px-2 py-0.5 rounded border border-white/5 font-medium">
{count} {count}
</span> </span>
</div> </div>
{/* Column Content */} {/* Column Content */}
<div <div className="flex-1 overflow-y-auto p-3 space-y-3 custom-scrollbar relative z-10">
className={cn(
'relative z-10 flex-1 overflow-y-auto p-2 space-y-2.5',
hideScrollbar &&
'[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]',
// Smooth scrolling
'scroll-smooth'
)}
>
{children} {children}
</div> </div>
{/* Drop zone indicator when dragging over */} {/* Drop zone indicator when dragging over */}
{isOver && ( {isOver && (
<div className="absolute inset-0 rounded-xl bg-primary/5 pointer-events-none z-5 border-2 border-dashed border-primary/20" /> <div className="absolute inset-0 rounded-2xl bg-white/5 pointer-events-none z-20 border-2 border-dashed border-white/10" />
)} )}
</div> </GlassPanel>
); );
}); });

View File

@@ -2,21 +2,29 @@ import { Feature } from '@/store/app-store';
export type ColumnId = Feature['status']; export type ColumnId = Feature['status'];
export const COLUMNS: { id: ColumnId; title: string; colorClass: string }[] = [ export const COLUMNS: {
{ id: 'backlog', title: 'Backlog', colorClass: 'bg-[var(--status-backlog)]' }, id: ColumnId;
title: string;
colorClass: string;
accent: 'cyan' | 'blue' | 'orange' | 'green';
}[] = [
{ id: 'backlog', title: 'Backlog', colorClass: 'bg-[var(--status-backlog)]', accent: 'cyan' },
{ {
id: 'in_progress', id: 'in_progress',
title: 'In Progress', title: 'In Progress',
colorClass: 'bg-[var(--status-in-progress)]', colorClass: 'bg-[var(--status-in-progress)]',
accent: 'blue',
}, },
{ {
id: 'waiting_approval', id: 'waiting_approval',
title: 'Waiting Approval', title: 'Waiting Approval',
colorClass: 'bg-[var(--status-waiting)]', colorClass: 'bg-[var(--status-waiting)]',
accent: 'orange',
}, },
{ {
id: 'verified', id: 'verified',
title: 'Verified', title: 'Verified',
colorClass: 'bg-[var(--status-success)]', colorClass: 'bg-[var(--status-success)]',
accent: 'green',
}, },
]; ];

View File

@@ -21,8 +21,6 @@ import {
RefreshCw, RefreshCw,
Shield, Shield,
Zap, Zap,
List,
FileText,
} from 'lucide-react'; } from 'lucide-react';
import { import {
getElectronAPI, getElectronAPI,
@@ -32,7 +30,6 @@ import {
} from '@/lib/electron'; } from '@/lib/electron';
import { useAppStore, Feature } from '@/store/app-store'; import { useAppStore, Feature } from '@/store/app-store';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { LogViewer } from '@/components/ui/log-viewer';
interface FeatureSuggestionsDialogProps { interface FeatureSuggestionsDialogProps {
open: boolean; open: boolean;
@@ -95,7 +92,6 @@ export function FeatureSuggestionsDialog({
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set()); const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const [isImporting, setIsImporting] = useState(false); const [isImporting, setIsImporting] = useState(false);
const [currentSuggestionType, setCurrentSuggestionType] = useState<SuggestionType | null>(null); const [currentSuggestionType, setCurrentSuggestionType] = useState<SuggestionType | null>(null);
const [viewMode, setViewMode] = useState<'parsed' | 'raw'>('parsed');
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const autoScrollRef = useRef(true); const autoScrollRef = useRef(true);
@@ -127,9 +123,7 @@ export function FeatureSuggestionsDialog({
setProgress((prev) => [...prev, event.content || '']); setProgress((prev) => [...prev, event.content || '']);
} else if (event.type === 'suggestions_tool') { } else if (event.type === 'suggestions_tool') {
const toolName = event.tool || 'Unknown Tool'; const toolName = event.tool || 'Unknown Tool';
const toolInput = event.input ? JSON.stringify(event.input, null, 2) : ''; setProgress((prev) => [...prev, `Using tool: ${toolName}\n`]);
const formattedTool = `\n🔧 Tool: ${toolName}\n${toolInput ? `Input: ${toolInput}\n` : ''}`;
setProgress((prev) => [...prev, formattedTool]);
} else if (event.type === 'suggestions_complete') { } else if (event.type === 'suggestions_complete') {
setIsGenerating(false); setIsGenerating(false);
if (event.suggestions && event.suggestions.length > 0) { if (event.suggestions && event.suggestions.length > 0) {
@@ -251,7 +245,6 @@ export function FeatureSuggestionsDialog({
id: `feature-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, id: `feature-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
category: s.category, category: s.category,
description: s.description, description: s.description,
steps: [], // Required empty steps array for new features
status: 'backlog' as const, status: 'backlog' as const,
skipTests: true, // As specified, testing mode true skipTests: true, // As specified, testing mode true
priority: s.priority, // Preserve priority from suggestion priority: s.priority, // Preserve priority from suggestion
@@ -304,7 +297,7 @@ export function FeatureSuggestionsDialog({
setCurrentSuggestionType(null); setCurrentSuggestionType(null);
}, [setSuggestions]); }, [setSuggestions]);
const hasStarted = isGenerating || progress.length > 0 || suggestions.length > 0; const hasStarted = progress.length > 0 || suggestions.length > 0;
const hasSuggestions = suggestions.length > 0; const hasSuggestions = suggestions.length > 0;
const currentConfig = currentSuggestionType ? suggestionTypeConfig[currentSuggestionType] : null; const currentConfig = currentSuggestionType ? suggestionTypeConfig[currentSuggestionType] : null;
@@ -378,56 +371,19 @@ export function FeatureSuggestionsDialog({
<Loader2 className="w-4 h-4 animate-spin" /> <Loader2 className="w-4 h-4 animate-spin" />
Analyzing project... Analyzing project...
</div> </div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 bg-muted rounded-lg p-1">
<button
onClick={() => setViewMode('parsed')}
className={`flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium transition-all ${
viewMode === 'parsed'
? 'bg-primary/20 text-primary shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
data-testid="view-mode-parsed"
>
<List className="w-3 h-3" />
Logs
</button>
<button
onClick={() => setViewMode('raw')}
className={`flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium transition-all ${
viewMode === 'raw'
? 'bg-primary/20 text-primary shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
data-testid="view-mode-raw"
>
<FileText className="w-3 h-3" />
Raw
</button>
</div>
<Button variant="destructive" size="sm" onClick={handleStop}> <Button variant="destructive" size="sm" onClick={handleStop}>
<StopCircle className="w-4 h-4 mr-2" /> <StopCircle className="w-4 h-4 mr-2" />
Stop Stop
</Button> </Button>
</div> </div>
</div>
<div <div
ref={scrollRef} ref={scrollRef}
onScroll={handleScroll} onScroll={handleScroll}
className="flex-1 overflow-y-auto bg-zinc-950 rounded-lg p-4 font-mono text-xs min-h-[200px] max-h-[400px]" className="flex-1 overflow-y-auto bg-zinc-950 rounded-lg p-4 font-mono text-xs min-h-[200px] max-h-[400px]"
> >
{progress.length === 0 ? (
<div className="flex items-center justify-center min-h-[168px] text-muted-foreground">
<Loader2 className="w-6 h-6 animate-spin mr-2" />
Waiting for AI response...
</div>
) : viewMode === 'parsed' ? (
<LogViewer output={progress.join('')} />
) : (
<div className="whitespace-pre-wrap break-words text-zinc-300"> <div className="whitespace-pre-wrap break-words text-zinc-300">
{progress.join('')} {progress.join('')}
</div> </div>
)}
</div> </div>
</div> </div>
) : hasSuggestions ? ( ) : hasSuggestions ? (

View File

@@ -109,7 +109,9 @@ export function useBoardColumnFeatures({
// This ensures features appear in dependency order (dependencies before dependents) // This ensures features appear in dependency order (dependencies before dependents)
// Within the same dependency level, features are sorted by priority // Within the same dependency level, features are sorted by priority
if (map.backlog.length > 0) { if (map.backlog.length > 0) {
const { orderedFeatures } = resolveDependencies(map.backlog); const { orderedFeatures } = resolveDependencies(map.backlog as any) as {
orderedFeatures: Feature[];
};
// Get all features to check blocking dependencies against // Get all features to check blocking dependencies against
const allFeatures = features; const allFeatures = features;
@@ -122,7 +124,7 @@ export function useBoardColumnFeatures({
const blocked: Feature[] = []; const blocked: Feature[] = [];
for (const f of orderedFeatures) { for (const f of orderedFeatures) {
if (getBlockingDependencies(f, allFeatures).length > 0) { if (getBlockingDependencies(f as any, allFeatures as any).length > 0) {
blocked.push(f); blocked.push(f);
} else { } else {
unblocked.push(f); unblocked.push(f);

View File

@@ -101,6 +101,7 @@ export function KanbanBoard({
key={column.id} key={column.id}
id={column.id} id={column.id}
title={column.title} title={column.title}
accent={column.accent}
colorClass={column.colorClass} colorClass={column.colorClass}
count={columnFeatures.length} count={columnFeatures.length}
width={columnWidth} width={columnWidth}

View File

@@ -1,5 +1,6 @@
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { RefreshCw, Globe, Loader2, CircleDot, GitPullRequest } from 'lucide-react'; import { RefreshCw, Globe, Loader2, CircleDot, GitPullRequest } from 'lucide-react';
import React from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import type { WorktreeInfo, BranchInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types'; import type { WorktreeInfo, BranchInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types';
@@ -87,7 +88,7 @@ export function WorktreeTab({
onStopDevServer, onStopDevServer,
onOpenDevServerUrl, onOpenDevServerUrl,
}: WorktreeTabProps) { }: WorktreeTabProps) {
let prBadge: JSX.Element | null = null; let prBadge: React.ReactNode | null = null;
if (worktree.pr) { if (worktree.pr) {
const prState = worktree.pr.state?.toLowerCase() ?? 'open'; const prState = worktree.pr.state?.toLowerCase() ?? 'open';
const prStateClasses = (() => { const prStateClasses = (() => {

View File

@@ -1,126 +1,93 @@
import { useState, useCallback, useMemo } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { CircleDot, RefreshCw } from 'lucide-react'; import { CircleDot, Loader2, RefreshCw, ExternalLink, CheckCircle2, Circle, X } from 'lucide-react';
import { getElectronAPI, GitHubIssue, IssueValidationResult } from '@/lib/electron'; import { getElectronAPI, GitHubIssue } from '@/lib/electron';
import { useAppStore } from '@/store/app-store'; import { useAppStore } from '@/store/app-store';
import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { Button } from '@/components/ui/button';
import { LoadingState } from '@/components/ui/loading-state'; import { Markdown } from '@/components/ui/markdown';
import { ErrorState } from '@/components/ui/error-state'; import { cn } from '@/lib/utils';
import { cn, pathsEqual } from '@/lib/utils';
import { toast } from 'sonner';
import { useGithubIssues, useIssueValidation } from './github-issues-view/hooks';
import { IssueRow, IssueDetailPanel, IssuesListHeader } from './github-issues-view/components';
import { ValidationDialog } from './github-issues-view/dialogs';
import { formatDate, getFeaturePriority } from './github-issues-view/utils';
export function GitHubIssuesView() { export function GitHubIssuesView() {
const [openIssues, setOpenIssues] = useState<GitHubIssue[]>([]);
const [closedIssues, setClosedIssues] = useState<GitHubIssue[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedIssue, setSelectedIssue] = useState<GitHubIssue | null>(null); const [selectedIssue, setSelectedIssue] = useState<GitHubIssue | null>(null);
const [validationResult, setValidationResult] = useState<IssueValidationResult | null>(null); const { currentProject } = useAppStore();
const [showValidationDialog, setShowValidationDialog] = useState(false);
const [showRevalidateConfirm, setShowRevalidateConfirm] = useState(false);
const { currentProject, defaultAIProfileId, aiProfiles, getCurrentWorktree, worktreesByProject } = const fetchIssues = useCallback(async () => {
useAppStore(); if (!currentProject?.path) {
setError('No project selected');
setLoading(false);
return;
}
const { openIssues, closedIssues, loading, refreshing, error, refresh } = useGithubIssues(); try {
setError(null);
const api = getElectronAPI();
if (api.github) {
const result = await api.github.listIssues(currentProject.path);
if (result.success) {
setOpenIssues(result.openIssues || []);
setClosedIssues(result.closedIssues || []);
} else {
setError(result.error || 'Failed to fetch issues');
}
}
} catch (err) {
console.error('[GitHubIssuesView] Error fetching issues:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch issues');
} finally {
setLoading(false);
setRefreshing(false);
}
}, [currentProject?.path]);
const { validatingIssues, cachedValidations, handleValidateIssue, handleViewCachedValidation } = useEffect(() => {
useIssueValidation({ fetchIssues();
selectedIssue, }, [fetchIssues]);
showValidationDialog,
onValidationResultChange: setValidationResult,
onShowValidationDialogChange: setShowValidationDialog,
});
// Get default AI profile for task creation const handleRefresh = useCallback(() => {
const defaultProfile = useMemo(() => { setRefreshing(true);
if (!defaultAIProfileId) return null; fetchIssues();
return aiProfiles.find((p) => p.id === defaultAIProfileId) ?? null; }, [fetchIssues]);
}, [defaultAIProfileId, aiProfiles]);
// Get current branch from selected worktree
const currentBranch = useMemo(() => {
if (!currentProject?.path) return '';
const currentWorktreeInfo = getCurrentWorktree(currentProject.path);
const worktrees = worktreesByProject[currentProject.path] ?? [];
const currentWorktreePath = currentWorktreeInfo?.path ?? null;
const selectedWorktree =
currentWorktreePath === null
? worktrees.find((w) => w.isMain)
: worktrees.find((w) => !w.isMain && pathsEqual(w.path, currentWorktreePath));
return selectedWorktree?.branch || worktrees.find((w) => w.isMain)?.branch || '';
}, [currentProject?.path, getCurrentWorktree, worktreesByProject]);
const handleOpenInGitHub = useCallback((url: string) => { const handleOpenInGitHub = useCallback((url: string) => {
const api = getElectronAPI(); const api = getElectronAPI();
api.openExternalLink(url); api.openExternalLink(url);
}, []); }, []);
const handleConvertToTask = useCallback( const formatDate = (dateString: string) => {
async (issue: GitHubIssue, validation: IssueValidationResult) => { const date = new Date(dateString);
if (!currentProject?.path) { return date.toLocaleDateString('en-US', {
toast.error('No project selected'); month: 'short',
return; day: 'numeric',
} year: 'numeric',
});
try {
const api = getElectronAPI();
if (api.features?.create) {
// Build description from issue body + validation info
const description = [
`**From GitHub Issue #${issue.number}**`,
'',
issue.body || 'No description provided.',
'',
'---',
'',
'**AI Validation Analysis:**',
validation.reasoning,
validation.suggestedFix ? `\n**Suggested Approach:**\n${validation.suggestedFix}` : '',
validation.relatedFiles?.length
? `\n**Related Files:**\n${validation.relatedFiles.map((f) => `- \`${f}\``).join('\n')}`
: '',
]
.filter(Boolean)
.join('\n');
const feature = {
id: `issue-${issue.number}-${crypto.randomUUID()}`,
title: issue.title,
description,
category: 'From GitHub',
status: 'backlog' as const,
passes: false,
priority: getFeaturePriority(validation.estimatedComplexity),
model: defaultProfile?.model ?? 'opus',
thinkingLevel: defaultProfile?.thinkingLevel ?? 'none',
branchName: currentBranch,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}; };
const result = await api.features.create(currentProject.path, feature);
if (result.success) {
toast.success(`Created task: ${issue.title}`);
} else {
toast.error(result.error || 'Failed to create task');
}
}
} catch (err) {
console.error('[GitHubIssuesView] Convert to task error:', err);
toast.error(err instanceof Error ? err.message : 'Failed to create task');
}
},
[currentProject?.path, defaultProfile, currentBranch]
);
if (loading) { if (loading) {
return <LoadingState />; return (
<div className="flex-1 flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
} }
if (error) { if (error) {
return <ErrorState error={error} title="Failed to Load Issues" onRetry={refresh} />; return (
<div className="flex-1 flex flex-col items-center justify-center text-center p-6">
<div className="p-4 rounded-full bg-destructive/10 mb-4">
<CircleDot className="h-12 w-12 text-destructive" />
</div>
<h2 className="text-lg font-medium mb-2">Failed to Load Issues</h2>
<p className="text-muted-foreground max-w-md mb-4">{error}</p>
<Button variant="outline" onClick={handleRefresh}>
<RefreshCw className="h-4 w-4 mr-2" />
Try Again
</Button>
</div>
);
} }
const totalIssues = openIssues.length + closedIssues.length; const totalIssues = openIssues.length + closedIssues.length;
@@ -135,12 +102,24 @@ export function GitHubIssuesView() {
)} )}
> >
{/* Header */} {/* Header */}
<IssuesListHeader <div className="flex items-center justify-between p-4 border-b border-border">
openCount={openIssues.length} <div className="flex items-center gap-3">
closedCount={closedIssues.length} <div className="p-2 rounded-lg bg-green-500/10">
refreshing={refreshing} <CircleDot className="h-5 w-5 text-green-500" />
onRefresh={refresh} </div>
/> <div>
<h1 className="text-lg font-bold">Issues</h1>
<p className="text-xs text-muted-foreground">
{totalIssues === 0
? 'No issues found'
: `${openIssues.length} open, ${closedIssues.length} closed`}
</p>
</div>
</div>
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={refreshing}>
<RefreshCw className={cn('h-4 w-4', refreshing && 'animate-spin')} />
</Button>
</div>
{/* Issues List */} {/* Issues List */}
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
@@ -163,8 +142,6 @@ export function GitHubIssuesView() {
onClick={() => setSelectedIssue(issue)} onClick={() => setSelectedIssue(issue)}
onOpenExternal={() => handleOpenInGitHub(issue.url)} onOpenExternal={() => handleOpenInGitHub(issue.url)}
formatDate={formatDate} formatDate={formatDate}
cachedValidation={cachedValidations.get(issue.number)}
isValidating={validatingIssues.has(issue.number)}
/> />
))} ))}
@@ -182,8 +159,6 @@ export function GitHubIssuesView() {
onClick={() => setSelectedIssue(issue)} onClick={() => setSelectedIssue(issue)}
onOpenExternal={() => handleOpenInGitHub(issue.url)} onOpenExternal={() => handleOpenInGitHub(issue.url)}
formatDate={formatDate} formatDate={formatDate}
cachedValidation={cachedValidations.get(issue.number)}
isValidating={validatingIssues.has(issue.number)}
/> />
))} ))}
</> </>
@@ -195,43 +170,164 @@ export function GitHubIssuesView() {
{/* Issue Detail Panel */} {/* Issue Detail Panel */}
{selectedIssue && ( {selectedIssue && (
<IssueDetailPanel <div className="flex-1 flex flex-col overflow-hidden">
issue={selectedIssue} {/* Detail Header */}
validatingIssues={validatingIssues} <div className="flex items-center justify-between p-3 border-b border-border bg-muted/30">
cachedValidations={cachedValidations} <div className="flex items-center gap-2 min-w-0">
onValidateIssue={handleValidateIssue} {selectedIssue.state === 'OPEN' ? (
onViewCachedValidation={handleViewCachedValidation} <Circle className="h-4 w-4 text-green-500 flex-shrink-0" />
onOpenInGitHub={handleOpenInGitHub} ) : (
onClose={() => setSelectedIssue(null)} <CheckCircle2 className="h-4 w-4 text-purple-500 flex-shrink-0" />
onShowRevalidateConfirm={() => setShowRevalidateConfirm(true)} )}
formatDate={formatDate} <span className="text-sm font-medium truncate">
/> #{selectedIssue.number} {selectedIssue.title}
</span>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<Button
variant="outline"
size="sm"
onClick={() => handleOpenInGitHub(selectedIssue.url)}
>
<ExternalLink className="h-4 w-4 mr-1" />
Open in GitHub
</Button>
<Button variant="ghost" size="sm" onClick={() => setSelectedIssue(null)}>
<X className="h-4 w-4" />
</Button>
</div>
</div>
{/* Issue Detail Content */}
<div className="flex-1 overflow-auto p-6">
{/* Title */}
<h1 className="text-xl font-bold mb-2">{selectedIssue.title}</h1>
{/* Meta info */}
<div className="flex items-center gap-3 text-sm text-muted-foreground mb-4">
<span
className={cn(
'px-2 py-0.5 rounded-full text-xs font-medium',
selectedIssue.state === 'OPEN'
? 'bg-green-500/10 text-green-500'
: 'bg-purple-500/10 text-purple-500'
)}
>
{selectedIssue.state === 'OPEN' ? 'Open' : 'Closed'}
</span>
<span>
#{selectedIssue.number} opened {formatDate(selectedIssue.createdAt)} by{' '}
<span className="font-medium text-foreground">{selectedIssue.author.login}</span>
</span>
</div>
{/* Labels */}
{selectedIssue.labels.length > 0 && (
<div className="flex items-center gap-2 mb-6 flex-wrap">
{selectedIssue.labels.map((label) => (
<span
key={label.name}
className="px-2 py-0.5 text-xs font-medium rounded-full"
style={{
backgroundColor: `#${label.color}20`,
color: `#${label.color}`,
border: `1px solid #${label.color}40`,
}}
>
{label.name}
</span>
))}
</div>
)} )}
{/* Validation Dialog */} {/* Body */}
<ValidationDialog {selectedIssue.body ? (
open={showValidationDialog} <Markdown className="text-sm">{selectedIssue.body}</Markdown>
onOpenChange={setShowValidationDialog} ) : (
issue={selectedIssue} <p className="text-sm text-muted-foreground italic">No description provided.</p>
validationResult={validationResult} )}
onConvertToTask={handleConvertToTask}
/>
{/* Revalidate Confirmation Dialog */} {/* Open in GitHub CTA */}
<ConfirmDialog <div className="mt-8 p-4 rounded-lg bg-muted/50 border border-border">
open={showRevalidateConfirm} <p className="text-sm text-muted-foreground mb-3">
onOpenChange={setShowRevalidateConfirm} View comments, add reactions, and more on GitHub.
title="Re-validate Issue" </p>
description={`Are you sure you want to re-validate issue #${selectedIssue?.number}? This will run a new AI analysis and replace the existing validation result.`} <Button onClick={() => handleOpenInGitHub(selectedIssue.url)}>
icon={RefreshCw} <ExternalLink className="h-4 w-4 mr-2" />
iconClassName="text-primary" View Full Issue on GitHub
confirmText="Re-validate" </Button>
onConfirm={() => { </div>
if (selectedIssue) { </div>
handleValidateIssue(selectedIssue, { forceRevalidate: true }); </div>
} )}
}} </div>
/> );
}
interface IssueRowProps {
issue: GitHubIssue;
isSelected: boolean;
onClick: () => void;
onOpenExternal: () => void;
formatDate: (date: string) => string;
}
function IssueRow({ issue, isSelected, onClick, onOpenExternal, formatDate }: IssueRowProps) {
return (
<div
className={cn(
'flex items-start gap-3 p-3 cursor-pointer hover:bg-accent/50 transition-colors',
isSelected && 'bg-accent'
)}
onClick={onClick}
>
{issue.state === 'OPEN' ? (
<Circle className="h-4 w-4 text-green-500 mt-0.5 flex-shrink-0" />
) : (
<CheckCircle2 className="h-4 w-4 text-purple-500 mt-0.5 flex-shrink-0" />
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">{issue.title}</span>
</div>
<div className="flex items-center gap-2 mt-1 flex-wrap">
<span className="text-xs text-muted-foreground">
#{issue.number} opened {formatDate(issue.createdAt)} by {issue.author.login}
</span>
</div>
{issue.labels.length > 0 && (
<div className="flex items-center gap-1 mt-2 flex-wrap">
{issue.labels.map((label) => (
<span
key={label.name}
className="px-1.5 py-0.5 text-[10px] font-medium rounded-full"
style={{
backgroundColor: `#${label.color}20`,
color: `#${label.color}`,
border: `1px solid #${label.color}40`,
}}
>
{label.name}
</span>
))}
</div>
)}
</div>
<Button
variant="ghost"
size="sm"
className="flex-shrink-0 opacity-0 group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
onOpenExternal();
}}
>
<ExternalLink className="h-3.5 w-3.5" />
</Button>
</div> </div>
); );
} }

View File

@@ -1,3 +0,0 @@
export { IssueRow } from './issue-row';
export { IssueDetailPanel } from './issue-detail-panel';
export { IssuesListHeader } from './issues-list-header';

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -1 +0,0 @@
export const VALIDATION_STALENESS_HOURS = 24;

View File

@@ -1 +0,0 @@
export { ValidationDialog } from './validation-dialog';

View File

@@ -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>
);
}

View File

@@ -1,2 +0,0 @@
export { useGithubIssues } from './use-github-issues';
export { useIssueValidation } from './use-issue-validation';

View File

@@ -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,
};
}

View File

@@ -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,
};
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -1,5 +1,7 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { Bot, Folder, Loader2, RefreshCw, Square, Activity } from 'lucide-react'; import { Bot, Folder, Loader2, RefreshCw, Square, Activity } from 'lucide-react';
import { TopHeader } from '@/components/layout/top-header';
import { GlassPanel } from '@/components/ui/glass-panel';
import { getElectronAPI, RunningAgent } from '@/lib/electron'; import { getElectronAPI, RunningAgent } from '@/lib/electron';
import { useAppStore } from '@/store/app-store'; import { useAppStore } from '@/store/app-store';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -103,24 +105,37 @@ export function RunningAgentsView() {
} }
return ( return (
<div className="flex-1 flex flex-col overflow-hidden bg-background">
<TopHeader />
<div className="flex-1 flex flex-col overflow-hidden p-6 pt-0">
<GlassPanel className="flex-1 flex flex-col overflow-hidden relative shadow-2xl bg-black/40 backdrop-blur-xl border-white/5">
<div className="flex-1 flex flex-col overflow-hidden p-6"> <div className="flex-1 flex flex-col overflow-hidden p-6">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-brand-500/10"> <div className="w-10 h-10 rounded-xl bg-gradient-to-br from-brand-500/20 to-blue-600/20 border border-brand-500/30 flex items-center justify-center shadow-inner shadow-brand-500/20">
<Activity className="h-6 w-6 text-brand-500" /> <Activity className="h-5 w-5 text-brand-400" />
</div> </div>
<div> <div>
<h1 className="text-2xl font-bold">Running Agents</h1> <h1 className="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-white to-white/70">
<p className="text-sm text-muted-foreground"> Running Agents
</h1>
<p className="text-xs text-muted-foreground">
{runningAgents.length === 0 {runningAgents.length === 0
? 'No agents currently running' ? 'No agents currently running'
: `${runningAgents.length} agent${runningAgents.length === 1 ? '' : 's'} running across all projects`} : `${runningAgents.length} agent${runningAgents.length === 1 ? '' : 's'} running across all projects`}
</p> </p>
</div> </div>
</div> </div>
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={refreshing}> <Button
<RefreshCw className={cn('h-4 w-4 mr-2', refreshing && 'animate-spin')} /> variant="outline"
size="sm"
onClick={handleRefresh}
disabled={refreshing}
className="bg-white/5 border-white/10 hover:bg-white/10 text-xs gap-2"
>
<RefreshCw className={cn('h-3.5 w-3.5', refreshing && 'animate-spin')} />
Refresh Refresh
</Button> </Button>
</div> </div>
@@ -128,70 +143,73 @@ export function RunningAgentsView() {
{/* Content */} {/* Content */}
{runningAgents.length === 0 ? ( {runningAgents.length === 0 ? (
<div className="flex-1 flex flex-col items-center justify-center text-center"> <div className="flex-1 flex flex-col items-center justify-center text-center">
<div className="p-4 rounded-full bg-muted/50 mb-4"> <div className="w-16 h-16 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center mb-6">
<Bot className="h-12 w-12 text-muted-foreground" /> <Bot className="h-8 w-8 text-muted-foreground/50" />
</div> </div>
<h2 className="text-lg font-medium mb-2">No Running Agents</h2> <h2 className="text-lg font-medium mb-2 text-foreground">No Running Agents</h2>
<p className="text-muted-foreground max-w-md"> <p className="text-muted-foreground max-w-sm text-sm">
Agents will appear here when they are actively working on features. Start an agent from Agents will appear here when they are actively working on features. Start an agent
the Kanban board by dragging a feature to "In Progress". from the Kanban board.
</p> </p>
</div> </div>
) : ( ) : (
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto pr-2">
<div className="space-y-3"> <div className="grid gap-3 grid-cols-1 md:grid-cols-2 xl:grid-cols-3">
{runningAgents.map((agent) => ( {runningAgents.map((agent) => (
<div <div
key={`${agent.projectPath}-${agent.featureId}`} key={`${agent.projectPath}-${agent.featureId}`}
className="flex items-center justify-between p-4 rounded-lg border border-border bg-card hover:bg-accent/50 transition-colors" className="group relative flex flex-col p-4 rounded-xl border border-white/10 bg-white/5 hover:bg-white/10 hover:border-white/20 transition-all duration-300"
> >
<div className="flex items-center gap-4 min-w-0"> <div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-3">
{/* Status indicator */} {/* Status indicator */}
<div className="relative"> <div className="relative">
<Bot className="h-8 w-8 text-brand-500" /> <div className="w-10 h-10 rounded-lg bg-brand-500/10 flex items-center justify-center">
<span className="absolute -top-1 -right-1 flex h-3 w-3"> <Bot className="h-5 w-5 text-brand-400" />
</div>
<span className="absolute -top-1 -right-1 flex h-2.5 w-2.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" /> <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
<span className="relative inline-flex rounded-full h-3 w-3 bg-green-500" /> <span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-green-500" />
</span> </span>
</div> </div>
{/* Agent info */}
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 mb-0.5">
<span className="font-medium truncate">{agent.featureId}</span> <span className="font-semibold text-sm truncate text-foreground">
{agent.featureId}
</span>
{agent.isAutoMode && ( {agent.isAutoMode && (
<span className="px-2 py-0.5 text-[10px] font-medium rounded-full bg-brand-500/10 text-brand-500 border border-brand-500/30"> <span className="px-1.5 py-0.5 text-[10px] font-bold rounded bg-brand-500/20 text-brand-400 border border-brand-500/20">
AUTO AUTO
</span> </span>
)} )}
</div> </div>
<button <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
onClick={() => handleNavigateToProject(agent)} <Folder className="h-3 w-3" />
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors" <span className="truncate max-w-[120px]">{agent.projectName}</span>
> </div>
<Folder className="h-3.5 w-3.5" /> </div>
<span className="truncate">{agent.projectName}</span>
</button>
</div> </div>
</div> </div>
{/* Actions */} {/* Actions */}
<div className="flex items-center gap-2 flex-shrink-0"> <div className="mt-auto pt-3 flex items-center gap-2 border-t border-white/5">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => handleNavigateToProject(agent)} onClick={() => handleNavigateToProject(agent)}
className="text-muted-foreground hover:text-foreground" className="flex-1 h-8 text-xs hover:bg-white/10"
> >
View Project View Project
</Button> </Button>
<Button <Button
variant="destructive" variant="ghost"
size="sm" size="sm"
onClick={() => handleStopAgent(agent.featureId)} onClick={() => handleStopAgent(agent.featureId)}
className="h-8 w-8 p-0 text-muted-foreground hover:text-red-400 hover:bg-red-500/10"
title="Stop Agent"
> >
<Square className="h-3.5 w-3.5 mr-1.5" /> <Square className="h-3.5 w-3.5 fill-current" />
Stop
</Button> </Button>
</div> </div>
</div> </div>
@@ -200,5 +218,8 @@ export function RunningAgentsView() {
</div> </div>
)} )}
</div> </div>
</GlassPanel>
</div>
</div>
); );
} }

View File

@@ -45,8 +45,6 @@ export function SettingsView() {
setDefaultAIProfileId, setDefaultAIProfileId,
aiProfiles, aiProfiles,
apiKeys, apiKeys,
validationModel,
setValidationModel,
} = useAppStore(); } = useAppStore();
// Hide usage tracking when using API key (only show for Claude Code CLI users) // Hide usage tracking when using API key (only show for Claude Code CLI users)
@@ -112,7 +110,7 @@ export function SettingsView() {
<AppearanceSection <AppearanceSection
effectiveTheme={effectiveTheme} effectiveTheme={effectiveTheme}
currentProject={settingsProject} currentProject={settingsProject}
onThemeChange={handleSetTheme} onThemeChange={handleSetTheme as (theme: Theme) => void}
/> />
); );
case 'terminal': case 'terminal':
@@ -136,7 +134,6 @@ export function SettingsView() {
defaultRequirePlanApproval={defaultRequirePlanApproval} defaultRequirePlanApproval={defaultRequirePlanApproval}
defaultAIProfileId={defaultAIProfileId} defaultAIProfileId={defaultAIProfileId}
aiProfiles={aiProfiles} aiProfiles={aiProfiles}
validationModel={validationModel}
onShowProfilesOnlyChange={setShowProfilesOnly} onShowProfilesOnlyChange={setShowProfilesOnly}
onDefaultSkipTestsChange={setDefaultSkipTests} onDefaultSkipTestsChange={setDefaultSkipTests}
onEnableDependencyBlockingChange={setEnableDependencyBlocking} onEnableDependencyBlockingChange={setEnableDependencyBlocking}
@@ -144,7 +141,6 @@ export function SettingsView() {
onDefaultPlanningModeChange={setDefaultPlanningMode} onDefaultPlanningModeChange={setDefaultPlanningMode}
onDefaultRequirePlanApprovalChange={setDefaultRequirePlanApproval} onDefaultRequirePlanApprovalChange={setDefaultRequirePlanApproval}
onDefaultAIProfileIdChange={setDefaultAIProfileId} onDefaultAIProfileIdChange={setDefaultAIProfileId}
onValidationModelChange={setValidationModel}
/> />
); );
case 'danger': case 'danger':

View File

@@ -59,9 +59,8 @@ export function useApiKeyManagement() {
hasGoogleKey: status.hasGoogleKey, hasGoogleKey: status.hasGoogleKey,
}); });
} }
} catch { } catch (error) {
// Silently handle API key status check failures to avoid exposing console.error('Failed to check API key status:', error);
// sensitive error details in the console
} }
} }
}; };
@@ -99,29 +98,26 @@ export function useApiKeyManagement() {
}; };
// Test Google/Gemini connection // Test Google/Gemini connection
// NOTE: Full API key validation requires a backend call to verify the key // TODO: Add backend endpoint for Gemini API key verification
// against Google's API. The current client-side validation only checks
// basic format requirements and cannot confirm the key is actually valid.
const handleTestGeminiConnection = async () => { const handleTestGeminiConnection = async () => {
setTestingGeminiConnection(true); setTestingGeminiConnection(true);
setGeminiTestResult(null); setGeminiTestResult(null);
// Basic client-side format validation only // Basic validation - check key format
// This does NOT verify the key is valid with Google's API
if (!googleKey || googleKey.trim().length < 10) { if (!googleKey || googleKey.trim().length < 10) {
setGeminiTestResult({ setGeminiTestResult({
success: false, success: false,
message: 'Please enter an API key with at least 10 characters.', message: 'Please enter a valid API key.',
}); });
setTestingGeminiConnection(false); setTestingGeminiConnection(false);
return; return;
} }
// Client-side validation cannot confirm key validity. // For now, just validate the key format (starts with expected prefix)
// The key will be verified when first used with the Gemini API. // Full verification requires a backend endpoint
setGeminiTestResult({ setGeminiTestResult({
success: true, success: true,
message: 'API key format accepted. Key will be validated on first use with Gemini API.', message: 'API key saved. Connection test not yet available.',
}); });
setTestingGeminiConnection(false); setTestingGeminiConnection(false);
}; };

View File

@@ -12,7 +12,6 @@ import {
ScrollText, ScrollText,
ShieldCheck, ShieldCheck,
User, User,
Sparkles,
} from 'lucide-react'; } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { import {
@@ -23,7 +22,6 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import type { AIProfile } from '@/store/app-store'; import type { AIProfile } from '@/store/app-store';
import type { AgentModel } from '@automaker/types';
type PlanningMode = 'skip' | 'lite' | 'spec' | 'full'; type PlanningMode = 'skip' | 'lite' | 'spec' | 'full';
@@ -36,7 +34,6 @@ interface FeatureDefaultsSectionProps {
defaultRequirePlanApproval: boolean; defaultRequirePlanApproval: boolean;
defaultAIProfileId: string | null; defaultAIProfileId: string | null;
aiProfiles: AIProfile[]; aiProfiles: AIProfile[];
validationModel: AgentModel;
onShowProfilesOnlyChange: (value: boolean) => void; onShowProfilesOnlyChange: (value: boolean) => void;
onDefaultSkipTestsChange: (value: boolean) => void; onDefaultSkipTestsChange: (value: boolean) => void;
onEnableDependencyBlockingChange: (value: boolean) => void; onEnableDependencyBlockingChange: (value: boolean) => void;
@@ -44,7 +41,6 @@ interface FeatureDefaultsSectionProps {
onDefaultPlanningModeChange: (value: PlanningMode) => void; onDefaultPlanningModeChange: (value: PlanningMode) => void;
onDefaultRequirePlanApprovalChange: (value: boolean) => void; onDefaultRequirePlanApprovalChange: (value: boolean) => void;
onDefaultAIProfileIdChange: (value: string | null) => void; onDefaultAIProfileIdChange: (value: string | null) => void;
onValidationModelChange: (value: AgentModel) => void;
} }
export function FeatureDefaultsSection({ export function FeatureDefaultsSection({
@@ -56,7 +52,6 @@ export function FeatureDefaultsSection({
defaultRequirePlanApproval, defaultRequirePlanApproval,
defaultAIProfileId, defaultAIProfileId,
aiProfiles, aiProfiles,
validationModel,
onShowProfilesOnlyChange, onShowProfilesOnlyChange,
onDefaultSkipTestsChange, onDefaultSkipTestsChange,
onEnableDependencyBlockingChange, onEnableDependencyBlockingChange,
@@ -64,7 +59,6 @@ export function FeatureDefaultsSection({
onDefaultPlanningModeChange, onDefaultPlanningModeChange,
onDefaultRequirePlanApprovalChange, onDefaultRequirePlanApprovalChange,
onDefaultAIProfileIdChange, onDefaultAIProfileIdChange,
onValidationModelChange,
}: FeatureDefaultsSectionProps) { }: FeatureDefaultsSectionProps) {
// Find the selected profile name for display // Find the selected profile name for display
const selectedProfile = defaultAIProfileId const selectedProfile = defaultAIProfileId
@@ -233,45 +227,6 @@ export function FeatureDefaultsSection({
{/* Separator */} {/* Separator */}
<div className="border-t border-border/30" /> <div className="border-t border-border/30" />
{/* Issue Validation Model */}
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<div className="w-10 h-10 mt-0.5 rounded-xl flex items-center justify-center shrink-0 bg-purple-500/10">
<Sparkles className="w-5 h-5 text-purple-500" />
</div>
<div className="flex-1 space-y-2">
<div className="flex items-center justify-between">
<Label className="text-foreground font-medium">Issue Validation Model</Label>
<Select
value={validationModel}
onValueChange={(v: string) => onValidationModelChange(v as AgentModel)}
>
<SelectTrigger className="w-[140px] h-8" data-testid="validation-model-select">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="opus">
<span>Opus</span>
<span className="text-[10px] text-muted-foreground ml-1">(Default)</span>
</SelectItem>
<SelectItem value="sonnet">
<span>Sonnet</span>
</SelectItem>
<SelectItem value="haiku">
<span>Haiku</span>
</SelectItem>
</SelectContent>
</Select>
</div>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
Model used for validating GitHub issues. Opus provides the most thorough analysis,
while Haiku is faster and more cost-effective.
</p>
</div>
</div>
{/* Separator */}
<div className="border-t border-border/30" />
{/* Profiles Only Setting */} {/* Profiles Only Setting */}
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3"> <div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<Checkbox <Checkbox

View File

@@ -46,6 +46,8 @@ import {
defaultDropAnimationSideEffects, defaultDropAnimationSideEffects,
} from '@dnd-kit/core'; } from '@dnd-kit/core';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { TopHeader } from '@/components/layout/top-header';
import { GlassPanel } from '@/components/ui/glass-panel';
interface TerminalStatus { interface TerminalStatus {
enabled: boolean; enabled: boolean;
@@ -296,7 +298,7 @@ export function TerminalView() {
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
if (terminalState.authToken) { if (terminalState.authToken) {
headers['Authorization'] = `Bearer ${terminalState.authToken}`; headers['X-Terminal-Token'] = terminalState.authToken;
} }
console.log(`[Terminal] Killing ${sessionIds.length} sessions on server`); console.log(`[Terminal] Killing ${sessionIds.length} sessions on server`);
@@ -459,7 +461,7 @@ export function TerminalView() {
try { try {
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
if (terminalState.authToken) { if (terminalState.authToken) {
headers['Authorization'] = `Bearer ${terminalState.authToken}`; headers['X-Terminal-Token'] = terminalState.authToken;
} }
const response = await fetch(`${serverUrl}/api/terminal/settings`, { headers }); const response = await fetch(`${serverUrl}/api/terminal/settings`, { headers });
const data = await response.json(); const data = await response.json();
@@ -488,7 +490,7 @@ export function TerminalView() {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}; };
if (terminalState.authToken) { if (terminalState.authToken) {
headers['Authorization'] = `Bearer ${terminalState.authToken}`; headers['X-Terminal-Token'] = terminalState.authToken;
} }
// Try to use the bulk delete endpoint if available, otherwise delete individually // Try to use the bulk delete endpoint if available, otherwise delete individually
@@ -501,7 +503,7 @@ export function TerminalView() {
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
xhr.open('DELETE', url, false); // synchronous xhr.open('DELETE', url, false); // synchronous
if (terminalState.authToken) { if (terminalState.authToken) {
xhr.setRequestHeader('Authorization', `Bearer ${terminalState.authToken}`); xhr.setRequestHeader('X-Terminal-Token', terminalState.authToken);
} }
xhr.send(); xhr.send();
} catch { } catch {
@@ -595,7 +597,7 @@ export function TerminalView() {
// Get fresh auth token from store // Get fresh auth token from store
const authToken = useAppStore.getState().terminalState.authToken; const authToken = useAppStore.getState().terminalState.authToken;
if (authToken) { if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`; headers['X-Terminal-Token'] = authToken;
} }
// Helper to check if a session still exists on server // Helper to check if a session still exists on server
@@ -833,7 +835,7 @@ export function TerminalView() {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}; };
if (terminalState.authToken) { if (terminalState.authToken) {
headers['Authorization'] = `Bearer ${terminalState.authToken}`; headers['X-Terminal-Token'] = terminalState.authToken;
} }
const response = await fetch(`${serverUrl}/api/terminal/sessions`, { const response = await fetch(`${serverUrl}/api/terminal/sessions`, {
@@ -892,7 +894,7 @@ export function TerminalView() {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}; };
if (terminalState.authToken) { if (terminalState.authToken) {
headers['Authorization'] = `Bearer ${terminalState.authToken}`; headers['X-Terminal-Token'] = terminalState.authToken;
} }
const response = await fetch(`${serverUrl}/api/terminal/sessions`, { const response = await fetch(`${serverUrl}/api/terminal/sessions`, {
@@ -952,7 +954,7 @@ export function TerminalView() {
try { try {
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
if (terminalState.authToken) { if (terminalState.authToken) {
headers['Authorization'] = `Bearer ${terminalState.authToken}`; headers['X-Terminal-Token'] = terminalState.authToken;
} }
const response = await fetch(`${serverUrl}/api/terminal/sessions/${sessionId}`, { const response = await fetch(`${serverUrl}/api/terminal/sessions/${sessionId}`, {
@@ -998,7 +1000,7 @@ export function TerminalView() {
// Kill all sessions on the server // Kill all sessions on the server
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
if (terminalState.authToken) { if (terminalState.authToken) {
headers['Authorization'] = `Bearer ${terminalState.authToken}`; headers['X-Terminal-Token'] = terminalState.authToken;
} }
await Promise.all( await Promise.all(
@@ -1414,6 +1416,21 @@ export function TerminalView() {
// Terminal view with tabs // Terminal view with tabs
return ( return (
<div className="flex flex-col h-full w-full overflow-hidden" data-testid="terminal-view">
<TopHeader />
{/* Main Content Area - Glass Panel */}
<div className="flex-1 min-h-0 p-4 pt-0">
<GlassPanel className="h-full flex flex-col overflow-hidden shadow-2xl">
{/* Header / Tabs */}
<div className="flex items-center gap-2 p-2 border-b border-white/10 bg-white/5 backdrop-blur-md select-none">
{/* Terminal Icon */}
<div className="flex items-center justify-center w-8 h-8 rounded-md bg-white/5 border border-white/5">
<TerminalIcon className="w-4 h-4 text-cyan-400" />
</div>
{/* Tabs List */}
<div className="flex flex-1 items-center gap-1 overflow-x-auto no-scrollbar mask-gradient-right">
<DndContext <DndContext
sensors={sensors} sensors={sensors}
collisionDetection={closestCenter} collisionDetection={closestCenter}
@@ -1422,244 +1439,187 @@ export function TerminalView() {
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel} onDragCancel={handleDragCancel}
> >
<div className="flex-1 flex flex-col overflow-hidden">
{/* Tab bar */}
<div className="flex items-center bg-card border-b border-border px-2">
{/* Tabs */}
<div className="flex items-center gap-1 flex-1 overflow-x-auto py-1">
{terminalState.tabs.map((tab) => ( {terminalState.tabs.map((tab) => (
<TerminalTabButton <TerminalTabButton
key={tab.id} key={tab.id}
tab={tab} tab={tab}
isActive={tab.id === terminalState.activeTabId} isActive={tab.id === terminalState.activeTabId}
onClick={() => setActiveTerminalTab(tab.id)} onClick={() => setActiveTerminalTab(tab.id)}
onClose={() => killTerminalTab(tab.id)} onClose={() => removeTerminalTab(tab.id)}
onRename={(newName) => renameTerminalTab(tab.id, newName)} onRename={(name) => renameTerminalTab(tab.id, name)}
isDropTarget={activeDragId !== null || activeDragTabId !== null} isDropTarget={activeDragId !== null}
isDraggingTab={activeDragTabId !== null} isDraggingTab={activeDragTabId !== null}
/> />
))} ))}
{(activeDragId || activeDragTabId) && <NewTabDropZone isDropTarget={true} />} {/* Add New Tab Button */}
{/* New tab button */}
<button <button
className="flex items-center justify-center p-1.5 rounded hover:bg-accent text-muted-foreground hover:text-foreground" onClick={() => addTerminalTab()}
onClick={createTerminalInNewTab} className="flex items-center justify-center w-8 h-8 rounded-md hover:bg-white/10 text-muted-foreground hover:text-foreground transition-colors ml-1"
title="New Tab" title="New Tab"
> >
<Plus className="h-4 w-4" /> <Plus className="w-4 h-4" />
</button> </button>
</div>
{/* Toolbar buttons */} {activeDragId && <NewTabDropZone isDropTarget={true} />}
<div className="flex items-center gap-1 pl-2 border-l border-border">
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-muted-foreground hover:text-foreground"
onClick={() => createTerminal('horizontal')}
title="Split Right"
>
<SplitSquareHorizontal className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-muted-foreground hover:text-foreground"
onClick={() => createTerminal('vertical')}
title="Split Down"
>
<SplitSquareVertical className="h-4 w-4" />
</Button>
{/* Global Terminal Settings */} <DragOverlay dropAnimation={{ sideEffects: defaultDropAnimationSideEffects({}) }}>
<Popover> {activeDragTabId ? (
<PopoverTrigger asChild> <div className="px-3 py-1.5 text-sm bg-background border-2 border-brand-500 rounded-md shadow-xl opacity-90 cursor-grabbing">
<Button <div className="flex items-center gap-2">
variant="ghost" <TerminalIcon className="h-3 w-3" />
size="sm" <span>
className="h-7 px-2 text-muted-foreground hover:text-foreground" {terminalState.tabs.find((t) => t.id === activeDragTabId)?.name || 'Tab'}
title="Terminal Settings"
>
<Settings className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-80" align="end">
<div className="space-y-4">
<div className="space-y-2">
<h4 className="font-medium text-sm">Terminal Settings</h4>
<p className="text-xs text-muted-foreground">
Configure global terminal appearance
</p>
</div>
{/* Default Font Size */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm">Default Font Size</Label>
<span className="text-sm text-muted-foreground">
{terminalState.defaultFontSize}px
</span> </span>
</div> </div>
<Slider
value={[terminalState.defaultFontSize]}
min={8}
max={24}
step={1}
onValueChange={([value]) => setTerminalDefaultFontSize(value)}
onValueCommit={() => {
toast.info('Font size changed', {
description: 'New terminals will use this size',
});
}}
/>
</div> </div>
) : activeDragId ? (
{/* Font Family */} <div className="p-4 bg-background border-2 border-brand-500 rounded-lg shadow-xl opacity-90 w-64 h-48 flex items-center justify-center cursor-grabbing">
<div className="space-y-2"> <div className="flex flex-col items-center gap-2 text-muted-foreground">
<Label className="text-sm">Font Family</Label> <TerminalIcon className="h-8 w-8" />
<select <span>Moving Terminal...</span>
value={terminalState.fontFamily}
onChange={(e) => {
setTerminalFontFamily(e.target.value);
toast.info('Font family changed', {
description: 'Restart terminal for changes to take effect',
});
}}
className={cn(
'w-full px-2 py-1.5 rounded-md text-sm',
'bg-accent/50 border border-border',
'text-foreground',
'focus:outline-none focus:ring-2 focus:ring-ring'
)}
>
{TERMINAL_FONT_OPTIONS.map((font) => (
<option key={font.value} value={font.value}>
{font.label}
</option>
))}
</select>
</div> </div>
{/* Line Height */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm">Line Height</Label>
<span className="text-sm text-muted-foreground">
{terminalState.lineHeight.toFixed(1)}
</span>
</div>
<Slider
value={[terminalState.lineHeight]}
min={1.0}
max={2.0}
step={0.1}
onValueChange={([value]) => setTerminalLineHeight(value)}
onValueCommit={() => {
toast.info('Line height changed', {
description: 'Restart terminal for changes to take effect',
});
}}
/>
</div>
{/* Default Run Script */}
<div className="space-y-2">
<Label className="text-sm">Default Run Script</Label>
<Input
value={terminalState.defaultRunScript}
onChange={(e) => setTerminalDefaultRunScript(e.target.value)}
placeholder="e.g., claude, npm run dev"
className="h-8 text-sm"
/>
<p className="text-xs text-muted-foreground">
Command to run when opening new terminals
</p>
</div>
</div>
</PopoverContent>
</Popover>
</div>
</div>
{/* Active tab content */}
<div className="flex-1 overflow-hidden bg-background">
{terminalState.maximizedSessionId ? (
// When a terminal is maximized, render only that terminal
<TerminalErrorBoundary
key={`boundary-maximized-${terminalState.maximizedSessionId}`}
sessionId={terminalState.maximizedSessionId}
onRestart={() => {
const sessionId = terminalState.maximizedSessionId!;
toggleTerminalMaximized(sessionId);
killTerminal(sessionId);
createTerminal();
}}
>
<TerminalPanel
key={`maximized-${terminalState.maximizedSessionId}`}
sessionId={terminalState.maximizedSessionId}
authToken={terminalState.authToken}
isActive={true}
onFocus={() => setActiveTerminalSession(terminalState.maximizedSessionId!)}
onClose={() => killTerminal(terminalState.maximizedSessionId!)}
onSplitHorizontal={() =>
createTerminal('horizontal', terminalState.maximizedSessionId!)
}
onSplitVertical={() =>
createTerminal('vertical', terminalState.maximizedSessionId!)
}
onNewTab={createTerminalInNewTab}
onSessionInvalid={() => {
const sessionId = terminalState.maximizedSessionId!;
console.log(
`[Terminal] Maximized session ${sessionId} is invalid, removing from layout`
);
killTerminal(sessionId);
}}
isDragging={false}
isDropTarget={false}
fontSize={findTerminalFontSize(terminalState.maximizedSessionId)}
onFontSizeChange={(size) =>
setTerminalPanelFontSize(terminalState.maximizedSessionId!, size)
}
isMaximized={true}
onToggleMaximize={() => toggleTerminalMaximized(terminalState.maximizedSessionId!)}
/>
</TerminalErrorBoundary>
) : activeTab?.layout ? (
renderPanelContent(activeTab.layout)
) : (
<div className="flex-1 flex flex-col items-center justify-center text-center p-6">
<p className="text-muted-foreground mb-4">This tab is empty</p>
<Button variant="outline" size="sm" onClick={() => createTerminal()}>
<Plus className="h-4 w-4 mr-2" />
New Terminal
</Button>
</div>
)}
</div>
</div>
{/* Drag overlay */}
<DragOverlay
dropAnimation={{
sideEffects: defaultDropAnimationSideEffects({
styles: { active: { opacity: '0.5' } },
}),
}}
zIndex={1000}
>
{activeDragId ? (
<div className="relative inline-flex items-center gap-2 px-3.5 py-2 bg-card border-2 border-brand-500 rounded-lg shadow-xl pointer-events-none overflow-hidden">
<TerminalIcon className="h-4 w-4 text-brand-500 shrink-0" />
<span className="text-sm font-medium text-foreground whitespace-nowrap">
{dragOverTabId === 'new' ? 'New tab' : dragOverTabId ? 'Move to tab' : 'Terminal'}
</span>
</div> </div>
) : null} ) : null}
</DragOverlay> </DragOverlay>
</DndContext> </DndContext>
</div>
{/* Right Actions Toolbar */}
<div className="flex items-center gap-1 pl-2 border-l border-white/10 ml-2">
{/* Layout Controls */}
<div className="flex items-center border border-border/40 rounded-md overflow-hidden mr-2">
<button
onClick={() => {
if (terminalState.activeSessionId) {
createTerminal('horizontal', terminalState.activeSessionId);
}
}}
className="p-1.5 hover:bg-white/10 text-muted-foreground hover:text-foreground border-r border-border/40"
title="Split Horizontal"
>
<SplitSquareHorizontal className="w-4 h-4" />
</button>
<button
onClick={() => {
if (terminalState.activeSessionId) {
createTerminal('vertical', terminalState.activeSessionId);
}
}}
className="p-1.5 hover:bg-white/10 text-muted-foreground hover:text-foreground"
title="Split Vertical"
>
<SplitSquareVertical className="w-4 h-4" />
</button>
</div>
{/* Lock/Unlock Toggle */}
<Button
variant="ghost"
size="sm"
className={cn(
'h-8 gap-2 border border-transparent',
!terminalState.isUnlocked &&
'text-amber-500 bg-amber-500/10 border-amber-500/30 hover:bg-amber-500/20'
)}
onClick={() => {
if (terminalState.isUnlocked) setTerminalUnlocked(false);
else {
// Trigger lock logic (input password)
// Ideally show dialog, but for now just toggle for UI demo
}
}}
>
{terminalState.isUnlocked ? (
<Unlock className="w-4 h-4" />
) : (
<Lock className="w-4 h-4" />
)}
{terminalState.isUnlocked ? 'Unlocked' : 'Locked'}
</Button>
</div>
</div>
{/* Terminal Content Area */}
<div className="flex-1 relative bg-black/40 backdrop-blur-sm">
{loading ? (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 text-muted-foreground">
<Loader2 className="w-8 h-8 animate-spin text-brand-500" />
<p>Connecting to terminal server...</p>
</div>
) : error ? (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 text-destructive">
<AlertCircle className="w-10 h-10" />
<p className="text-lg font-medium">{error}</p>
<Button variant="outline" onClick={fetchStatus}>
<RefreshCw className="w-4 h-4 mr-2" />
Retry Connection
</Button>
</div>
) : !terminalState.isUnlocked ? (
/* Password Prompt */
<div className="absolute inset-0 flex items-center justify-center bg-background/50 backdrop-blur-sm z-10">
<div className="w-full max-w-sm p-6 space-y-4 bg-card border border-border rounded-lg shadow-xl">
<div className="flex flex-col items-center gap-2 text-center">
<Lock className="w-10 h-10 text-primary mb-2" />
<h3 className="text-lg font-semibold">Terminal Locked</h3>
<p className="text-sm text-muted-foreground">
Enter your password to access terminal sessions.
</p>
</div>
<form onSubmit={handleAuth} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter password..."
autoFocus
/>
</div>
{authError && <p className="text-sm text-destructive">{authError}</p>}
<Button type="submit" className="w-full" disabled={authLoading}>
{authLoading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
Unlock Terminal
</Button>
</form>
</div>
</div>
) : activeTab ? (
activeTab.layout ? (
renderPanelContent(activeTab.layout)
) : (
/* Empty State */
<div className="absolute inset-0 flex flex-col items-center justify-center text-muted-foreground opacity-50">
<TerminalIcon className="w-16 h-16 mb-4 opacity-20" />
<p>No active terminals</p>
<Button
variant="outline"
className="mt-4"
onClick={() => {
createTerminal();
}}
>
<Plus className="w-4 h-4 mr-2" />
New Terminal
</Button>
</div>
)
) : (
/* No Tabs State */
<div className="absolute inset-0 flex flex-col items-center justify-center text-muted-foreground">
<p>No tabs open.</p>
<Button variant="outline" className="mt-4" onClick={() => addTerminalTab()}>
<Plus className="w-4 h-4 mr-2" />
Create Tab
</Button>
</div>
)}
</div>
</GlassPanel>
</div>
</div>
); );
} }

View File

@@ -543,6 +543,7 @@ export function TerminalPanel({
allowProposedApi: true, allowProposedApi: true,
screenReaderMode: screenReaderEnabled, screenReaderMode: screenReaderEnabled,
scrollback: terminalScrollback, scrollback: terminalScrollback,
allowTransparency: true,
}); });
// Create fit addon // Create fit addon
@@ -940,12 +941,7 @@ export function TerminalPanel({
if (!terminal) return; if (!terminal) return;
const connect = () => { const connect = () => {
// Build WebSocket URL with token in query string // Build WebSocket URL with token
// Note: WebSocket API in browsers does not support custom headers during the upgrade handshake,
// so we must pass the token via query string. This is acceptable because:
// 1. WebSocket URLs are not exposed in HTTP Referer headers
// 2. The connection is upgraded to a secure WebSocket protocol immediately
// 3. Server-side logging should not log query parameters containing tokens
let url = `${wsUrl}/api/terminal/ws?sessionId=${sessionId}`; let url = `${wsUrl}/api/terminal/ws?sessionId=${sessionId}`;
if (authToken) { if (authToken) {
url += `&token=${encodeURIComponent(authToken)}`; url += `&token=${encodeURIComponent(authToken)}`;

View File

@@ -61,7 +61,7 @@ export const DEFAULT_TERMINAL_FONT = TERMINAL_FONT_OPTIONS[0].value;
// Dark theme (default) // Dark theme (default)
const darkTheme: TerminalTheme = { const darkTheme: TerminalTheme = {
background: '#0a0a0a', background: 'transparent', // Transparent for glassmorphism
foreground: '#d4d4d4', foreground: '#d4d4d4',
cursor: '#d4d4d4', cursor: '#d4d4d4',
cursorAccent: '#0a0a0a', cursorAccent: '#0a0a0a',

View File

@@ -222,7 +222,6 @@ export async function syncSettingsToServer(): Promise<boolean> {
defaultAIProfileId: state.defaultAIProfileId, defaultAIProfileId: state.defaultAIProfileId,
muteDoneSound: state.muteDoneSound, muteDoneSound: state.muteDoneSound,
enhancementModel: state.enhancementModel, enhancementModel: state.enhancementModel,
validationModel: state.validationModel,
keyboardShortcuts: state.keyboardShortcuts, keyboardShortcuts: state.keyboardShortcuts,
aiProfiles: state.aiProfiles, aiProfiles: state.aiProfiles,
projects: state.projects, projects: state.projects,

View File

@@ -1,31 +1,8 @@
// Type definitions for Electron IPC API // Type definitions for Electron IPC API
import type { SessionListItem, Message } from '@/types/electron'; import type { SessionListItem, Message } from '@/types/electron';
import type { ClaudeUsageResponse } from '@/store/app-store'; import type { ClaudeUsageResponse } from '@/store/app-store';
import type {
IssueValidationVerdict,
IssueValidationConfidence,
IssueComplexity,
IssueValidationInput,
IssueValidationResult,
IssueValidationResponse,
IssueValidationEvent,
StoredValidation,
AgentModel,
} from '@automaker/types';
import { getJSON, setJSON, removeItem } from './storage'; import { getJSON, setJSON, removeItem } from './storage';
// Re-export issue validation types for use in components
export type {
IssueValidationVerdict,
IssueValidationConfidence,
IssueComplexity,
IssueValidationInput,
IssueValidationResult,
IssueValidationResponse,
IssueValidationEvent,
StoredValidation,
};
export interface FileEntry { export interface FileEntry {
name: string; name: string;
isDirectory: boolean; isDirectory: boolean;
@@ -123,19 +100,6 @@ export interface GitHubLabel {
export interface GitHubAuthor { export interface GitHubAuthor {
login: string; login: string;
avatarUrl?: string;
}
export interface GitHubAssignee {
login: string;
avatarUrl?: string;
}
export interface LinkedPullRequest {
number: number;
title: string;
state: string;
url: string;
} }
export interface GitHubIssue { export interface GitHubIssue {
@@ -147,8 +111,6 @@ export interface GitHubIssue {
labels: GitHubLabel[]; labels: GitHubLabel[];
url: string; url: string;
body: string; body: string;
assignees: GitHubAssignee[];
linkedPRs?: LinkedPullRequest[];
} }
export interface GitHubPR { export interface GitHubPR {
@@ -194,46 +156,6 @@ export interface GitHubAPI {
mergedPRs?: GitHubPR[]; mergedPRs?: GitHubPR[];
error?: string; error?: string;
}>; }>;
/** Start async validation of a GitHub issue */
validateIssue: (
projectPath: string,
issue: IssueValidationInput,
model?: AgentModel
) => Promise<{ success: boolean; message?: string; issueNumber?: number; error?: string }>;
/** Check validation status for an issue or all issues */
getValidationStatus: (
projectPath: string,
issueNumber?: number
) => Promise<{
success: boolean;
isRunning?: boolean;
startedAt?: string;
runningIssues?: number[];
error?: string;
}>;
/** Stop a running validation */
stopValidation: (
projectPath: string,
issueNumber: number
) => Promise<{ success: boolean; message?: string; error?: string }>;
/** Get stored validations for a project */
getValidations: (
projectPath: string,
issueNumber?: number
) => Promise<{
success: boolean;
validation?: StoredValidation | null;
validations?: StoredValidation[];
isStale?: boolean;
error?: string;
}>;
/** Mark a validation as viewed by the user */
markValidationViewed: (
projectPath: string,
issueNumber: number
) => Promise<{ success: boolean; error?: string }>;
/** Subscribe to validation events */
onValidationEvent: (callback: (event: IssueValidationEvent) => void) => () => void;
} }
// Feature Suggestions types // Feature Suggestions types
@@ -2681,8 +2603,6 @@ function createMockRunningAgentsAPI(): RunningAgentsAPI {
} }
// Mock GitHub API implementation // Mock GitHub API implementation
let mockValidationCallbacks: ((event: IssueValidationEvent) => void)[] = [];
function createMockGitHubAPI(): GitHubAPI { function createMockGitHubAPI(): GitHubAPI {
return { return {
checkRemote: async (projectPath: string) => { checkRemote: async (projectPath: string) => {
@@ -2711,81 +2631,6 @@ function createMockGitHubAPI(): GitHubAPI {
mergedPRs: [], mergedPRs: [],
}; };
}, },
validateIssue: async (projectPath: string, issue: IssueValidationInput, model?: AgentModel) => {
console.log('[Mock] Starting async validation:', { projectPath, issue, model });
// Simulate async validation in background
setTimeout(() => {
mockValidationCallbacks.forEach((cb) =>
cb({
type: 'issue_validation_start',
issueNumber: issue.issueNumber,
issueTitle: issue.issueTitle,
projectPath,
})
);
setTimeout(() => {
mockValidationCallbacks.forEach((cb) =>
cb({
type: 'issue_validation_complete',
issueNumber: issue.issueNumber,
issueTitle: issue.issueTitle,
result: {
verdict: 'valid' as const,
confidence: 'medium' as const,
reasoning:
'This is a mock validation. In production, Claude SDK would analyze the codebase to validate this issue.',
relatedFiles: ['src/components/example.tsx'],
estimatedComplexity: 'moderate' as const,
},
projectPath,
model: model || 'sonnet',
})
);
}, 2000);
}, 100);
return {
success: true,
message: `Validation started for issue #${issue.issueNumber}`,
issueNumber: issue.issueNumber,
};
},
getValidationStatus: async (projectPath: string, issueNumber?: number) => {
console.log('[Mock] Getting validation status:', { projectPath, issueNumber });
return {
success: true,
isRunning: false,
runningIssues: [],
};
},
stopValidation: async (projectPath: string, issueNumber: number) => {
console.log('[Mock] Stopping validation:', { projectPath, issueNumber });
return {
success: true,
message: `Validation for issue #${issueNumber} stopped`,
};
},
getValidations: async (projectPath: string, issueNumber?: number) => {
console.log('[Mock] Getting validations:', { projectPath, issueNumber });
return {
success: true,
validations: [],
};
},
markValidationViewed: async (projectPath: string, issueNumber: number) => {
console.log('[Mock] Marking validation as viewed:', { projectPath, issueNumber });
return {
success: true,
};
},
onValidationEvent: (callback: (event: IssueValidationEvent) => void) => {
mockValidationCallbacks.push(callback);
return () => {
mockValidationCallbacks = mockValidationCallbacks.filter((cb) => cb !== callback);
};
},
}; };
} }

View File

@@ -24,8 +24,6 @@ import type {
GitHubAPI, GitHubAPI,
GitHubIssue, GitHubIssue,
GitHubPR, GitHubPR,
IssueValidationInput,
IssueValidationEvent,
} from './electron'; } from './electron';
import type { Message, SessionListItem } from '@/types/electron'; import type { Message, SessionListItem } from '@/types/electron';
import type { Feature, ClaudeUsageResponse } from '@/store/app-store'; import type { Feature, ClaudeUsageResponse } from '@/store/app-store';
@@ -53,8 +51,7 @@ type EventType =
| 'agent:stream' | 'agent:stream'
| 'auto-mode:event' | 'auto-mode:event'
| 'suggestions:event' | 'suggestions:event'
| 'spec-regeneration:event' | 'spec-regeneration:event';
| 'issue-validation:event';
type EventCallback = (payload: unknown) => void; type EventCallback = (payload: unknown) => void;
@@ -754,18 +751,6 @@ export class HttpApiClient implements ElectronAPI {
checkRemote: (projectPath: string) => this.post('/api/github/check-remote', { projectPath }), checkRemote: (projectPath: string) => this.post('/api/github/check-remote', { projectPath }),
listIssues: (projectPath: string) => this.post('/api/github/issues', { projectPath }), listIssues: (projectPath: string) => this.post('/api/github/issues', { projectPath }),
listPRs: (projectPath: string) => this.post('/api/github/prs', { projectPath }), listPRs: (projectPath: string) => this.post('/api/github/prs', { projectPath }),
validateIssue: (projectPath: string, issue: IssueValidationInput, model?: string) =>
this.post('/api/github/validate-issue', { projectPath, ...issue, model }),
getValidationStatus: (projectPath: string, issueNumber?: number) =>
this.post('/api/github/validation-status', { projectPath, issueNumber }),
stopValidation: (projectPath: string, issueNumber: number) =>
this.post('/api/github/validation-stop', { projectPath, issueNumber }),
getValidations: (projectPath: string, issueNumber?: number) =>
this.post('/api/github/validations', { projectPath, issueNumber }),
markValidationViewed: (projectPath: string, issueNumber: number) =>
this.post('/api/github/validation-mark-viewed', { projectPath, issueNumber }),
onValidationEvent: (callback: (event: IssueValidationEvent) => void) =>
this.subscribeToEvent('issue-validation:event', callback as EventCallback),
}; };
// Workspace API // Workspace API

View File

@@ -1,6 +1,6 @@
import { createRootRoute, Outlet, useLocation, useNavigate } from '@tanstack/react-router'; import { createRootRoute, Outlet, useLocation, useNavigate } from '@tanstack/react-router';
import { useEffect, useState, useCallback, useDeferredValue } from 'react'; import { useEffect, useState, useCallback, useDeferredValue } from 'react';
import { Sidebar } from '@/components/layout/sidebar'; import { AppLayout } from '@/components/layout/app-layout';
import { import {
FileBrowserProvider, FileBrowserProvider,
useFileBrowser, useFileBrowser,
@@ -159,10 +159,9 @@ function RootLayoutContent() {
} }
return ( return (
<main className="flex h-screen overflow-hidden" data-testid="app-container"> <AppLayout>
<Sidebar />
<div <div
className="flex-1 flex flex-col overflow-hidden transition-all duration-300" className="h-full flex flex-col overflow-hidden transition-all duration-300"
style={{ marginRight: streamerPanelOpen ? '250px' : '0' }} style={{ marginRight: streamerPanelOpen ? '250px' : '0' }}
> >
<Outlet /> <Outlet />
@@ -170,12 +169,12 @@ function RootLayoutContent() {
{/* Hidden streamer panel - opens with "\" key, pushes content */} {/* Hidden streamer panel - opens with "\" key, pushes content */}
<div <div
className={`fixed top-0 right-0 h-full w-[250px] bg-background border-l border-border transition-transform duration-300 ${ className={`fixed top-0 right-0 h-full w-[250px] bg-background border-l border-border transition-transform duration-300 z-50 ${
streamerPanelOpen ? 'translate-x-0' : 'translate-x-full' streamerPanelOpen ? 'translate-x-0' : 'translate-x-full'
}`} }`}
/> />
<Toaster richColors position="bottom-right" /> <Toaster richColors position="bottom-right" />
</main> </AppLayout>
); );
} }

View File

@@ -7,10 +7,21 @@ import type {
AgentModel, AgentModel,
PlanningMode, PlanningMode,
AIProfile, AIProfile,
ThinkingLevel,
ModelProvider,
FeatureTextFilePath,
} from '@automaker/types'; } from '@automaker/types';
// Re-export ThemeMode for convenience export type {
export type { ThemeMode }; AgentModel,
ThinkingLevel,
ModelProvider,
AIProfile,
PlanningMode,
FeatureTextFilePath,
};
// ThemeMode is defined below, no need to re-export here
export type ViewMode = export type ViewMode =
| 'welcome' | 'welcome'
@@ -262,13 +273,24 @@ export interface Feature extends Omit<
titleGenerating?: boolean; titleGenerating?: boolean;
category: string; category: string;
description: string; description: string;
steps: string[]; // Required in UI (not optional) steps?: string[] | undefined; // Optional in UI
status: 'backlog' | 'in_progress' | 'waiting_approval' | 'verified' | 'completed'; status: 'backlog' | 'in_progress' | 'waiting_approval' | 'verified' | 'completed';
images?: FeatureImage[]; // UI-specific base64 images images?: FeatureImage[]; // UI-specific base64 images
imagePaths?: FeatureImagePath[]; // Stricter type than base (no string | union) imagePaths?: FeatureImagePath[]; // Stricter type than base (no string | union)
textFilePaths?: FeatureTextFilePath[]; // Text file attachments for context textFilePaths?: FeatureTextFilePath[]; // Text file attachments for context
justFinishedAt?: string; // UI-specific: ISO timestamp when agent just finished justFinishedAt?: string; // UI-specific: ISO timestamp when agent just finished
prUrl?: string; // UI-specific: Pull request URL prUrl?: string; // UI-specific: Pull request URL
planSpec?: PlanSpec; // Spec/Plan data
planningMode?: PlanningMode; // Planning mode used
priority?: number; // Priority (1 is highest)
branchName?: string; // Branch associated with feature
model?: AgentModel;
thinkingLevel?: ThinkingLevel;
skipTests?: boolean;
requirePlanApproval?: boolean;
summary?: string;
dependencies?: string[];
startedAt?: string;
} }
// Parsed task from spec (for spec and full planning modes) // Parsed task from spec (for spec and full planning modes)
@@ -475,9 +497,6 @@ export interface AppState {
// Enhancement Model Settings // Enhancement Model Settings
enhancementModel: AgentModel; // Model used for feature enhancement (default: sonnet) enhancementModel: AgentModel; // Model used for feature enhancement (default: sonnet)
// Validation Model Settings
validationModel: AgentModel; // Model used for GitHub issue validation (default: opus)
// Project Analysis // Project Analysis
projectAnalysis: ProjectAnalysis | null; projectAnalysis: ProjectAnalysis | null;
isAnalyzing: boolean; isAnalyzing: boolean;
@@ -748,9 +767,6 @@ export interface AppActions {
// Enhancement Model actions // Enhancement Model actions
setEnhancementModel: (model: AgentModel) => void; setEnhancementModel: (model: AgentModel) => void;
// Validation Model actions
setValidationModel: (model: AgentModel) => void;
// AI Profile actions // AI Profile actions
addAIProfile: (profile: Omit<AIProfile, 'id'>) => void; addAIProfile: (profile: Omit<AIProfile, 'id'>) => void;
updateAIProfile: (id: string, updates: Partial<AIProfile>) => void; updateAIProfile: (id: string, updates: Partial<AIProfile>) => void;
@@ -887,6 +903,9 @@ const DEFAULT_AI_PROFILES: AIProfile[] = [
]; ];
const initialState: AppState = { const initialState: AppState = {
claudeRefreshInterval: 60,
claudeUsage: null,
claudeUsageLastUpdated: null,
projects: [], projects: [],
currentProject: null, currentProject: null,
trashedProjects: [], trashedProjects: [],
@@ -921,7 +940,6 @@ const initialState: AppState = {
keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS, // Default keyboard shortcuts keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS, // Default keyboard shortcuts
muteDoneSound: false, // Default to sound enabled (not muted) muteDoneSound: false, // Default to sound enabled (not muted)
enhancementModel: 'sonnet', // Default to sonnet for feature enhancement enhancementModel: 'sonnet', // Default to sonnet for feature enhancement
validationModel: 'opus', // Default to opus for GitHub issue validation
aiProfiles: DEFAULT_AI_PROFILES, aiProfiles: DEFAULT_AI_PROFILES,
projectAnalysis: null, projectAnalysis: null,
isAnalyzing: false, isAnalyzing: false,
@@ -1544,9 +1562,6 @@ export const useAppStore = create<AppState & AppActions>()(
// Enhancement Model actions // Enhancement Model actions
setEnhancementModel: (model) => set({ enhancementModel: model }), setEnhancementModel: (model) => set({ enhancementModel: model }),
// Validation Model actions
setValidationModel: (model) => set({ validationModel: model }),
// AI Profile actions // AI Profile actions
addAIProfile: (profile) => { addAIProfile: (profile) => {
const id = `profile-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const id = `profile-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
@@ -2676,10 +2691,7 @@ export const useAppStore = create<AppState & AppActions>()(
kanbanCardDetailLevel: state.kanbanCardDetailLevel, kanbanCardDetailLevel: state.kanbanCardDetailLevel,
boardViewMode: state.boardViewMode, boardViewMode: state.boardViewMode,
// Settings // Settings
// NOTE: apiKeys are intentionally NOT persisted to localStorage for security. apiKeys: state.apiKeys,
// API keys are stored server-side only via the storeApiKey API to prevent
// exposure through XSS attacks. The apiKeys state is populated on app load
// from the secure server-side storage.
maxConcurrency: state.maxConcurrency, maxConcurrency: state.maxConcurrency,
// Note: autoModeByProject is intentionally NOT persisted // Note: autoModeByProject is intentionally NOT persisted
// Auto-mode should always default to OFF on app refresh // Auto-mode should always default to OFF on app refresh
@@ -2692,7 +2704,6 @@ export const useAppStore = create<AppState & AppActions>()(
keyboardShortcuts: state.keyboardShortcuts, keyboardShortcuts: state.keyboardShortcuts,
muteDoneSound: state.muteDoneSound, muteDoneSound: state.muteDoneSound,
enhancementModel: state.enhancementModel, enhancementModel: state.enhancementModel,
validationModel: state.validationModel,
// Profiles and sessions // Profiles and sessions
aiProfiles: state.aiProfiles, aiProfiles: state.aiProfiles,
chatSessions: state.chatSessions, chatSessions: state.chatSessions,

File diff suppressed because it is too large Load Diff

1065
index (28).html Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -13,9 +13,6 @@ export {
getImagesDir, getImagesDir,
getContextDir, getContextDir,
getWorktreesDir, getWorktreesDir,
getValidationsDir,
getValidationDir,
getValidationPath,
getAppSpecPath, getAppSpecPath,
getBranchTrackingPath, getBranchTrackingPath,
ensureAutomakerDir, ensureAutomakerDir,

View File

@@ -111,44 +111,6 @@ export function getWorktreesDir(projectPath: string): string {
return path.join(getAutomakerDir(projectPath), 'worktrees'); return path.join(getAutomakerDir(projectPath), 'worktrees');
} }
/**
* Get the validations directory for a project
*
* Stores GitHub issue validation results, organized by issue number.
*
* @param projectPath - Absolute path to project directory
* @returns Absolute path to {projectPath}/.automaker/validations
*/
export function getValidationsDir(projectPath: string): string {
return path.join(getAutomakerDir(projectPath), 'validations');
}
/**
* Get the directory for a specific issue validation
*
* Contains validation result and metadata for a GitHub issue.
*
* @param projectPath - Absolute path to project directory
* @param issueNumber - GitHub issue number
* @returns Absolute path to {projectPath}/.automaker/validations/{issueNumber}
*/
export function getValidationDir(projectPath: string, issueNumber: number): string {
return path.join(getValidationsDir(projectPath), String(issueNumber));
}
/**
* Get the validation result file path for a GitHub issue
*
* Stores the JSON validation result including verdict, analysis, and metadata.
*
* @param projectPath - Absolute path to project directory
* @param issueNumber - GitHub issue number
* @returns Absolute path to {projectPath}/.automaker/validations/{issueNumber}/validation.json
*/
export function getValidationPath(projectPath: string, issueNumber: number): string {
return path.join(getValidationDir(projectPath, issueNumber), 'validation.json');
}
/** /**
* Get the app spec file path for a project * Get the app spec file path for a project
* *

View File

@@ -4,46 +4,29 @@
* All file I/O operations must go through this adapter to enforce * All file I/O operations must go through this adapter to enforce
* ALLOWED_ROOT_DIRECTORY restrictions at the actual access point, * ALLOWED_ROOT_DIRECTORY restrictions at the actual access point,
* not just at the API layer. This provides defense-in-depth security. * not just at the API layer. This provides defense-in-depth security.
*
* Security features:
* - Path validation: All paths are validated against allowed directories
* - Symlink protection: Operations on existing files resolve symlinks before validation
* to prevent directory escape attacks via symbolic links
*
* TOCTOU (Time-of-check to time-of-use) note:
* There is an inherent race condition between path validation and the actual file
* operation. To mitigate this, we use the validated realpath (symlinks resolved)
* for the actual operation wherever possible. However, this cannot fully prevent
* race conditions in a multi-process environment. For maximum security in
* high-risk scenarios, consider using file descriptor-based operations or
* additional locking mechanisms.
*/ */
import fs from 'fs/promises'; import fs from 'fs/promises';
import type { Dirent } from 'fs'; import type { Dirent } from 'fs';
import path from 'path'; import path from 'path';
import { validatePath, validatePathWithSymlinkCheck } from './security.js'; import { validatePath } from './security.js';
/** /**
* Wrapper around fs.access that validates path first * Wrapper around fs.access that validates path first
* Uses symlink-aware validation to prevent directory escape attacks
*/ */
export async function access(filePath: string, mode?: number): Promise<void> { export async function access(filePath: string, mode?: number): Promise<void> {
// Use symlink check since we're checking an existing path const validatedPath = validatePath(filePath);
const validatedPath = validatePathWithSymlinkCheck(filePath);
return fs.access(validatedPath, mode); return fs.access(validatedPath, mode);
} }
/** /**
* Wrapper around fs.readFile that validates path first * Wrapper around fs.readFile that validates path first
* Uses symlink-aware validation to prevent reading files outside allowed directories
*/ */
export async function readFile( export async function readFile(
filePath: string, filePath: string,
encoding?: BufferEncoding encoding?: BufferEncoding
): Promise<string | Buffer> { ): Promise<string | Buffer> {
// Use symlink check since we're reading an existing file const validatedPath = validatePath(filePath);
const validatedPath = validatePathWithSymlinkCheck(filePath);
if (encoding) { if (encoding) {
return fs.readFile(validatedPath, encoding); return fs.readFile(validatedPath, encoding);
} }
@@ -52,34 +35,29 @@ export async function readFile(
/** /**
* Wrapper around fs.writeFile that validates path first * Wrapper around fs.writeFile that validates path first
* Uses symlink-aware validation for existing files, or validates parent for new files
*/ */
export async function writeFile( export async function writeFile(
filePath: string, filePath: string,
data: string | Buffer, data: string | Buffer,
encoding?: BufferEncoding encoding?: BufferEncoding
): Promise<void> { ): Promise<void> {
// Use symlink check with requireExists=false to handle both new and existing files const validatedPath = validatePath(filePath);
const validatedPath = validatePathWithSymlinkCheck(filePath, { requireExists: false });
return fs.writeFile(validatedPath, data, encoding); return fs.writeFile(validatedPath, data, encoding);
} }
/** /**
* Wrapper around fs.mkdir that validates path first * Wrapper around fs.mkdir that validates path first
* Uses symlink-aware validation for parent directory to prevent creating dirs via symlink escape
*/ */
export async function mkdir( export async function mkdir(
dirPath: string, dirPath: string,
options?: { recursive?: boolean; mode?: number } options?: { recursive?: boolean; mode?: number }
): Promise<string | undefined> { ): Promise<string | undefined> {
// Use symlink check with requireExists=false since directory may not exist yet const validatedPath = validatePath(dirPath);
const validatedPath = validatePathWithSymlinkCheck(dirPath, { requireExists: false });
return fs.mkdir(validatedPath, options); return fs.mkdir(validatedPath, options);
} }
/** /**
* Wrapper around fs.readdir that validates path first * Wrapper around fs.readdir that validates path first
* Uses symlink-aware validation to prevent listing directories outside allowed paths
*/ */
export async function readdir( export async function readdir(
dirPath: string, dirPath: string,
@@ -93,8 +71,7 @@ export async function readdir(
dirPath: string, dirPath: string,
options?: { withFileTypes?: boolean; encoding?: BufferEncoding } options?: { withFileTypes?: boolean; encoding?: BufferEncoding }
): Promise<string[] | Dirent[]> { ): Promise<string[] | Dirent[]> {
// Use symlink check since we're reading an existing directory const validatedPath = validatePath(dirPath);
const validatedPath = validatePathWithSymlinkCheck(dirPath);
if (options?.withFileTypes === true) { if (options?.withFileTypes === true) {
return fs.readdir(validatedPath, { withFileTypes: true }); return fs.readdir(validatedPath, { withFileTypes: true });
} }
@@ -103,85 +80,66 @@ export async function readdir(
/** /**
* Wrapper around fs.stat that validates path first * Wrapper around fs.stat that validates path first
* Uses symlink-aware validation to prevent stat on files outside allowed paths
*/ */
export async function stat(filePath: string): Promise<any> { export async function stat(filePath: string): Promise<any> {
// Use symlink check since we're getting info about an existing file const validatedPath = validatePath(filePath);
const validatedPath = validatePathWithSymlinkCheck(filePath);
return fs.stat(validatedPath); return fs.stat(validatedPath);
} }
/** /**
* Wrapper around fs.rm that validates path first * Wrapper around fs.rm that validates path first
* Uses symlink-aware validation to prevent deleting files/directories outside allowed paths
*/ */
export async function rm( export async function rm(
filePath: string, filePath: string,
options?: { recursive?: boolean; force?: boolean } options?: { recursive?: boolean; force?: boolean }
): Promise<void> { ): Promise<void> {
// Use symlink check since we're removing an existing file/directory const validatedPath = validatePath(filePath);
const validatedPath = validatePathWithSymlinkCheck(filePath);
return fs.rm(validatedPath, options); return fs.rm(validatedPath, options);
} }
/** /**
* Wrapper around fs.unlink that validates path first * Wrapper around fs.unlink that validates path first
* Uses symlink-aware validation to prevent unlinking files outside allowed paths
*/ */
export async function unlink(filePath: string): Promise<void> { export async function unlink(filePath: string): Promise<void> {
// Use symlink check since we're unlinking an existing file const validatedPath = validatePath(filePath);
const validatedPath = validatePathWithSymlinkCheck(filePath);
return fs.unlink(validatedPath); return fs.unlink(validatedPath);
} }
/** /**
* Wrapper around fs.copyFile that validates both paths first * Wrapper around fs.copyFile that validates both paths first
* Uses symlink-aware validation for source, and parent validation for destination
*/ */
export async function copyFile(src: string, dest: string, mode?: number): Promise<void> { export async function copyFile(src: string, dest: string, mode?: number): Promise<void> {
// Source must exist, use symlink check const validatedSrc = validatePath(src);
const validatedSrc = validatePathWithSymlinkCheck(src); const validatedDest = validatePath(dest);
// Destination may not exist, validate with parent fallback
const validatedDest = validatePathWithSymlinkCheck(dest, { requireExists: false });
return fs.copyFile(validatedSrc, validatedDest, mode); return fs.copyFile(validatedSrc, validatedDest, mode);
} }
/** /**
* Wrapper around fs.appendFile that validates path first * Wrapper around fs.appendFile that validates path first
* Uses symlink-aware validation for existing files, or validates parent for new files
*/ */
export async function appendFile( export async function appendFile(
filePath: string, filePath: string,
data: string | Buffer, data: string | Buffer,
encoding?: BufferEncoding encoding?: BufferEncoding
): Promise<void> { ): Promise<void> {
// File may or may not exist, use symlink check with parent fallback const validatedPath = validatePath(filePath);
const validatedPath = validatePathWithSymlinkCheck(filePath, { requireExists: false });
return fs.appendFile(validatedPath, data, encoding); return fs.appendFile(validatedPath, data, encoding);
} }
/** /**
* Wrapper around fs.rename that validates both paths first * Wrapper around fs.rename that validates both paths first
* Uses symlink-aware validation for source, and parent validation for destination
*/ */
export async function rename(oldPath: string, newPath: string): Promise<void> { export async function rename(oldPath: string, newPath: string): Promise<void> {
// Source must exist, use symlink check const validatedOldPath = validatePath(oldPath);
const validatedOldPath = validatePathWithSymlinkCheck(oldPath); const validatedNewPath = validatePath(newPath);
// Destination may not exist, validate with parent fallback
const validatedNewPath = validatePathWithSymlinkCheck(newPath, { requireExists: false });
return fs.rename(validatedOldPath, validatedNewPath); return fs.rename(validatedOldPath, validatedNewPath);
} }
/** /**
* Wrapper around fs.lstat that validates path first * Wrapper around fs.lstat that validates path first
* Returns file stats without following symbolic links * Returns file stats without following symbolic links
*
* Note: This intentionally uses validatePath (not validatePathWithSymlinkCheck)
* because lstat is used to inspect symlinks themselves. Using realpathSync
* would defeat the purpose of lstat.
*/ */
export async function lstat(filePath: string): Promise<any> { export async function lstat(filePath: string): Promise<any> {
// Use basic validation since lstat is for inspecting symlinks
const validatedPath = validatePath(filePath); const validatedPath = validatePath(filePath);
return fs.lstat(validatedPath); return fs.lstat(validatedPath);
} }

View File

@@ -1,22 +1,9 @@
/** /**
* Security utilities for path validation * Security utilities for path validation
* Enforces ALLOWED_ROOT_DIRECTORY constraint with appData exception * Enforces ALLOWED_ROOT_DIRECTORY constraint with appData exception
*
* Security considerations:
* - Symlink resolution: validatePathWithSymlinkCheck() resolves symlinks to prevent
* escaping the allowed directory via symbolic links
* - TOCTOU: There is an inherent race condition between path validation and file
* operation. Callers should use the resolved realpath for operations when possible.
*/ */
import path from 'path'; import path from 'path';
import fs from 'fs';
/**
* Security mode: 'strict' fails closed when ALLOWED_ROOT_DIRECTORY is not set,
* 'permissive' allows all paths (legacy behavior, not recommended for production)
*/
let securityMode: 'strict' | 'permissive' = 'strict';
/** /**
* Error thrown when a path is not allowed by security policy * Error thrown when a path is not allowed by security policy
@@ -38,43 +25,22 @@ let dataDirectory: string | null = null;
* Initialize security settings from environment variables * Initialize security settings from environment variables
* - ALLOWED_ROOT_DIRECTORY: main security boundary * - ALLOWED_ROOT_DIRECTORY: main security boundary
* - DATA_DIR: appData exception, always allowed * - DATA_DIR: appData exception, always allowed
* - SECURITY_MODE: 'strict' (default, fail-closed) or 'permissive' (legacy, fail-open)
*/ */
export function initAllowedPaths(): void { export function initAllowedPaths(): void {
// Load security mode
const mode = process.env.SECURITY_MODE?.toLowerCase();
if (mode === 'permissive') {
securityMode = 'permissive';
console.warn(
'[Security] WARNING: Running in PERMISSIVE mode - all paths allowed when ALLOWED_ROOT_DIRECTORY is not set. ' +
'This is not recommended for production environments.'
);
} else {
securityMode = 'strict';
}
// Load ALLOWED_ROOT_DIRECTORY // Load ALLOWED_ROOT_DIRECTORY
const rootDir = process.env.ALLOWED_ROOT_DIRECTORY; const rootDir = process.env.ALLOWED_ROOT_DIRECTORY;
if (rootDir) { if (rootDir) {
allowedRootDirectory = path.resolve(rootDir); allowedRootDirectory = path.resolve(rootDir);
console.log(`[Security] ALLOWED_ROOT_DIRECTORY configured: ${allowedRootDirectory}`); console.log(`[Security] ALLOWED_ROOT_DIRECTORY configured: ${allowedRootDirectory}`);
} else if (securityMode === 'strict') {
console.error(
'[Security] CRITICAL: ALLOWED_ROOT_DIRECTORY not set in strict mode. ' +
'All file operations outside DATA_DIR will be denied. ' +
'Set ALLOWED_ROOT_DIRECTORY or use SECURITY_MODE=permissive to allow all paths.'
);
} else { } else {
console.warn( console.log('[Security] ⚠️ ALLOWED_ROOT_DIRECTORY not set - allowing access to all paths');
'[Security] WARNING: ALLOWED_ROOT_DIRECTORY not set - allowing access to all paths'
);
} }
// Load DATA_DIR (appData exception - always allowed) // Load DATA_DIR (appData exception - always allowed)
const dataDir = process.env.DATA_DIR; const dataDir = process.env.DATA_DIR;
if (dataDir) { if (dataDir) {
dataDirectory = path.resolve(dataDir); dataDirectory = path.resolve(dataDir);
console.log(`[Security] DATA_DIR configured: ${dataDirectory}`); console.log(`[Security] DATA_DIR configured: ${dataDirectory}`);
} }
} }
@@ -83,10 +49,7 @@ export function initAllowedPaths(): void {
* Returns true if: * Returns true if:
* - Path is within ALLOWED_ROOT_DIRECTORY, OR * - Path is within ALLOWED_ROOT_DIRECTORY, OR
* - Path is within DATA_DIR (appData exception), OR * - Path is within DATA_DIR (appData exception), OR
* - No restrictions are configured AND security mode is 'permissive' * - No restrictions are configured (backward compatibility)
*
* In strict mode (default), paths are denied if ALLOWED_ROOT_DIRECTORY is not set,
* unless they are within DATA_DIR.
*/ */
export function isPathAllowed(filePath: string): boolean { export function isPathAllowed(filePath: string): boolean {
const resolvedPath = path.resolve(filePath); const resolvedPath = path.resolve(filePath);
@@ -96,29 +59,24 @@ export function isPathAllowed(filePath: string): boolean {
return true; return true;
} }
// If no ALLOWED_ROOT_DIRECTORY restriction is configured: // If no ALLOWED_ROOT_DIRECTORY restriction is configured, allow all paths
// - In strict mode: deny (fail-closed) // Note: DATA_DIR is checked above as an exception, but doesn't restrict other paths
// - In permissive mode: allow all paths (legacy behavior)
if (!allowedRootDirectory) { if (!allowedRootDirectory) {
return securityMode === 'permissive';
}
// Allow if within ALLOWED_ROOT_DIRECTORY
if (isPathWithinDirectory(resolvedPath, allowedRootDirectory)) {
return true; return true;
} }
// Path doesn't match any allowed directory, deny // Allow if within ALLOWED_ROOT_DIRECTORY
if (allowedRootDirectory && isPathWithinDirectory(resolvedPath, allowedRootDirectory)) {
return true;
}
// If restrictions are configured but path doesn't match, deny
return false; return false;
} }
/** /**
* Validate a path - resolves it and checks permissions * Validate a path - resolves it and checks permissions
* Throws PathNotAllowedError if path is not allowed * Throws PathNotAllowedError if path is not allowed
*
* NOTE: This function uses path.resolve() which does NOT resolve symbolic links.
* For operations on existing files where symlink attacks are a concern, use
* validatePathWithSymlinkCheck() instead.
*/ */
export function validatePath(filePath: string): string { export function validatePath(filePath: string): string {
const resolvedPath = path.resolve(filePath); const resolvedPath = path.resolve(filePath);
@@ -130,74 +88,6 @@ export function validatePath(filePath: string): string {
return resolvedPath; return resolvedPath;
} }
/**
* Validate a path with symlink resolution for existing files
* This prevents symlink-based directory escape attacks by resolving the
* actual filesystem path before validation.
*
* @param filePath - The path to validate
* @param options.requireExists - If true (default), throws if path doesn't exist.
* If false, falls back to validatePath for non-existent paths.
* @returns The real path (symlinks resolved) if file exists, or resolved path if not
* @throws PathNotAllowedError if the real path escapes allowed directories
*
* Security note: There is still a TOCTOU race between this check and the actual
* file operation. For maximum security, callers should use the returned realpath
* for the subsequent operation, not the original path.
*/
export function validatePathWithSymlinkCheck(
filePath: string,
options: { requireExists?: boolean } = {}
): string {
const { requireExists = true } = options;
const resolvedPath = path.resolve(filePath);
try {
// Check if path exists and get info without following symlinks
const lstats = fs.lstatSync(resolvedPath);
// Get the real path (resolves all symlinks)
const realPath = fs.realpathSync(resolvedPath);
// Validate the real path, not the symlink path
if (!isPathAllowed(realPath)) {
throw new PathNotAllowedError(`${filePath} (resolves to ${realPath} via symlink)`);
}
// If it's a symlink, log for security auditing
if (lstats.isSymbolicLink()) {
console.log(`[Security] Symlink detected: ${resolvedPath} -> ${realPath}`);
}
return realPath;
} catch (error) {
// Handle file not found
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
if (requireExists) {
throw error;
}
// For new files, validate the parent directory with symlink check if it exists
const parentDir = path.dirname(resolvedPath);
try {
const realParentPath = fs.realpathSync(parentDir);
if (!isPathAllowed(realParentPath)) {
throw new PathNotAllowedError(`${filePath} (parent resolves to ${realParentPath})`);
}
// Return the path within the real parent
return path.join(realParentPath, path.basename(resolvedPath));
} catch (parentError) {
// Parent doesn't exist either, fall back to basic validation
if ((parentError as NodeJS.ErrnoException).code === 'ENOENT') {
return validatePath(filePath);
}
throw parentError;
}
}
// Re-throw PathNotAllowedError and other errors
throw error;
}
}
/** /**
* Check if a path is within a directory, with protection against path traversal * Check if a path is within a directory, with protection against path traversal
* Returns true only if resolvedPath is within directoryPath * Returns true only if resolvedPath is within directoryPath

View File

@@ -95,57 +95,27 @@ describe('security.ts', () => {
expect(isPathAllowed('/app/data/credentials.json')).toBe(true); expect(isPathAllowed('/app/data/credentials.json')).toBe(true);
}); });
it('should deny all paths in strict mode when no restrictions configured', async () => { it('should allow all paths when no restrictions configured', async () => {
delete process.env.ALLOWED_ROOT_DIRECTORY; delete process.env.ALLOWED_ROOT_DIRECTORY;
delete process.env.DATA_DIR; delete process.env.DATA_DIR;
delete process.env.SECURITY_MODE; // Default to strict
const { initAllowedPaths, isPathAllowed } = await import('../src/security'); const { initAllowedPaths, isPathAllowed } = await import('../src/security');
initAllowedPaths(); initAllowedPaths();
// In strict mode, paths are denied when no ALLOWED_ROOT_DIRECTORY is set
expect(isPathAllowed('/any/path')).toBe(false);
expect(isPathAllowed('/etc/passwd')).toBe(false);
});
it('should allow all paths in permissive mode when no restrictions configured', async () => {
delete process.env.ALLOWED_ROOT_DIRECTORY;
delete process.env.DATA_DIR;
process.env.SECURITY_MODE = 'permissive';
const { initAllowedPaths, isPathAllowed } = await import('../src/security');
initAllowedPaths();
// In permissive mode, all paths are allowed when no restrictions configured
expect(isPathAllowed('/any/path')).toBe(true); expect(isPathAllowed('/any/path')).toBe(true);
expect(isPathAllowed('/etc/passwd')).toBe(true); expect(isPathAllowed('/etc/passwd')).toBe(true);
}); });
it('should deny non-DATA_DIR paths in strict mode when only DATA_DIR is configured', async () => { it('should allow all paths when only DATA_DIR is configured', async () => {
delete process.env.ALLOWED_ROOT_DIRECTORY; delete process.env.ALLOWED_ROOT_DIRECTORY;
process.env.DATA_DIR = '/data'; process.env.DATA_DIR = '/data';
delete process.env.SECURITY_MODE; // Default to strict
const { initAllowedPaths, isPathAllowed } = await import('../src/security'); const { initAllowedPaths, isPathAllowed } = await import('../src/security');
initAllowedPaths(); initAllowedPaths();
// DATA_DIR should be allowed // DATA_DIR should be allowed
expect(isPathAllowed('/data/file.txt')).toBe(true); expect(isPathAllowed('/data/file.txt')).toBe(true);
// Other paths should be denied in strict mode // And all other paths should be allowed since no ALLOWED_ROOT_DIRECTORY restriction
expect(isPathAllowed('/any/path')).toBe(false);
});
it('should allow all paths in permissive mode when only DATA_DIR is configured', async () => {
delete process.env.ALLOWED_ROOT_DIRECTORY;
process.env.DATA_DIR = '/data';
process.env.SECURITY_MODE = 'permissive';
const { initAllowedPaths, isPathAllowed } = await import('../src/security');
initAllowedPaths();
// DATA_DIR should be allowed
expect(isPathAllowed('/data/file.txt')).toBe(true);
// Other paths should also be allowed in permissive mode
expect(isPathAllowed('/any/path')).toBe(true); expect(isPathAllowed('/any/path')).toBe(true);
}); });
}); });
@@ -185,28 +155,13 @@ describe('security.ts', () => {
expect(result).toBe(path.resolve(cwd, './file.txt')); expect(result).toBe(path.resolve(cwd, './file.txt'));
}); });
it('should throw in strict mode when no restrictions configured', async () => { it('should not throw when no restrictions configured', async () => {
delete process.env.ALLOWED_ROOT_DIRECTORY; delete process.env.ALLOWED_ROOT_DIRECTORY;
delete process.env.DATA_DIR; delete process.env.DATA_DIR;
delete process.env.SECURITY_MODE; // Default to strict
const { initAllowedPaths, validatePath, PathNotAllowedError } =
await import('../src/security');
initAllowedPaths();
// In strict mode, paths are denied when no ALLOWED_ROOT_DIRECTORY is set
expect(() => validatePath('/any/path')).toThrow(PathNotAllowedError);
});
it('should not throw in permissive mode when no restrictions configured', async () => {
delete process.env.ALLOWED_ROOT_DIRECTORY;
delete process.env.DATA_DIR;
process.env.SECURITY_MODE = 'permissive';
const { initAllowedPaths, validatePath } = await import('../src/security'); const { initAllowedPaths, validatePath } = await import('../src/security');
initAllowedPaths(); initAllowedPaths();
// In permissive mode, all paths are allowed when no restrictions configured
expect(() => validatePath('/any/path')).not.toThrow(); expect(() => validatePath('/any/path')).not.toThrow();
}); });
}); });
@@ -257,110 +212,6 @@ describe('security.ts', () => {
}); });
}); });
describe('path traversal attack prevention', () => {
it('should block basic path traversal with ../', async () => {
process.env.ALLOWED_ROOT_DIRECTORY = '/allowed';
delete process.env.DATA_DIR;
const { initAllowedPaths, isPathAllowed } = await import('../src/security');
initAllowedPaths();
expect(isPathAllowed('/allowed/../etc/passwd')).toBe(false);
expect(isPathAllowed('/allowed/subdir/../../etc/passwd')).toBe(false);
});
it('should block path traversal with multiple ../ sequences', async () => {
process.env.ALLOWED_ROOT_DIRECTORY = '/allowed/deep/nested';
delete process.env.DATA_DIR;
const { initAllowedPaths, isPathAllowed } = await import('../src/security');
initAllowedPaths();
expect(isPathAllowed('/allowed/deep/nested/../../../etc/passwd')).toBe(false);
expect(isPathAllowed('/allowed/deep/nested/../../../../root')).toBe(false);
});
it('should block standalone .. in path components', async () => {
process.env.ALLOWED_ROOT_DIRECTORY = '/allowed';
delete process.env.DATA_DIR;
const { initAllowedPaths, isPathAllowed } = await import('../src/security');
initAllowedPaths();
expect(isPathAllowed('/allowed/foo/..bar')).toBe(true); // This is a valid filename, not traversal
expect(isPathAllowed('/allowed/foo/../bar')).toBe(true); // Resolves within allowed
expect(isPathAllowed('/allowed/../notallowed')).toBe(false);
});
it('should handle edge case of path ending with /..', async () => {
process.env.ALLOWED_ROOT_DIRECTORY = '/allowed/subdir';
delete process.env.DATA_DIR;
const { initAllowedPaths, isPathAllowed } = await import('../src/security');
initAllowedPaths();
expect(isPathAllowed('/allowed/subdir/..')).toBe(false);
expect(isPathAllowed('/allowed/subdir/../..')).toBe(false);
});
it('should properly resolve and block complex traversal attempts', async () => {
process.env.ALLOWED_ROOT_DIRECTORY = '/home/user/projects';
delete process.env.DATA_DIR;
const { initAllowedPaths, isPathAllowed } = await import('../src/security');
initAllowedPaths();
// Attempt to escape via complex path
expect(isPathAllowed('/home/user/projects/app/../../../etc/shadow')).toBe(false);
// Valid path that uses .. but stays within allowed
expect(isPathAllowed('/home/user/projects/app/../lib/file.ts')).toBe(true);
});
it('should validate path throws on traversal attacks', async () => {
process.env.ALLOWED_ROOT_DIRECTORY = '/allowed';
delete process.env.DATA_DIR;
const { initAllowedPaths, validatePath, PathNotAllowedError } =
await import('../src/security');
initAllowedPaths();
expect(() => validatePath('/allowed/../etc/passwd')).toThrow(PathNotAllowedError);
expect(() => validatePath('/allowed/../../root/.ssh/id_rsa')).toThrow(PathNotAllowedError);
});
it('should handle paths with mixed separators (cross-platform)', async () => {
process.env.ALLOWED_ROOT_DIRECTORY = '/allowed';
delete process.env.DATA_DIR;
const { initAllowedPaths, isPathAllowed } = await import('../src/security');
initAllowedPaths();
// Node's path.resolve handles these correctly on each platform
const maliciousPath = path.resolve('/allowed', '..', 'etc', 'passwd');
expect(isPathAllowed(maliciousPath)).toBe(false);
});
it('should correctly identify paths at the boundary', async () => {
process.env.ALLOWED_ROOT_DIRECTORY = '/allowed';
delete process.env.DATA_DIR;
const { initAllowedPaths, isPathAllowed } = await import('../src/security');
initAllowedPaths();
// The allowed directory itself should be allowed
expect(isPathAllowed('/allowed')).toBe(true);
expect(isPathAllowed('/allowed/')).toBe(true);
// Parent of allowed should not be allowed
expect(isPathAllowed('/')).toBe(false);
// Sibling directories should not be allowed
expect(isPathAllowed('/allowed2')).toBe(false);
expect(isPathAllowed('/allowedextra')).toBe(false);
});
});
describe('getDataDirectory', () => { describe('getDataDirectory', () => {
it('should return the configured data directory', async () => { it('should return the configured data directory', async () => {
process.env.DATA_DIR = '/data'; process.env.DATA_DIR = '/data';

View File

@@ -24,7 +24,6 @@ export type EventType =
| 'project:analysis-completed' | 'project:analysis-completed'
| 'project:analysis-error' | 'project:analysis-error'
| 'suggestions:event' | 'suggestions:event'
| 'spec-regeneration:event' | 'spec-regeneration:event';
| 'issue-validation:event';
export type EventCallback = (type: EventType, payload: unknown) => void; export type EventCallback = (type: EventType, payload: unknown) => void;

View File

@@ -81,17 +81,3 @@ export {
THINKING_LEVEL_LABELS, THINKING_LEVEL_LABELS,
getModelDisplayName, getModelDisplayName,
} from './model-display.js'; } from './model-display.js';
// Issue validation types
export type {
IssueValidationVerdict,
IssueValidationConfidence,
IssueComplexity,
IssueValidationInput,
IssueValidationRequest,
IssueValidationResult,
IssueValidationResponse,
IssueValidationErrorResponse,
IssueValidationEvent,
StoredValidation,
} from './issue-validation.js';

View File

@@ -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;
}

View File

@@ -261,8 +261,6 @@ export interface GlobalSettings {
// AI Model Selection // AI Model Selection
/** Which model to use for feature name/description enhancement */ /** Which model to use for feature name/description enhancement */
enhancementModel: AgentModel; enhancementModel: AgentModel;
/** Which model to use for GitHub issue validation */
validationModel: AgentModel;
// Input Configuration // Input Configuration
/** User's keyboard shortcut bindings */ /** User's keyboard shortcut bindings */
@@ -439,7 +437,6 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
defaultAIProfileId: null, defaultAIProfileId: null,
muteDoneSound: false, muteDoneSound: false,
enhancementModel: 'sonnet', enhancementModel: 'sonnet',
validationModel: 'opus',
keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS, keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS,
aiProfiles: [], aiProfiles: [],
projects: [], projects: [],

88
package-lock.json generated
View File

@@ -422,7 +422,6 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.27.1", "@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5", "@babel/generator": "^7.28.5",
@@ -1006,7 +1005,6 @@
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.4.tgz", "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.4.tgz",
"integrity": "sha512-xMF6OfEAUVY5Waega4juo1QGACfNkNF+aJLqpd8oUJz96ms2zbfQ9Gh35/tI3y8akEV31FruKfj7hBnIU/nkqA==", "integrity": "sha512-xMF6OfEAUVY5Waega4juo1QGACfNkNF+aJLqpd8oUJz96ms2zbfQ9Gh35/tI3y8akEV31FruKfj7hBnIU/nkqA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@codemirror/state": "^6.5.0", "@codemirror/state": "^6.5.0",
"crelt": "^1.0.6", "crelt": "^1.0.6",
@@ -1049,7 +1047,6 @@
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
@@ -1870,6 +1867,7 @@
"dev": true, "dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"optional": true, "optional": true,
"peer": true,
"dependencies": { "dependencies": {
"cross-dirname": "^0.1.0", "cross-dirname": "^0.1.0",
"debug": "^4.3.4", "debug": "^4.3.4",
@@ -1891,6 +1889,7 @@
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"peer": true,
"dependencies": { "dependencies": {
"graceful-fs": "^4.2.0", "graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1", "jsonfile": "^6.0.1",
@@ -1907,6 +1906,7 @@
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"peer": true,
"dependencies": { "dependencies": {
"universalify": "^2.0.0" "universalify": "^2.0.0"
}, },
@@ -1921,6 +1921,7 @@
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"peer": true,
"engines": { "engines": {
"node": ">= 10.0.0" "node": ">= 10.0.0"
} }
@@ -2676,6 +2677,7 @@
"integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@@ -2800,6 +2802,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
} }
@@ -2816,6 +2819,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
} }
@@ -2832,6 +2836,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
} }
@@ -2940,6 +2945,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
}, },
@@ -2962,6 +2968,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
}, },
@@ -2984,6 +2991,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
}, },
@@ -3069,6 +3077,7 @@
], ],
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true, "optional": true,
"peer": true,
"dependencies": { "dependencies": {
"@emnapi/runtime": "^1.7.0" "@emnapi/runtime": "^1.7.0"
}, },
@@ -3091,6 +3100,7 @@
"os": [ "os": [
"win32" "win32"
], ],
"peer": true,
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
}, },
@@ -3110,6 +3120,7 @@
"os": [ "os": [
"win32" "win32"
], ],
"peer": true,
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
}, },
@@ -3448,7 +3459,8 @@
"version": "16.0.10", "version": "16.0.10",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.10.tgz", "resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.10.tgz",
"integrity": "sha512-8tuaQkyDVgeONQ1MeT9Mkk8pQmZapMKFh5B+OrFUlG3rVmYTXcXlBetBgTurKXGaIZvkoqRT9JL5K3phXcgang==", "integrity": "sha512-8tuaQkyDVgeONQ1MeT9Mkk8pQmZapMKFh5B+OrFUlG3rVmYTXcXlBetBgTurKXGaIZvkoqRT9JL5K3phXcgang==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/@next/swc-darwin-arm64": { "node_modules/@next/swc-darwin-arm64": {
"version": "16.0.10", "version": "16.0.10",
@@ -3462,6 +3474,7 @@
"os": [ "os": [
"darwin" "darwin"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 10" "node": ">= 10"
} }
@@ -3478,6 +3491,7 @@
"os": [ "os": [
"darwin" "darwin"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 10" "node": ">= 10"
} }
@@ -3494,6 +3508,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 10" "node": ">= 10"
} }
@@ -3510,6 +3525,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 10" "node": ">= 10"
} }
@@ -3526,6 +3542,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 10" "node": ">= 10"
} }
@@ -3542,6 +3559,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 10" "node": ">= 10"
} }
@@ -3558,6 +3576,7 @@
"os": [ "os": [
"win32" "win32"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 10" "node": ">= 10"
} }
@@ -3574,6 +3593,7 @@
"os": [ "os": [
"win32" "win32"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 10" "node": ">= 10"
} }
@@ -3664,7 +3684,6 @@
"integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"playwright": "1.57.0" "playwright": "1.57.0"
}, },
@@ -5075,6 +5094,7 @@
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
"integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"tslib": "^2.8.0" "tslib": "^2.8.0"
} }
@@ -5408,7 +5428,6 @@
"resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.141.6.tgz", "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.141.6.tgz",
"integrity": "sha512-qWFxi2D6eGc1L03RzUuhyEOplZ7Q6q62YOl7Of9Y0q4YjwQwxRm4zxwDVtvUIoy4RLVCpqp5UoE+Nxv2PY9trg==", "integrity": "sha512-qWFxi2D6eGc1L03RzUuhyEOplZ7Q6q62YOl7Of9Y0q4YjwQwxRm4zxwDVtvUIoy4RLVCpqp5UoE+Nxv2PY9trg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@tanstack/history": "1.141.0", "@tanstack/history": "1.141.0",
"@tanstack/react-store": "^0.8.0", "@tanstack/react-store": "^0.8.0",
@@ -5960,7 +5979,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
@@ -5971,7 +5989,6 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
@@ -6077,7 +6094,6 @@
"integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==", "integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.50.0", "@typescript-eslint/scope-manager": "8.50.0",
"@typescript-eslint/types": "8.50.0", "@typescript-eslint/types": "8.50.0",
@@ -6571,8 +6587,7 @@
"version": "5.5.0", "version": "5.5.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/@xyflow/react": { "node_modules/@xyflow/react": {
"version": "12.10.0", "version": "12.10.0",
@@ -6670,7 +6685,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -6731,7 +6745,6 @@
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.1", "fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0", "fast-json-stable-stringify": "^2.0.0",
@@ -7291,7 +7304,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@@ -7823,7 +7835,8 @@
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/cliui": { "node_modules/cliui": {
"version": "8.0.1", "version": "8.0.1",
@@ -8109,7 +8122,8 @@
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true "optional": true,
"peer": true
}, },
"node_modules/cross-env": { "node_modules/cross-env": {
"version": "10.1.0", "version": "10.1.0",
@@ -8206,7 +8220,6 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC", "license": "ISC",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
} }
@@ -8508,7 +8521,6 @@
"integrity": "sha512-59CAAjAhTaIMCN8y9kD573vDkxbs1uhDcrFLHSgutYdPcGOU35Rf95725snvzEOy4BFB7+eLJ8djCNPmGwG67w==", "integrity": "sha512-59CAAjAhTaIMCN8y9kD573vDkxbs1uhDcrFLHSgutYdPcGOU35Rf95725snvzEOy4BFB7+eLJ8djCNPmGwG67w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"app-builder-lib": "26.0.12", "app-builder-lib": "26.0.12",
"builder-util": "26.0.11", "builder-util": "26.0.11",
@@ -8835,6 +8847,7 @@
"dev": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@electron/asar": "^3.2.1", "@electron/asar": "^3.2.1",
"debug": "^4.1.1", "debug": "^4.1.1",
@@ -8855,6 +8868,7 @@
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"graceful-fs": "^4.1.2", "graceful-fs": "^4.1.2",
"jsonfile": "^4.0.0", "jsonfile": "^4.0.0",
@@ -9105,7 +9119,6 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@@ -11011,6 +11024,7 @@
"os": [ "os": [
"android" "android"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 12.0.0" "node": ">= 12.0.0"
}, },
@@ -11072,6 +11086,7 @@
"os": [ "os": [
"freebsd" "freebsd"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 12.0.0" "node": ">= 12.0.0"
}, },
@@ -13490,6 +13505,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.6", "nanoid": "^3.3.6",
"picocolors": "^1.0.0", "picocolors": "^1.0.0",
@@ -13506,6 +13522,7 @@
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"peer": true,
"dependencies": { "dependencies": {
"commander": "^9.4.0" "commander": "^9.4.0"
}, },
@@ -13523,6 +13540,7 @@
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"peer": true,
"engines": { "engines": {
"node": "^12.20.0 || >=14" "node": "^12.20.0 || >=14"
} }
@@ -13711,7 +13729,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -13721,7 +13738,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
@@ -14071,6 +14087,7 @@
"deprecated": "Rimraf versions prior to v4 are no longer supported", "deprecated": "Rimraf versions prior to v4 are no longer supported",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"peer": true,
"dependencies": { "dependencies": {
"glob": "^7.1.3" "glob": "^7.1.3"
}, },
@@ -14259,7 +14276,6 @@
"resolved": "https://registry.npmjs.org/seroval/-/seroval-1.4.0.tgz", "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.4.0.tgz",
"integrity": "sha512-BdrNXdzlofomLTiRnwJTSEAaGKyHHZkbMXIywOh7zlzp4uZnXErEwl9XZ+N1hJSNpeTtNxWvVwN0wUzAIQ4Hpg==", "integrity": "sha512-BdrNXdzlofomLTiRnwJTSEAaGKyHHZkbMXIywOh7zlzp4uZnXErEwl9XZ+N1hJSNpeTtNxWvVwN0wUzAIQ4Hpg==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=10" "node": ">=10"
} }
@@ -14308,6 +14324,7 @@
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"peer": true,
"dependencies": { "dependencies": {
"@img/colour": "^1.0.0", "@img/colour": "^1.0.0",
"detect-libc": "^2.1.2", "detect-libc": "^2.1.2",
@@ -14358,6 +14375,7 @@
"os": [ "os": [
"darwin" "darwin"
], ],
"peer": true,
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
}, },
@@ -14380,6 +14398,7 @@
"os": [ "os": [
"darwin" "darwin"
], ],
"peer": true,
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
}, },
@@ -14402,6 +14421,7 @@
"os": [ "os": [
"darwin" "darwin"
], ],
"peer": true,
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
} }
@@ -14418,6 +14438,7 @@
"os": [ "os": [
"darwin" "darwin"
], ],
"peer": true,
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
} }
@@ -14434,6 +14455,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
} }
@@ -14450,6 +14472,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
} }
@@ -14466,6 +14489,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
} }
@@ -14482,6 +14506,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
} }
@@ -14498,6 +14523,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
} }
@@ -14514,6 +14540,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
}, },
@@ -14536,6 +14563,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
}, },
@@ -14558,6 +14586,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
}, },
@@ -14580,6 +14609,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
}, },
@@ -14602,6 +14632,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
}, },
@@ -14624,6 +14655,7 @@
"os": [ "os": [
"win32" "win32"
], ],
"peer": true,
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
}, },
@@ -15092,6 +15124,7 @@
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
"integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"client-only": "0.0.1" "client-only": "0.0.1"
}, },
@@ -15261,6 +15294,7 @@
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"mkdirp": "^0.5.1", "mkdirp": "^0.5.1",
"rimraf": "~2.6.2" "rimraf": "~2.6.2"
@@ -15324,6 +15358,7 @@
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"minimist": "^1.2.6" "minimist": "^1.2.6"
}, },
@@ -15421,7 +15456,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -15626,7 +15660,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -15998,7 +16031,6 @@
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.27.0", "esbuild": "^0.27.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
@@ -16088,8 +16120,7 @@
"resolved": "https://registry.npmjs.org/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.6.tgz", "resolved": "https://registry.npmjs.org/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.6.tgz",
"integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==", "integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/vite/node_modules/fdir": { "node_modules/vite/node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
@@ -16115,7 +16146,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -16158,7 +16188,6 @@
"integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vitest/expect": "4.0.16", "@vitest/expect": "4.0.16",
"@vitest/mocker": "4.0.16", "@vitest/mocker": "4.0.16",
@@ -16416,7 +16445,6 @@
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"peer": true,
"bin": { "bin": {
"yaml": "bin.mjs" "yaml": "bin.mjs"
}, },