Compare commits

..

67 Commits

Author SHA1 Message Date
Test User
c7ebdb1f80 feat: Implement API key authentication with rate limiting and secure comparison
- Added rate limiting to the authentication middleware to prevent brute-force attacks.
- Introduced a secure comparison function to mitigate timing attacks during API key validation.
- Created a new rate limiter class to track failed authentication attempts and block requests after exceeding the maximum allowed failures.
- Updated the authentication middleware to handle rate limiting and secure key comparison.
- Enhanced error handling for rate-limited requests, providing appropriate responses to clients.
2025-12-24 14:49:47 -05:00
Web Dev Cody
97af998066 Merge pull request #250 from AutoMaker-Org/feat/convert-issues-to-task
feat: abbility to analyze github issues with ai with confidence / task creation
2025-12-23 22:34:18 -05:00
Web Dev Cody
44e341ab41 Merge pull request #256 from AutoMaker-Org/feat/improve-ai-suggestions-ui
feat: Improve ai suggestion output ui
2025-12-23 22:33:53 -05:00
Kacper
38addacf1e refactor: Enhance fetchIssues logic with mounted state checks
- Introduced a useRef hook to track component mount status, preventing state updates on unmounted components.
- Updated fetchIssues function to conditionally set state only if the component is still mounted, improving reliability during asynchronous operations.
- Ensured proper cleanup in useEffect to maintain accurate mounted state, enhancing overall component stability.
2025-12-24 02:31:56 +01:00
Kacper
a85e1aaa89 refactor: Simplify validation handling in GitHubIssuesView
- Removed the isValidating prop from GitHubIssuesView and ValidationDialog components to streamline validation logic.
- Updated handleValidateIssue function to eliminate unnecessary dialog options, focusing on background validation notifications.
- Enhanced user feedback by notifying users when validation starts, improving overall experience during issue analysis.
2025-12-24 02:30:36 +01:00
Kacper
dd86e987a4 feat: Introduce ErrorState and LoadingState components for improved UI feedback
- Added ErrorState component to display error messages with retry functionality, enhancing user experience during issue loading failures.
- Implemented LoadingState component to provide visual feedback while issues are being fetched, improving the overall responsiveness of the GitHubIssuesView.
- Refactored GitHubIssuesView to utilize the new components, streamlining error and loading handling logic.
2025-12-24 02:23:12 +01:00
Kacper
6cd2898923 feat: Improve GitHubIssuesView with stable event handler references
- Introduced refs for selected issue and validation dialog state to prevent unnecessary re-subscribing on state changes.
- Added cleanup logic to ensure proper handling of asynchronous operations during component unmounting.
- Enhanced error handling in validation loading functions to only log errors if the component is still mounted, improving reliability.
2025-12-24 02:19:03 +01:00
Kacper
7fec9e7c5c chore: remove old app folder ? 2025-12-24 02:18:52 +01:00
Kacper
2c9a3c5161 feat: Refactor validation logic in GitHubIssuesView for improved clarity
- Simplified the validation staleness check by introducing a dedicated variable for stale validation status.
- Enhanced the conditions for unviewed and viewed validation indicators, improving user feedback on validation status.
- Added a visual indicator for viewed validations, enhancing the user interface and experience.
2025-12-24 02:12:22 +01:00
Kacper
bb3b1960c5 feat: Enhance GitHubIssuesView with AI profile and worktree integration
- Added support for default AI profile retrieval and integration into task creation, improving user experience in task management.
- Implemented current branch detection based on selected worktree, ensuring accurate context for issue handling.
- Updated fetchIssues function dependencies to include new profile and branch data, enhancing task creation logic.
2025-12-24 02:07:33 +01:00
Kacper
7007a8aa66 feat: Add ConfirmDialog component and integrate into GitHubIssuesView
- Introduced a new ConfirmDialog component for user confirmation prompts.
- Integrated ConfirmDialog into GitHubIssuesView to confirm re-validation of issues, enhancing user interaction and decision-making.
- Updated handleValidateIssue function to support re-validation options, improving flexibility in issue validation handling.
2025-12-24 01:53:40 +01:00
Kacper
1ff617703c fix: Update fetchLinkedPRs to prevent shell injection vulnerabilities
- Modified the fetchLinkedPRs function to use JSON.stringify for the request body, ensuring safe input handling when spawning the GitHub CLI command.
- Changed the command to read the query from stdin using the --input flag, enhancing security against shell injection risks.
2025-12-24 01:41:05 +01:00
Kacper
0461045767 Changes from feat/improve-ai-suggestions-ui 2025-12-24 01:02:49 +01:00
Kacper
7b61a274e5 fix: Prevent race condition in unviewed validations count update
- Added a guard to ensure the unviewed count is only updated if the current project matches the reference, preventing potential race conditions during state updates.
2025-12-23 23:28:02 +01:00
Kacper
ef8eaa0463 feat: Add unit tests for validation storage functionality
- Introduced comprehensive unit tests for the validation storage module, covering functions such as writeValidation, readValidation, getAllValidations, deleteValidation, and others.
- Implemented tests to ensure correct behavior for validation creation, retrieval, deletion, and freshness checks.
- Enhanced test coverage for edge cases, including handling of non-existent validations and directory structure validation.
2025-12-23 23:17:41 +01:00
Kacper
65319f93b4 feat: Improve GitHub issues view with validation indicators and Markdown support
- Added an `isValidating` prop to the `IssueRow` component to indicate ongoing validation for issues.
- Introduced a visual indicator for validation in progress, enhancing user feedback during analysis.
- Updated the `ValidationDialog` to render validation reasoning and suggested fixes using Markdown for better formatting and readability.
2025-12-23 22:49:37 +01:00
Kacper
dd27c5c4fb feat: Enhance validation viewing functionality with event emission
- Updated the `createMarkViewedHandler` to emit an event when a validation is marked as viewed, allowing the UI to update the unviewed count dynamically.
- Modified the `useUnviewedValidations` hook to handle the new event type for decrementing the unviewed validations count.
- Introduced a new event type `issue_validation_viewed` in the issue validation event type definition for better event handling.
2025-12-23 22:25:48 +01:00
Kacper
d1418aa054 feat: Implement stale validation cleanup and improve GitHub issue handling
- Added a scheduled task to clean up stale validation entries every hour, preventing memory leaks.
- Enhanced the `getAllValidations` function to read validation files in parallel for improved performance.
- Updated the `fetchLinkedPRs` function to use `spawn` for safer execution of GitHub CLI commands, mitigating shell injection risks.
- Modified event handling in the GitHub issues view to utilize the model for validation, ensuring consistency and reducing stale closure issues.
- Introduced a new property in the issue validation event to track the model used for validation.
2025-12-23 22:21:08 +01:00
Kacper
0c9f05ee38 feat: Add validation viewing functionality and UI updates
- Implemented a new function to mark validations as viewed by the user, updating the validation state accordingly.
- Added a new API endpoint for marking validations as viewed, integrated with the existing GitHub routes.
- Enhanced the sidebar to display the count of unviewed validations, providing real-time updates.
- Updated the GitHub issues view to mark validations as viewed when issues are accessed, improving user interaction.
- Introduced a visual indicator for unviewed validations in the issue list, enhancing user awareness of pending validations.
2025-12-23 22:11:26 +01:00
Web Dev Cody
d50b15e639 Merge pull request #245 from illia1f/feature/project-picker-scroll
feat(ProjectSelector): add auto-scroll and improved UX for project picker
2025-12-23 15:46:34 -05:00
Web Dev Cody
172f1a7a3f Merge pull request #251 from AutoMaker-Org/fix/list-branch-issue-on-fresh-repo
fix: branch list issue and improve ui feedback
2025-12-23 15:43:27 -05:00
Web Dev Cody
5edb38691c Merge pull request #249 from AutoMaker-Org/fix/new-project-dialog-path-overflow
fix: new project path overflow
2025-12-23 15:42:44 -05:00
Web Dev Cody
f1f149c6c0 Merge pull request #247 from AutoMaker-Org/fix/git-diff-loop
fix: git diff loop
2025-12-23 15:42:24 -05:00
Kacper
e0c5f55fe7 fix: adress pr reviews 2025-12-23 21:07:36 +01:00
Kacper
4958ee1dda Changes from fix/list-branch-issue-on-fresh-repo 2025-12-23 20:46:10 +01:00
Kacper
3d00f40ea0 Changes from fix/new-project-dialog-path-overflow 2025-12-23 18:58:15 +01:00
Kacper
c9e0957dfe feat(diff): add helper function to create synthetic diffs for new files
This update introduces a new function, createNewFileDiff, to streamline the generation of synthetic diffs for untracked files. The function reduces code duplication by handling the diff formatting for new files, including directories and large files, improving overall maintainability.
2025-12-23 18:39:43 +01:00
Kacper
9d4f912c93 Changes from main 2025-12-23 18:26:02 +01:00
Illia Filippov
4898a1307e refactor(ProjectSelector): enhance project picker scrollbar styling and improve selection logic 2025-12-23 18:17:12 +01:00
Kacper
6acb751eb3 feat: Implement GitHub issue validation management and UI enhancements
- Introduced CRUD operations for GitHub issue validation results, including storage and retrieval.
- Added new endpoints for checking validation status, stopping validations, and deleting stored validations.
- Enhanced the GitHub routes to support validation management features.
- Updated the UI to display validation results and manage validation states for GitHub issues.
- Integrated event handling for validation progress and completion notifications.
2025-12-23 18:15:30 +01:00
Web Dev Cody
629b7e7433 Merge pull request #244 from WikiRik/WikiRik/fix-urls
docs: update links to Claude
2025-12-23 11:54:17 -05:00
Illia Filippov
190f18ecae feat(ProjectSelector): add auto-scroll and improved UX for project picker 2025-12-23 17:45:04 +01:00
Rik Smale
e6eb5ad97e docs: update links to Claude
These links were referring to pages that do not exist anymore. I have updated them to what I think are the new URLs.
2025-12-23 16:12:52 +00:00
Kacper
5f0ecc8dd6 feat: Enhance GitHub issue handling with assignees and linked PRs
- Added support for assignees in GitHub issue data structure.
- Implemented fetching of linked pull requests for open issues using the GitHub GraphQL API.
- Updated UI to display assignees and linked PRs for selected issues.
- Adjusted issue listing commands to include assignees in the fetched data.
2025-12-23 16:57:29 +01:00
Web Dev Cody
e95912f931 Merge pull request #232 from leonvanzyl/main
fix: Open in Browser button not working on Windows
2025-12-23 10:27:27 -05:00
Web Dev Cody
eb1875f558 Merge pull request #239 from illia1f/refactor/project-selector-with-options
refactor(ProjectSelector): improve project selection logic and UI/UX
2025-12-23 10:24:59 -05:00
Web Dev Cody
c761ce8120 Merge pull request #240 from AutoMaker-Org/fix/onboarding-dialog-overflow
fix: onboarding dialog title overflowing
2025-12-23 10:14:24 -05:00
Illia Filippov
ee9cb4deec refactor(ProjectSelector): streamline project selection handling by removing unnecessary useCallback 2025-12-23 16:03:13 +01:00
Kacper
a881d175bc feat: Implement GitHub issue validation endpoint and UI integration
- Added a new endpoint for validating GitHub issues using the Claude SDK.
- Introduced validation schema and logic to handle issue validation requests.
- Updated GitHub routes to include the new validation route.
- Enhanced the UI with a validation dialog and button to trigger issue validation.
- Mapped issue complexity to feature priority for better task management.
- Integrated validation results display in the UI, allowing users to convert validated issues into tasks.
2025-12-23 15:50:10 +01:00
Kacper
17ed2be918 fix(OnboardingDialog): adjust layout for title and description to improve responsiveness 2025-12-23 14:54:45 +01:00
Illia Filippov
5a5165818e refactor(ProjectSelector): improve project selection logic and UI/UX 2025-12-23 13:44:09 +01:00
Auto
9a7d21438b fix: Open in Browser button not working on Windows
The handleOpenDevServerUrl function was looking up the dev server info using an un-normalized path, but the Map stores entries with normalized paths (forward slashes).

On Windows, paths come in as C:\Projects\foo but stored keys use C:/Projects/foo (normalized). The lookup used the raw path, so it never matched.

Fix: Use getWorktreeKey() helper which normalizes the path, consistent with how isDevServerRunning() and getDevServerInfo() already work.
2025-12-23 07:50:37 +02:00
Test User
d4d4b8fb3d feat(TaskNode): conditionally render title and adjust description styling 2025-12-22 23:08:58 -05:00
Web Dev Cody
48955e9a71 Merge pull request #231 from stephan271c/add-pause-button
feat: Add a stop button to halt agent execution when processing.
2025-12-22 21:49:43 -05:00
Web Dev Cody
870df88cd1 Merge pull request #225 from illia1f/fix/project-picker-dropdown
fix: project picker dropdown highlights first item instead of current project
2025-12-22 21:22:35 -05:00
Web Dev Cody
7618a75d85 Merge pull request #226 from JBotwina/graph-filtering-and-node-controls
feat: Graph Filtering and Node Controls
2025-12-22 21:18:19 -05:00
Stephan Cho
51281095ea feat: Add a stop button to halt agent execution when processing. 2025-12-22 21:08:04 -05:00
Illia Filippov
50a595a8da fix(useProjectPicker): ensure project selection resets correctly when project picker is opened 2025-12-23 02:30:28 +01:00
Illia Filippov
a398367f00 refactor: simplify project index retrieval and selection logic in project picker 2025-12-23 02:06:49 +01:00
James
fe6faf9aae fix type errors 2025-12-22 19:44:48 -05:00
James
a1331ed514 fix format 2025-12-22 19:37:36 -05:00
Illia Filippov
38f2e0beea fix: ensure current project is highlighted in project picker dropdown without side effects 2025-12-23 01:36:20 +01:00
James
ef4035a462 fix lock file 2025-12-22 19:35:48 -05:00
James
cb07206dae add use ts hooks 2025-12-22 19:30:44 -05:00
James
cc0405cf27 refactor: update graph view actions to include onViewDetails and remove onViewBranch
- Added onViewDetails callback to handle feature detail viewing.
- Removed onViewBranch functionality and associated UI elements for a cleaner interface.
2025-12-22 19:30:44 -05:00
James
4dd00a98e4 add more filters about process status 2025-12-22 19:30:44 -05:00
James
b3c321ce02 add node actions 2025-12-22 19:30:44 -05:00
James
12a796bcbb branch filtering 2025-12-22 19:30:44 -05:00
James
ffcdbf7d75 fix styling of graph controls 2025-12-22 19:30:44 -05:00
Illia Filippov
e70c3b7722 fix: project picker dropdown highlights first item instead of current project 2025-12-23 00:50:21 +01:00
Web Dev Cody
524a9736b4 Merge pull request #222 from JBotwina/claude/task-dependency-graph-iPz1k
feat: task dependency graph view
2025-12-22 17:30:52 -05:00
Test User
036a7d9d26 refactor: update e2e tests to use 'load' state for page navigation
- Changed instances of `waitForLoadState('networkidle')` to `waitForLoadState('load')` across multiple test files and utility functions to improve test reliability in applications with persistent connections.
- Added documentation to the e2e testing guide explaining the rationale behind using 'load' state instead of 'networkidle' to prevent timeouts and flaky tests.
2025-12-22 17:16:55 -05:00
Test User
c4df2c141a Merge branch 'main' of github.com:AutoMaker-Org/automaker into claude/task-dependency-graph-iPz1k 2025-12-22 17:01:18 -05:00
James
7c75c24b5c fix: graph nodes now respect theme colors
Override React Flow's default node styling (white background) with
transparent to allow the TaskNode component's bg-card class to show
through with the correct theme colors.
2025-12-22 15:53:15 -05:00
James
2588ecaafa Merge remote-tracking branch 'origin/main' into claude/task-dependency-graph-iPz1k 2025-12-22 15:37:24 -05:00
James Botwina
a071097c0d Merge branch 'AutoMaker-Org:main' into claude/task-dependency-graph-iPz1k 2025-12-22 14:23:18 -05:00
Claude
b930091c42 feat: add dependency graph view for task visualization
Add a new interactive graph view alongside the kanban board for visualizing
task dependencies. The graph view uses React Flow with dagre auto-layout to
display tasks as nodes connected by dependency edges.

Key features:
- Toggle between kanban and graph view via new control buttons
- Custom TaskNode component matching existing card styling/themes
- Animated edges that flow when tasks are in progress
- Status-aware node colors (backlog, in-progress, waiting, verified)
- Blocked tasks show lock icon with dependency count tooltip
- MiniMap for navigation in large graphs
- Zoom, pan, fit-view, and lock controls
- Horizontal/vertical layout options via dagre
- Click node to view details, double-click to edit
- Respects all 32 themes via CSS variables
- Reduced motion support for animations

New dependencies: @xyflow/react, dagre
2025-12-22 19:10:32 +00:00
120 changed files with 8108 additions and 1922 deletions

View File

@@ -61,7 +61,7 @@ Traditional development tools help you write code. Automaker helps you **orchest
### Powered by Claude Code
Automaker leverages the [Claude Agent SDK](https://docs.anthropic.com/en/docs/claude-code) to give AI agents full access to your codebase. Agents can read files, write code, execute commands, run tests, and make git commits—all while working in isolated git worktrees to keep your main branch safe.
Automaker leverages the [Claude Agent SDK](https://platform.claude.com/docs/en/agent-sdk/overview) to give AI agents full access to your codebase. Agents can read files, write code, execute commands, run tests, and make git commits—all while working in isolated git worktrees to keep your main branch safe.
### Why This Matters
@@ -106,7 +106,7 @@ https://discord.gg/jjem7aEDKU
- Node.js 18+
- npm
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and authenticated
- [Claude Code CLI](https://code.claude.com/docs/en/overview) installed and authenticated
### Quick Start

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -0,0 +1,208 @@
/**
* 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

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

View File

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

View File

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

View File

@@ -0,0 +1,287 @@
/**
* 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

@@ -0,0 +1,174 @@
/**
* 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

@@ -0,0 +1,236 @@
/**
* 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

@@ -0,0 +1,138 @@
/**
* 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 { SettingsService } from '../../../services/settings-service.js';
import { getErrorMessage, logError } from '../common.js';
import { logError } from '../common.js';
/**
* Create handler factory for GET /api/settings/credentials
@@ -29,7 +29,7 @@ export function createGetCredentialsHandler(settingsService: SettingsService) {
});
} catch (error) {
logError(error, 'Get credentials failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
res.status(500).json({ success: false, error: 'Failed to retrieve credentials' });
}
};
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,18 +1,36 @@
/**
* POST /logout endpoint - Invalidate a session token
*
* Security: Only allows invalidating the token used for authentication.
* This ensures users can only log out their own sessions.
*/
import type { Request, Response } from 'express';
import { deleteToken } from '../common.js';
import { deleteToken, extractBearerToken, validateTerminalToken } from '../common.js';
export function createLogoutHandler() {
return (req: Request, res: Response): void => {
const token = (req.headers['x-terminal-token'] as string) || req.body.token;
const token = extractBearerToken(req);
if (token) {
deleteToken(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);
res.json({
success: true,
});

View File

@@ -111,6 +111,19 @@ export async function isGitRepo(repoPath: string): Promise<boolean> {
}
}
/**
* Check if a git repository has at least one commit (i.e., HEAD exists)
* Returns false for freshly initialized repos with no commits
*/
export async function hasCommits(repoPath: string): Promise<boolean> {
try {
await execAsync('git rev-parse --verify HEAD', { cwd: repoPath });
return true;
} catch {
return false;
}
}
/**
* Check if an error is ENOENT (file/path not found or spawn failed)
* These are expected in test environments with mock paths

View File

@@ -4,6 +4,7 @@
import { Router } from 'express';
import { validatePathParams } from '../../middleware/validate-paths.js';
import { requireValidWorktree, requireValidProject, requireGitRepoOnly } from './middleware.js';
import { createInfoHandler } from './routes/info.js';
import { createStatusHandler } from './routes/status.js';
import { createListHandler } from './routes/list.js';
@@ -38,17 +39,42 @@ export function createWorktreeRoutes(): Router {
router.post('/list', createListHandler());
router.post('/diffs', validatePathParams('projectPath'), createDiffsHandler());
router.post('/file-diff', validatePathParams('projectPath', 'filePath'), createFileDiffHandler());
router.post('/merge', validatePathParams('projectPath'), createMergeHandler());
router.post(
'/merge',
validatePathParams('projectPath'),
requireValidProject,
createMergeHandler()
);
router.post('/create', validatePathParams('projectPath'), createCreateHandler());
router.post('/delete', validatePathParams('projectPath', 'worktreePath'), createDeleteHandler());
router.post('/create-pr', createCreatePRHandler());
router.post('/pr-info', createPRInfoHandler());
router.post('/commit', validatePathParams('worktreePath'), createCommitHandler());
router.post('/push', validatePathParams('worktreePath'), createPushHandler());
router.post('/pull', validatePathParams('worktreePath'), createPullHandler());
router.post('/checkout-branch', createCheckoutBranchHandler());
router.post('/list-branches', validatePathParams('worktreePath'), createListBranchesHandler());
router.post('/switch-branch', createSwitchBranchHandler());
router.post(
'/commit',
validatePathParams('worktreePath'),
requireGitRepoOnly,
createCommitHandler()
);
router.post(
'/push',
validatePathParams('worktreePath'),
requireValidWorktree,
createPushHandler()
);
router.post(
'/pull',
validatePathParams('worktreePath'),
requireValidWorktree,
createPullHandler()
);
router.post('/checkout-branch', requireValidWorktree, createCheckoutBranchHandler());
router.post(
'/list-branches',
validatePathParams('worktreePath'),
requireValidWorktree,
createListBranchesHandler()
);
router.post('/switch-branch', requireValidWorktree, createSwitchBranchHandler());
router.post('/open-in-editor', validatePathParams('worktreePath'), createOpenInEditorHandler());
router.get('/default-editor', createGetDefaultEditorHandler());
router.post('/init-git', validatePathParams('projectPath'), createInitGitHandler());

View File

@@ -0,0 +1,74 @@
/**
* Middleware for worktree route validation
*/
import type { Request, Response, NextFunction } from 'express';
import { isGitRepo, hasCommits } from './common.js';
interface ValidationOptions {
/** Check if the path is a git repository (default: true) */
requireGitRepo?: boolean;
/** Check if the repository has at least one commit (default: true) */
requireCommits?: boolean;
/** The name of the request body field containing the path (default: 'worktreePath') */
pathField?: 'worktreePath' | 'projectPath';
}
/**
* Middleware factory to validate that a path is a valid git repository with commits.
* This reduces code duplication across route handlers.
*
* @param options - Validation options
* @returns Express middleware function
*/
export function requireValidGitRepo(options: ValidationOptions = {}) {
const { requireGitRepo = true, requireCommits = true, pathField = 'worktreePath' } = options;
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
const repoPath = req.body[pathField] as string | undefined;
if (!repoPath) {
// Let the route handler deal with missing path validation
next();
return;
}
if (requireGitRepo && !(await isGitRepo(repoPath))) {
res.status(400).json({
success: false,
error: 'Not a git repository',
code: 'NOT_GIT_REPO',
});
return;
}
if (requireCommits && !(await hasCommits(repoPath))) {
res.status(400).json({
success: false,
error: 'Repository has no commits yet',
code: 'NO_COMMITS',
});
return;
}
next();
};
}
/**
* Middleware to validate git repo for worktreePath field
*/
export const requireValidWorktree = requireValidGitRepo({ pathField: 'worktreePath' });
/**
* Middleware to validate git repo for projectPath field
*/
export const requireValidProject = requireValidGitRepo({ pathField: 'projectPath' });
/**
* Middleware to validate git repo without requiring commits (for commit route)
*/
export const requireGitRepoOnly = requireValidGitRepo({
pathField: 'worktreePath',
requireCommits: false,
});

View File

@@ -1,5 +1,8 @@
/**
* POST /checkout-branch endpoint - Create and checkout a new branch
*
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
* the requireValidWorktree middleware in index.ts
*/
import type { Request, Response } from 'express';

View File

@@ -1,5 +1,8 @@
/**
* POST /commit endpoint - Commit changes in a worktree
*
* Note: Git repository validation (isGitRepo) is handled by
* the requireGitRepoOnly middleware in index.ts
*/
import type { Request, Response } from 'express';

View File

@@ -1,5 +1,8 @@
/**
* POST /list-branches endpoint - List all local branches
*
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
* the requireValidWorktree middleware in index.ts
*/
import type { Request, Response } from 'express';

View File

@@ -1,5 +1,8 @@
/**
* POST /merge endpoint - Merge feature (merge worktree branch into main)
*
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
* the requireValidProject middleware in index.ts
*/
import type { Request, Response } from 'express';

View File

@@ -1,5 +1,8 @@
/**
* POST /pull endpoint - Pull latest changes for a worktree/branch
*
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
* the requireValidWorktree middleware in index.ts
*/
import type { Request, Response } from 'express';

View File

@@ -1,5 +1,8 @@
/**
* POST /push endpoint - Push a worktree branch to remote
*
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
* the requireValidWorktree middleware in index.ts
*/
import type { Request, Response } from 'express';

View File

@@ -4,6 +4,9 @@
* Simple branch switching.
* If there are uncommitted changes, the switch will fail and
* the user should commit first.
*
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
* the requireValidWorktree middleware in index.ts
*/
import type { Request, Response } from 'express';

View File

@@ -1,6 +1,16 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { createMockExpressContext } from '../../utils/mocks.js';
/**
* Creates a mock Express context with socket properties for rate limiter support
*/
function createMockExpressContextWithSocket() {
const ctx = createMockExpressContext();
ctx.req.socket = { remoteAddress: '127.0.0.1' } as any;
ctx.res.setHeader = vi.fn().mockReturnThis();
return ctx;
}
/**
* Note: auth.ts reads AUTOMAKER_API_KEY at module load time.
* We need to reset modules and reimport for each test to get fresh state.
@@ -29,7 +39,7 @@ describe('auth.ts', () => {
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
const { authMiddleware } = await import('@/lib/auth.js');
const { req, res, next } = createMockExpressContext();
const { req, res, next } = createMockExpressContextWithSocket();
authMiddleware(req, res, next);
@@ -45,7 +55,7 @@ describe('auth.ts', () => {
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
const { authMiddleware } = await import('@/lib/auth.js');
const { req, res, next } = createMockExpressContext();
const { req, res, next } = createMockExpressContextWithSocket();
req.headers['x-api-key'] = 'wrong-key';
authMiddleware(req, res, next);
@@ -62,7 +72,7 @@ describe('auth.ts', () => {
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
const { authMiddleware } = await import('@/lib/auth.js');
const { req, res, next } = createMockExpressContext();
const { req, res, next } = createMockExpressContextWithSocket();
req.headers['x-api-key'] = 'test-secret-key';
authMiddleware(req, res, next);
@@ -113,4 +123,197 @@ 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

@@ -0,0 +1,249 @@
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

@@ -0,0 +1,307 @@
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

@@ -63,19 +63,22 @@
"@xterm/addon-web-links": "^0.11.0",
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",
"@xyflow/react": "^12.10.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dagre": "^0.8.5",
"dotenv": "^17.2.3",
"geist": "^1.5.1",
"lucide-react": "^0.562.0",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-markdown": "^10.1.0",
"rehype-raw": "^7.0.0",
"react-resizable-panels": "^3.0.6",
"rehype-raw": "^7.0.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"usehooks-ts": "^3.1.1",
"zustand": "^5.0.9"
},
"optionalDependencies": {
@@ -95,6 +98,7 @@
"@playwright/test": "^1.57.0",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/router-plugin": "^1.141.7",
"@types/dagre": "^0.7.53",
"@types/node": "^22",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",

View File

@@ -245,18 +245,21 @@ export function NewProjectModal({
{/* Workspace Directory Display */}
<div
className={cn(
'flex items-center gap-2 text-sm',
'flex items-start gap-2 text-sm',
errors.workspaceDir ? 'text-red-500' : 'text-muted-foreground'
)}
>
<Folder className="w-4 h-4 shrink-0" />
<span className="flex-1 min-w-0">
<Folder className="w-4 h-4 shrink-0 mt-0.5" />
<span className="flex-1 min-w-0 flex flex-col gap-1">
{isLoadingWorkspace ? (
'Loading workspace...'
) : workspaceDir ? (
<>
Will be created at:{' '}
<code className="text-xs bg-muted px-1.5 py-0.5 rounded truncate">
<span>Will be created at:</span>
<code
className="text-xs bg-muted px-1.5 py-0.5 rounded truncate block max-w-full"
title={projectPath || workspaceDir}
>
{projectPath || workspaceDir}
</code>
</>

View File

@@ -30,6 +30,7 @@ import {
useSetupDialog,
useTrashDialog,
useProjectTheme,
useUnviewedValidations,
} from './sidebar/hooks';
export function Sidebar() {
@@ -127,6 +128,9 @@ export function Sidebar() {
// Running agents count
const { runningAgentsCount } = useRunningAgents();
// Unviewed validations count
const { count: unviewedValidationsCount } = useUnviewedValidations(currentProject);
// Trash dialog and operations
const {
showTrashDialog,
@@ -235,6 +239,7 @@ export function Sidebar() {
setIsProjectPickerOpen,
cyclePrevProject,
cycleNextProject,
unviewedValidationsCount,
});
// Register keyboard shortcuts

View File

@@ -47,7 +47,6 @@ export function ProjectSelectorWithOptions({
setIsProjectPickerOpen,
setShowDeleteProjectDialog,
}: ProjectSelectorWithOptionsProps) {
// Get data from store
const {
projects,
currentProject,
@@ -59,25 +58,24 @@ export function ProjectSelectorWithOptions({
clearProjectHistory,
} = useAppStore();
// Get keyboard shortcuts
const shortcuts = useKeyboardShortcutsConfig();
const {
projectSearchQuery,
setProjectSearchQuery,
selectedProjectIndex,
projectSearchInputRef,
scrollContainerRef,
filteredProjects,
} = useProjectPicker({
projects,
currentProject,
isProjectPickerOpen,
setIsProjectPickerOpen,
setCurrentProject,
});
// Drag-and-drop handlers
const { sensors, handleDragEnd } = useDragAndDrop({ projects, reorderProjects });
// Theme management
const {
globalTheme,
setTheme,
@@ -106,7 +104,6 @@ export function ProjectSelectorWithOptions({
'shadow-sm shadow-black/5',
'text-foreground titlebar-no-drag min-w-0',
'transition-all duration-200 ease-out',
'hover:scale-[1.01] active:scale-[0.99]',
isProjectPickerOpen &&
'from-brand-500/10 to-brand-600/5 border-brand-500/30 ring-2 ring-brand-500/20 shadow-lg shadow-brand-500/5'
)}
@@ -139,7 +136,7 @@ export function ProjectSelectorWithOptions({
align="start"
data-testid="project-picker-dropdown"
>
{/* Search input for type-ahead filtering */}
{/* Search input */}
<div className="px-1 pb-2">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
@@ -150,10 +147,10 @@ export function ProjectSelectorWithOptions({
value={projectSearchQuery}
onChange={(e) => setProjectSearchQuery(e.target.value)}
className={cn(
'w-full h-9 pl-8 pr-3 text-sm rounded-lg',
'w-full h-8 pl-8 pr-3 text-sm rounded-lg',
'border border-border bg-background/50',
'text-foreground placeholder:text-muted-foreground',
'focus:outline-none focus:ring-2 focus:ring-brand-500/30 focus:border-brand-500/50',
'focus:outline-none focus:ring-1 focus:ring-brand-500/30 focus:border-brand-500/50',
'transition-all duration-200'
)}
data-testid="project-search-input"
@@ -175,7 +172,10 @@ export function ProjectSelectorWithOptions({
items={filteredProjects.map((p) => p.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-0.5 max-h-64 overflow-y-auto">
<div
ref={scrollContainerRef}
className="space-y-0.5 max-h-64 overflow-y-auto overflow-x-hidden scroll-smooth scrollbar-styled"
>
{filteredProjects.map((project, index) => (
<SortableProjectItem
key={project.id}
@@ -196,9 +196,9 @@ export function ProjectSelectorWithOptions({
{/* Keyboard hint */}
<div className="px-2 pt-2 mt-1.5 border-t border-border/50">
<p className="text-[10px] text-muted-foreground text-center tracking-wide">
<span className="text-foreground/60">arrow</span> navigate{' '}
<span className="text-foreground/60"></span> navigate{' '}
<span className="mx-1 text-foreground/30">|</span>{' '}
<span className="text-foreground/60">enter</span> select{' '}
<span className="text-foreground/60"></span> select{' '}
<span className="mx-1 text-foreground/30">|</span>{' '}
<span className="text-foreground/60">esc</span> close
</p>
@@ -206,7 +206,7 @@ export function ProjectSelectorWithOptions({
</DropdownMenuContent>
</DropdownMenu>
{/* Project Options Menu - theme and history */}
{/* Project Options Menu */}
{currentProject && (
<DropdownMenu
onOpenChange={(open) => {
@@ -223,8 +223,7 @@ export function ProjectSelectorWithOptions({
'text-muted-foreground hover:text-foreground',
'bg-transparent hover:bg-accent/60',
'border border-border/50 hover:border-border',
'transition-all duration-200 ease-out titlebar-no-drag',
'hover:scale-[1.02] active:scale-[0.98]'
'transition-all duration-200 ease-out titlebar-no-drag'
)}
title="Project options"
data-testid="project-options-menu"
@@ -252,7 +251,6 @@ export function ProjectSelectorWithOptions({
setPreviewTheme(null);
}}
>
{/* Use Global Option */}
<DropdownMenuRadioGroup
value={currentProject.theme || ''}
onValueChange={(value) => {
@@ -328,7 +326,7 @@ export function ProjectSelectorWithOptions({
</DropdownMenuSubContent>
</DropdownMenuSub>
{/* Project History Section - only show when there's history */}
{/* Project History Section */}
{projectHistory.length > 1 && (
<>
<DropdownMenuSeparator />

View File

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

View File

@@ -31,6 +31,7 @@ export function SortableProjectItem({
isHighlighted && 'bg-brand-500/10 text-foreground ring-1 ring-brand-500/20'
)}
data-testid={`project-option-${project.id}`}
onClick={() => onSelect(project)}
>
{/* Drag Handle */}
<button
@@ -43,9 +44,14 @@ export function SortableProjectItem({
<GripVertical className="h-3.5 w-3.5 text-muted-foreground/60" />
</button>
{/* Project content - clickable area */}
<div className="flex items-center gap-2.5 flex-1 min-w-0" onClick={() => onSelect(project)}>
<Folder className="h-4 w-4 shrink-0 text-muted-foreground" />
{/* Project content */}
<div className="flex items-center gap-2.5 flex-1 min-w-0">
<Folder
className={cn(
'h-4 w-4 shrink-0',
currentProjectId === project.id ? 'text-brand-500' : 'text-muted-foreground'
)}
/>
<span className="flex-1 truncate text-sm font-medium">{project.name}</span>
{currentProjectId === project.id && <Check className="h-4 w-4 text-brand-500 shrink-0" />}
</div>

View File

@@ -37,11 +37,11 @@ export function OnboardingDialog({
<DialogContent className="max-w-2xl bg-popover/95 backdrop-blur-xl">
<DialogHeader>
<div className="flex items-center gap-3 mb-2">
<div className="flex items-center justify-center w-12 h-12 rounded-full bg-brand-500/10 border border-brand-500/20">
<div className="flex items-center justify-center w-12 h-12 rounded-full bg-brand-500/10 border border-brand-500/20 shrink-0">
<Rocket className="w-6 h-6 text-brand-500" />
</div>
<div>
<DialogTitle className="text-2xl">Welcome to {newProjectName}!</DialogTitle>
<div className="min-w-0 flex-1">
<DialogTitle className="text-2xl truncate">Welcome to {newProjectName}!</DialogTitle>
<DialogDescription className="text-muted-foreground mt-1">
Your new project is ready. Let&apos;s get you started.
</DialogDescription>

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ import type { Project } from '@/lib/electron';
interface UseProjectPickerProps {
projects: Project[];
currentProject: Project | null;
isProjectPickerOpen: boolean;
setIsProjectPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
setCurrentProject: (project: Project) => void;
@@ -10,6 +11,7 @@ interface UseProjectPickerProps {
export function useProjectPicker({
projects,
currentProject,
isProjectPickerOpen,
setIsProjectPickerOpen,
setCurrentProject,
@@ -17,6 +19,7 @@ export function useProjectPicker({
const [projectSearchQuery, setProjectSearchQuery] = useState('');
const [selectedProjectIndex, setSelectedProjectIndex] = useState(0);
const projectSearchInputRef = useRef<HTMLInputElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
// Filtered projects based on search query
const filteredProjects = useMemo(() => {
@@ -27,29 +30,67 @@ export function useProjectPicker({
return projects.filter((project) => project.name.toLowerCase().includes(query));
}, [projects, projectSearchQuery]);
// Reset selection when filtered results change
useEffect(() => {
setSelectedProjectIndex(0);
}, [filteredProjects.length, projectSearchQuery]);
// Helper function to scroll to a specific project
const scrollToProject = useCallback((projectId: string) => {
if (!scrollContainerRef.current) return;
// Reset search query when dropdown closes
useEffect(() => {
if (!isProjectPickerOpen) {
setProjectSearchQuery('');
setSelectedProjectIndex(0);
const element = scrollContainerRef.current.querySelector(
`[data-testid="project-option-${projectId}"]`
);
if (element) {
element.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
});
}
}, [isProjectPickerOpen]);
}, []);
// Focus the search input when dropdown opens
// On open/close, handle search query reset and focus
useEffect(() => {
if (isProjectPickerOpen) {
// Small delay to ensure the dropdown is rendered
setTimeout(() => {
// Focus search input after DOM renders
requestAnimationFrame(() => {
projectSearchInputRef.current?.focus();
}, 0);
});
} else {
// Reset search when closing
setProjectSearchQuery('');
}
}, [isProjectPickerOpen]);
// Update selection when search query changes (while picker is open)
useEffect(() => {
if (!isProjectPickerOpen) {
setSelectedProjectIndex(0);
return;
}
if (projectSearchQuery.trim()) {
// When searching, reset to first result
setSelectedProjectIndex(0);
} else {
// When not searching (e.g., on open or search cleared), find and select the current project
const currentIndex = currentProject
? filteredProjects.findIndex((p) => p.id === currentProject.id)
: -1;
setSelectedProjectIndex(currentIndex !== -1 ? currentIndex : 0);
}
}, [isProjectPickerOpen, projectSearchQuery, filteredProjects, currentProject]);
// Scroll to highlighted item when selection changes
useEffect(() => {
if (!isProjectPickerOpen) return;
const targetProject = filteredProjects[selectedProjectIndex];
if (targetProject) {
// Use requestAnimationFrame to ensure DOM is rendered before scrolling
requestAnimationFrame(() => {
scrollToProject(targetProject.id);
});
}
}, [selectedProjectIndex, isProjectPickerOpen, filteredProjects, scrollToProject]);
// Handle selecting the currently highlighted project
const selectHighlightedProject = useCallback(() => {
if (filteredProjects.length > 0 && selectedProjectIndex < filteredProjects.length) {
@@ -99,6 +140,7 @@ export function useProjectPicker({
selectedProjectIndex,
setSelectedProjectIndex,
projectSearchInputRef,
scrollContainerRef,
filteredProjects,
selectHighlightedProject,
};

View File

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

View File

@@ -0,0 +1,83 @@
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

@@ -0,0 +1,36 @@
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,17 @@
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

@@ -17,6 +17,7 @@ import {
ImageIcon,
ChevronDown,
FileText,
Square,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useElectronAgent } from '@/hooks/use-electron-agent';
@@ -83,6 +84,7 @@ export function AgentView() {
isConnected,
sendMessage,
clearHistory,
stopExecution,
error: agentError,
} = useElectronAgent({
sessionId: currentSessionId || '',
@@ -914,21 +916,33 @@ export function AgentView() {
<Paperclip className="w-4 h-4" />
</Button>
{/* Send Button */}
<Button
onClick={handleSend}
disabled={
(!input.trim() &&
selectedImages.length === 0 &&
selectedTextFiles.length === 0) ||
isProcessing ||
!isConnected
}
className="h-11 px-4 rounded-xl"
data-testid="send-message"
>
<Send className="w-4 h-4" />
</Button>
{/* Send / Stop Button */}
{isProcessing ? (
<Button
onClick={stopExecution}
disabled={!isConnected}
className="h-11 px-4 rounded-xl"
variant="destructive"
data-testid="stop-agent"
title="Stop generation"
>
<Square className="w-4 h-4 fill-current" />
</Button>
) : (
<Button
onClick={handleSend}
disabled={
(!input.trim() &&
selectedImages.length === 0 &&
selectedTextFiles.length === 0) ||
!isConnected
}
className="h-11 px-4 rounded-xl"
data-testid="send-message"
>
<Send className="w-4 h-4" />
</Button>
)}
</div>
{/* Keyboard hint */}

View File

@@ -21,6 +21,7 @@ import { BoardHeader } from './board-view/board-header';
import { BoardSearchBar } from './board-view/board-search-bar';
import { BoardControls } from './board-view/board-controls';
import { KanbanBoard } from './board-view/kanban-board';
import { GraphView } from './graph-view';
import {
AddFeatureDialog,
AgentOutputModal,
@@ -69,6 +70,8 @@ export function BoardView() {
aiProfiles,
kanbanCardDetailLevel,
setKanbanCardDetailLevel,
boardViewMode,
setBoardViewMode,
specCreatingForProject,
setSpecCreatingForProject,
pendingPlanApproval,
@@ -989,40 +992,59 @@ export function BoardView() {
completedCount={completedFeatures.length}
kanbanCardDetailLevel={kanbanCardDetailLevel}
onDetailLevelChange={setKanbanCardDetailLevel}
boardViewMode={boardViewMode}
onBoardViewModeChange={setBoardViewMode}
/>
</div>
{/* Kanban Columns */}
<KanbanBoard
sensors={sensors}
collisionDetectionStrategy={collisionDetectionStrategy}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
activeFeature={activeFeature}
getColumnFeatures={getColumnFeatures}
backgroundImageStyle={backgroundImageStyle}
backgroundSettings={backgroundSettings}
onEdit={(feature) => setEditingFeature(feature)}
onDelete={(featureId) => handleDeleteFeature(featureId)}
onViewOutput={handleViewOutput}
onVerify={handleVerifyFeature}
onResume={handleResumeFeature}
onForceStop={handleForceStopFeature}
onManualVerify={handleManualVerify}
onMoveBackToInProgress={handleMoveBackToInProgress}
onFollowUp={handleOpenFollowUp}
onCommit={handleCommitFeature}
onComplete={handleCompleteFeature}
onImplement={handleStartImplementation}
onViewPlan={(feature) => setViewPlanFeature(feature)}
onApprovePlan={handleOpenApprovalDialog}
featuresWithContext={featuresWithContext}
runningAutoTasks={runningAutoTasks}
shortcuts={shortcuts}
onStartNextFeatures={handleStartNextFeatures}
onShowSuggestions={() => setShowSuggestionsDialog(true)}
suggestionsCount={suggestionsCount}
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
/>
{/* View Content - Kanban or Graph */}
{boardViewMode === 'kanban' ? (
<KanbanBoard
sensors={sensors}
collisionDetectionStrategy={collisionDetectionStrategy}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
activeFeature={activeFeature}
getColumnFeatures={getColumnFeatures}
backgroundImageStyle={backgroundImageStyle}
backgroundSettings={backgroundSettings}
onEdit={(feature) => setEditingFeature(feature)}
onDelete={(featureId) => handleDeleteFeature(featureId)}
onViewOutput={handleViewOutput}
onVerify={handleVerifyFeature}
onResume={handleResumeFeature}
onForceStop={handleForceStopFeature}
onManualVerify={handleManualVerify}
onMoveBackToInProgress={handleMoveBackToInProgress}
onFollowUp={handleOpenFollowUp}
onCommit={handleCommitFeature}
onComplete={handleCompleteFeature}
onImplement={handleStartImplementation}
onViewPlan={(feature) => setViewPlanFeature(feature)}
onApprovePlan={handleOpenApprovalDialog}
featuresWithContext={featuresWithContext}
runningAutoTasks={runningAutoTasks}
shortcuts={shortcuts}
onStartNextFeatures={handleStartNextFeatures}
onShowSuggestions={() => setShowSuggestionsDialog(true)}
suggestionsCount={suggestionsCount}
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
/>
) : (
<GraphView
features={hookFeatures}
runningAutoTasks={runningAutoTasks}
currentWorktreePath={currentWorktreePath}
currentWorktreeBranch={currentWorktreeBranch}
projectPath={currentProject?.path || null}
searchQuery={searchQuery}
onSearchQueryChange={setSearchQuery}
onEditFeature={(feature) => setEditingFeature(feature)}
onViewOutput={handleViewOutput}
onStartTask={handleStartImplementation}
onStopTask={handleForceStopFeature}
onResumeTask={handleResumeFeature}
/>
)}
</div>
{/* Board Background Modal */}

View File

@@ -1,7 +1,8 @@
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { ImageIcon, Archive, Minimize2, Square, Maximize2 } from 'lucide-react';
import { ImageIcon, Archive, Minimize2, Square, Maximize2, Columns3, Network } from 'lucide-react';
import { cn } from '@/lib/utils';
import { BoardViewMode } from '@/store/app-store';
interface BoardControlsProps {
isMounted: boolean;
@@ -10,6 +11,8 @@ interface BoardControlsProps {
completedCount: number;
kanbanCardDetailLevel: 'minimal' | 'standard' | 'detailed';
onDetailLevelChange: (level: 'minimal' | 'standard' | 'detailed') => void;
boardViewMode: BoardViewMode;
onBoardViewModeChange: (mode: BoardViewMode) => void;
}
export function BoardControls({
@@ -19,12 +22,59 @@ export function BoardControls({
completedCount,
kanbanCardDetailLevel,
onDetailLevelChange,
boardViewMode,
onBoardViewModeChange,
}: BoardControlsProps) {
if (!isMounted) return null;
return (
<TooltipProvider>
<div className="flex items-center gap-2 ml-4">
{/* View Mode Toggle - Kanban / Graph */}
<div
className="flex items-center rounded-lg bg-secondary border border-border"
data-testid="view-mode-toggle"
>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => onBoardViewModeChange('kanban')}
className={cn(
'p-2 rounded-l-lg transition-colors',
boardViewMode === 'kanban'
? 'bg-brand-500/20 text-brand-500'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
)}
data-testid="view-mode-kanban"
>
<Columns3 className="w-4 h-4" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Kanban Board View</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => onBoardViewModeChange('graph')}
className={cn(
'p-2 rounded-r-lg transition-colors',
boardViewMode === 'graph'
? 'bg-brand-500/20 text-brand-500'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
)}
data-testid="view-mode-graph"
>
<Network className="w-4 h-4" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Dependency Graph View</p>
</TooltipContent>
</Tooltip>
</div>
{/* Board Background Button */}
<Tooltip>
<TooltipTrigger asChild>

View File

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

View File

@@ -0,0 +1,44 @@
import type { ReactElement, ReactNode } from 'react';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
interface TooltipWrapperProps {
/** The element to wrap with a tooltip */
children: ReactElement;
/** The content to display in the tooltip */
tooltipContent: ReactNode;
/** Whether to show the tooltip (if false, renders children without tooltip) */
showTooltip: boolean;
/** The side where the tooltip should appear */
side?: 'top' | 'right' | 'bottom' | 'left';
}
/**
* A reusable wrapper that conditionally adds a tooltip to its children.
* When showTooltip is false, it renders the children directly without any tooltip.
* This is useful for adding tooltips to disabled elements that need to show
* a reason for being disabled.
*/
export function TooltipWrapper({
children,
tooltipContent,
showTooltip,
side = 'left',
}: TooltipWrapperProps) {
if (!showTooltip) {
return children;
}
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
{/* The div wrapper is necessary for tooltips to work on disabled elements */}
<div>{children}</div>
</TooltipTrigger>
<TooltipContent side={side}>
<p>{tooltipContent}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}

View File

@@ -20,9 +20,11 @@ import {
Globe,
MessageSquare,
GitMerge,
AlertCircle,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import type { WorktreeInfo, DevServerInfo, PRInfo } from '../types';
import type { WorktreeInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types';
import { TooltipWrapper } from './tooltip-wrapper';
interface WorktreeActionsDropdownProps {
worktree: WorktreeInfo;
@@ -35,6 +37,7 @@ interface WorktreeActionsDropdownProps {
isStartingDevServer: boolean;
isDevServerRunning: boolean;
devServerInfo?: DevServerInfo;
gitRepoStatus: GitRepoStatus;
onOpenChange: (open: boolean) => void;
onPull: (worktree: WorktreeInfo) => void;
onPush: (worktree: WorktreeInfo) => void;
@@ -60,6 +63,7 @@ export function WorktreeActionsDropdown({
isStartingDevServer,
isDevServerRunning,
devServerInfo,
gitRepoStatus,
onOpenChange,
onPull,
onPush,
@@ -76,6 +80,14 @@ export function WorktreeActionsDropdown({
// Check if there's a PR associated with this worktree from stored metadata
const hasPR = !!worktree.pr;
// Check git operations availability
const canPerformGitOps = gitRepoStatus.isGitRepo && gitRepoStatus.hasCommits;
const gitOpsDisabledReason = !gitRepoStatus.isGitRepo
? 'Not a git repository'
: !gitRepoStatus.hasCommits
? 'Repository has no commits yet'
: null;
return (
<DropdownMenu onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild>
@@ -92,6 +104,16 @@ export function WorktreeActionsDropdown({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
{/* Warning label when git operations are not available */}
{!canPerformGitOps && (
<>
<DropdownMenuLabel className="text-xs flex items-center gap-2 text-amber-600 dark:text-amber-400">
<AlertCircle className="w-3.5 h-3.5" />
{gitOpsDisabledReason}
</DropdownMenuLabel>
<DropdownMenuSeparator />
</>
)}
{isDevServerRunning ? (
<>
<DropdownMenuLabel className="text-xs flex items-center gap-2">
@@ -124,36 +146,58 @@ export function WorktreeActionsDropdown({
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem onClick={() => onPull(worktree)} disabled={isPulling} className="text-xs">
<Download className={cn('w-3.5 h-3.5 mr-2', isPulling && 'animate-pulse')} />
{isPulling ? 'Pulling...' : 'Pull'}
{behindCount > 0 && (
<span className="ml-auto text-[10px] bg-muted px-1.5 py-0.5 rounded">
{behindCount} behind
</span>
)}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onPush(worktree)}
disabled={isPushing || aheadCount === 0}
className="text-xs"
>
<Upload className={cn('w-3.5 h-3.5 mr-2', isPushing && 'animate-pulse')} />
{isPushing ? 'Pushing...' : 'Push'}
{aheadCount > 0 && (
<span className="ml-auto text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded">
{aheadCount} ahead
</span>
)}
</DropdownMenuItem>
{!worktree.isMain && (
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
<DropdownMenuItem
onClick={() => onResolveConflicts(worktree)}
className="text-xs text-purple-500 focus:text-purple-600"
onClick={() => canPerformGitOps && onPull(worktree)}
disabled={isPulling || !canPerformGitOps}
className={cn('text-xs', !canPerformGitOps && 'opacity-50 cursor-not-allowed')}
>
<GitMerge className="w-3.5 h-3.5 mr-2" />
Pull & Resolve Conflicts
<Download className={cn('w-3.5 h-3.5 mr-2', isPulling && 'animate-pulse')} />
{isPulling ? 'Pulling...' : 'Pull'}
{!canPerformGitOps && <AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />}
{canPerformGitOps && behindCount > 0 && (
<span className="ml-auto text-[10px] bg-muted px-1.5 py-0.5 rounded">
{behindCount} behind
</span>
)}
</DropdownMenuItem>
</TooltipWrapper>
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
<DropdownMenuItem
onClick={() => canPerformGitOps && onPush(worktree)}
disabled={isPushing || aheadCount === 0 || !canPerformGitOps}
className={cn('text-xs', !canPerformGitOps && 'opacity-50 cursor-not-allowed')}
>
<Upload className={cn('w-3.5 h-3.5 mr-2', isPushing && 'animate-pulse')} />
{isPushing ? 'Pushing...' : 'Push'}
{!canPerformGitOps && <AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />}
{canPerformGitOps && aheadCount > 0 && (
<span className="ml-auto text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded">
{aheadCount} ahead
</span>
)}
</DropdownMenuItem>
</TooltipWrapper>
{!worktree.isMain && (
<TooltipWrapper
showTooltip={!!gitOpsDisabledReason}
tooltipContent={gitOpsDisabledReason}
>
<DropdownMenuItem
onClick={() => canPerformGitOps && onResolveConflicts(worktree)}
disabled={!canPerformGitOps}
className={cn(
'text-xs text-purple-500 focus:text-purple-600',
!canPerformGitOps && 'opacity-50 cursor-not-allowed'
)}
>
<GitMerge className="w-3.5 h-3.5 mr-2" />
Pull & Resolve Conflicts
{!canPerformGitOps && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
</DropdownMenuItem>
</TooltipWrapper>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onOpenInEditor(worktree)} className="text-xs">
@@ -162,17 +206,41 @@ export function WorktreeActionsDropdown({
</DropdownMenuItem>
<DropdownMenuSeparator />
{worktree.hasChanges && (
<DropdownMenuItem onClick={() => onCommit(worktree)} className="text-xs">
<GitCommit className="w-3.5 h-3.5 mr-2" />
Commit Changes
</DropdownMenuItem>
<TooltipWrapper
showTooltip={!gitRepoStatus.isGitRepo}
tooltipContent="Not a git repository"
>
<DropdownMenuItem
onClick={() => gitRepoStatus.isGitRepo && onCommit(worktree)}
disabled={!gitRepoStatus.isGitRepo}
className={cn('text-xs', !gitRepoStatus.isGitRepo && 'opacity-50 cursor-not-allowed')}
>
<GitCommit className="w-3.5 h-3.5 mr-2" />
Commit Changes
{!gitRepoStatus.isGitRepo && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
</DropdownMenuItem>
</TooltipWrapper>
)}
{/* Show PR option for non-primary worktrees, or primary worktree with changes */}
{(!worktree.isMain || worktree.hasChanges) && !hasPR && (
<DropdownMenuItem onClick={() => onCreatePR(worktree)} className="text-xs">
<GitPullRequest className="w-3.5 h-3.5 mr-2" />
Create Pull Request
</DropdownMenuItem>
<TooltipWrapper
showTooltip={!!gitOpsDisabledReason}
tooltipContent={gitOpsDisabledReason}
>
<DropdownMenuItem
onClick={() => canPerformGitOps && onCreatePR(worktree)}
disabled={!canPerformGitOps}
className={cn('text-xs', !canPerformGitOps && 'opacity-50 cursor-not-allowed')}
>
<GitPullRequest className="w-3.5 h-3.5 mr-2" />
Create Pull Request
{!canPerformGitOps && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
</DropdownMenuItem>
</TooltipWrapper>
)}
{/* Show PR info and Address Comments button if PR exists */}
{!worktree.isMain && hasPR && worktree.pr && (

View File

@@ -2,7 +2,7 @@ import { Button } from '@/components/ui/button';
import { RefreshCw, Globe, Loader2, CircleDot, GitPullRequest } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import type { WorktreeInfo, BranchInfo, DevServerInfo, PRInfo } from '../types';
import type { WorktreeInfo, BranchInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types';
import { BranchSwitchDropdown } from './branch-switch-dropdown';
import { WorktreeActionsDropdown } from './worktree-actions-dropdown';
@@ -27,6 +27,7 @@ interface WorktreeTabProps {
isStartingDevServer: boolean;
aheadCount: number;
behindCount: number;
gitRepoStatus: GitRepoStatus;
onSelectWorktree: (worktree: WorktreeInfo) => void;
onBranchDropdownOpenChange: (open: boolean) => void;
onActionsDropdownOpenChange: (open: boolean) => void;
@@ -67,6 +68,7 @@ export function WorktreeTab({
isStartingDevServer,
aheadCount,
behindCount,
gitRepoStatus,
onSelectWorktree,
onBranchDropdownOpenChange,
onActionsDropdownOpenChange,
@@ -320,6 +322,7 @@ export function WorktreeTab({
isStartingDevServer={isStartingDevServer}
isDevServerRunning={isDevServerRunning}
devServerInfo={devServerInfo}
gitRepoStatus={gitRepoStatus}
onOpenChange={onActionsDropdownOpenChange}
onPull={onPull}
onPush={onPush}

View File

@@ -1,6 +1,6 @@
import { useState, useCallback } from 'react';
import { getElectronAPI } from '@/lib/electron';
import type { BranchInfo } from '../types';
import type { BranchInfo, GitRepoStatus } from '../types';
export function useBranches() {
const [branches, setBranches] = useState<BranchInfo[]>([]);
@@ -8,28 +8,58 @@ export function useBranches() {
const [behindCount, setBehindCount] = useState(0);
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
const [branchFilter, setBranchFilter] = useState('');
const [gitRepoStatus, setGitRepoStatus] = useState<GitRepoStatus>({
isGitRepo: true,
hasCommits: true,
});
const fetchBranches = useCallback(async (worktreePath: string) => {
setIsLoadingBranches(true);
try {
const api = getElectronAPI();
if (!api?.worktree?.listBranches) {
console.warn('List branches API not available');
return;
}
const result = await api.worktree.listBranches(worktreePath);
if (result.success && result.result) {
setBranches(result.result.branches);
setAheadCount(result.result.aheadCount || 0);
setBehindCount(result.result.behindCount || 0);
}
} catch (error) {
console.error('Failed to fetch branches:', error);
} finally {
setIsLoadingBranches(false);
}
/** Helper to reset branch state to initial values */
const resetBranchState = useCallback(() => {
setBranches([]);
setAheadCount(0);
setBehindCount(0);
}, []);
const fetchBranches = useCallback(
async (worktreePath: string) => {
setIsLoadingBranches(true);
try {
const api = getElectronAPI();
if (!api?.worktree?.listBranches) {
console.warn('List branches API not available');
return;
}
const result = await api.worktree.listBranches(worktreePath);
if (result.success && result.result) {
setBranches(result.result.branches);
setAheadCount(result.result.aheadCount || 0);
setBehindCount(result.result.behindCount || 0);
setGitRepoStatus({ isGitRepo: true, hasCommits: true });
} else if (result.code === 'NOT_GIT_REPO') {
// Not a git repository - clear branches silently without logging an error
resetBranchState();
setGitRepoStatus({ isGitRepo: false, hasCommits: false });
} else if (result.code === 'NO_COMMITS') {
// Git repo but no commits yet - clear branches silently without logging an error
resetBranchState();
setGitRepoStatus({ isGitRepo: true, hasCommits: false });
} else if (!result.success) {
// Other errors - log them
console.warn('Failed to fetch branches:', result.error);
resetBranchState();
}
} catch (error) {
console.error('Failed to fetch branches:', error);
resetBranchState();
// Reset git status to unknown state on network/API errors
setGitRepoStatus({ isGitRepo: true, hasCommits: true });
} finally {
setIsLoadingBranches(false);
}
},
[resetBranchState]
);
const resetBranchFilter = useCallback(() => {
setBranchFilter('');
}, []);
@@ -48,5 +78,6 @@ export function useBranches() {
setBranchFilter,
resetBranchFilter,
fetchBranches,
gitRepoStatus,
};
}

View File

@@ -114,13 +114,12 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
const handleOpenDevServerUrl = useCallback(
(worktree: WorktreeInfo) => {
const targetPath = worktree.isMain ? projectPath : worktree.path;
const serverInfo = runningDevServers.get(targetPath);
const serverInfo = runningDevServers.get(getWorktreeKey(worktree));
if (serverInfo) {
window.open(serverInfo.url, '_blank');
}
},
[projectPath, runningDevServers]
[runningDevServers, getWorktreeKey]
);
const isDevServerRunning = useCallback(

View File

@@ -3,6 +3,29 @@ import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import type { WorktreeInfo } from '../types';
// Error codes that need special user-friendly handling
const GIT_STATUS_ERROR_CODES = ['NOT_GIT_REPO', 'NO_COMMITS'] as const;
type GitStatusErrorCode = (typeof GIT_STATUS_ERROR_CODES)[number];
// User-friendly messages for git status errors
const GIT_STATUS_ERROR_MESSAGES: Record<GitStatusErrorCode, string> = {
NOT_GIT_REPO: 'This directory is not a git repository',
NO_COMMITS: 'Repository has no commits yet. Create an initial commit first.',
};
/**
* Helper to handle git status errors with user-friendly messages.
* @returns true if the error was a git status error and was handled, false otherwise.
*/
function handleGitStatusError(result: { code?: string; error?: string }): boolean {
const errorCode = result.code as GitStatusErrorCode | undefined;
if (errorCode && GIT_STATUS_ERROR_CODES.includes(errorCode)) {
toast.info(GIT_STATUS_ERROR_MESSAGES[errorCode] || result.error);
return true;
}
return false;
}
interface UseWorktreeActionsOptions {
fetchWorktrees: () => Promise<Array<{ path: string; branch: string }> | undefined>;
fetchBranches: (worktreePath: string) => Promise<void>;
@@ -29,6 +52,7 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre
toast.success(result.result.message);
fetchWorktrees();
} else {
if (handleGitStatusError(result)) return;
toast.error(result.error || 'Failed to switch branch');
}
} catch (error) {
@@ -56,6 +80,7 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre
toast.success(result.result.message);
fetchWorktrees();
} else {
if (handleGitStatusError(result)) return;
toast.error(result.error || 'Failed to pull latest changes');
}
} catch (error) {
@@ -84,6 +109,7 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre
fetchBranches(worktree.path);
fetchWorktrees();
} else {
if (handleGitStatusError(result)) return;
toast.error(result.error || 'Failed to push changes');
}
} catch (error) {

View File

@@ -23,6 +23,11 @@ export interface BranchInfo {
isRemote: boolean;
}
export interface GitRepoStatus {
isGitRepo: boolean;
hasCommits: boolean;
}
export interface DevServerInfo {
worktreePath: string;
port: number;

View File

@@ -61,6 +61,7 @@ export function WorktreePanel({
setBranchFilter,
resetBranchFilter,
fetchBranches,
gitRepoStatus,
} = useBranches();
const {
@@ -210,6 +211,7 @@ export function WorktreePanel({
isStartingDevServer={isStartingDevServer}
aheadCount={aheadCount}
behindCount={behindCount}
gitRepoStatus={gitRepoStatus}
onSelectWorktree={handleSelectWorktree}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(mainWorktree)}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(mainWorktree)}
@@ -264,6 +266,7 @@ export function WorktreePanel({
isStartingDevServer={isStartingDevServer}
aheadCount={aheadCount}
behindCount={behindCount}
gitRepoStatus={gitRepoStatus}
onSelectWorktree={handleSelectWorktree}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)}

View File

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

View File

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

View File

@@ -0,0 +1,242 @@
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

@@ -0,0 +1,136 @@
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

@@ -0,0 +1,38 @@
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

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

View File

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

View File

@@ -0,0 +1,231 @@
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

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

View File

@@ -0,0 +1,76 @@
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

@@ -0,0 +1,317 @@
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

@@ -0,0 +1,28 @@
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

@@ -0,0 +1,33 @@
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

@@ -0,0 +1,131 @@
import { memo } from 'react';
import { BaseEdge, getBezierPath, EdgeLabelRenderer } from '@xyflow/react';
import type { EdgeProps } from '@xyflow/react';
import { cn } from '@/lib/utils';
import { Feature } from '@/store/app-store';
export interface DependencyEdgeData {
sourceStatus: Feature['status'];
targetStatus: Feature['status'];
isHighlighted?: boolean;
isDimmed?: boolean;
}
const getEdgeColor = (sourceStatus?: Feature['status'], targetStatus?: Feature['status']) => {
// If source is completed/verified, the dependency is satisfied
if (sourceStatus === 'completed' || sourceStatus === 'verified') {
return 'var(--status-success)';
}
// If target is in progress, show active color
if (targetStatus === 'in_progress') {
return 'var(--status-in-progress)';
}
// If target is blocked (in backlog with incomplete deps)
if (targetStatus === 'backlog') {
return 'var(--border)';
}
// Default
return 'var(--border)';
};
export const DependencyEdge = memo(function DependencyEdge(props: EdgeProps) {
const {
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
data,
selected,
animated,
} = props;
const edgeData = data as DependencyEdgeData | undefined;
const [edgePath, labelX, labelY] = getBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
curvature: 0.25,
});
const isHighlighted = edgeData?.isHighlighted ?? false;
const isDimmed = edgeData?.isDimmed ?? false;
const edgeColor = isHighlighted
? 'var(--brand-500)'
: edgeData
? getEdgeColor(edgeData.sourceStatus, edgeData.targetStatus)
: 'var(--border)';
const isCompleted =
edgeData?.sourceStatus === 'completed' || edgeData?.sourceStatus === 'verified';
const isInProgress = edgeData?.targetStatus === 'in_progress';
return (
<>
{/* Background edge for better visibility */}
<BaseEdge
id={`${id}-bg`}
path={edgePath}
style={{
strokeWidth: isHighlighted ? 6 : 4,
stroke: 'var(--background)',
opacity: isDimmed ? 0.3 : 1,
}}
/>
{/* Main edge */}
<BaseEdge
id={id}
path={edgePath}
className={cn(
'transition-all duration-300',
animated && 'animated-edge',
isInProgress && 'edge-flowing',
isHighlighted && 'graph-edge-highlighted',
isDimmed && 'graph-edge-dimmed'
)}
style={{
strokeWidth: isHighlighted ? 4 : selected ? 3 : isDimmed ? 1 : 2,
stroke: edgeColor,
strokeDasharray: isCompleted ? 'none' : '5 5',
filter: isHighlighted
? 'drop-shadow(0 0 6px var(--brand-500))'
: selected
? 'drop-shadow(0 0 3px var(--brand-500))'
: 'none',
opacity: isDimmed ? 0.2 : 1,
}}
/>
{/* Animated particles for in-progress edges */}
{animated && (
<EdgeLabelRenderer>
<div
style={{
position: 'absolute',
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
pointerEvents: 'none',
}}
className="edge-particle"
>
<div
className={cn(
'w-2 h-2 rounded-full',
isInProgress
? 'bg-[var(--status-in-progress)] animate-ping'
: 'bg-brand-500 animate-pulse'
)}
/>
</div>
</EdgeLabelRenderer>
)}
</>
);
});

View File

@@ -0,0 +1,135 @@
import { useReactFlow, Panel } from '@xyflow/react';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import {
ZoomIn,
ZoomOut,
Maximize2,
Lock,
Unlock,
GitBranch,
ArrowRight,
ArrowDown,
} from 'lucide-react';
import { cn } from '@/lib/utils';
interface GraphControlsProps {
isLocked: boolean;
onToggleLock: () => void;
onRunLayout: (direction: 'LR' | 'TB') => void;
layoutDirection: 'LR' | 'TB';
}
export function GraphControls({
isLocked,
onToggleLock,
onRunLayout,
layoutDirection,
}: GraphControlsProps) {
const { zoomIn, zoomOut, fitView } = useReactFlow();
return (
<Panel position="bottom-left" className="flex flex-col gap-2">
<TooltipProvider delayDuration={200}>
<div className="flex flex-col gap-1 p-1.5 rounded-lg bg-popover/90 backdrop-blur-sm border border-border shadow-lg text-popover-foreground">
{/* Zoom controls */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => zoomIn({ duration: 200 })}
>
<ZoomIn className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">Zoom In</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => zoomOut({ duration: 200 })}
>
<ZoomOut className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">Zoom Out</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => fitView({ padding: 0.2, duration: 300 })}
>
<Maximize2 className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">Fit View</TooltipContent>
</Tooltip>
<div className="h-px bg-border my-1" />
{/* Layout controls */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn(
'h-8 w-8 p-0',
layoutDirection === 'LR' && 'bg-brand-500/20 text-brand-500'
)}
onClick={() => onRunLayout('LR')}
>
<ArrowRight className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">Horizontal Layout</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn(
'h-8 w-8 p-0',
layoutDirection === 'TB' && 'bg-brand-500/20 text-brand-500'
)}
onClick={() => onRunLayout('TB')}
>
<ArrowDown className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">Vertical Layout</TooltipContent>
</Tooltip>
<div className="h-px bg-border my-1" />
{/* Lock toggle */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn('h-8 w-8 p-0', isLocked && 'bg-brand-500/20 text-brand-500')}
onClick={onToggleLock}
>
{isLocked ? <Lock className="w-4 h-4" /> : <Unlock className="w-4 h-4" />}
</Button>
</TooltipTrigger>
<TooltipContent side="right">{isLocked ? 'Unlock Nodes' : 'Lock Nodes'}</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
</Panel>
);
}

View File

@@ -0,0 +1,329 @@
import { Panel } from '@xyflow/react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Switch } from '@/components/ui/switch';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import {
Filter,
X,
Eye,
EyeOff,
ChevronDown,
Play,
Pause,
Clock,
CheckCircle2,
CircleDot,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import {
GraphFilterState,
STATUS_FILTER_OPTIONS,
StatusFilterValue,
} from '../hooks/use-graph-filter';
// Status display configuration
const statusDisplayConfig: Record<
StatusFilterValue,
{ label: string; icon: typeof Play; colorClass: string }
> = {
running: { label: 'Running', icon: Play, colorClass: 'text-[var(--status-in-progress)]' },
paused: { label: 'Paused', icon: Pause, colorClass: 'text-[var(--status-warning)]' },
backlog: { label: 'Backlog', icon: Clock, colorClass: 'text-muted-foreground' },
waiting_approval: {
label: 'Waiting Approval',
icon: CircleDot,
colorClass: 'text-[var(--status-waiting)]',
},
verified: { label: 'Verified', icon: CheckCircle2, colorClass: 'text-[var(--status-success)]' },
};
interface GraphFilterControlsProps {
filterState: GraphFilterState;
availableCategories: string[];
hasActiveFilter: boolean;
onCategoriesChange: (categories: string[]) => void;
onStatusesChange: (statuses: string[]) => void;
onNegativeFilterChange: (isNegative: boolean) => void;
onClearFilters: () => void;
}
export function GraphFilterControls({
filterState,
availableCategories,
hasActiveFilter,
onCategoriesChange,
onStatusesChange,
onNegativeFilterChange,
onClearFilters,
}: GraphFilterControlsProps) {
const { selectedCategories, selectedStatuses, isNegativeFilter } = filterState;
const handleCategoryToggle = (category: string) => {
if (selectedCategories.includes(category)) {
onCategoriesChange(selectedCategories.filter((c) => c !== category));
} else {
onCategoriesChange([...selectedCategories, category]);
}
};
const handleSelectAllCategories = () => {
if (selectedCategories.length === availableCategories.length) {
onCategoriesChange([]);
} else {
onCategoriesChange([...availableCategories]);
}
};
const handleStatusToggle = (status: string) => {
if (selectedStatuses.includes(status)) {
onStatusesChange(selectedStatuses.filter((s) => s !== status));
} else {
onStatusesChange([...selectedStatuses, status]);
}
};
const handleSelectAllStatuses = () => {
if (selectedStatuses.length === STATUS_FILTER_OPTIONS.length) {
onStatusesChange([]);
} else {
onStatusesChange([...STATUS_FILTER_OPTIONS]);
}
};
const categoryButtonLabel =
selectedCategories.length === 0
? 'All Categories'
: selectedCategories.length === 1
? selectedCategories[0]
: `${selectedCategories.length} Categories`;
const statusButtonLabel =
selectedStatuses.length === 0
? 'All Statuses'
: selectedStatuses.length === 1
? statusDisplayConfig[selectedStatuses[0] as StatusFilterValue]?.label ||
selectedStatuses[0]
: `${selectedStatuses.length} Statuses`;
return (
<Panel position="top-left" className="flex items-center gap-2">
<TooltipProvider delayDuration={200}>
<div className="flex items-center gap-2 p-2 rounded-lg bg-popover/90 backdrop-blur-sm border border-border shadow-lg text-popover-foreground">
{/* Category Filter Dropdown */}
<Popover>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn(
'h-8 px-2 gap-1.5',
selectedCategories.length > 0 && 'bg-brand-500/20 text-brand-500'
)}
>
<Filter className="w-4 h-4" />
<span className="text-xs max-w-[100px] truncate">{categoryButtonLabel}</span>
<ChevronDown className="w-3 h-3 opacity-50" />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>Filter by Category</TooltipContent>
</Tooltip>
<PopoverContent align="start" className="w-56 p-2">
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground px-2 py-1">
Categories
</div>
{/* Select All option */}
<div
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-accent cursor-pointer"
onClick={handleSelectAllCategories}
>
<Checkbox
checked={
selectedCategories.length === availableCategories.length &&
availableCategories.length > 0
}
onCheckedChange={handleSelectAllCategories}
/>
<span className="text-sm font-medium">
{selectedCategories.length === availableCategories.length
? 'Deselect All'
: 'Select All'}
</span>
</div>
<div className="h-px bg-border" />
{/* Category list */}
<div className="max-h-48 overflow-y-auto space-y-0.5">
{availableCategories.length === 0 ? (
<div className="text-xs text-muted-foreground px-2 py-2">
No categories available
</div>
) : (
availableCategories.map((category) => (
<div
key={category}
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-accent cursor-pointer"
onClick={() => handleCategoryToggle(category)}
>
<Checkbox
checked={selectedCategories.includes(category)}
onCheckedChange={() => handleCategoryToggle(category)}
/>
<span className="text-sm truncate">{category}</span>
</div>
))
)}
</div>
</div>
</PopoverContent>
</Popover>
{/* Status Filter Dropdown */}
<Popover>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn(
'h-8 px-2 gap-1.5',
selectedStatuses.length > 0 && 'bg-brand-500/20 text-brand-500'
)}
>
<CircleDot className="w-4 h-4" />
<span className="text-xs max-w-[120px] truncate">{statusButtonLabel}</span>
<ChevronDown className="w-3 h-3 opacity-50" />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>Filter by Status</TooltipContent>
</Tooltip>
<PopoverContent align="start" className="w-56 p-2">
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground px-2 py-1">Status</div>
{/* Select All option */}
<div
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-accent cursor-pointer"
onClick={handleSelectAllStatuses}
>
<Checkbox
checked={selectedStatuses.length === STATUS_FILTER_OPTIONS.length}
onCheckedChange={handleSelectAllStatuses}
/>
<span className="text-sm font-medium">
{selectedStatuses.length === STATUS_FILTER_OPTIONS.length
? 'Deselect All'
: 'Select All'}
</span>
</div>
<div className="h-px bg-border" />
{/* Status list */}
<div className="space-y-0.5">
{STATUS_FILTER_OPTIONS.map((status) => {
const config = statusDisplayConfig[status];
const StatusIcon = config.icon;
return (
<div
key={status}
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-accent cursor-pointer"
onClick={() => handleStatusToggle(status)}
>
<Checkbox
checked={selectedStatuses.includes(status)}
onCheckedChange={() => handleStatusToggle(status)}
/>
<StatusIcon className={cn('w-3.5 h-3.5', config.colorClass)} />
<span className="text-sm">{config.label}</span>
</div>
);
})}
</div>
</div>
</PopoverContent>
</Popover>
{/* Divider */}
<div className="h-6 w-px bg-border" />
{/* Positive/Negative Filter Toggle */}
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-2">
<button
onClick={() => onNegativeFilterChange(!isNegativeFilter)}
aria-label={
isNegativeFilter
? 'Switch to show matching nodes'
: 'Switch to hide matching nodes'
}
aria-pressed={isNegativeFilter}
className={cn(
'flex items-center gap-1.5 px-2 py-1 rounded text-xs transition-colors',
isNegativeFilter
? 'bg-orange-500/20 text-orange-500'
: 'hover:bg-accent text-muted-foreground hover:text-foreground'
)}
>
{isNegativeFilter ? (
<>
<EyeOff className="w-3.5 h-3.5" />
<span>Hide</span>
</>
) : (
<>
<Eye className="w-3.5 h-3.5" />
<span>Show</span>
</>
)}
</button>
<Switch
checked={isNegativeFilter}
onCheckedChange={onNegativeFilterChange}
aria-label="Toggle between show and hide filter modes"
className="h-5 w-9 data-[state=checked]:bg-orange-500"
/>
</div>
</TooltipTrigger>
<TooltipContent>
{isNegativeFilter
? 'Negative filter: Highlighting non-matching nodes'
: 'Positive filter: Highlighting matching nodes'}
</TooltipContent>
</Tooltip>
{/* Clear Filters Button - only show when filters are active */}
{hasActiveFilter && (
<>
<div className="h-6 w-px bg-border" />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive"
onClick={onClearFilters}
aria-label="Clear all filters"
>
<X className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Clear All Filters</TooltipContent>
</Tooltip>
</>
)}
</div>
</TooltipProvider>
</Panel>
);
}

View File

@@ -0,0 +1,62 @@
import { Panel } from '@xyflow/react';
import { Clock, Play, Pause, CheckCircle2, Lock, AlertCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
const legendItems = [
{
icon: Clock,
label: 'Backlog',
colorClass: 'text-muted-foreground',
bgClass: 'bg-muted/50',
},
{
icon: Play,
label: 'In Progress',
colorClass: 'text-[var(--status-in-progress)]',
bgClass: 'bg-[var(--status-in-progress)]/20',
},
{
icon: Pause,
label: 'Waiting',
colorClass: 'text-[var(--status-waiting)]',
bgClass: 'bg-[var(--status-warning)]/20',
},
{
icon: CheckCircle2,
label: 'Verified',
colorClass: 'text-[var(--status-success)]',
bgClass: 'bg-[var(--status-success)]/20',
},
{
icon: Lock,
label: 'Blocked',
colorClass: 'text-orange-500',
bgClass: 'bg-orange-500/20',
},
{
icon: AlertCircle,
label: 'Error',
colorClass: 'text-[var(--status-error)]',
bgClass: 'bg-[var(--status-error)]/20',
},
];
export function GraphLegend() {
return (
<Panel position="bottom-right" className="pointer-events-none">
<div className="flex flex-wrap gap-3 p-2 rounded-lg bg-popover/90 backdrop-blur-sm border border-border shadow-lg pointer-events-auto text-popover-foreground">
{legendItems.map((item) => {
const Icon = item.icon;
return (
<div key={item.label} className="flex items-center gap-1.5">
<div className={cn('p-1 rounded', item.bgClass)}>
<Icon className={cn('w-3 h-3', item.colorClass)} />
</div>
<span className="text-xs text-muted-foreground">{item.label}</span>
</div>
);
})}
</div>
</Panel>
);
}

View File

@@ -0,0 +1,5 @@
export { TaskNode } from './task-node';
export { DependencyEdge } from './dependency-edge';
export { GraphControls } from './graph-controls';
export { GraphLegend } from './graph-legend';
export { GraphFilterControls } from './graph-filter-controls';

View File

@@ -0,0 +1,342 @@
import { memo } from 'react';
import { Handle, Position } from '@xyflow/react';
import type { NodeProps } from '@xyflow/react';
import { cn } from '@/lib/utils';
import {
Lock,
CheckCircle2,
Clock,
AlertCircle,
Play,
Pause,
Eye,
MoreVertical,
GitBranch,
Terminal,
RotateCcw,
} from 'lucide-react';
import { TaskNodeData } from '../hooks/use-graph-nodes';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
type TaskNodeProps = NodeProps & {
data: TaskNodeData;
};
const statusConfig = {
backlog: {
icon: Clock,
label: 'Backlog',
colorClass: 'text-muted-foreground',
borderClass: 'border-border',
bgClass: 'bg-card',
},
in_progress: {
icon: Play,
label: 'In Progress',
colorClass: 'text-[var(--status-in-progress)]',
borderClass: 'border-[var(--status-in-progress)]',
bgClass: 'bg-[var(--status-in-progress-bg)]',
},
waiting_approval: {
icon: Pause,
label: 'Waiting Approval',
colorClass: 'text-[var(--status-waiting)]',
borderClass: 'border-[var(--status-waiting)]',
bgClass: 'bg-[var(--status-warning-bg)]',
},
verified: {
icon: CheckCircle2,
label: 'Verified',
colorClass: 'text-[var(--status-success)]',
borderClass: 'border-[var(--status-success)]',
bgClass: 'bg-[var(--status-success-bg)]',
},
completed: {
icon: CheckCircle2,
label: 'Completed',
colorClass: 'text-[var(--status-success)]',
borderClass: 'border-[var(--status-success)]/50',
bgClass: 'bg-[var(--status-success-bg)]/50',
},
};
const priorityConfig = {
1: { label: 'High', colorClass: 'bg-[var(--status-error)] text-white' },
2: { label: 'Medium', colorClass: 'bg-[var(--status-warning)] text-black' },
3: { label: 'Low', colorClass: 'bg-[var(--status-info)] text-white' },
};
export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps) {
const config = statusConfig[data.status] || statusConfig.backlog;
const StatusIcon = config.icon;
const priorityConf = data.priority ? priorityConfig[data.priority as 1 | 2 | 3] : null;
// Filter highlight states
const isMatched = data.isMatched ?? false;
const isHighlighted = data.isHighlighted ?? false;
const isDimmed = data.isDimmed ?? false;
// Task is stopped if it's in_progress but not actively running
const isStopped = data.status === 'in_progress' && !data.isRunning;
return (
<>
{/* Target handle (left side - receives dependencies) */}
<Handle
type="target"
position={Position.Left}
className={cn(
'w-3 h-3 !bg-border border-2 border-background',
'transition-colors duration-200',
'hover:!bg-brand-500',
isDimmed && 'opacity-30'
)}
/>
<div
className={cn(
'min-w-[240px] max-w-[280px] rounded-xl border-2 bg-card shadow-md',
'transition-all duration-300',
config.borderClass,
selected && 'ring-2 ring-brand-500 ring-offset-2 ring-offset-background',
data.isRunning && 'animate-pulse-subtle',
data.error && 'border-[var(--status-error)]',
// Filter highlight states
isMatched && 'graph-node-matched',
isHighlighted && !isMatched && 'graph-node-highlighted',
isDimmed && 'graph-node-dimmed'
)}
>
{/* Header with status and actions */}
<div
className={cn(
'flex items-center justify-between px-3 py-2 rounded-t-[10px]',
config.bgClass
)}
>
<div className="flex items-center gap-2">
<StatusIcon className={cn('w-4 h-4', config.colorClass)} />
<span className={cn('text-xs font-medium', config.colorClass)}>{config.label}</span>
</div>
<div className="flex items-center gap-1">
{/* Priority badge */}
{priorityConf && (
<span
className={cn(
'text-[10px] font-bold px-1.5 py-0.5 rounded',
priorityConf.colorClass
)}
>
{data.priority === 1 ? 'H' : data.priority === 2 ? 'M' : 'L'}
</span>
)}
{/* Blocked indicator */}
{data.isBlocked && !data.error && data.status === 'backlog' && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<div className="p-1 rounded bg-orange-500/20">
<Lock className="w-3 h-3 text-orange-500" />
</div>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs max-w-[200px]">
<p>Blocked by {data.blockingDependencies.length} dependencies</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Error indicator */}
{data.error && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<div className="p-1 rounded bg-[var(--status-error-bg)]">
<AlertCircle className="w-3 h-3 text-[var(--status-error)]" />
</div>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs max-w-[250px]">
<p>{data.error}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Stopped indicator - task is in_progress but not actively running */}
{isStopped && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<div className="p-1 rounded bg-[var(--status-warning-bg)]">
<Pause className="w-3 h-3 text-[var(--status-warning)]" />
</div>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs max-w-[200px]">
<p>Task paused - click menu to resume</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Actions dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn(
'h-7 w-7 p-0 rounded-md',
'bg-background/60 hover:bg-background',
'border border-border/50 hover:border-border',
'shadow-sm',
'transition-all duration-150'
)}
onClick={(e) => e.stopPropagation()}
>
<MoreVertical className="w-4 h-4 text-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-44"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenuItem
className="text-xs cursor-pointer"
onClick={(e) => {
e.stopPropagation();
data.onViewLogs?.();
}}
>
<Terminal className="w-3 h-3 mr-2" />
View Agent Logs
</DropdownMenuItem>
<DropdownMenuItem
className="text-xs cursor-pointer"
onClick={(e) => {
e.stopPropagation();
data.onViewDetails?.();
}}
>
<Eye className="w-3 h-3 mr-2" />
View Details
</DropdownMenuItem>
{data.status === 'backlog' && !data.isBlocked && (
<DropdownMenuItem
className="text-xs cursor-pointer"
onClick={(e) => {
e.stopPropagation();
data.onStartTask?.();
}}
>
<Play className="w-3 h-3 mr-2" />
Start Task
</DropdownMenuItem>
)}
{data.isRunning && (
<DropdownMenuItem
className="text-xs text-[var(--status-error)] cursor-pointer"
onClick={(e) => {
e.stopPropagation();
data.onStopTask?.();
}}
>
<Pause className="w-3 h-3 mr-2" />
Stop Task
</DropdownMenuItem>
)}
{isStopped && (
<DropdownMenuItem
className="text-xs text-[var(--status-success)] cursor-pointer"
onClick={(e) => {
e.stopPropagation();
data.onResumeTask?.();
}}
>
<RotateCcw className="w-3 h-3 mr-2" />
Resume Task
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* Content */}
<div className="px-3 py-2">
{/* Category */}
<span className="text-[10px] text-muted-foreground font-medium uppercase tracking-wide">
{data.category}
</span>
{/* Title */}
{data.title && (
<h3 className="text-sm font-medium mt-1 line-clamp-1 text-foreground">{data.title}</h3>
)}
{/* Description */}
<p
className={cn(
'text-xs text-muted-foreground line-clamp-2',
data.title ? 'mt-1' : 'mt-1 font-medium text-foreground text-sm'
)}
>
{data.description}
</p>
{/* Progress indicator for in-progress tasks */}
{data.isRunning && (
<div className="mt-2 flex items-center gap-2">
<div className="flex-1 h-1.5 bg-muted rounded-full overflow-hidden">
<div className="h-full bg-[var(--status-in-progress)] rounded-full animate-progress-indeterminate" />
</div>
<span className="text-[10px] text-muted-foreground">Running...</span>
</div>
)}
{/* Paused indicator for stopped tasks */}
{isStopped && (
<div className="mt-2 flex items-center gap-2">
<div className="flex-1 h-1.5 bg-muted rounded-full overflow-hidden">
<div className="h-full w-1/2 bg-[var(--status-warning)] rounded-full" />
</div>
<span className="text-[10px] text-[var(--status-warning)] font-medium">Paused</span>
</div>
)}
{/* Branch name if assigned */}
{data.branchName && (
<div className="mt-2 flex items-center gap-1 text-[10px] text-muted-foreground">
<GitBranch className="w-3 h-3" />
<span className="truncate">{data.branchName}</span>
</div>
)}
</div>
</div>
{/* Source handle (right side - provides to dependents) */}
<Handle
type="source"
position={Position.Right}
className={cn(
'w-3 h-3 !bg-border border-2 border-background',
'transition-colors duration-200',
'hover:!bg-brand-500',
data.status === 'completed' || data.status === 'verified'
? '!bg-[var(--status-success)]'
: '',
isDimmed && 'opacity-30'
)}
/>
</>
);
});

View File

@@ -0,0 +1,243 @@
import { useCallback, useState, useEffect } from 'react';
import {
ReactFlow,
Background,
BackgroundVariant,
MiniMap,
Panel,
useNodesState,
useEdgesState,
ReactFlowProvider,
SelectionMode,
ConnectionMode,
Node,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { Feature } from '@/store/app-store';
import {
TaskNode,
DependencyEdge,
GraphControls,
GraphLegend,
GraphFilterControls,
} from './components';
import {
useGraphNodes,
useGraphLayout,
useGraphFilter,
type TaskNodeData,
type GraphFilterState,
type NodeActionCallbacks,
} from './hooks';
import { cn } from '@/lib/utils';
import { useDebounceValue } from 'usehooks-ts';
import { SearchX } from 'lucide-react';
import { Button } from '@/components/ui/button';
// Define custom node and edge types - using any to avoid React Flow's strict typing
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const nodeTypes: any = {
task: TaskNode,
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const edgeTypes: any = {
dependency: DependencyEdge,
};
interface GraphCanvasProps {
features: Feature[];
runningAutoTasks: string[];
searchQuery: string;
onSearchQueryChange: (query: string) => void;
onNodeDoubleClick?: (featureId: string) => void;
nodeActionCallbacks?: NodeActionCallbacks;
backgroundStyle?: React.CSSProperties;
className?: string;
}
function GraphCanvasInner({
features,
runningAutoTasks,
searchQuery,
onSearchQueryChange,
onNodeDoubleClick,
nodeActionCallbacks,
backgroundStyle,
className,
}: GraphCanvasProps) {
const [isLocked, setIsLocked] = useState(false);
const [layoutDirection, setLayoutDirection] = useState<'LR' | 'TB'>('LR');
// Filter state (category, status, and negative toggle are local to graph view)
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
const [selectedStatuses, setSelectedStatuses] = useState<string[]>([]);
const [isNegativeFilter, setIsNegativeFilter] = useState(false);
// Debounce search query for performance with large graphs
const [debouncedSearchQuery] = useDebounceValue(searchQuery, 200);
// Combined filter state
const filterState: GraphFilterState = {
searchQuery: debouncedSearchQuery,
selectedCategories,
selectedStatuses,
isNegativeFilter,
};
// Calculate filter results
const filterResult = useGraphFilter(features, filterState, runningAutoTasks);
// Transform features to nodes and edges with filter results
const { nodes: initialNodes, edges: initialEdges } = useGraphNodes({
features,
runningAutoTasks,
filterResult,
actionCallbacks: nodeActionCallbacks,
});
// Apply layout
const { layoutedNodes, layoutedEdges, runLayout } = useGraphLayout({
nodes: initialNodes,
edges: initialEdges,
});
// React Flow state
const [nodes, setNodes, onNodesChange] = useNodesState(layoutedNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(layoutedEdges);
// Update nodes/edges when features change
useEffect(() => {
setNodes(layoutedNodes);
setEdges(layoutedEdges);
}, [layoutedNodes, layoutedEdges, setNodes, setEdges]);
// Handle layout direction change
const handleRunLayout = useCallback(
(direction: 'LR' | 'TB') => {
setLayoutDirection(direction);
runLayout(direction);
},
[runLayout]
);
// Handle clear all filters
const handleClearFilters = useCallback(() => {
onSearchQueryChange('');
setSelectedCategories([]);
setSelectedStatuses([]);
setIsNegativeFilter(false);
}, [onSearchQueryChange]);
// Handle node double click
const handleNodeDoubleClick = useCallback(
(_event: React.MouseEvent, node: Node<TaskNodeData>) => {
onNodeDoubleClick?.(node.id);
},
[onNodeDoubleClick]
);
// MiniMap node color based on status
const minimapNodeColor = useCallback((node: Node<TaskNodeData>) => {
const data = node.data as TaskNodeData | undefined;
const status = data?.status;
switch (status) {
case 'completed':
case 'verified':
return 'var(--status-success)';
case 'in_progress':
return 'var(--status-in-progress)';
case 'waiting_approval':
return 'var(--status-waiting)';
default:
if (data?.isBlocked) return 'rgb(249, 115, 22)'; // orange-500
if (data?.error) return 'var(--status-error)';
return 'var(--muted-foreground)';
}
}, []);
return (
<div className={cn('w-full h-full', className)} style={backgroundStyle}>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={isLocked ? undefined : onNodesChange}
onEdgesChange={onEdgesChange}
onNodeDoubleClick={handleNodeDoubleClick}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
fitView
fitViewOptions={{ padding: 0.2 }}
minZoom={0.1}
maxZoom={2}
selectionMode={SelectionMode.Partial}
connectionMode={ConnectionMode.Loose}
proOptions={{ hideAttribution: true }}
className="graph-canvas"
>
<Background
variant={BackgroundVariant.Dots}
gap={20}
size={1}
color="var(--border)"
className="opacity-50"
/>
<MiniMap
nodeColor={minimapNodeColor}
nodeStrokeWidth={3}
zoomable
pannable
className="!bg-popover/90 !border-border rounded-lg shadow-lg"
/>
<GraphControls
isLocked={isLocked}
onToggleLock={() => setIsLocked(!isLocked)}
onRunLayout={handleRunLayout}
layoutDirection={layoutDirection}
/>
<GraphFilterControls
filterState={filterState}
availableCategories={filterResult.availableCategories}
hasActiveFilter={filterResult.hasActiveFilter}
onCategoriesChange={setSelectedCategories}
onStatusesChange={setSelectedStatuses}
onNegativeFilterChange={setIsNegativeFilter}
onClearFilters={handleClearFilters}
/>
<GraphLegend />
{/* Empty state when all nodes are filtered out */}
{filterResult.hasActiveFilter && filterResult.matchedNodeIds.size === 0 && (
<Panel position="top-center" className="mt-20">
<div className="flex flex-col items-center gap-3 p-6 rounded-lg bg-popover/95 backdrop-blur-sm border border-border shadow-lg text-popover-foreground">
<SearchX className="w-10 h-10 text-muted-foreground" />
<div className="text-center">
<p className="text-sm font-medium">No matching tasks</p>
<p className="text-xs text-muted-foreground mt-1">
Try adjusting your filters or search query
</p>
</div>
<Button variant="outline" size="sm" onClick={handleClearFilters} className="mt-1">
Clear Filters
</Button>
</div>
</Panel>
)}
</ReactFlow>
</div>
);
}
// Wrap with provider for hooks to work
export function GraphCanvas(props: GraphCanvasProps) {
return (
<ReactFlowProvider>
<GraphCanvasInner {...props} />
</ReactFlowProvider>
);
}

View File

@@ -0,0 +1,128 @@
import { useMemo, useCallback } from 'react';
import { Feature, useAppStore } from '@/store/app-store';
import { GraphCanvas } from './graph-canvas';
import { useBoardBackground } from '../board-view/hooks';
import { NodeActionCallbacks } from './hooks';
interface GraphViewProps {
features: Feature[];
runningAutoTasks: string[];
currentWorktreePath: string | null;
currentWorktreeBranch: string | null;
projectPath: string | null;
searchQuery: string;
onSearchQueryChange: (query: string) => void;
onEditFeature: (feature: Feature) => void;
onViewOutput: (feature: Feature) => void;
onStartTask?: (feature: Feature) => void;
onStopTask?: (feature: Feature) => void;
onResumeTask?: (feature: Feature) => void;
}
export function GraphView({
features,
runningAutoTasks,
currentWorktreePath,
currentWorktreeBranch,
projectPath,
searchQuery,
onSearchQueryChange,
onEditFeature,
onViewOutput,
onStartTask,
onStopTask,
onResumeTask,
}: GraphViewProps) {
const { currentProject } = useAppStore();
// Use the same background hook as the board view
const { backgroundImageStyle } = useBoardBackground({ currentProject });
// Filter features by current worktree (same logic as board view)
const filteredFeatures = useMemo(() => {
const effectiveBranch = currentWorktreeBranch;
return features.filter((f) => {
// Skip completed features (they're in archive)
if (f.status === 'completed') return false;
const featureBranch = f.branchName as string | undefined;
if (!featureBranch) {
// No branch assigned - show only on primary worktree
return currentWorktreePath === null;
} else if (effectiveBranch === null) {
// Viewing main but branch not initialized
return projectPath
? useAppStore.getState().isPrimaryWorktreeBranch(projectPath, featureBranch)
: false;
} else {
// Match by branch name
return featureBranch === effectiveBranch;
}
});
}, [features, currentWorktreePath, currentWorktreeBranch, projectPath]);
// Handle node double click - edit
const handleNodeDoubleClick = useCallback(
(featureId: string) => {
const feature = features.find((f) => f.id === featureId);
if (feature) {
onEditFeature(feature);
}
},
[features, onEditFeature]
);
// Node action callbacks for dropdown menu
const nodeActionCallbacks: NodeActionCallbacks = useMemo(
() => ({
onViewLogs: (featureId: string) => {
const feature = features.find((f) => f.id === featureId);
if (feature) {
onViewOutput(feature);
}
},
onViewDetails: (featureId: string) => {
const feature = features.find((f) => f.id === featureId);
if (feature) {
onEditFeature(feature);
}
},
onStartTask: (featureId: string) => {
const feature = features.find((f) => f.id === featureId);
if (feature) {
onStartTask?.(feature);
}
},
onStopTask: (featureId: string) => {
const feature = features.find((f) => f.id === featureId);
if (feature) {
onStopTask?.(feature);
}
},
onResumeTask: (featureId: string) => {
const feature = features.find((f) => f.id === featureId);
if (feature) {
onResumeTask?.(feature);
}
},
}),
[features, onViewOutput, onEditFeature, onStartTask, onStopTask, onResumeTask]
);
return (
<div className="flex-1 overflow-hidden relative">
<GraphCanvas
features={filteredFeatures}
runningAutoTasks={runningAutoTasks}
searchQuery={searchQuery}
onSearchQueryChange={onSearchQueryChange}
onNodeDoubleClick={handleNodeDoubleClick}
nodeActionCallbacks={nodeActionCallbacks}
backgroundStyle={backgroundImageStyle}
className="h-full"
/>
</div>
);
}

View File

@@ -0,0 +1,9 @@
export {
useGraphNodes,
type TaskNode,
type DependencyEdge,
type TaskNodeData,
type NodeActionCallbacks,
} from './use-graph-nodes';
export { useGraphLayout } from './use-graph-layout';
export { useGraphFilter, type GraphFilterState, type GraphFilterResult } from './use-graph-filter';

View File

@@ -0,0 +1,209 @@
import { useMemo } from 'react';
import { Feature } from '@/store/app-store';
export interface GraphFilterState {
searchQuery: string;
selectedCategories: string[];
selectedStatuses: string[];
isNegativeFilter: boolean;
}
// Available status filter values
export const STATUS_FILTER_OPTIONS = [
'running',
'paused',
'backlog',
'waiting_approval',
'verified',
] as const;
export type StatusFilterValue = (typeof STATUS_FILTER_OPTIONS)[number];
export interface GraphFilterResult {
matchedNodeIds: Set<string>;
highlightedNodeIds: Set<string>;
highlightedEdgeIds: Set<string>;
availableCategories: string[];
hasActiveFilter: boolean;
}
/**
* Traverses up the dependency tree to find all ancestors of a node
*/
function getAncestors(
featureId: string,
featureMap: Map<string, Feature>,
visited: Set<string>
): void {
if (visited.has(featureId)) return;
visited.add(featureId);
const feature = featureMap.get(featureId);
if (!feature?.dependencies) return;
const deps = feature.dependencies as string[] | undefined;
if (!deps) return;
for (const depId of deps) {
if (featureMap.has(depId)) {
getAncestors(depId, featureMap, visited);
}
}
}
/**
* Traverses down to find all descendants (features that depend on this one)
*/
function getDescendants(featureId: string, features: Feature[], visited: Set<string>): void {
if (visited.has(featureId)) return;
visited.add(featureId);
for (const feature of features) {
const deps = feature.dependencies as string[] | undefined;
if (deps?.includes(featureId)) {
getDescendants(feature.id, features, visited);
}
}
}
/**
* Gets all edges in the highlighted path
*/
function getHighlightedEdges(highlightedNodeIds: Set<string>, features: Feature[]): Set<string> {
const edges = new Set<string>();
for (const feature of features) {
if (!highlightedNodeIds.has(feature.id)) continue;
const deps = feature.dependencies as string[] | undefined;
if (!deps) continue;
for (const depId of deps) {
if (highlightedNodeIds.has(depId)) {
edges.add(`${depId}->${feature.id}`);
}
}
}
return edges;
}
/**
* Gets the effective status of a feature (accounting for running state)
*/
function getEffectiveStatus(feature: Feature, runningAutoTasks: string[]): StatusFilterValue {
if (feature.status === 'in_progress') {
return runningAutoTasks.includes(feature.id) ? 'running' : 'paused';
}
return feature.status as StatusFilterValue;
}
/**
* Hook to calculate graph filter results based on search query, categories, statuses, and filter mode
*/
export function useGraphFilter(
features: Feature[],
filterState: GraphFilterState,
runningAutoTasks: string[] = []
): GraphFilterResult {
const { searchQuery, selectedCategories, selectedStatuses, isNegativeFilter } = filterState;
return useMemo(() => {
// Extract all unique categories
const availableCategories = Array.from(
new Set(features.map((f) => f.category).filter(Boolean))
).sort();
const normalizedQuery = searchQuery.toLowerCase().trim();
const hasSearchQuery = normalizedQuery.length > 0;
const hasCategoryFilter = selectedCategories.length > 0;
const hasStatusFilter = selectedStatuses.length > 0;
const hasActiveFilter =
hasSearchQuery || hasCategoryFilter || hasStatusFilter || isNegativeFilter;
// If no filters active, return empty sets (show all nodes normally)
if (!hasActiveFilter) {
return {
matchedNodeIds: new Set<string>(),
highlightedNodeIds: new Set<string>(),
highlightedEdgeIds: new Set<string>(),
availableCategories,
hasActiveFilter: false,
};
}
// Find directly matched nodes
const matchedNodeIds = new Set<string>();
const featureMap = new Map(features.map((f) => [f.id, f]));
for (const feature of features) {
let matchesSearch = true;
let matchesCategory = true;
let matchesStatus = true;
// Check search query match (title or description)
if (hasSearchQuery) {
const titleMatch = feature.title?.toLowerCase().includes(normalizedQuery);
const descMatch = feature.description?.toLowerCase().includes(normalizedQuery);
matchesSearch = titleMatch || descMatch;
}
// Check category match
if (hasCategoryFilter) {
matchesCategory = selectedCategories.includes(feature.category);
}
// Check status match
if (hasStatusFilter) {
const effectiveStatus = getEffectiveStatus(feature, runningAutoTasks);
matchesStatus = selectedStatuses.includes(effectiveStatus);
}
// All conditions must be true for a match
if (matchesSearch && matchesCategory && matchesStatus) {
matchedNodeIds.add(feature.id);
}
}
// Apply negative filter if enabled (invert the matched set)
let effectiveMatchedIds: Set<string>;
if (isNegativeFilter) {
effectiveMatchedIds = new Set(
features.filter((f) => !matchedNodeIds.has(f.id)).map((f) => f.id)
);
} else {
effectiveMatchedIds = matchedNodeIds;
}
// Calculate full path (ancestors + descendants) for highlighted nodes
const highlightedNodeIds = new Set<string>();
for (const id of effectiveMatchedIds) {
// Add the matched node itself
highlightedNodeIds.add(id);
// Add all ancestors (dependencies)
getAncestors(id, featureMap, highlightedNodeIds);
// Add all descendants (dependents)
getDescendants(id, features, highlightedNodeIds);
}
// Get edges in the highlighted path
const highlightedEdgeIds = getHighlightedEdges(highlightedNodeIds, features);
return {
matchedNodeIds: effectiveMatchedIds,
highlightedNodeIds,
highlightedEdgeIds,
availableCategories,
hasActiveFilter: true,
};
}, [
features,
searchQuery,
selectedCategories,
selectedStatuses,
isNegativeFilter,
runningAutoTasks,
]);
}

View File

@@ -0,0 +1,93 @@
import { useCallback, useMemo } from 'react';
import dagre from 'dagre';
import { Node, Edge, useReactFlow } from '@xyflow/react';
import { TaskNode, DependencyEdge } from './use-graph-nodes';
const NODE_WIDTH = 280;
const NODE_HEIGHT = 120;
interface UseGraphLayoutProps {
nodes: TaskNode[];
edges: DependencyEdge[];
}
/**
* Applies dagre layout to position nodes in a hierarchical DAG
* Dependencies flow left-to-right
*/
export function useGraphLayout({ nodes, edges }: UseGraphLayoutProps) {
const { fitView, setNodes } = useReactFlow();
const getLayoutedElements = useCallback(
(
inputNodes: TaskNode[],
inputEdges: DependencyEdge[],
direction: 'LR' | 'TB' = 'LR'
): { nodes: TaskNode[]; edges: DependencyEdge[] } => {
const dagreGraph = new dagre.graphlib.Graph();
dagreGraph.setDefaultEdgeLabel(() => ({}));
const isHorizontal = direction === 'LR';
dagreGraph.setGraph({
rankdir: direction,
nodesep: 50,
ranksep: 100,
marginx: 50,
marginy: 50,
});
inputNodes.forEach((node) => {
dagreGraph.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT });
});
inputEdges.forEach((edge) => {
dagreGraph.setEdge(edge.source, edge.target);
});
dagre.layout(dagreGraph);
const layoutedNodes = inputNodes.map((node) => {
const nodeWithPosition = dagreGraph.node(node.id);
return {
...node,
position: {
x: nodeWithPosition.x - NODE_WIDTH / 2,
y: nodeWithPosition.y - NODE_HEIGHT / 2,
},
targetPosition: isHorizontal ? 'left' : 'top',
sourcePosition: isHorizontal ? 'right' : 'bottom',
} as TaskNode;
});
return { nodes: layoutedNodes, edges: inputEdges };
},
[]
);
// Initial layout
const layoutedElements = useMemo(() => {
if (nodes.length === 0) {
return { nodes: [], edges: [] };
}
return getLayoutedElements(nodes, edges, 'LR');
}, [nodes, edges, getLayoutedElements]);
// Manual re-layout function
const runLayout = useCallback(
(direction: 'LR' | 'TB' = 'LR') => {
const { nodes: layoutedNodes } = getLayoutedElements(nodes, edges, direction);
setNodes(layoutedNodes);
// Fit view after layout with a small delay to allow DOM updates
setTimeout(() => {
fitView({ padding: 0.2, duration: 300 });
}, 50);
},
[nodes, edges, getLayoutedElements, setNodes, fitView]
);
return {
layoutedNodes: layoutedElements.nodes,
layoutedEdges: layoutedElements.edges,
runLayout,
};
}

View File

@@ -0,0 +1,156 @@
import { useMemo } from 'react';
import { Node, Edge } from '@xyflow/react';
import { Feature } from '@/store/app-store';
import { getBlockingDependencies } from '@automaker/dependency-resolver';
import { GraphFilterResult } from './use-graph-filter';
export interface TaskNodeData extends Feature {
// Re-declare properties from BaseFeature that have index signature issues
priority?: number;
error?: string;
branchName?: string;
dependencies?: string[];
// Task node specific properties
isBlocked: boolean;
isRunning: boolean;
blockingDependencies: string[];
// Filter highlight states
isMatched?: boolean;
isHighlighted?: boolean;
isDimmed?: boolean;
// Action callbacks
onViewLogs?: () => void;
onViewDetails?: () => void;
onStartTask?: () => void;
onStopTask?: () => void;
onResumeTask?: () => void;
}
export type TaskNode = Node<TaskNodeData, 'task'>;
export type DependencyEdge = Edge<{
sourceStatus: Feature['status'];
targetStatus: Feature['status'];
isHighlighted?: boolean;
isDimmed?: boolean;
}>;
export interface NodeActionCallbacks {
onViewLogs?: (featureId: string) => void;
onViewDetails?: (featureId: string) => void;
onStartTask?: (featureId: string) => void;
onStopTask?: (featureId: string) => void;
onResumeTask?: (featureId: string) => void;
}
interface UseGraphNodesProps {
features: Feature[];
runningAutoTasks: string[];
filterResult?: GraphFilterResult;
actionCallbacks?: NodeActionCallbacks;
}
/**
* Transforms features into React Flow nodes and edges
* Creates dependency edges based on feature.dependencies array
*/
export function useGraphNodes({
features,
runningAutoTasks,
filterResult,
actionCallbacks,
}: UseGraphNodesProps) {
const { nodes, edges } = useMemo(() => {
const nodeList: TaskNode[] = [];
const edgeList: DependencyEdge[] = [];
const featureMap = new Map<string, Feature>();
// Create feature map for quick lookups
features.forEach((f) => featureMap.set(f.id, f));
// Extract filter state
const hasActiveFilter = filterResult?.hasActiveFilter ?? false;
const matchedNodeIds = filterResult?.matchedNodeIds ?? new Set<string>();
const highlightedNodeIds = filterResult?.highlightedNodeIds ?? new Set<string>();
const highlightedEdgeIds = filterResult?.highlightedEdgeIds ?? new Set<string>();
// Create nodes
features.forEach((feature) => {
const isRunning = runningAutoTasks.includes(feature.id);
const blockingDeps = getBlockingDependencies(feature, features);
// Calculate filter highlight states
const isMatched = hasActiveFilter && matchedNodeIds.has(feature.id);
const isHighlighted = hasActiveFilter && highlightedNodeIds.has(feature.id);
const isDimmed = hasActiveFilter && !highlightedNodeIds.has(feature.id);
const node: TaskNode = {
id: feature.id,
type: 'task',
position: { x: 0, y: 0 }, // Will be set by layout
data: {
...feature,
isBlocked: blockingDeps.length > 0,
isRunning,
blockingDependencies: blockingDeps,
// Filter states
isMatched,
isHighlighted,
isDimmed,
// Action callbacks (bound to this feature's ID)
onViewLogs: actionCallbacks?.onViewLogs
? () => actionCallbacks.onViewLogs!(feature.id)
: undefined,
onViewDetails: actionCallbacks?.onViewDetails
? () => actionCallbacks.onViewDetails!(feature.id)
: undefined,
onStartTask: actionCallbacks?.onStartTask
? () => actionCallbacks.onStartTask!(feature.id)
: undefined,
onStopTask: actionCallbacks?.onStopTask
? () => actionCallbacks.onStopTask!(feature.id)
: undefined,
onResumeTask: actionCallbacks?.onResumeTask
? () => actionCallbacks.onResumeTask!(feature.id)
: undefined,
},
};
nodeList.push(node);
// Create edges for dependencies
const deps = feature.dependencies as string[] | undefined;
if (deps && deps.length > 0) {
deps.forEach((depId: string) => {
// Only create edge if the dependency exists in current view
if (featureMap.has(depId)) {
const sourceFeature = featureMap.get(depId)!;
const edgeId = `${depId}->${feature.id}`;
// Calculate edge highlight states
const edgeIsHighlighted = hasActiveFilter && highlightedEdgeIds.has(edgeId);
const edgeIsDimmed = hasActiveFilter && !highlightedEdgeIds.has(edgeId);
const edge: DependencyEdge = {
id: edgeId,
source: depId,
target: feature.id,
type: 'dependency',
animated: isRunning || runningAutoTasks.includes(depId),
data: {
sourceStatus: sourceFeature.status,
targetStatus: feature.status,
isHighlighted: edgeIsHighlighted,
isDimmed: edgeIsDimmed,
},
};
edgeList.push(edge);
}
});
}
});
return { nodes: nodeList, edges: edgeList };
}, [features, runningAutoTasks, filterResult, actionCallbacks]);
return { nodes, edges };
}

View File

@@ -0,0 +1,9 @@
export { GraphView } from './graph-view';
export { GraphCanvas } from './graph-canvas';
export { TaskNode, DependencyEdge, GraphControls, GraphLegend } from './components';
export {
useGraphNodes,
useGraphLayout,
type TaskNode as TaskNodeType,
type DependencyEdge as DependencyEdgeType,
} from './hooks';

View File

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

View File

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

View File

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

View File

@@ -296,7 +296,7 @@ export function TerminalView() {
const headers: Record<string, string> = {};
if (terminalState.authToken) {
headers['X-Terminal-Token'] = terminalState.authToken;
headers['Authorization'] = `Bearer ${terminalState.authToken}`;
}
console.log(`[Terminal] Killing ${sessionIds.length} sessions on server`);
@@ -459,7 +459,7 @@ export function TerminalView() {
try {
const headers: Record<string, string> = {};
if (terminalState.authToken) {
headers['X-Terminal-Token'] = terminalState.authToken;
headers['Authorization'] = `Bearer ${terminalState.authToken}`;
}
const response = await fetch(`${serverUrl}/api/terminal/settings`, { headers });
const data = await response.json();
@@ -488,7 +488,7 @@ export function TerminalView() {
'Content-Type': 'application/json',
};
if (terminalState.authToken) {
headers['X-Terminal-Token'] = terminalState.authToken;
headers['Authorization'] = `Bearer ${terminalState.authToken}`;
}
// Try to use the bulk delete endpoint if available, otherwise delete individually
@@ -501,7 +501,7 @@ export function TerminalView() {
const xhr = new XMLHttpRequest();
xhr.open('DELETE', url, false); // synchronous
if (terminalState.authToken) {
xhr.setRequestHeader('X-Terminal-Token', terminalState.authToken);
xhr.setRequestHeader('Authorization', `Bearer ${terminalState.authToken}`);
}
xhr.send();
} catch {
@@ -595,7 +595,7 @@ export function TerminalView() {
// Get fresh auth token from store
const authToken = useAppStore.getState().terminalState.authToken;
if (authToken) {
headers['X-Terminal-Token'] = authToken;
headers['Authorization'] = `Bearer ${authToken}`;
}
// Helper to check if a session still exists on server
@@ -833,7 +833,7 @@ export function TerminalView() {
'Content-Type': 'application/json',
};
if (terminalState.authToken) {
headers['X-Terminal-Token'] = terminalState.authToken;
headers['Authorization'] = `Bearer ${terminalState.authToken}`;
}
const response = await fetch(`${serverUrl}/api/terminal/sessions`, {
@@ -892,7 +892,7 @@ export function TerminalView() {
'Content-Type': 'application/json',
};
if (terminalState.authToken) {
headers['X-Terminal-Token'] = terminalState.authToken;
headers['Authorization'] = `Bearer ${terminalState.authToken}`;
}
const response = await fetch(`${serverUrl}/api/terminal/sessions`, {
@@ -952,7 +952,7 @@ export function TerminalView() {
try {
const headers: Record<string, string> = {};
if (terminalState.authToken) {
headers['X-Terminal-Token'] = terminalState.authToken;
headers['Authorization'] = `Bearer ${terminalState.authToken}`;
}
const response = await fetch(`${serverUrl}/api/terminal/sessions/${sessionId}`, {
@@ -998,7 +998,7 @@ export function TerminalView() {
// Kill all sessions on the server
const headers: Record<string, string> = {};
if (terminalState.authToken) {
headers['X-Terminal-Token'] = terminalState.authToken;
headers['Authorization'] = `Bearer ${terminalState.authToken}`;
}
await Promise.all(

View File

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

View File

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

View File

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

View File

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

View File

@@ -47,6 +47,8 @@ export type ThemeMode =
export type KanbanCardDetailLevel = 'minimal' | 'standard' | 'detailed';
export type BoardViewMode = 'kanban' | 'graph';
export interface ApiKeys {
anthropic: string;
google: string;
@@ -435,6 +437,7 @@ export interface AppState {
// Kanban Card Display Settings
kanbanCardDetailLevel: KanbanCardDetailLevel; // Level of detail shown on kanban cards
boardViewMode: BoardViewMode; // Whether to show kanban or dependency graph view
// Feature Default Settings
defaultSkipTests: boolean; // Default value for skip tests when creating new features
@@ -472,6 +475,9 @@ export interface AppState {
// Enhancement Model Settings
enhancementModel: AgentModel; // Model used for feature enhancement (default: sonnet)
// Validation Model Settings
validationModel: AgentModel; // Model used for GitHub issue validation (default: opus)
// Project Analysis
projectAnalysis: ProjectAnalysis | null;
isAnalyzing: boolean;
@@ -698,6 +704,7 @@ export interface AppActions {
// Kanban Card Settings actions
setKanbanCardDetailLevel: (level: KanbanCardDetailLevel) => void;
setBoardViewMode: (mode: BoardViewMode) => void;
// Feature Default Settings actions
setDefaultSkipTests: (skip: boolean) => void;
@@ -741,6 +748,9 @@ export interface AppActions {
// Enhancement Model actions
setEnhancementModel: (model: AgentModel) => void;
// Validation Model actions
setValidationModel: (model: AgentModel) => void;
// AI Profile actions
addAIProfile: (profile: Omit<AIProfile, 'id'>) => void;
updateAIProfile: (id: string, updates: Partial<AIProfile>) => void;
@@ -901,6 +911,7 @@ const initialState: AppState = {
autoModeActivityLog: [],
maxConcurrency: 3, // Default to 3 concurrent agents
kanbanCardDetailLevel: 'standard', // Default to standard detail level
boardViewMode: 'kanban', // Default to kanban view
defaultSkipTests: true, // Default to manual verification (tests disabled)
enableDependencyBlocking: true, // Default to enabled (show dependency blocking UI)
useWorktrees: false, // Default to disabled (worktree feature is experimental)
@@ -910,6 +921,7 @@ const initialState: AppState = {
keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS, // Default keyboard shortcuts
muteDoneSound: false, // Default to sound enabled (not muted)
enhancementModel: 'sonnet', // Default to sonnet for feature enhancement
validationModel: 'opus', // Default to opus for GitHub issue validation
aiProfiles: DEFAULT_AI_PROFILES,
projectAnalysis: null,
isAnalyzing: false,
@@ -1451,6 +1463,7 @@ export const useAppStore = create<AppState & AppActions>()(
// Kanban Card Settings actions
setKanbanCardDetailLevel: (level) => set({ kanbanCardDetailLevel: level }),
setBoardViewMode: (mode) => set({ boardViewMode: mode }),
// Feature Default Settings actions
setDefaultSkipTests: (skip) => set({ defaultSkipTests: skip }),
@@ -1531,6 +1544,9 @@ export const useAppStore = create<AppState & AppActions>()(
// Enhancement Model actions
setEnhancementModel: (model) => set({ enhancementModel: model }),
// Validation Model actions
setValidationModel: (model) => set({ validationModel: model }),
// AI Profile actions
addAIProfile: (profile) => {
const id = `profile-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
@@ -2658,8 +2674,12 @@ export const useAppStore = create<AppState & AppActions>()(
sidebarOpen: state.sidebarOpen,
chatHistoryOpen: state.chatHistoryOpen,
kanbanCardDetailLevel: state.kanbanCardDetailLevel,
boardViewMode: state.boardViewMode,
// Settings
apiKeys: state.apiKeys,
// NOTE: apiKeys are intentionally NOT persisted to localStorage for security.
// API keys are stored server-side only via the storeApiKey API to prevent
// exposure through XSS attacks. The apiKeys state is populated on app load
// from the secure server-side storage.
maxConcurrency: state.maxConcurrency,
// Note: autoModeByProject is intentionally NOT persisted
// Auto-mode should always default to OFF on app refresh
@@ -2672,6 +2692,7 @@ export const useAppStore = create<AppState & AppActions>()(
keyboardShortcuts: state.keyboardShortcuts,
muteDoneSound: state.muteDoneSound,
enhancementModel: state.enhancementModel,
validationModel: state.validationModel,
// Profiles and sessions
aiProfiles: state.aiProfiles,
chatSessions: state.chatSessions,

Some files were not shown because too many files have changed in this diff Show More