Merge branch: resolve conflict in worktree-actions-dropdown.tsx

This commit is contained in:
Kacper
2026-01-11 20:08:19 +01:00
118 changed files with 6327 additions and 1795 deletions

View File

@@ -37,7 +37,14 @@ jobs:
git config --global user.email "ci@example.com" git config --global user.email "ci@example.com"
- name: Start backend server - name: Start backend server
run: npm run start --workspace=apps/server & run: |
echo "Starting backend server..."
# Start server in background and save PID
npm run start --workspace=apps/server > backend.log 2>&1 &
SERVER_PID=$!
echo "Server started with PID: $SERVER_PID"
echo "SERVER_PID=$SERVER_PID" >> $GITHUB_ENV
env: env:
PORT: 3008 PORT: 3008
NODE_ENV: test NODE_ENV: test
@@ -53,21 +60,70 @@ jobs:
- name: Wait for backend server - name: Wait for backend server
run: | run: |
echo "Waiting for backend server to be ready..." echo "Waiting for backend server to be ready..."
# Check if server process is running
if [ -z "$SERVER_PID" ]; then
echo "ERROR: Server PID not found in environment"
cat backend.log 2>/dev/null || echo "No backend log found"
exit 1
fi
# Check if process is actually running
if ! kill -0 $SERVER_PID 2>/dev/null; then
echo "ERROR: Server process $SERVER_PID is not running!"
echo "=== Backend logs ==="
cat backend.log
echo ""
echo "=== Recent system logs ==="
dmesg 2>/dev/null | tail -20 || echo "No dmesg available"
exit 1
fi
# Wait for health endpoint
for i in {1..60}; do for i in {1..60}; do
if curl -s -f http://localhost:3008/api/health > /dev/null 2>&1; then if curl -s -f http://localhost:3008/api/health > /dev/null 2>&1; then
echo "Backend server is ready!" echo "Backend server is ready!"
curl -s http://localhost:3008/api/health | jq . 2>/dev/null || echo "Health check response: $(curl -s http://localhost:3008/api/health 2>/dev/null || echo 'No response')" echo "=== Backend logs ==="
cat backend.log
echo ""
echo "Health check response:"
curl -s http://localhost:3008/api/health | jq . 2>/dev/null || echo "Health check: $(curl -s http://localhost:3008/api/health 2>/dev/null || echo 'No response')"
exit 0 exit 0
fi fi
# Check if server process is still running
if ! kill -0 $SERVER_PID 2>/dev/null; then
echo "ERROR: Server process died during wait!"
echo "=== Backend logs ==="
cat backend.log
exit 1
fi
echo "Waiting... ($i/60)" echo "Waiting... ($i/60)"
sleep 1 sleep 1
done done
echo "Backend server failed to start!"
echo "Checking server status..." echo "ERROR: Backend server failed to start within 60 seconds!"
echo "=== Backend logs ==="
cat backend.log
echo ""
echo "=== Process status ==="
ps aux | grep -E "(node|tsx)" | grep -v grep || echo "No node processes found" ps aux | grep -E "(node|tsx)" | grep -v grep || echo "No node processes found"
echo ""
echo "=== Port status ==="
netstat -tlnp 2>/dev/null | grep :3008 || echo "Port 3008 not listening" netstat -tlnp 2>/dev/null | grep :3008 || echo "Port 3008 not listening"
echo "Testing health endpoint..." lsof -i :3008 2>/dev/null || echo "lsof not available or port not in use"
echo ""
echo "=== Health endpoint test ==="
curl -v http://localhost:3008/api/health 2>&1 || echo "Health endpoint failed" curl -v http://localhost:3008/api/health 2>&1 || echo "Health endpoint failed"
# Kill the server process if it's still hanging
if kill -0 $SERVER_PID 2>/dev/null; then
echo ""
echo "Killing stuck server process..."
kill -9 $SERVER_PID 2>/dev/null || true
fi
exit 1 exit 1
- name: Run E2E tests - name: Run E2E tests
@@ -81,6 +137,18 @@ jobs:
# Keep UI-side login/defaults consistent # Keep UI-side login/defaults consistent
AUTOMAKER_API_KEY: test-api-key-for-e2e-tests AUTOMAKER_API_KEY: test-api-key-for-e2e-tests
- name: Print backend logs on failure
if: failure()
run: |
echo "=== E2E Tests Failed - Backend Logs ==="
cat backend.log 2>/dev/null || echo "No backend log found"
echo ""
echo "=== Process status at failure ==="
ps aux | grep -E "(node|tsx)" | grep -v grep || echo "No node processes found"
echo ""
echo "=== Port status ==="
netstat -tlnp 2>/dev/null | grep :3008 || echo "Port 3008 not listening"
- name: Upload Playwright report - name: Upload Playwright report
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
if: always() if: always()
@@ -98,3 +166,13 @@ jobs:
apps/ui/test-results/ apps/ui/test-results/
retention-days: 7 retention-days: 7
if-no-files-found: ignore if-no-files-found: ignore
- name: Cleanup - Kill backend server
if: always()
run: |
if [ -n "$SERVER_PID" ]; then
echo "Cleaning up backend server (PID: $SERVER_PID)..."
kill $SERVER_PID 2>/dev/null || true
kill -9 $SERVER_PID 2>/dev/null || true
echo "Backend server cleanup complete"
fi

8
.gitignore vendored
View File

@@ -88,3 +88,11 @@ docker-compose.override.yml
pnpm-lock.yaml pnpm-lock.yaml
yarn.lock yarn.lock
# Fork-specific workflow files (should never be committed)
DEVELOPMENT_WORKFLOW.md
check-sync.sh
# API key files
data/.api-key
data/credentials.json
data/

View File

@@ -597,6 +597,26 @@ const startServer = (port: number) => {
startServer(PORT); startServer(PORT);
// Global error handlers to prevent crashes from uncaught errors
process.on('unhandledRejection', (reason: unknown, _promise: Promise<unknown>) => {
logger.error('Unhandled Promise Rejection:', {
reason: reason instanceof Error ? reason.message : String(reason),
stack: reason instanceof Error ? reason.stack : undefined,
});
// Don't exit - log the error and continue running
// This prevents the server from crashing due to unhandled rejections
});
process.on('uncaughtException', (error: Error) => {
logger.error('Uncaught Exception:', {
message: error.message,
stack: error.stack,
});
// Exit on uncaught exceptions to prevent undefined behavior
// The process is in an unknown state after an uncaught exception
process.exit(1);
});
// Graceful shutdown // Graceful shutdown
process.on('SIGTERM', () => { process.on('SIGTERM', () => {
logger.info('SIGTERM received, shutting down...'); logger.info('SIGTERM received, shutting down...');

View File

@@ -6,26 +6,57 @@ import { createLogger } from '@automaker/utils';
const logger = createLogger('SpecRegeneration'); const logger = createLogger('SpecRegeneration');
// Shared state for tracking generation status - private // Shared state for tracking generation status - scoped by project path
let isRunning = false; const runningProjects = new Map<string, boolean>();
let currentAbortController: AbortController | null = null; const abortControllers = new Map<string, AbortController>();
/** /**
* Get the current running state * Get the running state for a specific project
*/ */
export function getSpecRegenerationStatus(): { export function getSpecRegenerationStatus(projectPath?: string): {
isRunning: boolean; isRunning: boolean;
currentAbortController: AbortController | null; currentAbortController: AbortController | null;
projectPath?: string;
} { } {
return { isRunning, currentAbortController }; if (projectPath) {
return {
isRunning: runningProjects.get(projectPath) || false,
currentAbortController: abortControllers.get(projectPath) || null,
projectPath,
};
}
// Fallback: check if any project is running (for backward compatibility)
const isAnyRunning = Array.from(runningProjects.values()).some((running) => running);
return { isRunning: isAnyRunning, currentAbortController: null };
} }
/** /**
* Set the running state and abort controller * Get the project path that is currently running (if any)
*/ */
export function setRunningState(running: boolean, controller: AbortController | null = null): void { export function getRunningProjectPath(): string | null {
isRunning = running; for (const [path, running] of runningProjects.entries()) {
currentAbortController = controller; if (running) return path;
}
return null;
}
/**
* Set the running state and abort controller for a specific project
*/
export function setRunningState(
projectPath: string,
running: boolean,
controller: AbortController | null = null
): void {
if (running) {
runningProjects.set(projectPath, true);
if (controller) {
abortControllers.set(projectPath, controller);
}
} else {
runningProjects.delete(projectPath);
abortControllers.delete(projectPath);
}
} }
/** /**

View File

@@ -47,17 +47,17 @@ export function createCreateHandler(events: EventEmitter) {
return; return;
} }
const { isRunning } = getSpecRegenerationStatus(); const { isRunning } = getSpecRegenerationStatus(projectPath);
if (isRunning) { if (isRunning) {
logger.warn('Generation already running, rejecting request'); logger.warn('Generation already running for project:', projectPath);
res.json({ success: false, error: 'Spec generation already running' }); res.json({ success: false, error: 'Spec generation already running for this project' });
return; return;
} }
logAuthStatus('Before starting generation'); logAuthStatus('Before starting generation');
const abortController = new AbortController(); const abortController = new AbortController();
setRunningState(true, abortController); setRunningState(projectPath, true, abortController);
logger.info('Starting background generation task...'); logger.info('Starting background generation task...');
// Start generation in background // Start generation in background
@@ -80,7 +80,7 @@ export function createCreateHandler(events: EventEmitter) {
}) })
.finally(() => { .finally(() => {
logger.info('Generation task finished (success or error)'); logger.info('Generation task finished (success or error)');
setRunningState(false, null); setRunningState(projectPath, false, null);
}); });
logger.info('Returning success response (generation running in background)'); logger.info('Returning success response (generation running in background)');

View File

@@ -40,17 +40,17 @@ export function createGenerateFeaturesHandler(
return; return;
} }
const { isRunning } = getSpecRegenerationStatus(); const { isRunning } = getSpecRegenerationStatus(projectPath);
if (isRunning) { if (isRunning) {
logger.warn('Generation already running, rejecting request'); logger.warn('Generation already running for project:', projectPath);
res.json({ success: false, error: 'Generation already running' }); res.json({ success: false, error: 'Generation already running for this project' });
return; return;
} }
logAuthStatus('Before starting feature generation'); logAuthStatus('Before starting feature generation');
const abortController = new AbortController(); const abortController = new AbortController();
setRunningState(true, abortController); setRunningState(projectPath, true, abortController);
logger.info('Starting background feature generation task...'); logger.info('Starting background feature generation task...');
generateFeaturesFromSpec(projectPath, events, abortController, maxFeatures, settingsService) generateFeaturesFromSpec(projectPath, events, abortController, maxFeatures, settingsService)
@@ -63,7 +63,7 @@ export function createGenerateFeaturesHandler(
}) })
.finally(() => { .finally(() => {
logger.info('Feature generation task finished (success or error)'); logger.info('Feature generation task finished (success or error)');
setRunningState(false, null); setRunningState(projectPath, false, null);
}); });
logger.info('Returning success response (generation running in background)'); logger.info('Returning success response (generation running in background)');

View File

@@ -48,17 +48,17 @@ export function createGenerateHandler(events: EventEmitter, settingsService?: Se
return; return;
} }
const { isRunning } = getSpecRegenerationStatus(); const { isRunning } = getSpecRegenerationStatus(projectPath);
if (isRunning) { if (isRunning) {
logger.warn('Generation already running, rejecting request'); logger.warn('Generation already running for project:', projectPath);
res.json({ success: false, error: 'Spec generation already running' }); res.json({ success: false, error: 'Spec generation already running for this project' });
return; return;
} }
logAuthStatus('Before starting generation'); logAuthStatus('Before starting generation');
const abortController = new AbortController(); const abortController = new AbortController();
setRunningState(true, abortController); setRunningState(projectPath, true, abortController);
logger.info('Starting background generation task...'); logger.info('Starting background generation task...');
generateSpec( generateSpec(
@@ -81,7 +81,7 @@ export function createGenerateHandler(events: EventEmitter, settingsService?: Se
}) })
.finally(() => { .finally(() => {
logger.info('Generation task finished (success or error)'); logger.info('Generation task finished (success or error)');
setRunningState(false, null); setRunningState(projectPath, false, null);
}); });
logger.info('Returning success response (generation running in background)'); logger.info('Returning success response (generation running in background)');

View File

@@ -6,10 +6,11 @@ import type { Request, Response } from 'express';
import { getSpecRegenerationStatus, getErrorMessage } from '../common.js'; import { getSpecRegenerationStatus, getErrorMessage } from '../common.js';
export function createStatusHandler() { export function createStatusHandler() {
return async (_req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
try { try {
const { isRunning } = getSpecRegenerationStatus(); const projectPath = req.query.projectPath as string | undefined;
res.json({ success: true, isRunning }); const { isRunning } = getSpecRegenerationStatus(projectPath);
res.json({ success: true, isRunning, projectPath });
} catch (error) { } catch (error) {
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }

View File

@@ -6,13 +6,16 @@ import type { Request, Response } from 'express';
import { getSpecRegenerationStatus, setRunningState, getErrorMessage } from '../common.js'; import { getSpecRegenerationStatus, setRunningState, getErrorMessage } from '../common.js';
export function createStopHandler() { export function createStopHandler() {
return async (_req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
try { try {
const { currentAbortController } = getSpecRegenerationStatus(); const { projectPath } = req.body as { projectPath?: string };
const { currentAbortController } = getSpecRegenerationStatus(projectPath);
if (currentAbortController) { if (currentAbortController) {
currentAbortController.abort(); currentAbortController.abort();
} }
setRunningState(false, null); if (projectPath) {
setRunningState(projectPath, false, null);
}
res.json({ success: true }); res.json({ success: true });
} catch (error) { } catch (error) {
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });

View File

@@ -17,6 +17,7 @@ import { createAnalyzeProjectHandler } from './routes/analyze-project.js';
import { createFollowUpFeatureHandler } from './routes/follow-up-feature.js'; import { createFollowUpFeatureHandler } from './routes/follow-up-feature.js';
import { createCommitFeatureHandler } from './routes/commit-feature.js'; import { createCommitFeatureHandler } from './routes/commit-feature.js';
import { createApprovePlanHandler } from './routes/approve-plan.js'; import { createApprovePlanHandler } from './routes/approve-plan.js';
import { createResumeInterruptedHandler } from './routes/resume-interrupted.js';
export function createAutoModeRoutes(autoModeService: AutoModeService): Router { export function createAutoModeRoutes(autoModeService: AutoModeService): Router {
const router = Router(); const router = Router();
@@ -63,6 +64,11 @@ export function createAutoModeRoutes(autoModeService: AutoModeService): Router {
validatePathParams('projectPath'), validatePathParams('projectPath'),
createApprovePlanHandler(autoModeService) createApprovePlanHandler(autoModeService)
); );
router.post(
'/resume-interrupted',
validatePathParams('projectPath'),
createResumeInterruptedHandler(autoModeService)
);
return router; return router;
} }

View File

@@ -0,0 +1,42 @@
/**
* Resume Interrupted Features Handler
*
* Checks for features that were interrupted (in pipeline steps or in_progress)
* when the server was restarted and resumes them.
*/
import type { Request, Response } from 'express';
import { createLogger } from '@automaker/utils';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
const logger = createLogger('ResumeInterrupted');
interface ResumeInterruptedRequest {
projectPath: string;
}
export function createResumeInterruptedHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => {
const { projectPath } = req.body as ResumeInterruptedRequest;
if (!projectPath) {
res.status(400).json({ error: 'Project path is required' });
return;
}
logger.info(`Checking for interrupted features in ${projectPath}`);
try {
await autoModeService.resumeInterruptedFeatures(projectPath);
res.json({
success: true,
message: 'Resume check completed',
});
} catch (error) {
logger.error('Error resuming interrupted features:', error);
res.status(500).json({
error: error instanceof Error ? error.message : 'Unknown error',
});
}
};
}

View File

@@ -188,6 +188,7 @@ export function createEnhanceHandler(
technical: prompts.enhancement.technicalSystemPrompt, technical: prompts.enhancement.technicalSystemPrompt,
simplify: prompts.enhancement.simplifySystemPrompt, simplify: prompts.enhancement.simplifySystemPrompt,
acceptance: prompts.enhancement.acceptanceSystemPrompt, acceptance: prompts.enhancement.acceptanceSystemPrompt,
'ux-reviewer': prompts.enhancement.uxReviewerSystemPrompt,
}; };
const systemPrompt = systemPromptMap[validMode]; const systemPrompt = systemPromptMap[validMode];

View File

@@ -10,6 +10,7 @@ import { createGetHandler } from './routes/get.js';
import { createCreateHandler } from './routes/create.js'; import { createCreateHandler } from './routes/create.js';
import { createUpdateHandler } from './routes/update.js'; import { createUpdateHandler } from './routes/update.js';
import { createBulkUpdateHandler } from './routes/bulk-update.js'; import { createBulkUpdateHandler } from './routes/bulk-update.js';
import { createBulkDeleteHandler } from './routes/bulk-delete.js';
import { createDeleteHandler } from './routes/delete.js'; import { createDeleteHandler } from './routes/delete.js';
import { createAgentOutputHandler, createRawOutputHandler } from './routes/agent-output.js'; import { createAgentOutputHandler, createRawOutputHandler } from './routes/agent-output.js';
import { createGenerateTitleHandler } from './routes/generate-title.js'; import { createGenerateTitleHandler } from './routes/generate-title.js';
@@ -26,6 +27,11 @@ export function createFeaturesRoutes(featureLoader: FeatureLoader): Router {
validatePathParams('projectPath'), validatePathParams('projectPath'),
createBulkUpdateHandler(featureLoader) createBulkUpdateHandler(featureLoader)
); );
router.post(
'/bulk-delete',
validatePathParams('projectPath'),
createBulkDeleteHandler(featureLoader)
);
router.post('/delete', validatePathParams('projectPath'), createDeleteHandler(featureLoader)); router.post('/delete', validatePathParams('projectPath'), createDeleteHandler(featureLoader));
router.post('/agent-output', createAgentOutputHandler(featureLoader)); router.post('/agent-output', createAgentOutputHandler(featureLoader));
router.post('/raw-output', createRawOutputHandler(featureLoader)); router.post('/raw-output', createRawOutputHandler(featureLoader));

View File

@@ -0,0 +1,61 @@
/**
* POST /bulk-delete endpoint - Delete multiple features at once
*/
import type { Request, Response } from 'express';
import { FeatureLoader } from '../../../services/feature-loader.js';
import { getErrorMessage, logError } from '../common.js';
interface BulkDeleteRequest {
projectPath: string;
featureIds: string[];
}
interface BulkDeleteResult {
featureId: string;
success: boolean;
error?: string;
}
export function createBulkDeleteHandler(featureLoader: FeatureLoader) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, featureIds } = req.body as BulkDeleteRequest;
if (!projectPath || !featureIds || !Array.isArray(featureIds) || featureIds.length === 0) {
res.status(400).json({
success: false,
error: 'projectPath and featureIds (non-empty array) are required',
});
return;
}
const results = await Promise.all(
featureIds.map(async (featureId) => {
const success = await featureLoader.delete(projectPath, featureId);
if (success) {
return { featureId, success: true };
}
return {
featureId,
success: false,
error: 'Deletion failed. Check server logs for details.',
};
})
);
const successCount = results.reduce((count, r) => count + (r.success ? 1 : 0), 0);
const failureCount = results.length - successCount;
res.json({
success: failureCount === 0,
deletedCount: successCount,
failedCount: failureCount,
results,
});
} catch (error) {
logError(error, 'Bulk delete features failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -10,14 +10,21 @@ import { getErrorMessage, logError } from '../common.js';
export function createUpdateHandler(featureLoader: FeatureLoader) { export function createUpdateHandler(featureLoader: FeatureLoader) {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
try { try {
const { projectPath, featureId, updates, descriptionHistorySource, enhancementMode } = const {
req.body as { projectPath,
projectPath: string; featureId,
featureId: string; updates,
updates: Partial<Feature>; descriptionHistorySource,
descriptionHistorySource?: 'enhance' | 'edit'; enhancementMode,
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance'; preEnhancementDescription,
}; } = req.body as {
projectPath: string;
featureId: string;
updates: Partial<Feature>;
descriptionHistorySource?: 'enhance' | 'edit';
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer';
preEnhancementDescription?: string;
};
if (!projectPath || !featureId || !updates) { if (!projectPath || !featureId || !updates) {
res.status(400).json({ res.status(400).json({
@@ -32,7 +39,8 @@ export function createUpdateHandler(featureLoader: FeatureLoader) {
featureId, featureId,
updates, updates,
descriptionHistorySource, descriptionHistorySource,
enhancementMode enhancementMode,
preEnhancementDescription
); );
res.json({ success: true, feature: updated }); res.json({ success: true, feature: updated });
} catch (error) { } catch (error) {

View File

@@ -25,6 +25,8 @@ import { createSwitchBranchHandler } from './routes/switch-branch.js';
import { import {
createOpenInEditorHandler, createOpenInEditorHandler,
createGetDefaultEditorHandler, createGetDefaultEditorHandler,
createGetAvailableEditorsHandler,
createRefreshEditorsHandler,
} from './routes/open-in-editor.js'; } from './routes/open-in-editor.js';
import { createInitGitHandler } from './routes/init-git.js'; import { createInitGitHandler } from './routes/init-git.js';
import { createMigrateHandler } from './routes/migrate.js'; import { createMigrateHandler } from './routes/migrate.js';
@@ -84,6 +86,8 @@ export function createWorktreeRoutes(events: EventEmitter): Router {
router.post('/switch-branch', requireValidWorktree, createSwitchBranchHandler()); router.post('/switch-branch', requireValidWorktree, createSwitchBranchHandler());
router.post('/open-in-editor', validatePathParams('worktreePath'), createOpenInEditorHandler()); router.post('/open-in-editor', validatePathParams('worktreePath'), createOpenInEditorHandler());
router.get('/default-editor', createGetDefaultEditorHandler()); router.get('/default-editor', createGetDefaultEditorHandler());
router.get('/available-editors', createGetAvailableEditorsHandler());
router.post('/refresh-editors', createRefreshEditorsHandler());
router.post('/init-git', validatePathParams('projectPath'), createInitGitHandler()); router.post('/init-git', validatePathParams('projectPath'), createInitGitHandler());
router.post('/migrate', createMigrateHandler()); router.post('/migrate', createMigrateHandler());
router.post( router.post(

View File

@@ -2,18 +2,23 @@
* POST /list endpoint - List all git worktrees * POST /list endpoint - List all git worktrees
* *
* Returns actual git worktrees from `git worktree list`. * Returns actual git worktrees from `git worktree list`.
* Also scans .worktrees/ directory to discover worktrees that may have been
* created externally or whose git state was corrupted.
* Does NOT include tracked branches - only real worktrees with separate directories. * Does NOT include tracked branches - only real worktrees with separate directories.
*/ */
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { exec } from 'child_process'; import { exec } from 'child_process';
import { promisify } from 'util'; import { promisify } from 'util';
import path from 'path';
import * as secureFs from '../../../lib/secure-fs.js'; import * as secureFs from '../../../lib/secure-fs.js';
import { isGitRepo } from '@automaker/git-utils'; import { isGitRepo } from '@automaker/git-utils';
import { getErrorMessage, logError, normalizePath } from '../common.js'; import { getErrorMessage, logError, normalizePath } from '../common.js';
import { readAllWorktreeMetadata, type WorktreePRInfo } from '../../../lib/worktree-metadata.js'; import { readAllWorktreeMetadata, type WorktreePRInfo } from '../../../lib/worktree-metadata.js';
import { createLogger } from '@automaker/utils';
const execAsync = promisify(exec); const execAsync = promisify(exec);
const logger = createLogger('Worktree');
interface WorktreeInfo { interface WorktreeInfo {
path: string; path: string;
@@ -35,6 +40,87 @@ async function getCurrentBranch(cwd: string): Promise<string> {
} }
} }
/**
* Scan the .worktrees directory to discover worktrees that may exist on disk
* but are not registered with git (e.g., created externally or corrupted state).
*/
async function scanWorktreesDirectory(
projectPath: string,
knownWorktreePaths: Set<string>
): Promise<Array<{ path: string; branch: string }>> {
const discovered: Array<{ path: string; branch: string }> = [];
const worktreesDir = path.join(projectPath, '.worktrees');
try {
// Check if .worktrees directory exists
await secureFs.access(worktreesDir);
} catch {
// .worktrees directory doesn't exist
return discovered;
}
try {
const entries = await secureFs.readdir(worktreesDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const worktreePath = path.join(worktreesDir, entry.name);
const normalizedPath = normalizePath(worktreePath);
// Skip if already known from git worktree list
if (knownWorktreePaths.has(normalizedPath)) continue;
// Check if this is a valid git repository
const gitPath = path.join(worktreePath, '.git');
try {
const gitStat = await secureFs.stat(gitPath);
// Git worktrees have a .git FILE (not directory) that points to the parent repo
// Regular repos have a .git DIRECTORY
if (gitStat.isFile() || gitStat.isDirectory()) {
// Try to get the branch name
const branch = await getCurrentBranch(worktreePath);
if (branch) {
logger.info(
`Discovered worktree in .worktrees/ not in git worktree list: ${entry.name} (branch: ${branch})`
);
discovered.push({
path: normalizedPath,
branch,
});
} else {
// Try to get branch from HEAD if branch --show-current fails (detached HEAD)
try {
const { stdout: headRef } = await execAsync('git rev-parse --abbrev-ref HEAD', {
cwd: worktreePath,
});
const headBranch = headRef.trim();
if (headBranch && headBranch !== 'HEAD') {
logger.info(
`Discovered worktree in .worktrees/ not in git worktree list: ${entry.name} (branch: ${headBranch})`
);
discovered.push({
path: normalizedPath,
branch: headBranch,
});
}
} catch {
// Can't determine branch, skip this directory
}
}
}
} catch {
// Not a git repo, skip
}
}
} catch (error) {
logger.warn(`Failed to scan .worktrees directory: ${getErrorMessage(error)}`);
}
return discovered;
}
export function createListHandler() { export function createListHandler() {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
try { try {
@@ -116,6 +202,22 @@ export function createListHandler() {
} }
} }
// Scan .worktrees directory to discover worktrees that exist on disk
// but are not registered with git (e.g., created externally)
const knownPaths = new Set(worktrees.map((w) => w.path));
const discoveredWorktrees = await scanWorktreesDirectory(projectPath, knownPaths);
// Add discovered worktrees to the list
for (const discovered of discoveredWorktrees) {
worktrees.push({
path: discovered.path,
branch: discovered.branch,
isMain: false,
isCurrent: discovered.branch === currentBranch,
hasWorktree: true,
});
}
// Read all worktree metadata to get PR info // Read all worktree metadata to get PR info
const allMetadata = await readAllWorktreeMetadata(projectPath); const allMetadata = await readAllWorktreeMetadata(projectPath);

View File

@@ -1,78 +1,40 @@
/** /**
* POST /open-in-editor endpoint - Open a worktree directory in the default code editor * POST /open-in-editor endpoint - Open a worktree directory in the default code editor
* GET /default-editor endpoint - Get the name of the default code editor * GET /default-editor endpoint - Get the name of the default code editor
* POST /refresh-editors endpoint - Clear editor cache and re-detect available editors
*
* This module uses @automaker/platform for cross-platform editor detection and launching.
*/ */
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { exec } from 'child_process'; import { isAbsolute } from 'path';
import { promisify } from 'util'; import {
clearEditorCache,
detectAllEditors,
detectDefaultEditor,
openInEditor,
openInFileManager,
} from '@automaker/platform';
import { createLogger } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js'; import { getErrorMessage, logError } from '../common.js';
const execAsync = promisify(exec); const logger = createLogger('open-in-editor');
// Editor detection with caching export function createGetAvailableEditorsHandler() {
interface EditorInfo { return async (_req: Request, res: Response): Promise<void> => {
name: string; try {
command: string; const editors = await detectAllEditors();
} res.json({
success: true,
let cachedEditor: EditorInfo | null = null; result: {
editors,
/** },
* Detect which code editor is available on the system });
*/ } catch (error) {
async function detectDefaultEditor(): Promise<EditorInfo> { logError(error, 'Get available editors failed');
// Return cached result if available res.status(500).json({ success: false, error: getErrorMessage(error) });
if (cachedEditor) { }
return cachedEditor; };
}
// Try Cursor first (if user has Cursor, they probably prefer it)
try {
await execAsync('which cursor || where cursor');
cachedEditor = { name: 'Cursor', command: 'cursor' };
return cachedEditor;
} catch {
// Cursor not found
}
// Try VS Code
try {
await execAsync('which code || where code');
cachedEditor = { name: 'VS Code', command: 'code' };
return cachedEditor;
} catch {
// VS Code not found
}
// Try Zed
try {
await execAsync('which zed || where zed');
cachedEditor = { name: 'Zed', command: 'zed' };
return cachedEditor;
} catch {
// Zed not found
}
// Try Sublime Text
try {
await execAsync('which subl || where subl');
cachedEditor = { name: 'Sublime Text', command: 'subl' };
return cachedEditor;
} catch {
// Sublime not found
}
// Fallback to file manager
const platform = process.platform;
if (platform === 'darwin') {
cachedEditor = { name: 'Finder', command: 'open' };
} else if (platform === 'win32') {
cachedEditor = { name: 'Explorer', command: 'explorer' };
} else {
cachedEditor = { name: 'File Manager', command: 'xdg-open' };
}
return cachedEditor;
} }
export function createGetDefaultEditorHandler() { export function createGetDefaultEditorHandler() {
@@ -93,11 +55,41 @@ export function createGetDefaultEditorHandler() {
}; };
} }
/**
* Handler to refresh the editor cache and re-detect available editors
* Useful when the user has installed/uninstalled editors
*/
export function createRefreshEditorsHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
// Clear the cache
clearEditorCache();
// Re-detect editors (this will repopulate the cache)
const editors = await detectAllEditors();
logger.info(`Editor cache refreshed, found ${editors.length} editors`);
res.json({
success: true,
result: {
editors,
message: `Found ${editors.length} available editors`,
},
});
} catch (error) {
logError(error, 'Refresh editors failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}
export function createOpenInEditorHandler() { export function createOpenInEditorHandler() {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
try { try {
const { worktreePath } = req.body as { const { worktreePath, editorCommand } = req.body as {
worktreePath: string; worktreePath: string;
editorCommand?: string;
}; };
if (!worktreePath) { if (!worktreePath) {
@@ -108,42 +100,44 @@ export function createOpenInEditorHandler() {
return; return;
} }
const editor = await detectDefaultEditor(); // Security: Validate that worktreePath is an absolute path
if (!isAbsolute(worktreePath)) {
res.status(400).json({
success: false,
error: 'worktreePath must be an absolute path',
});
return;
}
try { try {
await execAsync(`${editor.command} "${worktreePath}"`); // Use the platform utility to open in editor
const result = await openInEditor(worktreePath, editorCommand);
res.json({ res.json({
success: true, success: true,
result: { result: {
message: `Opened ${worktreePath} in ${editor.name}`, message: `Opened ${worktreePath} in ${result.editorName}`,
editorName: editor.name, editorName: result.editorName,
}, },
}); });
} catch (editorError) { } catch (editorError) {
// If the detected editor fails, try opening in default file manager as fallback // If the specified editor fails, try opening in default file manager as fallback
const platform = process.platform; logger.warn(
let openCommand: string; `Failed to open in editor, falling back to file manager: ${getErrorMessage(editorError)}`
let fallbackName: string; );
if (platform === 'darwin') { try {
openCommand = `open "${worktreePath}"`; const result = await openInFileManager(worktreePath);
fallbackName = 'Finder'; res.json({
} else if (platform === 'win32') { success: true,
openCommand = `explorer "${worktreePath}"`; result: {
fallbackName = 'Explorer'; message: `Opened ${worktreePath} in ${result.editorName}`,
} else { editorName: result.editorName,
openCommand = `xdg-open "${worktreePath}"`; },
fallbackName = 'File Manager'; });
} catch (fallbackError) {
// Both editor and file manager failed
throw fallbackError;
} }
await execAsync(openCommand);
res.json({
success: true,
result: {
message: `Opened ${worktreePath} in ${fallbackName}`,
editorName: fallbackName,
},
});
} }
} catch (error) { } catch (error) {
logError(error, 'Open in editor failed'); logError(error, 'Open in editor failed');

View File

@@ -31,7 +31,13 @@ import {
const logger = createLogger('AutoMode'); const logger = createLogger('AutoMode');
import { resolveModelString, resolvePhaseModel, DEFAULT_MODELS } from '@automaker/model-resolver'; import { resolveModelString, resolvePhaseModel, DEFAULT_MODELS } from '@automaker/model-resolver';
import { resolveDependencies, areDependenciesSatisfied } from '@automaker/dependency-resolver'; import { resolveDependencies, areDependenciesSatisfied } from '@automaker/dependency-resolver';
import { getFeatureDir, getAutomakerDir, getFeaturesDir } from '@automaker/platform'; import {
getFeatureDir,
getAutomakerDir,
getFeaturesDir,
getExecutionStatePath,
ensureAutomakerDir,
} from '@automaker/platform';
import { exec } from 'child_process'; import { exec } from 'child_process';
import { promisify } from 'util'; import { promisify } from 'util';
import path from 'path'; import path from 'path';
@@ -201,6 +207,29 @@ interface AutoModeConfig {
projectPath: string; projectPath: string;
} }
/**
* Execution state for recovery after server restart
* Tracks which features were running and auto-loop configuration
*/
interface ExecutionState {
version: 1;
autoLoopWasRunning: boolean;
maxConcurrency: number;
projectPath: string;
runningFeatureIds: string[];
savedAt: string;
}
// Default empty execution state
const DEFAULT_EXECUTION_STATE: ExecutionState = {
version: 1,
autoLoopWasRunning: false,
maxConcurrency: 3,
projectPath: '',
runningFeatureIds: [],
savedAt: '',
};
// Constants for consecutive failure tracking // Constants for consecutive failure tracking
const CONSECUTIVE_FAILURE_THRESHOLD = 3; // Pause after 3 consecutive failures const CONSECUTIVE_FAILURE_THRESHOLD = 3; // Pause after 3 consecutive failures
const FAILURE_WINDOW_MS = 60000; // Failures within 1 minute count as consecutive const FAILURE_WINDOW_MS = 60000; // Failures within 1 minute count as consecutive
@@ -322,6 +351,9 @@ export class AutoModeService {
projectPath, projectPath,
}); });
// Save execution state for recovery after restart
await this.saveExecutionState(projectPath);
// Note: Memory folder initialization is now handled by loadContextFiles // Note: Memory folder initialization is now handled by loadContextFiles
// Run the loop in the background // Run the loop in the background
@@ -390,17 +422,23 @@ export class AutoModeService {
*/ */
async stopAutoLoop(): Promise<number> { async stopAutoLoop(): Promise<number> {
const wasRunning = this.autoLoopRunning; const wasRunning = this.autoLoopRunning;
const projectPath = this.config?.projectPath;
this.autoLoopRunning = false; this.autoLoopRunning = false;
if (this.autoLoopAbortController) { if (this.autoLoopAbortController) {
this.autoLoopAbortController.abort(); this.autoLoopAbortController.abort();
this.autoLoopAbortController = null; this.autoLoopAbortController = null;
} }
// Clear execution state when auto-loop is explicitly stopped
if (projectPath) {
await this.clearExecutionState(projectPath);
}
// Emit stop event immediately when user explicitly stops // Emit stop event immediately when user explicitly stops
if (wasRunning) { if (wasRunning) {
this.emitAutoModeEvent('auto_mode_stopped', { this.emitAutoModeEvent('auto_mode_stopped', {
message: 'Auto mode stopped', message: 'Auto mode stopped',
projectPath: this.config?.projectPath, projectPath,
}); });
} }
@@ -441,6 +479,11 @@ export class AutoModeService {
}; };
this.runningFeatures.set(featureId, tempRunningFeature); this.runningFeatures.set(featureId, tempRunningFeature);
// Save execution state when feature starts
if (isAutoMode) {
await this.saveExecutionState(projectPath);
}
try { try {
// Validate that project path is allowed using centralized validation // Validate that project path is allowed using centralized validation
validateWorkingDirectory(projectPath); validateWorkingDirectory(projectPath);
@@ -695,6 +738,11 @@ export class AutoModeService {
`Pending approvals at cleanup: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}` `Pending approvals at cleanup: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}`
); );
this.runningFeatures.delete(featureId); this.runningFeatures.delete(featureId);
// Update execution state after feature completes
if (this.autoLoopRunning && projectPath) {
await this.saveExecutionState(projectPath);
}
} }
} }
@@ -2950,6 +2998,149 @@ Begin implementing task ${task.id} now.`;
}); });
} }
// ============================================================================
// Execution State Persistence - For recovery after server restart
// ============================================================================
/**
* Save execution state to disk for recovery after server restart
*/
private async saveExecutionState(projectPath: string): Promise<void> {
try {
await ensureAutomakerDir(projectPath);
const statePath = getExecutionStatePath(projectPath);
const state: ExecutionState = {
version: 1,
autoLoopWasRunning: this.autoLoopRunning,
maxConcurrency: this.config?.maxConcurrency ?? 3,
projectPath,
runningFeatureIds: Array.from(this.runningFeatures.keys()),
savedAt: new Date().toISOString(),
};
await secureFs.writeFile(statePath, JSON.stringify(state, null, 2), 'utf-8');
logger.info(`Saved execution state: ${state.runningFeatureIds.length} running features`);
} catch (error) {
logger.error('Failed to save execution state:', error);
}
}
/**
* Load execution state from disk
*/
private async loadExecutionState(projectPath: string): Promise<ExecutionState> {
try {
const statePath = getExecutionStatePath(projectPath);
const content = (await secureFs.readFile(statePath, 'utf-8')) as string;
const state = JSON.parse(content) as ExecutionState;
return state;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
logger.error('Failed to load execution state:', error);
}
return DEFAULT_EXECUTION_STATE;
}
}
/**
* Clear execution state (called on successful shutdown or when auto-loop stops)
*/
private async clearExecutionState(projectPath: string): Promise<void> {
try {
const statePath = getExecutionStatePath(projectPath);
await secureFs.unlink(statePath);
logger.info('Cleared execution state');
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
logger.error('Failed to clear execution state:', error);
}
}
}
/**
* Check for and resume interrupted features after server restart
* This should be called during server initialization
*/
async resumeInterruptedFeatures(projectPath: string): Promise<void> {
logger.info('Checking for interrupted features to resume...');
// Load all features and find those that were interrupted
const featuresDir = getFeaturesDir(projectPath);
try {
const entries = await secureFs.readdir(featuresDir, { withFileTypes: true });
const interruptedFeatures: Feature[] = [];
for (const entry of entries) {
if (entry.isDirectory()) {
const featurePath = path.join(featuresDir, entry.name, 'feature.json');
try {
const data = (await secureFs.readFile(featurePath, 'utf-8')) as string;
const feature = JSON.parse(data) as Feature;
// Check if feature was interrupted (in_progress or pipeline_*)
if (
feature.status === 'in_progress' ||
(feature.status && feature.status.startsWith('pipeline_'))
) {
// Verify it has existing context (agent-output.md)
const featureDir = getFeatureDir(projectPath, feature.id);
const contextPath = path.join(featureDir, 'agent-output.md');
try {
await secureFs.access(contextPath);
interruptedFeatures.push(feature);
logger.info(
`Found interrupted feature: ${feature.id} (${feature.title}) - status: ${feature.status}`
);
} catch {
// No context file, skip this feature - it will be restarted fresh
logger.info(`Interrupted feature ${feature.id} has no context, will restart fresh`);
}
}
} catch {
// Skip invalid features
}
}
}
if (interruptedFeatures.length === 0) {
logger.info('No interrupted features found');
return;
}
logger.info(`Found ${interruptedFeatures.length} interrupted feature(s) to resume`);
// Emit event to notify UI
this.emitAutoModeEvent('auto_mode_resuming_features', {
message: `Resuming ${interruptedFeatures.length} interrupted feature(s) after server restart`,
projectPath,
featureIds: interruptedFeatures.map((f) => f.id),
features: interruptedFeatures.map((f) => ({
id: f.id,
title: f.title,
status: f.status,
})),
});
// Resume each interrupted feature
for (const feature of interruptedFeatures) {
try {
logger.info(`Resuming feature: ${feature.id} (${feature.title})`);
// Use resumeFeature which will detect the existing context and continue
await this.resumeFeature(projectPath, feature.id, true);
} catch (error) {
logger.error(`Failed to resume feature ${feature.id}:`, error);
// Continue with other features
}
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
logger.info('No features directory found, nothing to resume');
} else {
logger.error('Error checking for interrupted features:', error);
}
}
}
/** /**
* Extract and record learnings from a completed feature * Extract and record learnings from a completed feature
* Uses a quick Claude call to identify important decisions and patterns * Uses a quick Claude call to identify important decisions and patterns

View File

@@ -2,6 +2,7 @@ import { spawn } from 'child_process';
import * as os from 'os'; import * as os from 'os';
import * as pty from 'node-pty'; import * as pty from 'node-pty';
import { ClaudeUsage } from '../routes/claude/types.js'; import { ClaudeUsage } from '../routes/claude/types.js';
import { createLogger } from '@automaker/utils';
/** /**
* Claude Usage Service * Claude Usage Service
@@ -14,6 +15,8 @@ import { ClaudeUsage } from '../routes/claude/types.js';
* - macOS: Uses 'expect' command for PTY * - macOS: Uses 'expect' command for PTY
* - Windows/Linux: Uses node-pty for PTY * - Windows/Linux: Uses node-pty for PTY
*/ */
const logger = createLogger('ClaudeUsage');
export class ClaudeUsageService { export class ClaudeUsageService {
private claudeBinary = 'claude'; private claudeBinary = 'claude';
private timeout = 30000; // 30 second timeout private timeout = 30000; // 30 second timeout
@@ -164,21 +167,40 @@ export class ClaudeUsageService {
const shell = this.isWindows ? 'cmd.exe' : '/bin/sh'; const shell = this.isWindows ? 'cmd.exe' : '/bin/sh';
const args = this.isWindows ? ['/c', 'claude', '/usage'] : ['-c', 'claude /usage']; const args = this.isWindows ? ['/c', 'claude', '/usage'] : ['-c', 'claude /usage'];
const ptyProcess = pty.spawn(shell, args, { let ptyProcess: any = null;
name: 'xterm-256color',
cols: 120, try {
rows: 30, ptyProcess = pty.spawn(shell, args, {
cwd: workingDirectory, name: 'xterm-256color',
env: { cols: 120,
...process.env, rows: 30,
TERM: 'xterm-256color', cwd: workingDirectory,
} as Record<string, string>, env: {
}); ...process.env,
TERM: 'xterm-256color',
} as Record<string, string>,
});
} catch (spawnError) {
// pty.spawn() can throw synchronously if the native module fails to load
// or if PTY is not available in the current environment (e.g., containers without /dev/pts)
const errorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError);
logger.error('[executeClaudeUsageCommandPty] Failed to spawn PTY:', errorMessage);
// Return a user-friendly error instead of crashing
reject(
new Error(
`Unable to access terminal: ${errorMessage}. Claude CLI may not be available or PTY support is limited in this environment.`
)
);
return;
}
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
if (!settled) { if (!settled) {
settled = true; settled = true;
ptyProcess.kill(); if (ptyProcess && !ptyProcess.killed) {
ptyProcess.kill();
}
// Don't fail if we have data - return it instead // Don't fail if we have data - return it instead
if (output.includes('Current session')) { if (output.includes('Current session')) {
resolve(output); resolve(output);
@@ -188,7 +210,7 @@ export class ClaudeUsageService {
} }
}, this.timeout); }, this.timeout);
ptyProcess.onData((data) => { ptyProcess.onData((data: string) => {
output += data; output += data;
// Check if we've seen the usage data (look for "Current session") // Check if we've seen the usage data (look for "Current session")
@@ -196,12 +218,12 @@ export class ClaudeUsageService {
hasSeenUsageData = true; hasSeenUsageData = true;
// Wait for full output, then send escape to exit // Wait for full output, then send escape to exit
setTimeout(() => { setTimeout(() => {
if (!settled) { if (!settled && ptyProcess && !ptyProcess.killed) {
ptyProcess.write('\x1b'); // Send escape key ptyProcess.write('\x1b'); // Send escape key
// Fallback: if ESC doesn't exit (Linux), use SIGTERM after 2s // Fallback: if ESC doesn't exit (Linux), use SIGTERM after 2s
setTimeout(() => { setTimeout(() => {
if (!settled) { if (!settled && ptyProcess && !ptyProcess.killed) {
ptyProcess.kill('SIGTERM'); ptyProcess.kill('SIGTERM');
} }
}, 2000); }, 2000);
@@ -212,14 +234,14 @@ export class ClaudeUsageService {
// Fallback: if we see "Esc to cancel" but haven't seen usage data yet // Fallback: if we see "Esc to cancel" but haven't seen usage data yet
if (!hasSeenUsageData && output.includes('Esc to cancel')) { if (!hasSeenUsageData && output.includes('Esc to cancel')) {
setTimeout(() => { setTimeout(() => {
if (!settled) { if (!settled && ptyProcess && !ptyProcess.killed) {
ptyProcess.write('\x1b'); // Send escape key ptyProcess.write('\x1b'); // Send escape key
} }
}, 3000); }, 3000);
} }
}); });
ptyProcess.onExit(({ exitCode }) => { ptyProcess.onExit(({ exitCode }: { exitCode: number }) => {
clearTimeout(timeoutId); clearTimeout(timeoutId);
if (settled) return; if (settled) return;
settled = true; settled = true;

View File

@@ -308,13 +308,15 @@ export class FeatureLoader {
* @param updates - Partial feature updates * @param updates - Partial feature updates
* @param descriptionHistorySource - Source of description change ('enhance' or 'edit') * @param descriptionHistorySource - Source of description change ('enhance' or 'edit')
* @param enhancementMode - Enhancement mode if source is 'enhance' * @param enhancementMode - Enhancement mode if source is 'enhance'
* @param preEnhancementDescription - Description before enhancement (for restoring original)
*/ */
async update( async update(
projectPath: string, projectPath: string,
featureId: string, featureId: string,
updates: Partial<Feature>, updates: Partial<Feature>,
descriptionHistorySource?: 'enhance' | 'edit', descriptionHistorySource?: 'enhance' | 'edit',
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer',
preEnhancementDescription?: string
): Promise<Feature> { ): Promise<Feature> {
const feature = await this.get(projectPath, featureId); const feature = await this.get(projectPath, featureId);
if (!feature) { if (!feature) {
@@ -338,9 +340,31 @@ export class FeatureLoader {
updates.description !== feature.description && updates.description !== feature.description &&
updates.description.trim() updates.description.trim()
) { ) {
const timestamp = new Date().toISOString();
// If this is an enhancement and we have the pre-enhancement description,
// add the original text to history first (so user can restore to it)
if (
descriptionHistorySource === 'enhance' &&
preEnhancementDescription &&
preEnhancementDescription.trim()
) {
// Check if this pre-enhancement text is different from the last history entry
const lastEntry = updatedHistory[updatedHistory.length - 1];
if (!lastEntry || lastEntry.description !== preEnhancementDescription) {
const preEnhanceEntry: DescriptionHistoryEntry = {
description: preEnhancementDescription,
timestamp,
source: updatedHistory.length === 0 ? 'initial' : 'edit',
};
updatedHistory = [...updatedHistory, preEnhanceEntry];
}
}
// Add the new/enhanced description to history
const historyEntry: DescriptionHistoryEntry = { const historyEntry: DescriptionHistoryEntry = {
description: updates.description, description: updates.description,
timestamp: new Date().toISOString(), timestamp,
source: descriptionHistorySource || 'edit', source: descriptionHistorySource || 'edit',
...(descriptionHistorySource === 'enhance' && enhancementMode ? { enhancementMode } : {}), ...(descriptionHistorySource === 'enhance' && enhancementMode ? { enhancementMode } : {}),
}; };

View File

@@ -0,0 +1,220 @@
import type { ComponentType, ComponentProps } from 'react';
import { FolderOpen } from 'lucide-react';
type IconProps = ComponentProps<'svg'>;
type IconComponent = ComponentType<IconProps>;
const ANTIGRAVITY_COMMANDS = ['antigravity', 'agy'] as const;
const [PRIMARY_ANTIGRAVITY_COMMAND, LEGACY_ANTIGRAVITY_COMMAND] = ANTIGRAVITY_COMMANDS;
/**
* Cursor editor logo icon - from LobeHub icons
*/
export function CursorIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M22.106 5.68L12.5.135a.998.998 0 00-.998 0L1.893 5.68a.84.84 0 00-.419.726v11.186c0 .3.16.577.42.727l9.607 5.547a.999.999 0 00.998 0l9.608-5.547a.84.84 0 00.42-.727V6.407a.84.84 0 00-.42-.726zm-.603 1.176L12.228 22.92c-.063.108-.228.064-.228-.061V12.34a.59.59 0 00-.295-.51l-9.11-5.26c-.107-.062-.063-.228.062-.228h18.55c.264 0 .428.286.296.514z" />
</svg>
);
}
/**
* VS Code editor logo icon
*/
export function VSCodeIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M23.15 2.587L18.21.21a1.494 1.494 0 0 0-1.705.29l-9.46 8.63-4.12-3.128a.999.999 0 0 0-1.276.057L.327 7.261A1 1 0 0 0 .326 8.74L3.899 12 .326 15.26a1 1 0 0 0 .001 1.479L1.65 17.94a.999.999 0 0 0 1.276.057l4.12-3.128 9.46 8.63a1.492 1.492 0 0 0 1.704.29l4.942-2.377A1.5 1.5 0 0 0 24 20.06V3.939a1.5 1.5 0 0 0-.85-1.352zm-5.146 14.861L10.826 12l7.178-5.448v10.896z" />
</svg>
);
}
/**
* VS Code Insiders editor logo icon (same as VS Code)
*/
export function VSCodeInsidersIcon(props: IconProps) {
return <VSCodeIcon {...props} />;
}
/**
* Kiro editor logo icon (VS Code fork)
*/
export function KiroIcon(props: IconProps) {
return (
<svg viewBox="0 0 32 32" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M6.594.016A7.006 7.006 0 0 0 .742 3.875a6.996 6.996 0 0 0-.726 2.793C.004 6.878 0 9.93.004 16.227c.004 8.699.008 9.265.031 9.476.113.93.324 1.652.707 2.422a6.918 6.918 0 0 0 3.172 3.148c.75.372 1.508.59 2.398.692.227.027.77.027 9.688.027 8.945 0 9.457 0 9.688-.027.917-.106 1.66-.32 2.437-.707a6.918 6.918 0 0 0 3.148-3.172c.372-.75.59-1.508.692-2.398.027-.227.027-.77.027-9.665 0-9.976.004-9.53-.07-10.03a6.993 6.993 0 0 0-3.024-4.798 6.427 6.427 0 0 0-.757-.445 7.06 7.06 0 0 0-2.774-.734c-.328-.02-18.437-.02-18.773 0Zm10.789 5.406a7.556 7.556 0 0 1 6.008 3.805c.148.257.406.796.52 1.085.394 1 .632 2.157.769 3.75.035.38.05 1.965.023 2.407-.125 2.168-.625 4.183-1.515 6.078a9.77 9.77 0 0 1-.801 1.437c-.93 1.305-2.32 2.332-3.48 2.57-.895.184-1.602-.1-2.048-.827a3.42 3.42 0 0 1-.25-.528c-.035-.097-.062-.129-.086-.09-.003.008-.09.075-.191.153-.95.722-2.02 1.175-3.059 1.293-.273.03-.859.023-1.085-.016-.715-.121-1.286-.441-1.649-.93a2.563 2.563 0 0 1-.328-.632c-.117-.36-.156-.813-.117-1.227.054-.55.226-1.184.484-1.766a.48.48 0 0 0 .043-.117 2.11 2.11 0 0 0-.137.055c-.363.16-.898.305-1.308.351-.844.098-1.426-.14-1.715-.699-.106-.203-.149-.39-.16-.676-.008-.261.008-.43.066-.656.059-.23.121-.367.403-.89.382-.72.492-.946.636-1.348.328-.899.48-1.723.688-3.754.148-1.469.254-2.14.433-2.766.028-.09.078-.277.114-.414.796-3.074 3.113-5.183 6.148-5.601.129-.016.309-.04.399-.047.238-.016.96-.02 1.195 0Zm0 0" />
<path d="M16.754 11.336a.815.815 0 0 0-.375.219c-.176.18-.293.441-.356.804-.039.235-.058.602-.039.868.028.406.082.64.204.894.128.262.304.426.546.496.106.031.383.031.5 0 .422-.113.703-.531.801-1.191a4.822 4.822 0 0 0-.012-.95c-.062-.378-.183-.675-.359-.863a.808.808 0 0 0-.648-.293.804.804 0 0 0-.262.016ZM20.375 11.328a1.01 1.01 0 0 0-.363.188c-.164.144-.293.402-.364.718-.05.23-.07.426-.07.743 0 .32.02.511.07.742.11.496.352.808.688.898.121.031.379.031.5 0 .402-.105.68-.5.781-1.11.035-.198.047-.648.024-.87-.063-.63-.293-1.059-.649-1.23a1.513 1.513 0 0 0-.219-.079 1.362 1.362 0 0 0-.398 0Zm0 0" />
</svg>
);
}
/**
* Zed editor logo icon (from Simple Icons)
*/
export function ZedIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M2.25 1.5a.75.75 0 0 0-.75.75v16.5H0V2.25A2.25 2.25 0 0 1 2.25 0h20.095c1.002 0 1.504 1.212.795 1.92L10.764 14.298h3.486V12.75h1.5v1.922a1.125 1.125 0 0 1-1.125 1.125H9.264l-2.578 2.578h11.689V9h1.5v9.375a1.5 1.5 0 0 1-1.5 1.5H5.185L2.562 22.5H21.75a.75.75 0 0 0 .75-.75V5.25H24v16.5A2.25 2.25 0 0 1 21.75 24H1.655C.653 24 .151 22.788.86 22.08L13.19 9.75H9.75v1.5h-1.5V9.375A1.125 1.125 0 0 1 9.375 8.25h5.314l2.625-2.625H5.625V15h-1.5V5.625a1.5 1.5 0 0 1 1.5-1.5h13.19L21.438 1.5z" />
</svg>
);
}
/**
* Sublime Text editor logo icon
*/
export function SublimeTextIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M20.953.004a.397.397 0 0 0-.18.045L3.473 8.63a.397.397 0 0 0-.033.69l4.873 3.33-5.26 2.882a.397.397 0 0 0-.006.692l17.3 9.73a.397.397 0 0 0 .593-.344V15.094a.397.397 0 0 0-.203-.346l-4.917-2.763 5.233-2.725a.397.397 0 0 0 .207-.348V.397a.397.397 0 0 0-.307-.393z" />
</svg>
);
}
/**
* macOS Finder icon
*/
export function FinderIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M2.5 3A2.5 2.5 0 0 0 0 5.5v13A2.5 2.5 0 0 0 2.5 21h19a2.5 2.5 0 0 0 2.5-2.5v-13A2.5 2.5 0 0 0 21.5 3h-19zM7 8.5a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3zm10 0a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3zm-9 6c0-.276.336-.5.75-.5h6.5c.414 0 .75.224.75.5v1c0 .828-1.343 2.5-4 2.5s-4-1.672-4-2.5v-1z" />
</svg>
);
}
/**
* Windsurf editor logo icon (by Codeium) - from LobeHub icons
*/
export function WindsurfIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M23.78 5.004h-.228a2.187 2.187 0 00-2.18 2.196v4.912c0 .98-.804 1.775-1.76 1.775a1.818 1.818 0 01-1.472-.773L13.168 5.95a2.197 2.197 0 00-1.81-.95c-1.134 0-2.154.972-2.154 2.173v4.94c0 .98-.797 1.775-1.76 1.775-.57 0-1.136-.289-1.472-.773L.408 5.098C.282 4.918 0 5.007 0 5.228v4.284c0 .216.066.426.188.604l5.475 7.889c.324.466.8.812 1.351.938 1.377.316 2.645-.754 2.645-2.117V11.89c0-.98.787-1.775 1.76-1.775h.002c.586 0 1.135.288 1.472.773l4.972 7.163a2.15 2.15 0 001.81.95c1.158 0 2.151-.973 2.151-2.173v-4.939c0-.98.787-1.775 1.76-1.775h.194c.122 0 .22-.1.22-.222V5.225a.221.221 0 00-.22-.222z"
/>
</svg>
);
}
/**
* Trae editor logo icon (by ByteDance) - from LobeHub icons
*/
export function TraeIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M24 20.541H3.428v-3.426H0V3.4h24V20.54zM3.428 17.115h17.144V6.827H3.428v10.288zm8.573-5.196l-2.425 2.424-2.424-2.424 2.424-2.424 2.425 2.424zm6.857-.001l-2.424 2.423-2.425-2.423 2.425-2.425 2.424 2.425z" />
</svg>
);
}
/**
* JetBrains Rider logo icon
*/
export function RiderIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M0 0v24h24V0zm7.031 3.113A4.063 4.063 0 0 1 9.72 4.14a3.23 3.23 0 0 1 .84 2.28A3.16 3.16 0 0 1 8.4 9.54l2.46 3.6H8.28L6.12 9.9H4.38v3.24H2.16V3.12c1.61-.004 3.281.009 4.871-.007zm5.509.007h3.96c3.18 0 5.34 2.16 5.34 5.04 0 2.82-2.16 5.04-5.34 5.04h-3.96zm4.069 1.976c-.607.01-1.235.004-1.849.004v6.06h1.74a2.882 2.882 0 0 0 3.06-3 2.897 2.897 0 0 0-2.951-3.064zM4.319 5.1v2.88H6.6c1.08 0 1.68-.6 1.68-1.44 0-.96-.66-1.44-1.74-1.44zM2.16 19.5h9V21h-9Z" />
</svg>
);
}
/**
* JetBrains WebStorm logo icon
*/
export function WebStormIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M0 0v24h24V0H0zm17.889 2.889c1.444 0 2.667.444 3.667 1.278l-1.111 1.667c-.889-.611-1.722-1-2.556-1s-1.278.389-1.278.889v.056c0 .667.444.889 2.111 1.333 2 .556 3.111 1.278 3.111 3v.056c0 2-1.5 3.111-3.611 3.111-1.5-.056-3-.611-4.167-1.667l1.278-1.556c.889.722 1.833 1.222 2.944 1.222.889 0 1.389-.333 1.389-.944v-.056c0-.556-.333-.833-2-1.278-2-.5-3.222-1.056-3.222-3.056v-.056c0-1.833 1.444-3 3.444-3zm-16.111.222h2.278l1.5 5.778 1.722-5.778h1.667l1.667 5.778 1.5-5.778h2.333l-2.833 9.944H9.723L8.112 7.277l-1.667 5.778H4.612L1.779 3.111zm.5 16.389h9V21h-9v-1.5z" />
</svg>
);
}
/**
* Xcode logo icon
*/
export function XcodeIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M19.06 5.3327c.4517-.1936.7744-.2581 1.097-.1936.5163.1291.7744.5163.968.7098.1936.3872.9034.7744 1.2261.8389.2581.0645.7098-.6453 1.0325-1.2906.3227-.5808.5163-1.3552.4517-1.5488-.0645-.1936-.968-.5808-1.1616-.5808-.1291 0-.3872.1291-.8389.0645-.4517-.0645-.9034-.5808-1.1616-.968-.4517-.6453-1.097-1.0325-1.6778-1.3552-.6453-.3227-1.3552-.5163-2.065-.6453-1.0325-.2581-2.065-.4517-3.0975-.3227-.5808.0645-1.2906.1291-1.8069.3227-.0645 0-.1936.1936-.0645.1936s.5808.0645.5808.0645-.5807.1292-.5807.2583c0 .1291.0645.1291.1291.1291.0645 0 1.4842-.0645 2.065 0 .6453.1291 1.3552.4517 1.8069 1.2261.7744 1.4197.4517 2.7749.2581 3.2266-.968 2.1295-8.6472 15.2294-9.0344 16.1328-.3873.9034-.5163 1.4842.5807 2.065s1.6778.3227 2.0005-.0645c.3872-.5163 7.0339-17.1654 9.2925-18.2624zm-3.6138 8.7117h1.5488c1.0325 0 1.2261.5163 1.2261.7098.0645.5163-.1936 1.1616-1.2261 1.1616h-.968l.7744 1.2906c.4517.7744.2581 1.1616 0 1.4197-.3872.3872-1.2261.3872-1.6778-.4517l-.9034-1.5488c-.6453 1.4197-1.2906 2.9684-2.065 4.7753h4.0009c1.9359 0 3.5492-1.6133 3.5492-3.5492V6.5588c-.0645-.1291-.1936-.0645-.2581 0-.3872.4517-1.4842 2.0004-4.001 7.4856zm-9.8087 8.0019h-.3227c-2.3231 0-4.1945-1.8714-4.1945-4.1945V7.0105c0-2.3231 1.8714-4.1945 4.1945-4.1945h9.3571c-.1936-.1936-.968-.5163-1.7423-.4517-.3227 0-.968.1291-1.3552-.1291-.3872-.3227-.3227-.5163-.9034-.5163H4.9277c-2.6458 0-4.7753 2.1295-4.7753 4.7753v11.7447c0 2.6458 2.1295 4.7753 4.4527 4.7108.6452 0 .8388-.5162 1.0324-.9034zM20.4152 6.9459v10.9058c0 2.3231-1.8714 4.1945-4.1945 4.1945H11.897s-.3872 1.0325.8389 1.0325h3.8719c2.6458 0 4.7753-2.1295 4.7753-4.7753V8.8173c.0646-.9034-.7098-1.4842-.9679-1.8714zm-18.5851.0646v10.8413c0 1.9359 1.6133 3.5492 3.5492 3.5492h.5808c0-.0645.7744-1.4197 2.4522-4.2591.1936-.3872.4517-.7744.7098-1.2261H4.4114c-.5808 0-.9034-.3872-.968-.7098-.1291-.5163.1936-1.1616.9034-1.1616h2.3877l3.033-5.2916s-.7098-1.2906-.9034-1.6133c-.2582-.4517-.1291-.9034.129-1.1615.3872-.3872 1.0325-.5808 1.6778.4517l.2581.3872.2581-.3872c.5808-.8389.968-.7744 1.2906-.7098.5163.1291.8389.7098.3872 1.6133L8.864 14.0444h1.3552c.4517-.7744.9034-1.5488 1.3552-2.3877-.0645-.3227-.1291-.7098-.0645-1.0325.0645-.5163.3227-.968.6453-1.3552l.3872.6453c1.2261-2.1295 2.1295-3.9364 2.3877-4.6463.1291-.3872.3227-1.1616.1291-1.8069H5.3794c-2.0005.0001-3.5493 1.6134-3.5493 3.5494zM4.605 17.7872c0-.0645.7744-1.4197.7744-1.4197 1.2261-.3227 1.8069.4517 1.8714.5163 0 0-.8389 1.4842-1.097 1.7423s-.5808.3227-.9034.2581c-.5164-.129-.839-.6453-.6454-1.097z" />
</svg>
);
}
/**
* Android Studio logo icon
*/
export function AndroidStudioIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M19.2693 10.3368c-.3321 0-.6026.2705-.6026.6031v9.8324h-1.7379l-3.3355-6.9396c.476-.5387.6797-1.286.5243-2.0009a2.2862 2.2862 0 0 0-1.2893-1.6248v-.8124c.0121-.2871-.1426-.5787-.4043-.7407-.1391-.0825-.2884-.1234-.4402-.1234a.8478.8478 0 0 0-.4318.1182c-.2701.1671-.4248.4587-.4123.7662l-.0003.721c-1.0149.3668-1.6619 1.4153-1.4867 2.5197a2.282 2.282 0 0 0 .5916 1.2103l-3.2096 6.9064H4.0928c-1.0949-.007-1.9797-.8948-1.9832-1.9896V5.016c-.0055 1.1024.8836 2.0006 1.9859 2.0062a2.024 2.024 0 0 0 .1326-.0037h14.7453s2.5343-.2189 2.8619 1.5392c-.2491.0287-.4449.2321-.4449.4889 0 .7115-.5791 1.2901-1.3028 1.2901h-.8183zM17.222 22.5366c.2347.4837.0329 1.066-.4507 1.3007-.1296.0629-.2666.0895-.4018.0927a.9738.9738 0 0 1-.3194-.0455c-.024-.0078-.046-.0209-.0694-.0305a.9701.9701 0 0 1-.2277-.1321c-.0247-.0192-.0495-.038-.0724-.0598-.0825-.0783-.1574-.1672-.21-.2757l-1.2554-2.6143-1.5585-3.2452a.7725.7725 0 0 0-.6995-.4443h-.0024a.792.792 0 0 0-.7083.4443l-1.5109 3.2452-1.2321 2.6464a.9722.9722 0 0 1-.7985.5795c-.0626.0053-.1238-.0024-.185-.0087-.0344-.0036-.069-.0053-.1025-.0124-.0489-.0103-.0954-.0278-.142-.0452-.0301-.0113-.0613-.0197-.0901-.0339-.0496-.0244-.0948-.0565-.1397-.0889-.0217-.0156-.0457-.0275-.0662-.045a.9862.9862 0 0 1-.1695-.1844.9788.9788 0 0 1-.0708-.9852l.8469-1.8223 3.2676-7.0314a1.7964 1.7964 0 0 1-.7072-1.1637c-.1555-.9799.5129-1.9003 1.4928-2.0559V9.3946a.3542.3542 0 0 1 .1674-.3155.3468.3468 0 0 1 .3541 0 .354.354 0 0 1 .1674.3155v1.159l.0129.0064a1.8028 1.8028 0 0 1 1.2878 1.378 1.7835 1.7835 0 0 1-.6439 1.7836l3.3889 7.0507.8481 1.7643zM12.9841 12.306c.0042-.6081-.4854-1.1044-1.0935-1.1085a1.1204 1.1204 0 0 0-.7856.3219 1.101 1.101 0 0 0-.323.7716c-.0042.6081.4854 1.1044 1.0935 1.1085h.0077c.6046 0 1.0967-.488 1.1009-1.0935zm-1.027 5.2768c-.1119.0005-.2121.0632-.2571.1553l-1.4127 3.0342h3.3733l-1.4564-3.0328a.274.274 0 0 0-.2471-.1567zm8.1432-6.7459l-.0129-.0001h-.8177a.103.103 0 0 0-.103.103v12.9103a.103.103 0 0 0 .0966.103h.8435c.9861-.0035 1.7836-.804 1.7836-1.79V9.0468c0 .9887-.8014 1.7901-1.7901 1.7901zM2.6098 5.0161v.019c.0039.816.6719 1.483 1.4874 1.4869a12.061 12.061 0 0 1 .1309-.0034h1.1286c.1972-1.315.7607-2.525 1.638-3.4859H4.0993c-.9266.0031-1.6971.6401-1.9191 1.4975.2417.0355.4296.235.4296.4859zm6.3381-2.8977L7.9112.3284a.219.219 0 0 1 0-.2189A.2384.2384 0 0 1 8.098 0a.219.219 0 0 1 .1867.1094l1.0496 1.8158a6.4907 6.4907 0 0 1 5.3186 0L15.696.1094a.2189.2189 0 0 1 .3734.2189l-1.0302 1.79c1.6671.9125 2.7974 2.5439 3.0975 4.4018l-12.286-.0014c.3004-1.8572 1.4305-3.488 3.0972-4.4003zm5.3774 2.6202a.515.515 0 0 0 .5271.5028.515.515 0 0 0 .5151-.5151.5213.5213 0 0 0-.8885-.367.5151.5151 0 0 0-.1537.3793zm-5.7178-.0067a.5151.5151 0 0 0 .5207.5095.5086.5086 0 0 0 .367-.1481.5215.5215 0 1 0-.734-.7341.515.515 0 0 0-.1537.3727z" />
</svg>
);
}
/**
* Google Antigravity IDE logo icon - stylized "A" arch shape
*/
export function AntigravityIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12 1C11 1 9.5 3 8 7c-1.5 4-3 8.5-4 11.5-.5 1.5-.3 2.8.5 3.3.8.5 2 .2 3-.8.8-.8 1.3-2 1.8-3.2.3-.8.8-1.3 1.5-1.3h2.4c.7 0 1.2.5 1.5 1.3.5 1.2 1 2.4 1.8 3.2 1 1 2.2 1.3 3 .8.8-.5 1-1.8.5-3.3-1-3-2.5-7.5-4-11.5C14.5 3 13 1 12 1zm0 5c.8 2 2 5.5 3 8.5H9c1-3 2.2-6.5 3-8.5z"
/>
</svg>
);
}
/**
* Get the appropriate icon component for an editor command
*/
export function getEditorIcon(command: string): IconComponent {
// Handle direct CLI commands
const cliIcons: Record<string, IconComponent> = {
cursor: CursorIcon,
code: VSCodeIcon,
'code-insiders': VSCodeInsidersIcon,
kido: KiroIcon,
zed: ZedIcon,
subl: SublimeTextIcon,
windsurf: WindsurfIcon,
trae: TraeIcon,
rider: RiderIcon,
webstorm: WebStormIcon,
xed: XcodeIcon,
studio: AndroidStudioIcon,
[PRIMARY_ANTIGRAVITY_COMMAND]: AntigravityIcon,
[LEGACY_ANTIGRAVITY_COMMAND]: AntigravityIcon,
open: FinderIcon,
explorer: FolderOpen,
'xdg-open': FolderOpen,
};
// Check direct match first
if (cliIcons[command]) {
return cliIcons[command];
}
// Handle 'open' commands (macOS) - both 'open -a AppName' and 'open "/path/to/App.app"'
if (command.startsWith('open')) {
const cmdLower = command.toLowerCase();
if (cmdLower.includes('cursor')) return CursorIcon;
if (cmdLower.includes('visual studio code - insiders')) return VSCodeInsidersIcon;
if (cmdLower.includes('visual studio code')) return VSCodeIcon;
if (cmdLower.includes('kiro')) return KiroIcon;
if (cmdLower.includes('zed')) return ZedIcon;
if (cmdLower.includes('sublime')) return SublimeTextIcon;
if (cmdLower.includes('windsurf')) return WindsurfIcon;
if (cmdLower.includes('trae')) return TraeIcon;
if (cmdLower.includes('rider')) return RiderIcon;
if (cmdLower.includes('webstorm')) return WebStormIcon;
if (cmdLower.includes('xcode')) return XcodeIcon;
if (cmdLower.includes('android studio')) return AndroidStudioIcon;
if (cmdLower.includes('antigravity')) return AntigravityIcon;
// If just 'open' without app name, it's Finder
if (command === 'open') return FinderIcon;
}
return FolderOpen;
}

View File

@@ -126,6 +126,9 @@ export function Sidebar() {
// Derive isCreatingSpec from store state // Derive isCreatingSpec from store state
const isCreatingSpec = specCreatingForProject !== null; const isCreatingSpec = specCreatingForProject !== null;
const creatingSpecProjectPath = specCreatingForProject; const creatingSpecProjectPath = specCreatingForProject;
// Check if the current project is specifically the one generating spec
const isCurrentProjectGeneratingSpec =
specCreatingForProject !== null && specCreatingForProject === currentProject?.path;
// Auto-collapse sidebar on small screens and update Electron window minWidth // Auto-collapse sidebar on small screens and update Electron window minWidth
useSidebarAutoCollapse({ sidebarOpen, toggleSidebar }); useSidebarAutoCollapse({ sidebarOpen, toggleSidebar });
@@ -241,6 +244,7 @@ export function Sidebar() {
cyclePrevProject, cyclePrevProject,
cycleNextProject, cycleNextProject,
unviewedValidationsCount, unviewedValidationsCount,
isSpecGenerating: isCurrentProjectGeneratingSpec,
}); });
// Register keyboard shortcuts // Register keyboard shortcuts

View File

@@ -1,4 +1,5 @@
import type { NavigateOptions } from '@tanstack/react-router'; import type { NavigateOptions } from '@tanstack/react-router';
import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { formatShortcut } from '@/store/app-store'; import { formatShortcut } from '@/store/app-store';
import type { NavSection } from '../types'; import type { NavSection } from '../types';
@@ -80,14 +81,23 @@ export function SidebarNavigation({
data-testid={`nav-${item.id}`} data-testid={`nav-${item.id}`}
> >
<div className="relative"> <div className="relative">
<Icon {item.isLoading ? (
className={cn( <Loader2
'w-[18px] h-[18px] shrink-0 transition-all duration-200', className={cn(
isActive 'w-[18px] h-[18px] shrink-0 animate-spin',
? 'text-brand-500 drop-shadow-sm' isActive ? 'text-brand-500' : 'text-muted-foreground'
: 'group-hover:text-brand-400 group-hover:scale-110' )}
)} />
/> ) : (
<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 */} {/* Count badge for collapsed state */}
{!sidebarOpen && item.count !== undefined && item.count > 0 && ( {!sidebarOpen && item.count !== undefined && item.count > 0 && (
<span <span

View File

@@ -1,3 +1,4 @@
import { useRef } from 'react';
import { Rocket, CheckCircle2, Zap, FileText, Sparkles, ArrowRight } from 'lucide-react'; import { Rocket, CheckCircle2, Zap, FileText, Sparkles, ArrowRight } from 'lucide-react';
import { import {
Dialog, Dialog,
@@ -24,13 +25,25 @@ export function OnboardingDialog({
onSkip, onSkip,
onGenerateSpec, onGenerateSpec,
}: OnboardingDialogProps) { }: OnboardingDialogProps) {
// Track if we're closing because user clicked "Generate App Spec"
// to avoid incorrectly calling onSkip
const isGeneratingRef = useRef(false);
const handleGenerateSpec = () => {
isGeneratingRef.current = true;
onGenerateSpec();
};
return ( return (
<Dialog <Dialog
open={open} open={open}
onOpenChange={(isOpen) => { onOpenChange={(isOpen) => {
if (!isOpen) { if (!isOpen && !isGeneratingRef.current) {
// Only call onSkip when user dismisses dialog (escape, click outside, or skip button)
// NOT when they click "Generate App Spec"
onSkip(); onSkip();
} }
isGeneratingRef.current = false;
onOpenChange(isOpen); onOpenChange(isOpen);
}} }}
> >
@@ -108,7 +121,7 @@ export function OnboardingDialog({
Skip for now Skip for now
</Button> </Button>
<Button <Button
onClick={onGenerateSpec} onClick={handleGenerateSpec}
className="bg-gradient-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-600 text-white border-0" className="bg-gradient-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-600 text-white border-0"
> >
<Sparkles className="w-4 h-4 mr-2" /> <Sparkles className="w-4 h-4 mr-2" />

View File

@@ -9,6 +9,8 @@ import {
CircleDot, CircleDot,
GitPullRequest, GitPullRequest,
Lightbulb, Lightbulb,
Brain,
Network,
} from 'lucide-react'; } from 'lucide-react';
import type { NavSection, NavItem } from '../types'; import type { NavSection, NavItem } from '../types';
import type { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts'; import type { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts';
@@ -24,7 +26,9 @@ interface UseNavigationProps {
cycleNextProject: string; cycleNextProject: string;
spec: string; spec: string;
context: string; context: string;
memory: string;
board: string; board: string;
graph: string;
agent: string; agent: string;
terminal: string; terminal: string;
settings: string; settings: string;
@@ -46,6 +50,8 @@ interface UseNavigationProps {
cycleNextProject: () => void; cycleNextProject: () => void;
/** Count of unviewed validations to show on GitHub Issues nav item */ /** Count of unviewed validations to show on GitHub Issues nav item */
unviewedValidationsCount?: number; unviewedValidationsCount?: number;
/** Whether spec generation is currently running for the current project */
isSpecGenerating?: boolean;
} }
export function useNavigation({ export function useNavigation({
@@ -63,6 +69,7 @@ export function useNavigation({
cyclePrevProject, cyclePrevProject,
cycleNextProject, cycleNextProject,
unviewedValidationsCount, unviewedValidationsCount,
isSpecGenerating,
}: UseNavigationProps) { }: UseNavigationProps) {
// Track if current project has a GitHub remote // Track if current project has a GitHub remote
const [hasGitHubRemote, setHasGitHubRemote] = useState(false); const [hasGitHubRemote, setHasGitHubRemote] = useState(false);
@@ -102,6 +109,7 @@ export function useNavigation({
label: 'Spec Editor', label: 'Spec Editor',
icon: FileText, icon: FileText,
shortcut: shortcuts.spec, shortcut: shortcuts.spec,
isLoading: isSpecGenerating,
}, },
{ {
id: 'context', id: 'context',
@@ -109,6 +117,12 @@ export function useNavigation({
icon: BookOpen, icon: BookOpen,
shortcut: shortcuts.context, shortcut: shortcuts.context,
}, },
{
id: 'memory',
label: 'Memory',
icon: Brain,
shortcut: shortcuts.memory,
},
]; ];
// Filter out hidden items // Filter out hidden items
@@ -130,6 +144,12 @@ export function useNavigation({
icon: LayoutGrid, icon: LayoutGrid,
shortcut: shortcuts.board, shortcut: shortcuts.board,
}, },
{
id: 'graph',
label: 'Graph View',
icon: Network,
shortcut: shortcuts.graph,
},
{ {
id: 'agent', id: 'agent',
label: 'Agent Runner', label: 'Agent Runner',
@@ -189,6 +209,7 @@ export function useNavigation({
hideTerminal, hideTerminal,
hasGitHubRemote, hasGitHubRemote,
unviewedValidationsCount, unviewedValidationsCount,
isSpecGenerating,
]); ]);
// Build keyboard shortcuts for navigation // Build keyboard shortcuts for navigation

View File

@@ -13,6 +13,8 @@ export interface NavItem {
shortcut?: string; shortcut?: string;
/** Optional count badge to display next to the nav item */ /** Optional count badge to display next to the nav item */
count?: number; count?: number;
/** Whether this nav item is in a loading state (shows spinner) */
isLoading?: boolean;
} }
export interface SortableProjectItemProps { export interface SortableProjectItemProps {

View File

@@ -84,9 +84,11 @@ const KEYBOARD_ROWS = [
// Map shortcut names to human-readable labels // Map shortcut names to human-readable labels
const SHORTCUT_LABELS: Record<keyof KeyboardShortcuts, string> = { const SHORTCUT_LABELS: Record<keyof KeyboardShortcuts, string> = {
board: 'Kanban Board', board: 'Kanban Board',
graph: 'Graph View',
agent: 'Agent Runner', agent: 'Agent Runner',
spec: 'Spec Editor', spec: 'Spec Editor',
context: 'Context', context: 'Context',
memory: 'Memory',
settings: 'Settings', settings: 'Settings',
terminal: 'Terminal', terminal: 'Terminal',
ideation: 'Ideation', ideation: 'Ideation',
@@ -110,9 +112,11 @@ const SHORTCUT_LABELS: Record<keyof KeyboardShortcuts, string> = {
// Categorize shortcuts for color coding // Categorize shortcuts for color coding
const SHORTCUT_CATEGORIES: Record<keyof KeyboardShortcuts, 'navigation' | 'ui' | 'action'> = { const SHORTCUT_CATEGORIES: Record<keyof KeyboardShortcuts, 'navigation' | 'ui' | 'action'> = {
board: 'navigation', board: 'navigation',
graph: 'navigation',
agent: 'navigation', agent: 'navigation',
spec: 'navigation', spec: 'navigation',
context: 'navigation', context: 'navigation',
memory: 'navigation',
settings: 'navigation', settings: 'navigation',
terminal: 'navigation', terminal: 'navigation',
ideation: 'navigation', ideation: 'navigation',

View File

@@ -15,6 +15,9 @@ const PROVIDER_ICON_KEYS = {
nova: 'nova', nova: 'nova',
meta: 'meta', meta: 'meta',
mistral: 'mistral', mistral: 'mistral',
minimax: 'minimax',
glm: 'glm',
bigpickle: 'bigpickle',
} as const; } as const;
type ProviderIconKey = keyof typeof PROVIDER_ICON_KEYS; type ProviderIconKey = keyof typeof PROVIDER_ICON_KEYS;
@@ -87,6 +90,22 @@ const PROVIDER_ICON_DEFINITIONS: Record<ProviderIconKey, ProviderIconDefinition>
viewBox: '0 0 24 24', viewBox: '0 0 24 24',
path: '', path: '',
}, },
minimax: {
viewBox: '0 0 24 24',
// Official MiniMax logo from lobehub/lobe-icons
path: 'M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z',
},
glm: {
viewBox: '0 0 24 24',
// Official Z.ai logo from lobehub/lobe-icons (GLM provider)
path: 'M12.105 2L9.927 4.953H.653L2.83 2h9.276zM23.254 19.048L21.078 22h-9.242l2.174-2.952h9.244zM24 2L9.264 22H0L14.736 2H24z',
},
bigpickle: {
viewBox: '0 0 24 24',
// Big Pickle logo - stylized shape with dots
path: 'M8 4c-2.21 0-4 1.79-4 4v8c0 2.21 1.79 4 4 4h8c2.21 0 4-1.79 4-4V8c0-2.21-1.79-4-4-4H8zm0 2h8c1.103 0 2 .897 2 2v8c0 1.103-.897 2-2 2H8c-1.103 0-2-.897-2-2V8c0-1.103.897-2 2-2zm2 3a1 1 0 100 2 1 1 0 000-2zm4 0a1 1 0 100 2 1 1 0 000-2zm-4 4a1 1 0 100 2 1 1 0 000-2zm4 0a1 1 0 100 2 1 1 0 000-2z',
fill: '#4ADE80',
},
}; };
export interface ProviderIconProps extends Omit<SVGProps<SVGSVGElement>, 'viewBox'> { export interface ProviderIconProps extends Omit<SVGProps<SVGSVGElement>, 'viewBox'> {
@@ -280,6 +299,83 @@ export function MetaIcon({ className, title, ...props }: { className?: string; t
); );
} }
export function MiniMaxIcon({
className,
title,
...props
}: {
className?: string;
title?: string;
}) {
const hasAccessibleLabel = Boolean(title);
return (
<svg
viewBox="0 0 24 24"
className={cn('inline-block', className)}
role={hasAccessibleLabel ? 'img' : 'presentation'}
aria-hidden={!hasAccessibleLabel}
focusable="false"
{...props}
>
{title && <title>{title}</title>}
<path
d="M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z"
fill="currentColor"
/>
</svg>
);
}
export function GlmIcon({ className, title, ...props }: { className?: string; title?: string }) {
const hasAccessibleLabel = Boolean(title);
return (
<svg
viewBox="0 0 24 24"
className={cn('inline-block', className)}
role={hasAccessibleLabel ? 'img' : 'presentation'}
aria-hidden={!hasAccessibleLabel}
focusable="false"
{...props}
>
{title && <title>{title}</title>}
<path
d="M12.105 2L9.927 4.953H.653L2.83 2h9.276zM23.254 19.048L21.078 22h-9.242l2.174-2.952h9.244zM24 2L9.264 22H0L14.736 2H24z"
fill="currentColor"
/>
</svg>
);
}
export function BigPickleIcon({
className,
title,
...props
}: {
className?: string;
title?: string;
}) {
const hasAccessibleLabel = Boolean(title);
return (
<svg
viewBox="0 0 24 24"
className={cn('inline-block', className)}
role={hasAccessibleLabel ? 'img' : 'presentation'}
aria-hidden={!hasAccessibleLabel}
focusable="false"
{...props}
>
{title && <title>{title}</title>}
<path
d="M8 4c-2.21 0-4 1.79-4 4v8c0 2.21 1.79 4 4 4h8c2.21 0 4-1.79 4-4V8c0-2.21-1.79-4-4-4H8zm0 2h8c1.103 0 2 .897 2 2v8c0 1.103-.897 2-2 2H8c-1.103 0-2-.897-2-2V8c0-1.103.897-2 2-2zm2 3a1 1 0 100 2 1 1 0 000-2zm4 0a1 1 0 100 2 1 1 0 000-2zm-4 4a1 1 0 100 2 1 1 0 000-2zm4 0a1 1 0 100 2 1 1 0 000-2z"
fill="#4ADE80"
/>
</svg>
);
}
export const PROVIDER_ICON_COMPONENTS: Record< export const PROVIDER_ICON_COMPONENTS: Record<
ModelProvider, ModelProvider,
ComponentType<{ className?: string }> ComponentType<{ className?: string }>
@@ -299,33 +395,50 @@ function getUnderlyingModelIcon(model?: AgentModel | string): ProviderIconKey {
const modelStr = typeof model === 'string' ? model.toLowerCase() : model; const modelStr = typeof model === 'string' ? model.toLowerCase() : model;
// Check for OpenCode models (opencode/, amazon-bedrock/, opencode-*) // Check for Amazon Bedrock models first (amazon-bedrock/...)
if (modelStr.includes('opencode')) { if (modelStr.startsWith('amazon-bedrock/')) {
// For OpenCode models, check which specific provider // Bedrock-hosted models - detect the specific provider
if (modelStr.includes('amazon-bedrock')) { if (modelStr.includes('anthropic') || modelStr.includes('claude')) {
// Bedrock-hosted models - detect the specific provider return 'anthropic';
if (modelStr.includes('anthropic') || modelStr.includes('claude')) {
return 'anthropic';
}
if (modelStr.includes('deepseek')) {
return 'deepseek';
}
if (modelStr.includes('nova')) {
return 'nova';
}
if (modelStr.includes('meta') || modelStr.includes('llama')) {
return 'meta';
}
if (modelStr.includes('mistral')) {
return 'mistral';
}
if (modelStr.includes('qwen')) {
return 'qwen';
}
// Default for Bedrock
return 'opencode';
} }
// Native OpenCode models (opencode/big-pickle, etc.) if (modelStr.includes('deepseek')) {
return 'deepseek';
}
if (modelStr.includes('nova')) {
return 'nova';
}
if (modelStr.includes('meta') || modelStr.includes('llama')) {
return 'meta';
}
if (modelStr.includes('mistral')) {
return 'mistral';
}
if (modelStr.includes('qwen')) {
return 'qwen';
}
// Default for unknown Bedrock models
return 'opencode';
}
// Check for native OpenCode models (opencode/...)
if (modelStr.startsWith('opencode/')) {
// Native OpenCode models - check specific model types
if (modelStr.includes('big-pickle')) {
return 'bigpickle';
}
if (modelStr.includes('grok')) {
return 'grok';
}
if (modelStr.includes('glm')) {
return 'glm';
}
if (modelStr.includes('gpt-5-nano') || modelStr.includes('nano')) {
return 'openai'; // GPT-5 Nano uses OpenAI icon
}
if (modelStr.includes('minimax')) {
return 'minimax';
}
// Default for other OpenCode models
return 'opencode'; return 'opencode';
} }
@@ -371,6 +484,9 @@ export function getProviderIconForModel(
nova: NovaIcon, nova: NovaIcon,
meta: MetaIcon, meta: MetaIcon,
mistral: MistralIcon, mistral: MistralIcon,
minimax: MiniMaxIcon,
glm: GlmIcon,
bigpickle: BigPickleIcon,
}; };
return iconMap[iconKey] || AnthropicIcon; return iconMap[iconKey] || AnthropicIcon;

View File

@@ -40,10 +40,7 @@ import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
import { useWindowState } from '@/hooks/use-window-state'; import { useWindowState } from '@/hooks/use-window-state';
// Board-view specific imports // Board-view specific imports
import { BoardHeader } from './board-view/board-header'; import { 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 { KanbanBoard } from './board-view/kanban-board';
import { GraphView } from './graph-view';
import { import {
AddFeatureDialog, AddFeatureDialog,
AgentOutputModal, AgentOutputModal,
@@ -92,8 +89,6 @@ export function BoardView() {
maxConcurrency, maxConcurrency,
setMaxConcurrency, setMaxConcurrency,
defaultSkipTests, defaultSkipTests,
boardViewMode,
setBoardViewMode,
specCreatingForProject, specCreatingForProject,
setSpecCreatingForProject, setSpecCreatingForProject,
pendingPlanApproval, pendingPlanApproval,
@@ -174,12 +169,14 @@ export function BoardView() {
followUpPrompt, followUpPrompt,
followUpImagePaths, followUpImagePaths,
followUpPreviewMap, followUpPreviewMap,
followUpPromptHistory,
setShowFollowUpDialog, setShowFollowUpDialog,
setFollowUpFeature, setFollowUpFeature,
setFollowUpPrompt, setFollowUpPrompt,
setFollowUpImagePaths, setFollowUpImagePaths,
setFollowUpPreviewMap, setFollowUpPreviewMap,
handleFollowUpDialogChange, handleFollowUpDialogChange,
addToPromptHistory,
} = useFollowUpState(); } = useFollowUpState();
// Selection mode hook for mass editing // Selection mode hook for mass editing
@@ -521,6 +518,45 @@ export function BoardView() {
[currentProject, selectedFeatureIds, updateFeature, exitSelectionMode] [currentProject, selectedFeatureIds, updateFeature, exitSelectionMode]
); );
// Handler for bulk deleting multiple features
const handleBulkDelete = useCallback(async () => {
if (!currentProject || selectedFeatureIds.size === 0) return;
try {
const api = getHttpApiClient();
const featureIds = Array.from(selectedFeatureIds);
const result = await api.features.bulkDelete(currentProject.path, featureIds);
const successfullyDeletedIds =
result.results?.filter((r) => r.success).map((r) => r.featureId) ?? [];
if (successfullyDeletedIds.length > 0) {
// Delete from local state without calling the API again
successfullyDeletedIds.forEach((featureId) => {
useAppStore.getState().removeFeature(featureId);
});
toast.success(`Deleted ${successfullyDeletedIds.length} features`);
}
if (result.failedCount && result.failedCount > 0) {
toast.error('Failed to delete some features', {
description: `${result.failedCount} features failed to delete`,
});
}
// Exit selection mode and reload if the operation was at least partially processed.
if (result.results) {
exitSelectionMode();
loadFeatures();
} else if (!result.success) {
toast.error('Failed to delete features', { description: result.error });
}
} catch (error) {
logger.error('Bulk delete failed:', error);
toast.error('Failed to delete features');
}
}, [currentProject, selectedFeatureIds, exitSelectionMode, loadFeatures]);
// Get selected features for mass edit dialog // Get selected features for mass edit dialog
const selectedFeatures = useMemo(() => { const selectedFeatures = useMemo(() => {
return hookFeatures.filter((f) => selectedFeatureIds.has(f.id)); return hookFeatures.filter((f) => selectedFeatureIds.has(f.id));
@@ -1166,7 +1202,6 @@ export function BoardView() {
> >
{/* Header */} {/* Header */}
<BoardHeader <BoardHeader
projectName={currentProject.name}
projectPath={currentProject.path} projectPath={currentProject.path}
maxConcurrency={maxConcurrency} maxConcurrency={maxConcurrency}
runningAgentsCount={runningAutoTasks.length} runningAgentsCount={runningAutoTasks.length}
@@ -1181,6 +1216,13 @@ export function BoardView() {
}} }}
onOpenPlanDialog={() => setShowPlanDialog(true)} onOpenPlanDialog={() => setShowPlanDialog(true)}
isMounted={isMounted} isMounted={isMounted}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
isCreatingSpec={isCreatingSpec}
creatingSpecProjectPath={creatingSpecProjectPath}
onShowBoardBackground={() => setShowBoardBackgroundModal(true)}
onShowCompletedModal={() => setShowCompletedModal(true)}
completedCount={completedFeatures.length}
/> />
{/* Worktree Panel - conditionally rendered based on visibility setting */} {/* Worktree Panel - conditionally rendered based on visibility setting */}
@@ -1219,89 +1261,46 @@ export function BoardView() {
{/* Main Content Area */} {/* Main Content Area */}
<div className="flex-1 flex flex-col overflow-hidden"> <div className="flex-1 flex flex-col overflow-hidden">
{/* Search Bar Row */} {/* View Content - Kanban Board */}
<div className="px-4 pt-4 pb-2 flex items-center justify-between"> <KanbanBoard
<BoardSearchBar sensors={sensors}
searchQuery={searchQuery} collisionDetectionStrategy={collisionDetectionStrategy}
onSearchChange={setSearchQuery} onDragStart={handleDragStart}
isCreatingSpec={isCreatingSpec} onDragEnd={handleDragEnd}
creatingSpecProjectPath={creatingSpecProjectPath ?? undefined} activeFeature={activeFeature}
currentProjectPath={currentProject?.path} getColumnFeatures={getColumnFeatures}
/> backgroundImageStyle={backgroundImageStyle}
backgroundSettings={backgroundSettings}
{/* Board Background & Detail Level Controls */} onEdit={(feature) => setEditingFeature(feature)}
<BoardControls onDelete={(featureId) => handleDeleteFeature(featureId)}
isMounted={isMounted} onViewOutput={handleViewOutput}
onShowBoardBackground={() => setShowBoardBackgroundModal(true)} onVerify={handleVerifyFeature}
onShowCompletedModal={() => setShowCompletedModal(true)} onResume={handleResumeFeature}
completedCount={completedFeatures.length} onForceStop={handleForceStopFeature}
boardViewMode={boardViewMode} onManualVerify={handleManualVerify}
onBoardViewModeChange={setBoardViewMode} onMoveBackToInProgress={handleMoveBackToInProgress}
/> onFollowUp={handleOpenFollowUp}
</div> onComplete={handleCompleteFeature}
{/* View Content - Kanban or Graph */} onImplement={handleStartImplementation}
{boardViewMode === 'kanban' ? ( onViewPlan={(feature) => setViewPlanFeature(feature)}
<KanbanBoard onApprovePlan={handleOpenApprovalDialog}
sensors={sensors} onSpawnTask={(feature) => {
collisionDetectionStrategy={collisionDetectionStrategy} setSpawnParentFeature(feature);
onDragStart={handleDragStart} setShowAddDialog(true);
onDragEnd={handleDragEnd} }}
activeFeature={activeFeature} featuresWithContext={featuresWithContext}
getColumnFeatures={getColumnFeatures} runningAutoTasks={runningAutoTasks}
backgroundImageStyle={backgroundImageStyle} onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
backgroundSettings={backgroundSettings} onAddFeature={() => setShowAddDialog(true)}
onEdit={(feature) => setEditingFeature(feature)} pipelineConfig={
onDelete={(featureId) => handleDeleteFeature(featureId)} currentProject?.path ? pipelineConfigByProject[currentProject.path] || null : null
onViewOutput={handleViewOutput} }
onVerify={handleVerifyFeature} onOpenPipelineSettings={() => setShowPipelineSettings(true)}
onResume={handleResumeFeature} isSelectionMode={isSelectionMode}
onForceStop={handleForceStopFeature} selectedFeatureIds={selectedFeatureIds}
onManualVerify={handleManualVerify} onToggleFeatureSelection={toggleFeatureSelection}
onMoveBackToInProgress={handleMoveBackToInProgress} onToggleSelectionMode={toggleSelectionMode}
onFollowUp={handleOpenFollowUp} />
onComplete={handleCompleteFeature}
onImplement={handleStartImplementation}
onViewPlan={(feature) => setViewPlanFeature(feature)}
onApprovePlan={handleOpenApprovalDialog}
onSpawnTask={(feature) => {
setSpawnParentFeature(feature);
setShowAddDialog(true);
}}
featuresWithContext={featuresWithContext}
runningAutoTasks={runningAutoTasks}
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
onAddFeature={() => setShowAddDialog(true)}
pipelineConfig={
currentProject?.path ? pipelineConfigByProject[currentProject.path] || null : null
}
onOpenPipelineSettings={() => setShowPipelineSettings(true)}
isSelectionMode={isSelectionMode}
selectedFeatureIds={selectedFeatureIds}
onToggleFeatureSelection={toggleFeatureSelection}
onToggleSelectionMode={toggleSelectionMode}
/>
) : (
<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}
onUpdateFeature={updateFeature}
onSpawnTask={(feature) => {
setSpawnParentFeature(feature);
setShowAddDialog(true);
}}
onDeleteTask={(feature) => handleDeleteFeature(feature.id)}
/>
)}
</div> </div>
{/* Selection Action Bar */} {/* Selection Action Bar */}
@@ -1310,6 +1309,7 @@ export function BoardView() {
selectedCount={selectedCount} selectedCount={selectedCount}
totalCount={allSelectableFeatureIds.length} totalCount={allSelectableFeatureIds.length}
onEdit={() => setShowMassEditDialog(true)} onEdit={() => setShowMassEditDialog(true)}
onDelete={handleBulkDelete}
onClear={clearSelection} onClear={clearSelection}
onSelectAll={() => selectAll(allSelectableFeatureIds)} onSelectAll={() => selectAll(allSelectableFeatureIds)}
/> />
@@ -1435,6 +1435,8 @@ export function BoardView() {
onPreviewMapChange={setFollowUpPreviewMap} onPreviewMapChange={setFollowUpPreviewMap}
onSend={handleSendFollowUp} onSend={handleSendFollowUp}
isMaximized={isMaximized} isMaximized={isMaximized}
promptHistory={followUpPromptHistory}
onHistoryAdd={addToPromptHistory}
/> />
{/* Backlog Plan Dialog */} {/* Backlog Plan Dialog */}

View File

@@ -1,16 +1,12 @@
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { ImageIcon, Archive, Columns3, Network } from 'lucide-react'; import { ImageIcon, Archive } from 'lucide-react';
import { cn } from '@/lib/utils';
import { BoardViewMode } from '@/store/app-store';
interface BoardControlsProps { interface BoardControlsProps {
isMounted: boolean; isMounted: boolean;
onShowBoardBackground: () => void; onShowBoardBackground: () => void;
onShowCompletedModal: () => void; onShowCompletedModal: () => void;
completedCount: number; completedCount: number;
boardViewMode: BoardViewMode;
onBoardViewModeChange: (mode: BoardViewMode) => void;
} }
export function BoardControls({ export function BoardControls({
@@ -18,59 +14,12 @@ export function BoardControls({
onShowBoardBackground, onShowBoardBackground,
onShowCompletedModal, onShowCompletedModal,
completedCount, completedCount,
boardViewMode,
onBoardViewModeChange,
}: BoardControlsProps) { }: BoardControlsProps) {
if (!isMounted) return null; if (!isMounted) return null;
return ( return (
<TooltipProvider> <TooltipProvider>
<div className="flex items-center gap-2 ml-4"> <div className="flex items-center gap-2">
{/* 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 */} {/* Board Background Button */}
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>

View File

@@ -10,9 +10,10 @@ import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store'; import { useSetupStore } from '@/store/setup-store';
import { AutoModeSettingsDialog } from './dialogs/auto-mode-settings-dialog'; import { AutoModeSettingsDialog } from './dialogs/auto-mode-settings-dialog';
import { getHttpApiClient } from '@/lib/http-api-client'; import { getHttpApiClient } from '@/lib/http-api-client';
import { BoardSearchBar } from './board-search-bar';
import { BoardControls } from './board-controls';
interface BoardHeaderProps { interface BoardHeaderProps {
projectName: string;
projectPath: string; projectPath: string;
maxConcurrency: number; maxConcurrency: number;
runningAgentsCount: number; runningAgentsCount: number;
@@ -21,6 +22,15 @@ interface BoardHeaderProps {
onAutoModeToggle: (enabled: boolean) => void; onAutoModeToggle: (enabled: boolean) => void;
onOpenPlanDialog: () => void; onOpenPlanDialog: () => void;
isMounted: boolean; isMounted: boolean;
// Search bar props
searchQuery: string;
onSearchChange: (query: string) => void;
isCreatingSpec: boolean;
creatingSpecProjectPath?: string;
// Board controls props
onShowBoardBackground: () => void;
onShowCompletedModal: () => void;
completedCount: number;
} }
// Shared styles for header control containers // Shared styles for header control containers
@@ -28,7 +38,6 @@ const controlContainerClass =
'flex items-center gap-1.5 px-3 h-8 rounded-md bg-secondary border border-border'; 'flex items-center gap-1.5 px-3 h-8 rounded-md bg-secondary border border-border';
export function BoardHeader({ export function BoardHeader({
projectName,
projectPath, projectPath,
maxConcurrency, maxConcurrency,
runningAgentsCount, runningAgentsCount,
@@ -37,6 +46,13 @@ export function BoardHeader({
onAutoModeToggle, onAutoModeToggle,
onOpenPlanDialog, onOpenPlanDialog,
isMounted, isMounted,
searchQuery,
onSearchChange,
isCreatingSpec,
creatingSpecProjectPath,
onShowBoardBackground,
onShowCompletedModal,
completedCount,
}: BoardHeaderProps) { }: BoardHeaderProps) {
const [showAutoModeSettings, setShowAutoModeSettings] = useState(false); const [showAutoModeSettings, setShowAutoModeSettings] = useState(false);
const apiKeys = useAppStore((state) => state.apiKeys); const apiKeys = useAppStore((state) => state.apiKeys);
@@ -84,9 +100,20 @@ export function BoardHeader({
return ( return (
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md"> <div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
<div> <div className="flex items-center gap-4">
<h1 className="text-xl font-bold">Kanban Board</h1> <BoardSearchBar
<p className="text-sm text-muted-foreground">{projectName}</p> searchQuery={searchQuery}
onSearchChange={onSearchChange}
isCreatingSpec={isCreatingSpec}
creatingSpecProjectPath={creatingSpecProjectPath}
currentProjectPath={projectPath}
/>
<BoardControls
isMounted={isMounted}
onShowBoardBackground={onShowBoardBackground}
onShowCompletedModal={onShowCompletedModal}
completedCount={completedCount}
/>
</div> </div>
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
{/* Usage Popover - show if either provider is authenticated */} {/* Usage Popover - show if either provider is authenticated */}

View File

@@ -70,6 +70,7 @@ export function AgentInfoPanel({
}: AgentInfoPanelProps) { }: AgentInfoPanelProps) {
const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null); const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null);
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false); const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
const [isTodosExpanded, setIsTodosExpanded] = useState(false);
useEffect(() => { useEffect(() => {
const loadContext = async () => { const loadContext = async () => {
@@ -197,32 +198,47 @@ export function AgentInfoPanel({
{agentInfo.todos.length} tasks {agentInfo.todos.length} tasks
</span> </span>
</div> </div>
<div className="space-y-0.5 max-h-16 overflow-y-auto"> <div
{agentInfo.todos.slice(0, 3).map((todo, idx) => ( className={cn(
<div key={idx} className="flex items-center gap-1.5 text-[10px]"> 'space-y-0.5 overflow-y-auto',
{todo.status === 'completed' ? ( isTodosExpanded ? 'max-h-40' : 'max-h-16'
<CheckCircle2 className="w-2.5 h-2.5 text-[var(--status-success)] shrink-0" /> )}
) : todo.status === 'in_progress' ? ( >
<Loader2 className="w-2.5 h-2.5 text-[var(--status-warning)] animate-spin shrink-0" /> {(isTodosExpanded ? agentInfo.todos : agentInfo.todos.slice(0, 3)).map(
) : ( (todo, idx) => (
<Circle className="w-2.5 h-2.5 text-muted-foreground/50 shrink-0" /> <div key={idx} className="flex items-center gap-1.5 text-[10px]">
)} {todo.status === 'completed' ? (
<span <CheckCircle2 className="w-2.5 h-2.5 text-[var(--status-success)] shrink-0" />
className={cn( ) : todo.status === 'in_progress' ? (
'break-words hyphens-auto line-clamp-2 leading-relaxed', <Loader2 className="w-2.5 h-2.5 text-[var(--status-warning)] animate-spin shrink-0" />
todo.status === 'completed' && 'text-muted-foreground/60 line-through', ) : (
todo.status === 'in_progress' && 'text-[var(--status-warning)]', <Circle className="w-2.5 h-2.5 text-muted-foreground/50 shrink-0" />
todo.status === 'pending' && 'text-muted-foreground/80'
)} )}
> <span
{todo.content} className={cn(
</span> 'break-words hyphens-auto line-clamp-2 leading-relaxed',
</div> todo.status === 'completed' && 'text-muted-foreground/60 line-through',
))} todo.status === 'in_progress' && 'text-[var(--status-warning)]',
todo.status === 'pending' && 'text-muted-foreground/80'
)}
>
{todo.content}
</span>
</div>
)
)}
{agentInfo.todos.length > 3 && ( {agentInfo.todos.length > 3 && (
<p className="text-[10px] text-muted-foreground/60 pl-4"> <button
+{agentInfo.todos.length - 3} more onClick={(e) => {
</p> e.stopPropagation();
setIsTodosExpanded(!isTodosExpanded);
}}
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
className="text-[10px] text-muted-foreground/60 pl-4 hover:text-muted-foreground transition-colors cursor-pointer"
>
{isTodosExpanded ? 'Show less' : `+${agentInfo.todos.length - 3} more`}
</button>
)} )}
</div> </div>
</div> </div>

View File

@@ -35,6 +35,7 @@ export function SummaryDialog({
data-testid={`summary-dialog-${feature.id}`} data-testid={`summary-dialog-${feature.id}`}
onPointerDown={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
> >
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">

View File

@@ -1,11 +1,21 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Pencil, X, CheckSquare } from 'lucide-react'; import { Pencil, X, CheckSquare, Trash2 } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
interface SelectionActionBarProps { interface SelectionActionBarProps {
selectedCount: number; selectedCount: number;
totalCount: number; totalCount: number;
onEdit: () => void; onEdit: () => void;
onDelete: () => void;
onClear: () => void; onClear: () => void;
onSelectAll: () => void; onSelectAll: () => void;
} }
@@ -14,65 +24,126 @@ export function SelectionActionBar({
selectedCount, selectedCount,
totalCount, totalCount,
onEdit, onEdit,
onDelete,
onClear, onClear,
onSelectAll, onSelectAll,
}: SelectionActionBarProps) { }: SelectionActionBarProps) {
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
if (selectedCount === 0) return null; if (selectedCount === 0) return null;
const allSelected = selectedCount === totalCount; const allSelected = selectedCount === totalCount;
const handleDeleteClick = () => {
setShowDeleteDialog(true);
};
const handleConfirmDelete = () => {
setShowDeleteDialog(false);
onDelete();
};
return ( return (
<div <>
className={cn( <div
'fixed bottom-6 left-1/2 -translate-x-1/2 z-50', className={cn(
'flex items-center gap-3 px-4 py-3 rounded-xl', 'fixed bottom-6 left-1/2 -translate-x-1/2 z-50',
'bg-background/95 backdrop-blur-sm border border-border shadow-lg', 'flex items-center gap-3 px-4 py-3 rounded-xl',
'animate-in slide-in-from-bottom-4 fade-in duration-200' 'bg-background/95 backdrop-blur-sm border border-border shadow-lg',
)} 'animate-in slide-in-from-bottom-4 fade-in duration-200'
data-testid="selection-action-bar" )}
> data-testid="selection-action-bar"
<span className="text-sm font-medium text-foreground"> >
{selectedCount} feature{selectedCount !== 1 ? 's' : ''} selected <span className="text-sm font-medium text-foreground">
</span> {selectedCount} feature{selectedCount !== 1 ? 's' : ''} selected
</span>
<div className="h-4 w-px bg-border" /> <div className="h-4 w-px bg-border" />
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
variant="default" variant="default"
size="sm" size="sm"
onClick={onEdit} onClick={onEdit}
className="h-8 bg-brand-500 hover:bg-brand-600" className="h-8 bg-brand-500 hover:bg-brand-600"
data-testid="selection-edit-button" data-testid="selection-edit-button"
> >
<Pencil className="w-4 h-4 mr-1.5" /> <Pencil className="w-4 h-4 mr-1.5" />
Edit Selected Edit Selected
</Button> </Button>
{!allSelected && (
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={onSelectAll} onClick={handleDeleteClick}
className="h-8" className="h-8 text-destructive hover:text-destructive hover:bg-destructive/10"
data-testid="selection-select-all-button" data-testid="selection-delete-button"
> >
<CheckSquare className="w-4 h-4 mr-1.5" /> <Trash2 className="w-4 h-4 mr-1.5" />
Select All ({totalCount}) Delete
</Button> </Button>
)}
<Button {!allSelected && (
variant="ghost" <Button
size="sm" variant="outline"
onClick={onClear} size="sm"
className="h-8 text-muted-foreground hover:text-foreground" onClick={onSelectAll}
data-testid="selection-clear-button" className="h-8"
> data-testid="selection-select-all-button"
<X className="w-4 h-4 mr-1.5" /> >
Clear <CheckSquare className="w-4 h-4 mr-1.5" />
</Button> Select All ({totalCount})
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={onClear}
className="h-8 text-muted-foreground hover:text-foreground"
data-testid="selection-clear-button"
>
<X className="w-4 h-4 mr-1.5" />
Clear
</Button>
</div>
</div> </div>
</div>
{/* Delete Confirmation Dialog */}
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<DialogContent data-testid="bulk-delete-confirmation-dialog">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-destructive">
<Trash2 className="w-5 h-5" />
Delete Selected Features?
</DialogTitle>
<DialogDescription>
Are you sure you want to permanently delete {selectedCount} feature
{selectedCount !== 1 ? 's' : ''}?
<span className="block mt-2 text-destructive font-medium">
This action cannot be undone.
</span>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setShowDeleteDialog(false)}
data-testid="cancel-bulk-delete-button"
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleConfirmDelete}
data-testid="confirm-bulk-delete-button"
>
<Trash2 className="w-4 h-4 mr-2" />
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
); );
} }

View File

@@ -0,0 +1,254 @@
import { useState, useRef, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Upload } from 'lucide-react';
import { toast } from 'sonner';
import type { PipelineStep } from '@automaker/types';
import { cn } from '@/lib/utils';
import { STEP_TEMPLATES } from './pipeline-step-templates';
// Color options for pipeline columns
const COLOR_OPTIONS = [
{ value: 'bg-blue-500/20', label: 'Blue', preview: 'bg-blue-500' },
{ value: 'bg-purple-500/20', label: 'Purple', preview: 'bg-purple-500' },
{ value: 'bg-green-500/20', label: 'Green', preview: 'bg-green-500' },
{ value: 'bg-orange-500/20', label: 'Orange', preview: 'bg-orange-500' },
{ value: 'bg-red-500/20', label: 'Red', preview: 'bg-red-500' },
{ value: 'bg-pink-500/20', label: 'Pink', preview: 'bg-pink-500' },
{ value: 'bg-cyan-500/20', label: 'Cyan', preview: 'bg-cyan-500' },
{ value: 'bg-amber-500/20', label: 'Amber', preview: 'bg-amber-500' },
{ value: 'bg-indigo-500/20', label: 'Indigo', preview: 'bg-indigo-500' },
];
interface AddEditPipelineStepDialogProps {
open: boolean;
onClose: () => void;
onSave: (step: Omit<PipelineStep, 'id' | 'createdAt' | 'updatedAt'> & { id?: string }) => void;
existingStep?: PipelineStep | null;
defaultOrder: number;
}
export function AddEditPipelineStepDialog({
open,
onClose,
onSave,
existingStep,
defaultOrder,
}: AddEditPipelineStepDialogProps) {
const isEditing = !!existingStep;
const fileInputRef = useRef<HTMLInputElement>(null);
const [name, setName] = useState('');
const [instructions, setInstructions] = useState('');
const [colorClass, setColorClass] = useState(COLOR_OPTIONS[0].value);
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null);
// Reset form when dialog opens/closes or existingStep changes
useEffect(() => {
if (open) {
if (existingStep) {
setName(existingStep.name);
setInstructions(existingStep.instructions);
setColorClass(existingStep.colorClass);
setSelectedTemplate(null);
} else {
setName('');
setInstructions('');
setColorClass(COLOR_OPTIONS[defaultOrder % COLOR_OPTIONS.length].value);
setSelectedTemplate(null);
}
}
}, [open, existingStep, defaultOrder]);
const handleTemplateClick = (templateId: string) => {
const template = STEP_TEMPLATES.find((t) => t.id === templateId);
if (template) {
setName(template.name);
setInstructions(template.instructions);
setColorClass(template.colorClass);
setSelectedTemplate(templateId);
toast.success(`Loaded "${template.name}" template`);
}
};
const handleFileUpload = () => {
fileInputRef.current?.click();
};
const handleFileInputChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const content = await file.text();
setInstructions(content);
toast.success('Instructions loaded from file');
} catch {
toast.error('Failed to load file');
}
// Reset the input so the same file can be selected again
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleSave = () => {
if (!name.trim()) {
toast.error('Step name is required');
return;
}
if (!instructions.trim()) {
toast.error('Step instructions are required');
return;
}
onSave({
id: existingStep?.id,
name: name.trim(),
instructions: instructions.trim(),
colorClass,
order: existingStep?.order ?? defaultOrder,
});
onClose();
};
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-hidden flex flex-col">
{/* Hidden file input for loading instructions from .md files */}
<input
ref={fileInputRef}
type="file"
accept=".md,.txt"
className="hidden"
onChange={handleFileInputChange}
/>
<DialogHeader>
<DialogTitle>{isEditing ? 'Edit Pipeline Step' : 'Add Pipeline Step'}</DialogTitle>
<DialogDescription>
{isEditing
? 'Modify the step configuration below.'
: 'Configure a new step for your pipeline. Choose a template to get started quickly, or create from scratch.'}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto py-4 space-y-6">
{/* Template Quick Start - Only show for new steps */}
{!isEditing && (
<div className="space-y-3">
<Label className="text-sm font-medium">Quick Start from Template</Label>
<div className="flex flex-wrap gap-2">
{STEP_TEMPLATES.map((template) => (
<button
key={template.id}
type="button"
onClick={() => handleTemplateClick(template.id)}
className={cn(
'flex items-center gap-2 px-3 py-2 rounded-lg border transition-all text-sm',
selectedTemplate === template.id
? 'border-primary bg-primary/10 ring-1 ring-primary'
: 'border-border hover:border-primary/50 hover:bg-muted/50'
)}
>
<div
className={cn('w-2 h-2 rounded-full', template.colorClass.replace('/20', ''))}
/>
{template.name}
</button>
))}
</div>
<p className="text-xs text-muted-foreground">
Click a template to pre-fill the form, then customize as needed.
</p>
</div>
)}
{/* Divider */}
{!isEditing && <div className="border-t" />}
{/* Step Name */}
<div className="space-y-2">
<Label htmlFor="step-name">
Step Name <span className="text-destructive">*</span>
</Label>
<Input
id="step-name"
placeholder="e.g., Code Review, Testing, Documentation"
value={name}
onChange={(e) => setName(e.target.value)}
autoFocus={isEditing}
/>
</div>
{/* Color Selection */}
<div className="space-y-2">
<Label>Column Color</Label>
<div className="flex flex-wrap gap-2">
{COLOR_OPTIONS.map((color) => (
<button
key={color.value}
type="button"
className={cn(
'w-8 h-8 rounded-full transition-all',
color.preview,
colorClass === color.value
? 'ring-2 ring-offset-2 ring-primary'
: 'opacity-60 hover:opacity-100'
)}
onClick={() => setColorClass(color.value)}
title={color.label}
/>
))}
</div>
</div>
{/* Agent Instructions */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="step-instructions">
Agent Instructions <span className="text-destructive">*</span>
</Label>
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={handleFileUpload}>
<Upload className="h-3 w-3 mr-1" />
Load from file
</Button>
</div>
<Textarea
id="step-instructions"
placeholder="Instructions for the agent to follow during this pipeline step. Use markdown formatting for best results."
value={instructions}
onChange={(e) => setInstructions(e.target.value)}
rows={10}
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
These instructions will be sent to the agent when this step runs. Be specific about
what you want the agent to review, check, or modify.
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSave}>{isEditing ? 'Update Step' : 'Add to Pipeline'}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -21,11 +21,9 @@ import {
FeatureTextFilePath as DescriptionTextFilePath, FeatureTextFilePath as DescriptionTextFilePath,
ImagePreviewMap, ImagePreviewMap,
} from '@/components/ui/description-image-dropzone'; } from '@/components/ui/description-image-dropzone';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { Play, Cpu, FolderKanban } from 'lucide-react';
import { Sparkles, ChevronDown, ChevronRight, Play, Cpu, FolderKanban } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { getElectronAPI } from '@/lib/electron';
import { modelSupportsThinking } from '@/lib/utils'; import { modelSupportsThinking } from '@/lib/utils';
import { import {
useAppStore, useAppStore,
@@ -43,16 +41,12 @@ import {
WorkModeSelector, WorkModeSelector,
PlanningModeSelect, PlanningModeSelect,
AncestorContextSection, AncestorContextSection,
EnhanceWithAI,
EnhancementHistoryButton,
type BaseHistoryEntry,
} from '../shared'; } from '../shared';
import type { WorkMode } from '../shared'; import type { WorkMode } from '../shared';
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector'; import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
import { ModelOverrideTrigger, useModelOverride } from '@/components/shared';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { import {
getAncestors, getAncestors,
@@ -97,6 +91,13 @@ interface AddFeatureDialogProps {
allFeatures?: Feature[]; allFeatures?: Feature[];
} }
/**
* A single entry in the description history
*/
interface DescriptionHistoryEntry extends BaseHistoryEntry {
description: string;
}
export function AddFeatureDialog({ export function AddFeatureDialog({
open, open,
onOpenChange, onOpenChange,
@@ -139,11 +140,9 @@ export function AddFeatureDialog({
// UI state // UI state
const [previewMap, setPreviewMap] = useState<ImagePreviewMap>(() => new Map()); const [previewMap, setPreviewMap] = useState<ImagePreviewMap>(() => new Map());
const [descriptionError, setDescriptionError] = useState(false); const [descriptionError, setDescriptionError] = useState(false);
const [isEnhancing, setIsEnhancing] = useState(false);
const [enhancementMode, setEnhancementMode] = useState< // Description history state
'improve' | 'technical' | 'simplify' | 'acceptance' const [descriptionHistory, setDescriptionHistory] = useState<DescriptionHistoryEntry[]>([]);
>('improve');
const [enhanceOpen, setEnhanceOpen] = useState(false);
// Spawn mode state // Spawn mode state
const [ancestors, setAncestors] = useState<AncestorContext[]>([]); const [ancestors, setAncestors] = useState<AncestorContext[]>([]);
@@ -152,9 +151,6 @@ export function AddFeatureDialog({
// Get defaults from store // Get defaults from store
const { defaultPlanningMode, defaultRequirePlanApproval } = useAppStore(); const { defaultPlanningMode, defaultRequirePlanApproval } = useAppStore();
// Enhancement model override
const enhancementOverride = useModelOverride({ phase: 'enhancementModel' });
// Track previous open state to detect when dialog opens // Track previous open state to detect when dialog opens
const wasOpenRef = useRef(false); const wasOpenRef = useRef(false);
@@ -171,6 +167,9 @@ export function AddFeatureDialog({
setRequirePlanApproval(defaultRequirePlanApproval); setRequirePlanApproval(defaultRequirePlanApproval);
setModelEntry({ model: 'opus' }); setModelEntry({ model: 'opus' });
// Initialize description history (empty for new feature)
setDescriptionHistory([]);
// Initialize ancestors for spawn mode // Initialize ancestors for spawn mode
if (parentFeature) { if (parentFeature) {
const ancestorList = getAncestors(parentFeature, allFeatures); const ancestorList = getAncestors(parentFeature, allFeatures);
@@ -279,7 +278,7 @@ export function AddFeatureDialog({
setRequirePlanApproval(defaultRequirePlanApproval); setRequirePlanApproval(defaultRequirePlanApproval);
setPreviewMap(new Map()); setPreviewMap(new Map());
setDescriptionError(false); setDescriptionError(false);
setEnhanceOpen(false); setDescriptionHistory([]);
onOpenChange(false); onOpenChange(false);
}; };
@@ -302,33 +301,6 @@ export function AddFeatureDialog({
} }
}; };
const handleEnhanceDescription = async () => {
if (!description.trim() || isEnhancing) return;
setIsEnhancing(true);
try {
const api = getElectronAPI();
const result = await api.enhancePrompt?.enhance(
description,
enhancementMode,
enhancementOverride.effectiveModel,
enhancementOverride.effectiveModelEntry.thinkingLevel
);
if (result?.success && result.enhancedText) {
setDescription(result.enhancedText);
toast.success('Description enhanced!');
} else {
toast.error(result?.error || 'Failed to enhance description');
}
} catch (error) {
logger.error('Enhancement failed:', error);
toast.error('Failed to enhance description');
} finally {
setIsEnhancing(false);
}
};
// Shared card styling // Shared card styling
const cardClass = 'rounded-lg border border-border/50 bg-muted/30 p-4 space-y-3'; const cardClass = 'rounded-lg border border-border/50 bg-muted/30 p-4 space-y-3';
const sectionHeaderClass = 'flex items-center gap-2 text-sm font-medium text-foreground'; const sectionHeaderClass = 'flex items-center gap-2 text-sm font-medium text-foreground';
@@ -380,7 +352,18 @@ export function AddFeatureDialog({
{/* Task Details Section */} {/* Task Details Section */}
<div className={cardClass}> <div className={cardClass}>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="description">Description</Label> <div className="flex items-center justify-between">
<Label htmlFor="description">Description</Label>
{/* Version History Button */}
<EnhancementHistoryButton
history={descriptionHistory}
currentValue={description}
onRestore={setDescription}
valueAccessor={(entry) => entry.description}
title="Version History"
restoreMessage="Description restored from history"
/>
</div>
<DescriptionImageDropZone <DescriptionImageDropZone
value={description} value={description}
onChange={(value) => { onChange={(value) => {
@@ -409,71 +392,35 @@ export function AddFeatureDialog({
/> />
</div> </div>
{/* Collapsible Enhancement Section */} {/* Enhancement Section */}
<Collapsible open={enhanceOpen} onOpenChange={setEnhanceOpen}> <EnhanceWithAI
<CollapsibleTrigger asChild> value={description}
<button className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors w-full py-1"> onChange={setDescription}
{enhanceOpen ? ( onHistoryAdd={({ mode, originalText, enhancedText }) => {
<ChevronDown className="w-4 h-4" /> const timestamp = new Date().toISOString();
) : ( setDescriptionHistory((prev) => {
<ChevronRight className="w-4 h-4" /> const newHistory = [...prev];
)} // Add original text first (so user can restore to pre-enhancement state)
<Sparkles className="w-4 h-4" /> // Only add if it's different from the last entry to avoid duplicates
<span>Enhance with AI</span> const lastEntry = prev[prev.length - 1];
</button> if (!lastEntry || lastEntry.description !== originalText) {
</CollapsibleTrigger> newHistory.push({
<CollapsibleContent className="pt-3"> description: originalText,
<div className="flex flex-wrap items-center gap-2 pl-6"> timestamp,
<DropdownMenu> source: prev.length === 0 ? 'initial' : 'edit',
<DropdownMenuTrigger asChild> });
<Button variant="outline" size="sm" className="h-8 text-xs"> }
{enhancementMode === 'improve' && 'Improve Clarity'} // Add enhanced text
{enhancementMode === 'technical' && 'Add Technical Details'} newHistory.push({
{enhancementMode === 'simplify' && 'Simplify'} description: enhancedText,
{enhancementMode === 'acceptance' && 'Add Acceptance Criteria'} timestamp,
<ChevronDown className="w-3 h-3 ml-1" /> source: 'enhance',
</Button> enhancementMode: mode,
</DropdownMenuTrigger> });
<DropdownMenuContent align="start"> return newHistory;
<DropdownMenuItem onClick={() => setEnhancementMode('improve')}> });
Improve Clarity }}
</DropdownMenuItem> />
<DropdownMenuItem onClick={() => setEnhancementMode('technical')}>
Add Technical Details
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setEnhancementMode('simplify')}>
Simplify
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setEnhancementMode('acceptance')}>
Add Acceptance Criteria
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
type="button"
variant="default"
size="sm"
className="h-8 text-xs"
onClick={handleEnhanceDescription}
disabled={!description.trim() || isEnhancing}
loading={isEnhancing}
>
<Sparkles className="w-3 h-3 mr-1" />
Enhance
</Button>
<ModelOverrideTrigger
currentModelEntry={enhancementOverride.effectiveModelEntry}
onModelChange={enhancementOverride.setOverride}
phase="enhancementModel"
isOverridden={enhancementOverride.isOverridden}
size="sm"
variant="icon"
/>
</div>
</CollapsibleContent>
</Collapsible>
</div> </div>
{/* AI & Execution Section */} {/* AI & Execution Section */}

View File

@@ -21,18 +21,8 @@ import {
FeatureTextFilePath as DescriptionTextFilePath, FeatureTextFilePath as DescriptionTextFilePath,
ImagePreviewMap, ImagePreviewMap,
} from '@/components/ui/description-image-dropzone'; } from '@/components/ui/description-image-dropzone';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { GitBranch, Cpu, FolderKanban } from 'lucide-react';
import {
Sparkles,
ChevronDown,
ChevronRight,
GitBranch,
History,
Cpu,
FolderKanban,
} from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { getElectronAPI } from '@/lib/electron';
import { cn, modelSupportsThinking } from '@/lib/utils'; import { cn, modelSupportsThinking } from '@/lib/utils';
import { Feature, ModelAlias, ThinkingLevel, useAppStore, PlanningMode } from '@/store/app-store'; import { Feature, ModelAlias, ThinkingLevel, useAppStore, PlanningMode } from '@/store/app-store';
import type { ReasoningEffort, PhaseModelEntry, DescriptionHistoryEntry } from '@automaker/types'; import type { ReasoningEffort, PhaseModelEntry, DescriptionHistoryEntry } from '@automaker/types';
@@ -41,17 +31,12 @@ import {
PrioritySelector, PrioritySelector,
WorkModeSelector, WorkModeSelector,
PlanningModeSelect, PlanningModeSelect,
EnhanceWithAI,
EnhancementHistoryButton,
type EnhancementMode,
} from '../shared'; } from '../shared';
import type { WorkMode } from '../shared'; import type { WorkMode } from '../shared';
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector'; import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
import { ModelOverrideTrigger, useModelOverride } from '@/components/shared';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { DependencyTreeDialog } from './dependency-tree-dialog'; import { DependencyTreeDialog } from './dependency-tree-dialog';
import { isClaudeModel, supportsReasoningEffort } from '@automaker/types'; import { isClaudeModel, supportsReasoningEffort } from '@automaker/types';
@@ -79,7 +64,8 @@ interface EditFeatureDialogProps {
requirePlanApproval: boolean; requirePlanApproval: boolean;
}, },
descriptionHistorySource?: 'enhance' | 'edit', descriptionHistorySource?: 'enhance' | 'edit',
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' enhancementMode?: EnhancementMode,
preEnhancementDescription?: string
) => void; ) => void;
categorySuggestions: string[]; categorySuggestions: string[];
branchSuggestions: string[]; branchSuggestions: string[];
@@ -110,11 +96,6 @@ export function EditFeatureDialog({
const [editFeaturePreviewMap, setEditFeaturePreviewMap] = useState<ImagePreviewMap>( const [editFeaturePreviewMap, setEditFeaturePreviewMap] = useState<ImagePreviewMap>(
() => new Map() () => new Map()
); );
const [isEnhancing, setIsEnhancing] = useState(false);
const [enhancementMode, setEnhancementMode] = useState<
'improve' | 'technical' | 'simplify' | 'acceptance'
>('improve');
const [enhanceOpen, setEnhanceOpen] = useState(false);
const [showDependencyTree, setShowDependencyTree] = useState(false); const [showDependencyTree, setShowDependencyTree] = useState(false);
const [planningMode, setPlanningMode] = useState<PlanningMode>(feature?.planningMode ?? 'skip'); const [planningMode, setPlanningMode] = useState<PlanningMode>(feature?.planningMode ?? 'skip');
const [requirePlanApproval, setRequirePlanApproval] = useState( const [requirePlanApproval, setRequirePlanApproval] = useState(
@@ -133,15 +114,16 @@ export function EditFeatureDialog({
// Track the source of description changes for history // Track the source of description changes for history
const [descriptionChangeSource, setDescriptionChangeSource] = useState< const [descriptionChangeSource, setDescriptionChangeSource] = useState<
{ source: 'enhance'; mode: 'improve' | 'technical' | 'simplify' | 'acceptance' } | 'edit' | null { source: 'enhance'; mode: EnhancementMode } | 'edit' | null
>(null); >(null);
// Track the original description when the dialog opened for comparison // Track the original description when the dialog opened for comparison
const [originalDescription, setOriginalDescription] = useState(feature?.description ?? ''); const [originalDescription, setOriginalDescription] = useState(feature?.description ?? '');
// Track if history dropdown is open // Track the description before enhancement (so it can be restored)
const [showHistory, setShowHistory] = useState(false); const [preEnhancementDescription, setPreEnhancementDescription] = useState<string | null>(null);
// Local history state for real-time display (combines persisted + session history)
// Enhancement model override const [localHistory, setLocalHistory] = useState<DescriptionHistoryEntry[]>(
const enhancementOverride = useModelOverride({ phase: 'enhancementModel' }); feature?.descriptionHistory ?? []
);
useEffect(() => { useEffect(() => {
setEditingFeature(feature); setEditingFeature(feature);
@@ -153,8 +135,8 @@ export function EditFeatureDialog({
// Reset history tracking state // Reset history tracking state
setOriginalDescription(feature.description ?? ''); setOriginalDescription(feature.description ?? '');
setDescriptionChangeSource(null); setDescriptionChangeSource(null);
setShowHistory(false); setPreEnhancementDescription(null);
setEnhanceOpen(false); setLocalHistory(feature.descriptionHistory ?? []);
// Reset model entry // Reset model entry
setModelEntry({ setModelEntry({
model: (feature.model as ModelAlias) || 'opus', model: (feature.model as ModelAlias) || 'opus',
@@ -164,7 +146,8 @@ export function EditFeatureDialog({
} else { } else {
setEditFeaturePreviewMap(new Map()); setEditFeaturePreviewMap(new Map());
setDescriptionChangeSource(null); setDescriptionChangeSource(null);
setShowHistory(false); setPreEnhancementDescription(null);
setLocalHistory([]);
} }
}, [feature]); }, [feature]);
@@ -226,7 +209,13 @@ export function EditFeatureDialog({
} }
} }
onUpdate(editingFeature.id, updates, historySource, historyEnhancementMode); onUpdate(
editingFeature.id,
updates,
historySource,
historyEnhancementMode,
preEnhancementDescription ?? undefined
);
setEditFeaturePreviewMap(new Map()); setEditFeaturePreviewMap(new Map());
onClose(); onClose();
}; };
@@ -237,36 +226,6 @@ export function EditFeatureDialog({
} }
}; };
const handleEnhanceDescription = async () => {
if (!editingFeature?.description.trim() || isEnhancing) return;
setIsEnhancing(true);
try {
const api = getElectronAPI();
const result = await api.enhancePrompt?.enhance(
editingFeature.description,
enhancementMode,
enhancementOverride.effectiveModel, // API accepts string, extract from PhaseModelEntry
enhancementOverride.effectiveModelEntry.thinkingLevel // Pass thinking level
);
if (result?.success && result.enhancedText) {
const enhancedText = result.enhancedText;
setEditingFeature((prev) => (prev ? { ...prev, description: enhancedText } : prev));
// Track that this change was from enhancement
setDescriptionChangeSource({ source: 'enhance', mode: enhancementMode });
toast.success('Description enhanced!');
} else {
toast.error(result?.error || 'Failed to enhance description');
}
} catch (error) {
logger.error('Enhancement failed:', error);
toast.error('Failed to enhance description');
} finally {
setIsEnhancing(false);
}
};
if (!editingFeature) { if (!editingFeature) {
return null; return null;
} }
@@ -304,85 +263,18 @@ export function EditFeatureDialog({
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label htmlFor="edit-description">Description</Label> <Label htmlFor="edit-description">Description</Label>
{/* Version History Button */} {/* Version History Button - uses local history for real-time updates */}
{feature?.descriptionHistory && feature.descriptionHistory.length > 0 && ( <EnhancementHistoryButton
<Popover open={showHistory} onOpenChange={setShowHistory}> history={localHistory}
<PopoverTrigger asChild> currentValue={editingFeature.description}
<Button onRestore={(description) => {
type="button" setEditingFeature((prev) => (prev ? { ...prev, description } : prev));
variant="ghost" setDescriptionChangeSource('edit');
size="sm" }}
className="h-7 gap-1.5 text-xs text-muted-foreground" valueAccessor={(entry) => entry.description}
> title="Version History"
<History className="w-3.5 h-3.5" /> restoreMessage="Description restored from history"
History ({feature.descriptionHistory.length}) />
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="end">
<div className="p-3 border-b">
<h4 className="font-medium text-sm">Version History</h4>
<p className="text-xs text-muted-foreground mt-1">
Click a version to restore it
</p>
</div>
<div className="max-h-64 overflow-y-auto p-2 space-y-1">
{[...(feature.descriptionHistory || [])]
.reverse()
.map((entry: DescriptionHistoryEntry, index: number) => {
const isCurrentVersion =
entry.description === editingFeature.description;
const date = new Date(entry.timestamp);
const formattedDate = date.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
const sourceLabel =
entry.source === 'initial'
? 'Original'
: entry.source === 'enhance'
? `Enhanced (${entry.enhancementMode || 'improve'})`
: 'Edited';
return (
<button
key={`${entry.timestamp}-${index}`}
onClick={() => {
setEditingFeature((prev) =>
prev ? { ...prev, description: entry.description } : prev
);
// Mark as edit since user is restoring from history
setDescriptionChangeSource('edit');
setShowHistory(false);
toast.success('Description restored from history');
}}
className={`w-full text-left p-2 rounded-md hover:bg-muted transition-colors ${
isCurrentVersion ? 'bg-muted/50 border border-primary/20' : ''
}`}
>
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium">{sourceLabel}</span>
<span className="text-xs text-muted-foreground">
{formattedDate}
</span>
</div>
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
{entry.description.slice(0, 100)}
{entry.description.length > 100 ? '...' : ''}
</p>
{isCurrentVersion && (
<span className="text-xs text-primary font-medium mt-1 block">
Current version
</span>
)}
</button>
);
})}
</div>
</PopoverContent>
</Popover>
)}
</div> </div>
<DescriptionImageDropZone <DescriptionImageDropZone
value={editingFeature.description} value={editingFeature.description}
@@ -433,71 +325,40 @@ export function EditFeatureDialog({
/> />
</div> </div>
{/* Collapsible Enhancement Section */} {/* Enhancement Section */}
<Collapsible open={enhanceOpen} onOpenChange={setEnhanceOpen}> <EnhanceWithAI
<CollapsibleTrigger asChild> value={editingFeature.description}
<button className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors w-full py-1"> onChange={(enhanced) =>
{enhanceOpen ? ( setEditingFeature((prev) => (prev ? { ...prev, description: enhanced } : prev))
<ChevronDown className="w-4 h-4" /> }
) : ( onHistoryAdd={({ mode, originalText, enhancedText }) => {
<ChevronRight className="w-4 h-4" /> setDescriptionChangeSource({ source: 'enhance', mode });
)} setPreEnhancementDescription(originalText);
<Sparkles className="w-4 h-4" />
<span>Enhance with AI</span>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="pt-3">
<div className="flex flex-wrap items-center gap-2 pl-6">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-8 text-xs">
{enhancementMode === 'improve' && 'Improve Clarity'}
{enhancementMode === 'technical' && 'Add Technical Details'}
{enhancementMode === 'simplify' && 'Simplify'}
{enhancementMode === 'acceptance' && 'Add Acceptance Criteria'}
<ChevronDown className="w-3 h-3 ml-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => setEnhancementMode('improve')}>
Improve Clarity
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setEnhancementMode('technical')}>
Add Technical Details
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setEnhancementMode('simplify')}>
Simplify
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setEnhancementMode('acceptance')}>
Add Acceptance Criteria
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button // Update local history for real-time display
type="button" const timestamp = new Date().toISOString();
variant="default" setLocalHistory((prev) => {
size="sm" const newHistory = [...prev];
className="h-8 text-xs" // Add original text first (so user can restore to pre-enhancement state)
onClick={handleEnhanceDescription} const lastEntry = prev[prev.length - 1];
disabled={!editingFeature.description.trim() || isEnhancing} if (!lastEntry || lastEntry.description !== originalText) {
loading={isEnhancing} newHistory.push({
> description: originalText,
<Sparkles className="w-3 h-3 mr-1" /> timestamp,
Enhance source: prev.length === 0 ? 'initial' : 'edit',
</Button> });
}
<ModelOverrideTrigger // Add enhanced text
currentModelEntry={enhancementOverride.effectiveModelEntry} newHistory.push({
onModelChange={enhancementOverride.setOverride} description: enhancedText,
phase="enhancementModel" timestamp,
isOverridden={enhancementOverride.isOverridden} source: 'enhance',
size="sm" enhancementMode: mode,
variant="icon" });
/> return newHistory;
</div> });
</CollapsibleContent> }}
</Collapsible> />
</div> </div>
{/* AI & Execution Section */} {/* AI & Execution Section */}

View File

@@ -1,4 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -17,6 +18,21 @@ import {
} from '@/components/ui/description-image-dropzone'; } from '@/components/ui/description-image-dropzone';
import { MessageSquare } from 'lucide-react'; import { MessageSquare } from 'lucide-react';
import { Feature } from '@/store/app-store'; import { Feature } from '@/store/app-store';
import {
EnhanceWithAI,
EnhancementHistoryButton,
type EnhancementMode,
type BaseHistoryEntry,
} from '../shared';
const logger = createLogger('FollowUpDialog');
/**
* A single entry in the follow-up prompt history
*/
export interface FollowUpHistoryEntry extends BaseHistoryEntry {
prompt: string;
}
interface FollowUpDialogProps { interface FollowUpDialogProps {
open: boolean; open: boolean;
@@ -30,6 +46,10 @@ interface FollowUpDialogProps {
onPreviewMapChange: (map: ImagePreviewMap) => void; onPreviewMapChange: (map: ImagePreviewMap) => void;
onSend: () => void; onSend: () => void;
isMaximized: boolean; isMaximized: boolean;
/** History of prompt versions for restoration */
promptHistory?: FollowUpHistoryEntry[];
/** Callback to add a new entry to prompt history */
onHistoryAdd?: (entry: FollowUpHistoryEntry) => void;
} }
export function FollowUpDialog({ export function FollowUpDialog({
@@ -44,9 +64,11 @@ export function FollowUpDialog({
onPreviewMapChange, onPreviewMapChange,
onSend, onSend,
isMaximized, isMaximized,
promptHistory = [],
onHistoryAdd,
}: FollowUpDialogProps) { }: FollowUpDialogProps) {
const handleClose = (open: boolean) => { const handleClose = (openState: boolean) => {
if (!open) { if (!openState) {
onOpenChange(false); onOpenChange(false);
} }
}; };
@@ -77,7 +99,18 @@ export function FollowUpDialog({
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-4 overflow-y-auto flex-1 min-h-0"> <div className="space-y-4 py-4 overflow-y-auto flex-1 min-h-0">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="follow-up-prompt">Instructions</Label> <div className="flex items-center justify-between">
<Label htmlFor="follow-up-prompt">Instructions</Label>
{/* Version History Button */}
<EnhancementHistoryButton
history={promptHistory}
currentValue={prompt}
onRestore={onPromptChange}
valueAccessor={(entry) => entry.prompt}
title="Prompt History"
restoreMessage="Prompt restored from history"
/>
</div>
<DescriptionImageDropZone <DescriptionImageDropZone
value={prompt} value={prompt}
onChange={onPromptChange} onChange={onPromptChange}
@@ -88,6 +121,33 @@ export function FollowUpDialog({
onPreviewMapChange={onPreviewMapChange} onPreviewMapChange={onPreviewMapChange}
/> />
</div> </div>
{/* Enhancement Section */}
<EnhanceWithAI
value={prompt}
onChange={onPromptChange}
onHistoryAdd={({ mode, originalText, enhancedText }) => {
const timestamp = new Date().toISOString();
// Add original text first (so user can restore to pre-enhancement state)
// Only add if it's different from the last history entry
const lastEntry = promptHistory[promptHistory.length - 1];
if (!lastEntry || lastEntry.prompt !== originalText) {
onHistoryAdd?.({
prompt: originalText,
timestamp,
source: promptHistory.length === 0 ? 'initial' : 'edit',
});
}
// Add enhanced text
onHistoryAdd?.({
prompt: enhancedText,
timestamp,
source: 'enhance',
enhancementMode: mode,
});
}}
/>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
The agent will continue from where it left off, using the existing context. You can The agent will continue from where it left off, using the existing context. You can
attach screenshots to help explain the issue. attach screenshots to help explain the issue.

View File

@@ -5,6 +5,6 @@ export { CompletedFeaturesModal } from './completed-features-modal';
export { ArchiveAllVerifiedDialog } from './archive-all-verified-dialog'; export { ArchiveAllVerifiedDialog } from './archive-all-verified-dialog';
export { DeleteCompletedFeatureDialog } from './delete-completed-feature-dialog'; export { DeleteCompletedFeatureDialog } from './delete-completed-feature-dialog';
export { EditFeatureDialog } from './edit-feature-dialog'; export { EditFeatureDialog } from './edit-feature-dialog';
export { FollowUpDialog } from './follow-up-dialog'; export { FollowUpDialog, type FollowUpHistoryEntry } from './follow-up-dialog';
export { PlanApprovalDialog } from './plan-approval-dialog'; export { PlanApprovalDialog } from './plan-approval-dialog';
export { MassEditDialog } from './mass-edit-dialog'; export { MassEditDialog } from './mass-edit-dialog';

View File

@@ -1,4 +1,4 @@
import { useState, useRef, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -8,223 +8,11 @@ import {
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Plus, Trash2, ChevronUp, ChevronDown, Pencil } from 'lucide-react';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Plus, Trash2, ChevronUp, ChevronDown, Upload, Pencil, X, FileText } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import type { PipelineConfig, PipelineStep } from '@automaker/types'; import type { PipelineConfig, PipelineStep } from '@automaker/types';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { AddEditPipelineStepDialog } from './add-edit-pipeline-step-dialog';
// Color options for pipeline columns
const COLOR_OPTIONS = [
{ value: 'bg-blue-500/20', label: 'Blue', preview: 'bg-blue-500' },
{ value: 'bg-purple-500/20', label: 'Purple', preview: 'bg-purple-500' },
{ value: 'bg-green-500/20', label: 'Green', preview: 'bg-green-500' },
{ value: 'bg-orange-500/20', label: 'Orange', preview: 'bg-orange-500' },
{ value: 'bg-red-500/20', label: 'Red', preview: 'bg-red-500' },
{ value: 'bg-pink-500/20', label: 'Pink', preview: 'bg-pink-500' },
{ value: 'bg-cyan-500/20', label: 'Cyan', preview: 'bg-cyan-500' },
{ value: 'bg-amber-500/20', label: 'Amber', preview: 'bg-amber-500' },
{ value: 'bg-indigo-500/20', label: 'Indigo', preview: 'bg-indigo-500' },
];
// Pre-built step templates with well-designed prompts
const STEP_TEMPLATES = [
{
id: 'code-review',
name: 'Code Review',
colorClass: 'bg-blue-500/20',
instructions: `## Code Review
Please perform a thorough code review of the changes made in this feature. Focus on:
### Code Quality
- **Readability**: Is the code easy to understand? Are variable/function names descriptive?
- **Maintainability**: Will this code be easy to modify in the future?
- **DRY Principle**: Is there any duplicated code that should be abstracted?
- **Single Responsibility**: Do functions and classes have a single, clear purpose?
### Best Practices
- Follow established patterns and conventions used in the codebase
- Ensure proper error handling is in place
- Check for appropriate logging where needed
- Verify that magic numbers/strings are replaced with named constants
### Performance
- Identify any potential performance bottlenecks
- Check for unnecessary re-renders (React) or redundant computations
- Ensure efficient data structures are used
### Testing
- Verify that new code has appropriate test coverage
- Check that edge cases are handled
### Action Required
After reviewing, make any necessary improvements directly. If you find issues:
1. Fix them immediately if they are straightforward
2. For complex issues, document them clearly with suggested solutions
Provide a brief summary of changes made or issues found.`,
},
{
id: 'security-review',
name: 'Security Review',
colorClass: 'bg-red-500/20',
instructions: `## Security Review
Perform a comprehensive security audit of the changes made in this feature. Check for vulnerabilities in the following areas:
### Input Validation & Sanitization
- Verify all user inputs are properly validated and sanitized
- Check for SQL injection vulnerabilities
- Check for XSS (Cross-Site Scripting) vulnerabilities
- Ensure proper encoding of output data
### Authentication & Authorization
- Verify authentication checks are in place where needed
- Ensure authorization logic correctly restricts access
- Check for privilege escalation vulnerabilities
- Verify session management is secure
### Data Protection
- Ensure sensitive data is not logged or exposed
- Check that secrets/credentials are not hardcoded
- Verify proper encryption is used for sensitive data
- Check for secure transmission of data (HTTPS, etc.)
### Common Vulnerabilities (OWASP Top 10)
- Injection flaws
- Broken authentication
- Sensitive data exposure
- XML External Entities (XXE)
- Broken access control
- Security misconfiguration
- Cross-Site Scripting (XSS)
- Insecure deserialization
- Using components with known vulnerabilities
- Insufficient logging & monitoring
### Action Required
1. Fix any security vulnerabilities immediately
2. For complex security issues, document them with severity levels
3. Add security-related comments where appropriate
Provide a security assessment summary with any issues found and fixes applied.`,
},
{
id: 'testing',
name: 'Testing',
colorClass: 'bg-green-500/20',
instructions: `## Testing Step
Please ensure comprehensive test coverage for the changes made in this feature.
### Unit Tests
- Write unit tests for all new functions and methods
- Ensure edge cases are covered
- Test error handling paths
- Aim for high code coverage on new code
### Integration Tests
- Test interactions between components/modules
- Verify API endpoints work correctly
- Test database operations if applicable
### Test Quality
- Tests should be readable and well-documented
- Each test should have a clear purpose
- Use descriptive test names that explain the scenario
- Follow the Arrange-Act-Assert pattern
### Run Tests
After writing tests, run the full test suite and ensure:
1. All new tests pass
2. No existing tests are broken
3. Test coverage meets project standards
Provide a summary of tests added and any issues found during testing.`,
},
{
id: 'documentation',
name: 'Documentation',
colorClass: 'bg-amber-500/20',
instructions: `## Documentation Step
Please ensure all changes are properly documented.
### Code Documentation
- Add/update JSDoc or docstrings for new functions and classes
- Document complex algorithms or business logic
- Add inline comments for non-obvious code
### API Documentation
- Document any new or modified API endpoints
- Include request/response examples
- Document error responses
### README Updates
- Update README if new setup steps are required
- Document any new environment variables
- Update architecture diagrams if applicable
### Changelog
- Document notable changes for the changelog
- Include breaking changes if any
Provide a summary of documentation added or updated.`,
},
{
id: 'optimization',
name: 'Performance Optimization',
colorClass: 'bg-cyan-500/20',
instructions: `## Performance Optimization Step
Review and optimize the performance of the changes made in this feature.
### Code Performance
- Identify and optimize slow algorithms (O(n²) → O(n log n), etc.)
- Remove unnecessary computations or redundant operations
- Optimize loops and iterations
- Use appropriate data structures
### Memory Usage
- Check for memory leaks
- Optimize memory-intensive operations
- Ensure proper cleanup of resources
### Database/API
- Optimize database queries (add indexes, reduce N+1 queries)
- Implement caching where appropriate
- Batch API calls when possible
### Frontend (if applicable)
- Minimize bundle size
- Optimize render performance
- Implement lazy loading where appropriate
- Use memoization for expensive computations
### Action Required
1. Profile the code to identify bottlenecks
2. Apply optimizations
3. Measure improvements
Provide a summary of optimizations applied and performance improvements achieved.`,
},
];
// Helper to get template color class
const getTemplateColorClass = (templateId: string): string => {
const template = STEP_TEMPLATES.find((t) => t.id === templateId);
return template?.colorClass || COLOR_OPTIONS[0].value;
};
interface PipelineSettingsDialogProps { interface PipelineSettingsDialogProps {
open: boolean; open: boolean;
@@ -234,18 +22,10 @@ interface PipelineSettingsDialogProps {
onSave: (config: PipelineConfig) => Promise<void>; onSave: (config: PipelineConfig) => Promise<void>;
} }
interface EditingStep {
id?: string;
name: string;
instructions: string;
colorClass: string;
order: number;
}
export function PipelineSettingsDialog({ export function PipelineSettingsDialog({
open, open,
onClose, onClose,
projectPath, projectPath: _projectPath,
pipelineConfig, pipelineConfig,
onSave, onSave,
}: PipelineSettingsDialogProps) { }: PipelineSettingsDialogProps) {
@@ -262,9 +42,11 @@ export function PipelineSettingsDialog({
}; };
const [steps, setSteps] = useState<PipelineStep[]>(() => validateSteps(pipelineConfig?.steps)); const [steps, setSteps] = useState<PipelineStep[]>(() => validateSteps(pipelineConfig?.steps));
const [editingStep, setEditingStep] = useState<EditingStep | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Sub-dialog state
const [addEditDialogOpen, setAddEditDialogOpen] = useState(false);
const [editingStep, setEditingStep] = useState<PipelineStep | null>(null);
// Sync steps when dialog opens or pipelineConfig changes // Sync steps when dialog opens or pipelineConfig changes
useEffect(() => { useEffect(() => {
@@ -276,22 +58,13 @@ export function PipelineSettingsDialog({
const sortedSteps = [...steps].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); const sortedSteps = [...steps].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
const handleAddStep = () => { const handleAddStep = () => {
setEditingStep({ setEditingStep(null);
name: '', setAddEditDialogOpen(true);
instructions: '',
colorClass: COLOR_OPTIONS[steps.length % COLOR_OPTIONS.length].value,
order: steps.length,
});
}; };
const handleEditStep = (step: PipelineStep) => { const handleEditStep = (step: PipelineStep) => {
setEditingStep({ setEditingStep(step);
id: step.id, setAddEditDialogOpen(true);
name: step.name,
instructions: step.instructions,
colorClass: step.colorClass,
order: step.order,
});
}; };
const handleDeleteStep = (stepId: string) => { const handleDeleteStep = (stepId: string) => {
@@ -323,53 +96,21 @@ export function PipelineSettingsDialog({
setSteps(newSteps); setSteps(newSteps);
}; };
const handleFileUpload = () => { const handleSaveStep = (
fileInputRef.current?.click(); stepData: Omit<PipelineStep, 'id' | 'createdAt' | 'updatedAt'> & { id?: string }
}; ) => {
const handleFileInputChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const content = await file.text();
setEditingStep((prev) => (prev ? { ...prev, instructions: content } : null));
toast.success('Instructions loaded from file');
} catch (error) {
toast.error('Failed to load file');
}
// Reset the input so the same file can be selected again
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleSaveStep = () => {
if (!editingStep) return;
if (!editingStep.name.trim()) {
toast.error('Step name is required');
return;
}
if (!editingStep.instructions.trim()) {
toast.error('Step instructions are required');
return;
}
const now = new Date().toISOString(); const now = new Date().toISOString();
if (editingStep.id) { if (stepData.id) {
// Update existing step // Update existing step
setSteps((prev) => setSteps((prev) =>
prev.map((s) => prev.map((s) =>
s.id === editingStep.id s.id === stepData.id
? { ? {
...s, ...s,
name: editingStep.name, name: stepData.name,
instructions: editingStep.instructions, instructions: stepData.instructions,
colorClass: editingStep.colorClass, colorClass: stepData.colorClass,
updatedAt: now, updatedAt: now,
} }
: s : s
@@ -379,90 +120,21 @@ export function PipelineSettingsDialog({
// Add new step // Add new step
const newStep: PipelineStep = { const newStep: PipelineStep = {
id: `step_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 8)}`, id: `step_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 8)}`,
name: editingStep.name, name: stepData.name,
instructions: editingStep.instructions, instructions: stepData.instructions,
colorClass: editingStep.colorClass, colorClass: stepData.colorClass,
order: steps.length, order: steps.length,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}; };
setSteps((prev) => [...prev, newStep]); setSteps((prev) => [...prev, newStep]);
} }
setEditingStep(null);
}; };
const handleSaveConfig = async () => { const handleSaveConfig = async () => {
setIsSubmitting(true); setIsSubmitting(true);
try { try {
// If the user is currently editing a step and clicks "Save Configuration", const sortedEffectiveSteps = [...steps].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
// include that step in the config (common expectation) instead of silently dropping it.
let effectiveSteps = steps;
if (editingStep) {
if (!editingStep.name.trim()) {
toast.error('Step name is required');
return;
}
if (!editingStep.instructions.trim()) {
toast.error('Step instructions are required');
return;
}
const now = new Date().toISOString();
if (editingStep.id) {
// Update existing (or add if missing for some reason)
const existingIdx = effectiveSteps.findIndex((s) => s.id === editingStep.id);
if (existingIdx >= 0) {
effectiveSteps = effectiveSteps.map((s) =>
s.id === editingStep.id
? {
...s,
name: editingStep.name,
instructions: editingStep.instructions,
colorClass: editingStep.colorClass,
updatedAt: now,
}
: s
);
} else {
effectiveSteps = [
...effectiveSteps,
{
id: editingStep.id,
name: editingStep.name,
instructions: editingStep.instructions,
colorClass: editingStep.colorClass,
order: effectiveSteps.length,
createdAt: now,
updatedAt: now,
},
];
}
} else {
// Add new step
effectiveSteps = [
...effectiveSteps,
{
id: `step_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 8)}`,
name: editingStep.name,
instructions: editingStep.instructions,
colorClass: editingStep.colorClass,
order: effectiveSteps.length,
createdAt: now,
updatedAt: now,
},
];
}
// Keep local UI state consistent with what we are saving.
setSteps(effectiveSteps);
setEditingStep(null);
}
const sortedEffectiveSteps = [...effectiveSteps].sort(
(a, b) => (a.order ?? 0) - (b.order ?? 0)
);
const config: PipelineConfig = { const config: PipelineConfig = {
version: 1, version: 1,
steps: sortedEffectiveSteps.map((s, index) => ({ ...s, order: index })), steps: sortedEffectiveSteps.map((s, index) => ({ ...s, order: index })),
@@ -470,7 +142,7 @@ export function PipelineSettingsDialog({
await onSave(config); await onSave(config);
toast.success('Pipeline configuration saved'); toast.success('Pipeline configuration saved');
onClose(); onClose();
} catch (error) { } catch {
toast.error('Failed to save pipeline configuration'); toast.error('Failed to save pipeline configuration');
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
@@ -478,259 +150,121 @@ export function PipelineSettingsDialog({
}; };
return ( return (
<Dialog open={open} onOpenChange={(open) => !open && onClose()}> <>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-hidden flex flex-col"> <Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
{/* Hidden file input for loading instructions from .md files */} <DialogContent className="max-w-2xl max-h-[85vh] overflow-hidden flex flex-col">
<input <DialogHeader>
ref={fileInputRef} <DialogTitle>Pipeline Settings</DialogTitle>
type="file" <DialogDescription>
accept=".md,.txt" Configure custom pipeline steps that run after a feature completes "In Progress". Each
className="hidden" step will automatically prompt the agent with its instructions.
onChange={handleFileInputChange} </DialogDescription>
/> </DialogHeader>
<DialogHeader>
<DialogTitle>Pipeline Settings</DialogTitle>
<DialogDescription>
Configure custom pipeline steps that run after a feature completes "In Progress". Each
step will automatically prompt the agent with its instructions.
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto py-4 space-y-4">
{/* Steps List */}
{sortedSteps.length > 0 ? (
<div className="space-y-2">
{sortedSteps.map((step, index) => (
<div
key={step.id}
className="flex items-center gap-2 p-3 border rounded-lg bg-muted/30"
>
<div className="flex flex-col gap-1">
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() => handleMoveStep(step.id, 'up')}
disabled={index === 0}
>
<ChevronUp className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() => handleMoveStep(step.id, 'down')}
disabled={index === sortedSteps.length - 1}
>
<ChevronDown className="h-3 w-3" />
</Button>
</div>
<div className="flex-1 overflow-y-auto py-4 space-y-4">
{/* Steps List */}
{sortedSteps.length > 0 ? (
<div className="space-y-2">
{sortedSteps.map((step, index) => (
<div <div
className={cn( key={step.id}
'w-3 h-8 rounded', className="flex items-center gap-2 p-3 border rounded-lg bg-muted/30"
(step.colorClass || 'bg-blue-500/20').replace('/20', '') >
)} <div className="flex flex-col gap-1">
/> <Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() => handleMoveStep(step.id, 'up')}
disabled={index === 0}
>
<ChevronUp className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() => handleMoveStep(step.id, 'down')}
disabled={index === sortedSteps.length - 1}
>
<ChevronDown className="h-3 w-3" />
</Button>
</div>
<div className="flex-1 min-w-0"> <div
<div className="font-medium truncate">{step.name || 'Unnamed Step'}</div> className={cn(
<div className="text-xs text-muted-foreground truncate"> 'w-3 h-8 rounded',
{(step.instructions || '').substring(0, 100)} (step.colorClass || 'bg-blue-500/20').replace('/20', '')
{(step.instructions || '').length > 100 ? '...' : ''} )}
/>
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{step.name || 'Unnamed Step'}</div>
<div className="text-xs text-muted-foreground truncate">
{(step.instructions || '').substring(0, 100)}
{(step.instructions || '').length > 100 ? '...' : ''}
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => handleEditStep(step)}
>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive"
onClick={() => handleDeleteStep(step.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div> </div>
</div> </div>
))}
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
<p>No pipeline steps configured.</p>
<p className="text-sm">
Add steps to create a custom workflow after features complete.
</p>
</div>
)}
<div className="flex items-center gap-1"> {/* Add Step Button */}
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => handleEditStep(step)}
>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive"
onClick={() => handleDeleteStep(step.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
<p>No pipeline steps configured.</p>
<p className="text-sm">
Add steps to create a custom workflow after features complete.
</p>
</div>
)}
{/* Add Step Button */}
{!editingStep && (
<Button variant="outline" className="w-full" onClick={handleAddStep}> <Button variant="outline" className="w-full" onClick={handleAddStep}>
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
Add Pipeline Step Add Pipeline Step
</Button> </Button>
)} </div>
{/* Edit/Add Step Form */} <DialogFooter>
{editingStep && ( <Button variant="outline" onClick={onClose}>
<div className="border rounded-lg p-4 space-y-4 bg-muted/20"> Cancel
<div className="flex items-center justify-between"> </Button>
<h4 className="font-medium">{editingStep.id ? 'Edit Step' : 'New Step'}</h4> <Button onClick={handleSaveConfig} disabled={isSubmitting}>
<Button {isSubmitting ? 'Saving...' : 'Save Pipeline'}
variant="ghost" </Button>
size="icon" </DialogFooter>
className="h-6 w-6" </DialogContent>
onClick={() => setEditingStep(null)} </Dialog>
>
<X className="h-4 w-4" />
</Button>
</div>
{/* Template Selector - only show for new steps */} {/* Sub-dialog for adding/editing steps */}
{!editingStep.id && ( <AddEditPipelineStepDialog
<div className="space-y-2"> open={addEditDialogOpen}
<Label>Start from Template</Label> onClose={() => {
<Select setAddEditDialogOpen(false);
onValueChange={(templateId) => { setEditingStep(null);
const template = STEP_TEMPLATES.find((t) => t.id === templateId); }}
if (template) { onSave={handleSaveStep}
setEditingStep((prev) => existingStep={editingStep}
prev defaultOrder={steps.length}
? { />
...prev, </>
name: template.name,
instructions: template.instructions,
colorClass: template.colorClass,
}
: null
);
toast.success(`Loaded "${template.name}" template`);
}
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Choose a template (optional)" />
</SelectTrigger>
<SelectContent>
{STEP_TEMPLATES.map((template) => (
<SelectItem key={template.id} value={template.id}>
<div className="flex items-center gap-2">
<div
className={cn(
'w-2 h-2 rounded-full',
template.colorClass.replace('/20', '')
)}
/>
{template.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Select a pre-built template to populate the form, or create your own from
scratch.
</p>
</div>
)}
<div className="space-y-2">
<Label htmlFor="step-name">Step Name</Label>
<Input
id="step-name"
placeholder="e.g., Code Review, Testing, Documentation"
value={editingStep.name}
onChange={(e) =>
setEditingStep((prev) => (prev ? { ...prev, name: e.target.value } : null))
}
/>
</div>
<div className="space-y-2">
<Label>Color</Label>
<div className="flex flex-wrap gap-2">
{COLOR_OPTIONS.map((color) => (
<button
key={color.value}
type="button"
className={cn(
'w-8 h-8 rounded-full transition-all',
color.preview,
editingStep.colorClass === color.value
? 'ring-2 ring-offset-2 ring-primary'
: 'opacity-60 hover:opacity-100'
)}
onClick={() =>
setEditingStep((prev) =>
prev ? { ...prev, colorClass: color.value } : null
)
}
title={color.label}
/>
))}
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="step-instructions">Agent Instructions</Label>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={handleFileUpload}
>
<Upload className="h-3 w-3 mr-1" />
Load from .md file
</Button>
</div>
<Textarea
id="step-instructions"
placeholder="Instructions for the agent to follow during this pipeline step..."
value={editingStep.instructions}
onChange={(e) =>
setEditingStep((prev) =>
prev ? { ...prev, instructions: e.target.value } : null
)
}
rows={6}
className="font-mono text-sm"
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setEditingStep(null)}>
Cancel
</Button>
<Button onClick={handleSaveStep}>
{editingStep.id ? 'Update Step' : 'Add Step'}
</Button>
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSaveConfig} disabled={isSubmitting}>
{isSubmitting
? 'Saving...'
: editingStep
? 'Save Step & Configuration'
: 'Save Configuration'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
); );
} }

View File

@@ -0,0 +1,94 @@
export const codeReviewTemplate = {
id: 'code-review',
name: 'Code Review',
colorClass: 'bg-blue-500/20',
instructions: `## Code Review & Update
# ⚠️ CRITICAL REQUIREMENT: YOU MUST UPDATE THE CODE ⚠️
**THIS IS NOT OPTIONAL. AFTER REVIEWING, YOU MUST MODIFY THE CODE WITH YOUR FINDINGS.**
This step has TWO mandatory phases:
1. **REVIEW** the code (identify issues)
2. **UPDATE** the code (fix the issues you found)
**You cannot complete this step by only reviewing. You MUST make code changes based on your review findings.**
---
### Phase 1: Review Phase
Perform a thorough code review of the changes made in this feature. Focus on:
#### Code Quality
- **Readability**: Is the code easy to understand? Are variable/function names descriptive?
- **Maintainability**: Will this code be easy to modify in the future?
- **DRY Principle**: Is there any duplicated code that should be abstracted?
- **Single Responsibility**: Do functions and classes have a single, clear purpose?
#### Best Practices
- Follow established patterns and conventions used in the codebase
- Ensure proper error handling is in place
- Check for appropriate logging where needed
- Verify that magic numbers/strings are replaced with named constants
#### Performance
- Identify any potential performance bottlenecks
- Check for unnecessary re-renders (React) or redundant computations
- Ensure efficient data structures are used
#### Testing
- Verify that new code has appropriate test coverage
- Check that edge cases are handled
---
### Phase 2: Update Phase - ⚠️ MANDATORY ACTION REQUIRED ⚠️
**YOU MUST NOW MODIFY THE CODE BASED ON YOUR REVIEW FINDINGS.**
**This is not optional. Every issue you identify must be addressed with code changes.**
#### Action Steps (You MUST complete these):
1. **Fix Issues Immediately**: For every issue you found during review:
- ✅ Refactor code for better readability
- ✅ Extract duplicated code into reusable functions
- ✅ Improve variable/function names for clarity
- ✅ Add missing error handling
- ✅ Replace magic numbers/strings with named constants
- ✅ Optimize performance bottlenecks
- ✅ Fix any code quality issues you identify
- ✅ **MAKE THE ACTUAL CODE CHANGES - DO NOT JUST DOCUMENT THEM**
2. **Apply All Improvements**: Don't just identify problems - fix them in code:
- ✅ Improve code structure and organization
- ✅ Enhance error handling and logging
- ✅ Optimize performance where possible
- ✅ Ensure consistency with codebase patterns
- ✅ Add or improve comments where needed
- ✅ **MODIFY THE FILES DIRECTLY WITH YOUR IMPROVEMENTS**
3. **For Complex Issues**: If you encounter issues that require significant refactoring:
- ✅ Make the improvements you can make safely
- ✅ Document remaining issues with clear explanations
- ✅ Provide specific suggestions for future improvements
- ✅ **STILL MAKE AS MANY CODE CHANGES AS POSSIBLE**
---
### Summary Required
After completing BOTH review AND update phases, provide:
- A summary of issues found during review
- **A detailed list of ALL code changes and improvements made (this proves you updated the code)**
- Any remaining issues that need attention (if applicable)
---
# ⚠️ FINAL REMINDER ⚠️
**Reviewing without updating is INCOMPLETE and UNACCEPTABLE.**
**You MUST modify the code files directly with your improvements.**
**You MUST show evidence of code changes in your summary.**
**This step is only complete when code has been updated.**`,
};

View File

@@ -0,0 +1,77 @@
export const documentationTemplate = {
id: 'documentation',
name: 'Documentation',
colorClass: 'bg-amber-500/20',
instructions: `## Documentation Step
# ⚠️ CRITICAL REQUIREMENT: YOU MUST UPDATE THE CODE WITH DOCUMENTATION ⚠️
**THIS IS NOT OPTIONAL. YOU MUST ADD/UPDATE DOCUMENTATION IN THE CODEBASE.**
This step requires you to:
1. **REVIEW** what needs documentation
2. **UPDATE** the code by adding/updating documentation files and code comments
**You cannot complete this step by only identifying what needs documentation. You MUST add the documentation directly to the codebase.**
---
### Phase 1: Review Phase
Identify what documentation is needed:
- Review new functions, classes, and modules
- Identify new or modified API endpoints
- Check for missing README updates
- Identify changelog entries needed
---
### Phase 2: Update Phase - ⚠️ MANDATORY ACTION REQUIRED ⚠️
**YOU MUST NOW ADD/UPDATE DOCUMENTATION IN THE CODEBASE.**
**This is not optional. You must modify files to add documentation.**
#### Action Steps (You MUST complete these):
1. **Code Documentation** - UPDATE THE CODE FILES:
- ✅ Add/update JSDoc or docstrings for new functions and classes
- ✅ Document complex algorithms or business logic
- ✅ Add inline comments for non-obvious code
- ✅ **MODIFY THE SOURCE FILES DIRECTLY WITH DOCUMENTATION**
2. **API Documentation** - UPDATE API DOCUMENTATION FILES:
- ✅ Document any new or modified API endpoints
- ✅ Include request/response examples
- ✅ Document error responses
- ✅ **UPDATE THE API DOCUMENTATION FILES DIRECTLY**
3. **README Updates** - UPDATE THE README FILE:
- ✅ Update README if new setup steps are required
- ✅ Document any new environment variables
- ✅ Update architecture diagrams if applicable
- ✅ **MODIFY THE README FILE DIRECTLY**
4. **Changelog** - UPDATE THE CHANGELOG FILE:
- ✅ Document notable changes for the changelog
- ✅ Include breaking changes if any
- ✅ **UPDATE THE CHANGELOG FILE DIRECTLY**
---
### Summary Required
After completing BOTH review AND update phases, provide:
- A summary of documentation needs identified
- **A detailed list of ALL documentation files and code comments added/updated (this proves you updated the code)**
- Specific files modified with documentation
---
# ⚠️ FINAL REMINDER ⚠️
**Identifying documentation needs without adding documentation is INCOMPLETE and UNACCEPTABLE.**
**You MUST modify the code files directly to add documentation.**
**You MUST show evidence of documentation changes in your summary.**
**This step is only complete when documentation has been added to the codebase.**`,
};

View File

@@ -0,0 +1,28 @@
import { codeReviewTemplate } from './code-review';
import { securityReviewTemplate } from './security-review';
import { uxReviewTemplate } from './ux-review';
import { testingTemplate } from './testing';
import { documentationTemplate } from './documentation';
import { optimizationTemplate } from './optimization';
export interface PipelineStepTemplate {
id: string;
name: string;
colorClass: string;
instructions: string;
}
export const STEP_TEMPLATES: PipelineStepTemplate[] = [
codeReviewTemplate,
securityReviewTemplate,
uxReviewTemplate,
testingTemplate,
documentationTemplate,
optimizationTemplate,
];
// Helper to get template color class
export const getTemplateColorClass = (templateId: string): string => {
const template = STEP_TEMPLATES.find((t) => t.id === templateId);
return template?.colorClass || 'bg-blue-500/20';
};

View File

@@ -0,0 +1,103 @@
export const optimizationTemplate = {
id: 'optimization',
name: 'Performance',
colorClass: 'bg-cyan-500/20',
instructions: `## Performance Optimization Step
# ⚠️ CRITICAL REQUIREMENT: YOU MUST UPDATE THE CODE WITH OPTIMIZATIONS ⚠️
**THIS IS NOT OPTIONAL. AFTER IDENTIFYING OPTIMIZATION OPPORTUNITIES, YOU MUST UPDATE THE CODE.**
This step has TWO mandatory phases:
1. **REVIEW** the code for performance issues (identify bottlenecks)
2. **UPDATE** the code with optimizations (fix the performance issues)
**You cannot complete this step by only identifying performance issues. You MUST modify the code to optimize it.**
---
### Phase 1: Review Phase
Identify performance bottlenecks and optimization opportunities:
#### Code Performance
- Identify slow algorithms (O(n²) → O(n log n), etc.)
- Find unnecessary computations or redundant operations
- Identify inefficient loops and iterations
- Check for inappropriate data structures
#### Memory Usage
- Check for memory leaks
- Identify memory-intensive operations
- Check for proper cleanup of resources
#### Database/API
- Identify slow database queries (N+1 queries, missing indexes)
- Find opportunities for caching
- Identify API calls that could be batched
#### Frontend (if applicable)
- Identify bundle size issues
- Find render performance problems
- Identify opportunities for lazy loading
- Find expensive computations that need memoization
---
### Phase 2: Update Phase - ⚠️ MANDATORY ACTION REQUIRED ⚠️
**YOU MUST NOW MODIFY THE CODE TO APPLY OPTIMIZATIONS.**
**This is not optional. Every performance issue you identify must be addressed with code changes.**
#### Action Steps (You MUST complete these):
1. **Optimize Code Performance** - UPDATE THE CODE:
- ✅ Optimize slow algorithms (O(n²) → O(n log n), etc.)
- ✅ Remove unnecessary computations or redundant operations
- ✅ Optimize loops and iterations
- ✅ Use appropriate data structures
- ✅ **MODIFY THE SOURCE FILES DIRECTLY WITH OPTIMIZATIONS**
2. **Fix Memory Issues** - UPDATE THE CODE:
- ✅ Fix memory leaks
- ✅ Optimize memory-intensive operations
- ✅ Ensure proper cleanup of resources
- ✅ **MAKE THE ACTUAL CODE CHANGES**
3. **Optimize Database/API** - UPDATE THE CODE:
- ✅ Optimize database queries (add indexes, reduce N+1 queries)
- ✅ Implement caching where appropriate
- ✅ Batch API calls when possible
- ✅ **MODIFY THE DATABASE/API CODE DIRECTLY**
4. **Optimize Frontend** (if applicable) - UPDATE THE CODE:
- ✅ Minimize bundle size
- ✅ Optimize render performance
- ✅ Implement lazy loading where appropriate
- ✅ Use memoization for expensive computations
- ✅ **MODIFY THE FRONTEND CODE DIRECTLY**
5. **Profile and Measure**:
- ✅ Profile the code to verify bottlenecks are fixed
- ✅ Measure improvements achieved
- ✅ **DOCUMENT THE PERFORMANCE IMPROVEMENTS**
---
### Summary Required
After completing BOTH review AND update phases, provide:
- A summary of performance issues identified
- **A detailed list of ALL optimizations applied to the code (this proves you updated the code)**
- Performance improvements achieved (with metrics if possible)
- Any remaining optimization opportunities
---
# ⚠️ FINAL REMINDER ⚠️
**Identifying performance issues without optimizing the code is INCOMPLETE and UNACCEPTABLE.**
**You MUST modify the code files directly with optimizations.**
**You MUST show evidence of optimization changes in your summary.**
**This step is only complete when code has been optimized.**`,
};

View File

@@ -0,0 +1,114 @@
export const securityReviewTemplate = {
id: 'security-review',
name: 'Security Review',
colorClass: 'bg-red-500/20',
instructions: `## Security Review & Update
# ⚠️ CRITICAL REQUIREMENT: YOU MUST UPDATE THE CODE TO FIX SECURITY ISSUES ⚠️
**THIS IS NOT OPTIONAL. AFTER REVIEWING FOR SECURITY ISSUES, YOU MUST FIX THEM IN THE CODE.**
This step has TWO mandatory phases:
1. **REVIEW** the code for security vulnerabilities (identify issues)
2. **UPDATE** the code to fix vulnerabilities (secure the code)
**You cannot complete this step by only identifying security issues. You MUST modify the code to fix them.**
**Security vulnerabilities left unfixed are unacceptable. You must address them with code changes.**
---
### Phase 1: Review Phase
Perform a comprehensive security audit of the changes made in this feature. Check for vulnerabilities in the following areas:
#### Input Validation & Sanitization
- Verify all user inputs are properly validated and sanitized
- Check for SQL injection vulnerabilities
- Check for XSS (Cross-Site Scripting) vulnerabilities
- Ensure proper encoding of output data
#### Authentication & Authorization
- Verify authentication checks are in place where needed
- Ensure authorization logic correctly restricts access
- Check for privilege escalation vulnerabilities
- Verify session management is secure
#### Data Protection
- Ensure sensitive data is not logged or exposed
- Check that secrets/credentials are not hardcoded
- Verify proper encryption is used for sensitive data
- Check for secure transmission of data (HTTPS, etc.)
#### Common Vulnerabilities (OWASP Top 10)
- Injection flaws
- Broken authentication
- Sensitive data exposure
- XML External Entities (XXE)
- Broken access control
- Security misconfiguration
- Cross-Site Scripting (XSS)
- Insecure deserialization
- Using components with known vulnerabilities
- Insufficient logging & monitoring
---
### Phase 2: Update Phase - ⚠️ MANDATORY ACTION REQUIRED ⚠️
**YOU MUST NOW MODIFY THE CODE TO FIX ALL SECURITY VULNERABILITIES.**
**This is not optional. Every security issue you identify must be fixed with code changes.**
**Security vulnerabilities cannot be left unfixed. You must address them immediately.**
#### Action Steps (You MUST complete these):
1. **Fix Vulnerabilities Immediately** - UPDATE THE CODE:
- ✅ Add input validation and sanitization where missing
- ✅ Fix SQL injection vulnerabilities by using parameterized queries
- ✅ Fix XSS vulnerabilities by properly encoding output
- ✅ Add authentication/authorization checks where needed
- ✅ Remove hardcoded secrets and credentials
- ✅ Implement proper encryption for sensitive data
- ✅ Fix broken access control
- ✅ Add security headers and configurations
- ✅ Fix any other security vulnerabilities you find
- ✅ **MODIFY THE SOURCE FILES DIRECTLY TO FIX SECURITY ISSUES**
2. **Apply Security Best Practices** - UPDATE THE CODE:
- ✅ Implement proper input validation on all user inputs
- ✅ Ensure all outputs are properly encoded
- ✅ Add authentication checks to protected routes/endpoints
- ✅ Implement proper authorization logic
- ✅ Remove or secure any exposed sensitive data
- ✅ Add security logging and monitoring
- ✅ Update dependencies with known vulnerabilities
- ✅ **MAKE THE ACTUAL CODE CHANGES - DO NOT JUST DOCUMENT THEM**
3. **For Complex Security Issues** - UPDATE THE CODE:
- ✅ Fix what you can fix safely
- ✅ Document critical security issues with severity levels
- ✅ Provide specific remediation steps for complex issues
- ✅ Add security-related comments explaining protections in place
- ✅ **STILL MAKE AS MANY SECURITY FIXES AS POSSIBLE**
---
### Summary Required
After completing BOTH review AND update phases, provide:
- A security assessment summary of vulnerabilities found
- **A detailed list of ALL security fixes applied to the code (this proves you updated the code)**
- Any remaining security concerns that need attention (if applicable)
- Severity levels for any unfixed issues
---
# ⚠️ FINAL REMINDER ⚠️
**Reviewing security without fixing vulnerabilities is INCOMPLETE, UNACCEPTABLE, and DANGEROUS.**
**You MUST modify the code files directly to fix security issues.**
**You MUST show evidence of security fixes in your summary.**
**This step is only complete when security vulnerabilities have been fixed in the code.**
**Security issues cannot be left as documentation - they must be fixed.**`,
};

View File

@@ -0,0 +1,81 @@
export const testingTemplate = {
id: 'testing',
name: 'Testing',
colorClass: 'bg-green-500/20',
instructions: `## Testing Step
# ⚠️ CRITICAL REQUIREMENT: YOU MUST UPDATE THE CODEBASE WITH TESTS ⚠️
**THIS IS NOT OPTIONAL. YOU MUST WRITE AND ADD TESTS TO THE CODEBASE.**
This step requires you to:
1. **REVIEW** what needs testing
2. **UPDATE** the codebase by writing and adding test files
**You cannot complete this step by only identifying what needs testing. You MUST create test files and write tests.**
---
### Phase 1: Review Phase
Identify what needs test coverage:
- Review new functions, methods, and classes
- Identify new API endpoints
- Check for edge cases that need testing
- Identify integration points that need testing
---
### Phase 2: Update Phase - ⚠️ MANDATORY ACTION REQUIRED ⚠️
**YOU MUST NOW WRITE AND ADD TESTS TO THE CODEBASE.**
**This is not optional. You must create test files and write actual test code.**
#### Action Steps (You MUST complete these):
1. **Write Unit Tests** - CREATE TEST FILES:
- ✅ Write unit tests for all new functions and methods
- ✅ Ensure edge cases are covered
- ✅ Test error handling paths
- ✅ Aim for high code coverage on new code
- ✅ **CREATE TEST FILES AND WRITE THE ACTUAL TEST CODE**
2. **Write Integration Tests** - CREATE TEST FILES:
- ✅ Test interactions between components/modules
- ✅ Verify API endpoints work correctly
- ✅ Test database operations if applicable
- ✅ **CREATE INTEGRATION TEST FILES AND WRITE THE ACTUAL TEST CODE**
3. **Ensure Test Quality** - WRITE QUALITY TESTS:
- ✅ Tests should be readable and well-documented
- ✅ Each test should have a clear purpose
- ✅ Use descriptive test names that explain the scenario
- ✅ Follow the Arrange-Act-Assert pattern
- ✅ **WRITE COMPLETE, FUNCTIONAL TESTS**
4. **Run Tests** - VERIFY TESTS WORK:
- ✅ Run the full test suite and ensure all new tests pass
- ✅ Verify no existing tests are broken
- ✅ Check that test coverage meets project standards
- ✅ **FIX ANY FAILING TESTS**
---
### Summary Required
After completing BOTH review AND update phases, provide:
- A summary of testing needs identified
- **A detailed list of ALL test files created and tests written (this proves you updated the codebase)**
- Test coverage metrics achieved
- Any issues found during testing and how they were resolved
---
# ⚠️ FINAL REMINDER ⚠️
**Identifying what needs testing without writing tests is INCOMPLETE and UNACCEPTABLE.**
**You MUST create test files and write actual test code.**
**You MUST show evidence of test files created in your summary.**
**This step is only complete when tests have been written and added to the codebase.**`,
};

View File

@@ -0,0 +1,116 @@
export const uxReviewTemplate = {
id: 'ux-reviewer',
name: 'User Experience',
colorClass: 'bg-purple-500/20',
instructions: `## User Experience Review & Update
# ⚠️ CRITICAL REQUIREMENT: YOU MUST UPDATE THE CODE TO IMPROVE UX ⚠️
**THIS IS NOT OPTIONAL. AFTER REVIEWING THE USER EXPERIENCE, YOU MUST UPDATE THE CODE.**
This step has TWO mandatory phases:
1. **REVIEW** the user experience (identify UX issues)
2. **UPDATE** the code to improve UX (fix the issues you found)
**You cannot complete this step by only reviewing UX. You MUST modify the code to improve the user experience.**
---
### Phase 1: Review Phase
Review the changes made in this feature from a user experience and design perspective. Focus on creating an exceptional user experience.
#### User-Centered Design
- **User Goals**: Does this feature solve a real user problem?
- **Clarity**: Is the interface clear and easy to understand?
- **Simplicity**: Can the feature be simplified without losing functionality?
- **Consistency**: Does it follow existing design patterns and conventions?
#### Visual Design & Hierarchy
- **Layout**: Is the visual hierarchy clear? Does important information stand out?
- **Spacing**: Is there appropriate whitespace and grouping?
- **Typography**: Is text readable with proper sizing and contrast?
- **Color**: Does color usage support functionality and meet accessibility standards?
#### Accessibility (WCAG 2.1)
- **Keyboard Navigation**: Can all functionality be accessed via keyboard?
- **Screen Readers**: Are ARIA labels and semantic HTML used appropriately?
- **Color Contrast**: Does text meet WCAG AA standards (4.5:1 for body, 3:1 for large)?
- **Focus Indicators**: Are focus states visible and clear?
- **Touch Targets**: Are interactive elements at least 44x44px on mobile?
#### Responsive Design
- **Mobile Experience**: Does it work well on small screens?
- **Touch Targets**: Are buttons and links easy to tap?
- **Content Adaptation**: Does content adapt appropriately to different screen sizes?
- **Navigation**: Is navigation accessible and intuitive on mobile?
#### User Feedback & States
- **Loading States**: Are loading indicators shown for async operations?
- **Error States**: Are error messages clear and actionable?
- **Empty States**: Do empty states guide users on what to do next?
- **Success States**: Are successful actions clearly confirmed?
#### Performance & Perceived Performance
- **Loading Speed**: Does the feature load quickly?
- **Skeleton Screens**: Are skeleton screens used for better perceived performance?
- **Optimistic Updates**: Can optimistic UI updates improve perceived speed?
- **Micro-interactions**: Do animations and transitions enhance the experience?
---
### Phase 2: Update Phase - ⚠️ MANDATORY ACTION REQUIRED ⚠️
**YOU MUST NOW MODIFY THE CODE TO IMPROVE THE USER EXPERIENCE.**
**This is not optional. Every UX issue you identify must be addressed with code changes.**
#### Action Steps (You MUST complete these):
1. **Fix UX Issues Immediately** - UPDATE THE CODE:
- ✅ Improve visual hierarchy and layout
- ✅ Fix spacing and typography issues
- ✅ Add missing ARIA labels and semantic HTML
- ✅ Fix color contrast issues
- ✅ Add or improve focus indicators
- ✅ Ensure touch targets meet size requirements
- ✅ Add missing loading, error, empty, and success states
- ✅ Improve responsive design for mobile
- ✅ Add keyboard navigation support
- ✅ Fix any accessibility issues
- ✅ **MODIFY THE UI COMPONENT FILES DIRECTLY WITH UX IMPROVEMENTS**
2. **Apply UX Improvements** - UPDATE THE CODE:
- ✅ Refactor components for better clarity and simplicity
- ✅ Improve visual design and spacing
- ✅ Enhance accessibility features
- ✅ Add user feedback mechanisms (loading, error, success states)
- ✅ Optimize for mobile and responsive design
- ✅ Improve micro-interactions and animations
- ✅ Ensure consistency with design system
- ✅ **MAKE THE ACTUAL CODE CHANGES - DO NOT JUST DOCUMENT THEM**
3. **For Complex UX Issues** - UPDATE THE CODE:
- ✅ Make the improvements you can make safely
- ✅ Document UX considerations and recommendations
- ✅ Provide specific suggestions for major UX improvements
- ✅ **STILL MAKE AS MANY UX IMPROVEMENTS AS POSSIBLE**
---
### Summary Required
After completing BOTH review AND update phases, provide:
- A summary of UX issues found during review
- **A detailed list of ALL UX improvements made to the code (this proves you updated the code)**
- Any remaining UX considerations that need attention (if applicable)
- Recommendations for future UX enhancements
---
# ⚠️ FINAL REMINDER ⚠️
**Reviewing UX without updating the code is INCOMPLETE and UNACCEPTABLE.**
**You MUST modify the UI component files directly with UX improvements.**
**You MUST show evidence of UX code changes in your summary.**
**This step is only complete when code has been updated to improve the user experience.**`,
};

View File

@@ -30,7 +30,8 @@ interface UseBoardActionsProps {
featureId: string, featureId: string,
updates: Partial<Feature>, updates: Partial<Feature>,
descriptionHistorySource?: 'enhance' | 'edit', descriptionHistorySource?: 'enhance' | 'edit',
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer',
preEnhancementDescription?: string
) => Promise<void>; ) => Promise<void>;
persistFeatureDelete: (featureId: string) => Promise<void>; persistFeatureDelete: (featureId: string) => Promise<void>;
saveCategory: (category: string) => Promise<void>; saveCategory: (category: string) => Promise<void>;
@@ -251,7 +252,8 @@ export function useBoardActions({
workMode?: 'current' | 'auto' | 'custom'; workMode?: 'current' | 'auto' | 'custom';
}, },
descriptionHistorySource?: 'enhance' | 'edit', descriptionHistorySource?: 'enhance' | 'edit',
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer',
preEnhancementDescription?: string
) => { ) => {
const workMode = updates.workMode || 'current'; const workMode = updates.workMode || 'current';
@@ -308,7 +310,13 @@ export function useBoardActions({
}; };
updateFeature(featureId, finalUpdates); updateFeature(featureId, finalUpdates);
persistFeatureUpdate(featureId, finalUpdates, descriptionHistorySource, enhancementMode); persistFeatureUpdate(
featureId,
finalUpdates,
descriptionHistorySource,
enhancementMode,
preEnhancementDescription
);
if (updates.category) { if (updates.category) {
saveCategory(updates.category); saveCategory(updates.category);
} }

View File

@@ -70,9 +70,21 @@ export function useBoardColumnFeatures({
// We're viewing main but branch hasn't been initialized yet // We're viewing main but branch hasn't been initialized yet
// (worktrees disabled or haven't loaded yet). // (worktrees disabled or haven't loaded yet).
// Show features assigned to primary worktree's branch. // Show features assigned to primary worktree's branch.
matchesWorktree = projectPath if (projectPath) {
? useAppStore.getState().isPrimaryWorktreeBranch(projectPath, featureBranch) const worktrees = useAppStore.getState().worktreesByProject[projectPath] ?? [];
: false; if (worktrees.length === 0) {
// Worktrees not loaded yet - fallback to showing features on common default branches
// This prevents features from disappearing during initial load
matchesWorktree =
featureBranch === 'main' || featureBranch === 'master' || featureBranch === 'develop';
} else {
matchesWorktree = useAppStore
.getState()
.isPrimaryWorktreeBranch(projectPath, featureBranch);
}
} else {
matchesWorktree = false;
}
} else { } else {
// Match by branch name // Match by branch name
matchesWorktree = featureBranch === effectiveBranch; matchesWorktree = featureBranch === effectiveBranch;

View File

@@ -75,6 +75,17 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
if (isProjectSwitch) { if (isProjectSwitch) {
setPersistedCategories([]); setPersistedCategories([]);
} }
// Check for interrupted features and resume them
// This handles server restarts where features were in pipeline steps
if (api.autoMode?.resumeInterrupted) {
try {
await api.autoMode.resumeInterrupted(currentProject.path);
logger.info('Checked for interrupted features');
} catch (resumeError) {
logger.warn('Failed to check for interrupted features:', resumeError);
}
}
} else if (!result.success && result.error) { } else if (!result.success && result.error) {
logger.error('API returned error:', result.error); logger.error('API returned error:', result.error);
// If it's a new project or the error indicates no features found, // If it's a new project or the error indicates no features found,

View File

@@ -19,7 +19,8 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
featureId: string, featureId: string,
updates: Partial<Feature>, updates: Partial<Feature>,
descriptionHistorySource?: 'enhance' | 'edit', descriptionHistorySource?: 'enhance' | 'edit',
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer',
preEnhancementDescription?: string
) => { ) => {
if (!currentProject) return; if (!currentProject) return;
@@ -35,7 +36,8 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
featureId, featureId,
updates, updates,
descriptionHistorySource, descriptionHistorySource,
enhancementMode enhancementMode,
preEnhancementDescription
); );
if (result.success && result.feature) { if (result.success && result.feature) {
updateFeature(result.feature.id, result.feature); updateFeature(result.feature.id, result.feature);

View File

@@ -4,13 +4,18 @@ import {
FeatureImagePath as DescriptionImagePath, FeatureImagePath as DescriptionImagePath,
ImagePreviewMap, ImagePreviewMap,
} from '@/components/ui/description-image-dropzone'; } from '@/components/ui/description-image-dropzone';
import type { FollowUpHistoryEntry } from '../dialogs/follow-up-dialog';
/**
* Custom hook for managing follow-up dialog state including prompt history
*/
export function useFollowUpState() { export function useFollowUpState() {
const [showFollowUpDialog, setShowFollowUpDialog] = useState(false); const [showFollowUpDialog, setShowFollowUpDialog] = useState(false);
const [followUpFeature, setFollowUpFeature] = useState<Feature | null>(null); const [followUpFeature, setFollowUpFeature] = useState<Feature | null>(null);
const [followUpPrompt, setFollowUpPrompt] = useState(''); const [followUpPrompt, setFollowUpPrompt] = useState('');
const [followUpImagePaths, setFollowUpImagePaths] = useState<DescriptionImagePath[]>([]); const [followUpImagePaths, setFollowUpImagePaths] = useState<DescriptionImagePath[]>([]);
const [followUpPreviewMap, setFollowUpPreviewMap] = useState<ImagePreviewMap>(() => new Map()); const [followUpPreviewMap, setFollowUpPreviewMap] = useState<ImagePreviewMap>(() => new Map());
const [followUpPromptHistory, setFollowUpPromptHistory] = useState<FollowUpHistoryEntry[]>([]);
const resetFollowUpState = useCallback(() => { const resetFollowUpState = useCallback(() => {
setShowFollowUpDialog(false); setShowFollowUpDialog(false);
@@ -18,6 +23,7 @@ export function useFollowUpState() {
setFollowUpPrompt(''); setFollowUpPrompt('');
setFollowUpImagePaths([]); setFollowUpImagePaths([]);
setFollowUpPreviewMap(new Map()); setFollowUpPreviewMap(new Map());
setFollowUpPromptHistory([]);
}, []); }, []);
const handleFollowUpDialogChange = useCallback( const handleFollowUpDialogChange = useCallback(
@@ -31,6 +37,13 @@ export function useFollowUpState() {
[resetFollowUpState] [resetFollowUpState]
); );
/**
* Adds a new entry to the prompt history
*/
const addToPromptHistory = useCallback((entry: FollowUpHistoryEntry) => {
setFollowUpPromptHistory((prev) => [...prev, entry]);
}, []);
return { return {
// State // State
showFollowUpDialog, showFollowUpDialog,
@@ -38,14 +51,17 @@ export function useFollowUpState() {
followUpPrompt, followUpPrompt,
followUpImagePaths, followUpImagePaths,
followUpPreviewMap, followUpPreviewMap,
followUpPromptHistory,
// Setters // Setters
setShowFollowUpDialog, setShowFollowUpDialog,
setFollowUpFeature, setFollowUpFeature,
setFollowUpPrompt, setFollowUpPrompt,
setFollowUpImagePaths, setFollowUpImagePaths,
setFollowUpPreviewMap, setFollowUpPreviewMap,
setFollowUpPromptHistory,
// Helpers // Helpers
resetFollowUpState, resetFollowUpState,
handleFollowUpDialogChange, handleFollowUpDialogChange,
addToPromptHistory,
}; };
} }

View File

@@ -99,7 +99,7 @@ export function KanbanBoard({
const { columnWidth, containerStyle } = useResponsiveKanban(columns.length); const { columnWidth, containerStyle } = useResponsiveKanban(columns.length);
return ( return (
<div className="flex-1 overflow-x-auto px-5 pb-4 relative" style={backgroundImageStyle}> <div className="flex-1 overflow-x-auto px-5 pt-4 pb-4 relative" style={backgroundImageStyle}>
<DndContext <DndContext
sensors={sensors} sensors={sensors}
collisionDetection={collisionDetectionStrategy} collisionDetection={collisionDetectionStrategy}

View File

@@ -0,0 +1,152 @@
import { useState } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { Button } from '@/components/ui/button';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Sparkles, ChevronDown, ChevronRight } from 'lucide-react';
import { toast } from 'sonner';
import { getElectronAPI } from '@/lib/electron';
import { ModelOverrideTrigger, useModelOverride } from '@/components/shared';
import { EnhancementMode, ENHANCEMENT_MODE_LABELS } from './enhancement-constants';
const logger = createLogger('EnhanceWithAI');
interface EnhanceWithAIProps {
/** Current text value to enhance */
value: string;
/** Callback when text is enhanced */
onChange: (enhancedText: string) => void;
/** Optional callback to track enhancement in history */
onHistoryAdd?: (entry: {
mode: EnhancementMode;
originalText: string;
enhancedText: string;
}) => void;
/** Disable the enhancement feature */
disabled?: boolean;
/** Additional CSS classes */
className?: string;
}
/**
* Reusable "Enhance with AI" component
*
* Provides AI-powered text enhancement with multiple modes:
* - Improve Clarity
* - Add Technical Details
* - Simplify
* - Add Acceptance Criteria
* - User Experience
*
* Used in Add Feature, Edit Feature, and Follow-Up dialogs.
*/
export function EnhanceWithAI({
value,
onChange,
onHistoryAdd,
disabled = false,
className,
}: EnhanceWithAIProps) {
const [isEnhancing, setIsEnhancing] = useState(false);
const [enhancementMode, setEnhancementMode] = useState<EnhancementMode>('improve');
const [enhanceOpen, setEnhanceOpen] = useState(false);
// Enhancement model override
const enhancementOverride = useModelOverride({ phase: 'enhancementModel' });
const handleEnhance = async () => {
if (!value.trim() || isEnhancing || disabled) return;
setIsEnhancing(true);
try {
const api = getElectronAPI();
const result = await api.enhancePrompt?.enhance(
value,
enhancementMode,
enhancementOverride.effectiveModel,
enhancementOverride.effectiveModelEntry.thinkingLevel
);
if (result?.success && result.enhancedText) {
const originalText = value;
const enhancedText = result.enhancedText;
onChange(enhancedText);
// Track in history if callback provided (includes original for restoration)
onHistoryAdd?.({ mode: enhancementMode, originalText, enhancedText });
toast.success('Enhanced successfully!');
} else {
toast.error(result?.error || 'Failed to enhance');
}
} catch (error) {
logger.error('Enhancement failed:', error);
toast.error('Failed to enhance');
} finally {
setIsEnhancing(false);
}
};
return (
<Collapsible open={enhanceOpen} onOpenChange={setEnhanceOpen} className={className}>
<CollapsibleTrigger asChild>
<button
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors w-full py-1"
disabled={disabled}
>
{enhanceOpen ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
<Sparkles className="w-4 h-4" />
<span>Enhance with AI</span>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="pt-3">
<div className="flex flex-wrap items-center gap-2 pl-6">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-8 text-xs" disabled={disabled}>
{ENHANCEMENT_MODE_LABELS[enhancementMode]}
<ChevronDown className="w-3 h-3 ml-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{(Object.entries(ENHANCEMENT_MODE_LABELS) as [EnhancementMode, string][]).map(
([mode, label]) => (
<DropdownMenuItem key={mode} onClick={() => setEnhancementMode(mode)}>
{label}
</DropdownMenuItem>
)
)}
</DropdownMenuContent>
</DropdownMenu>
<Button
type="button"
variant="default"
size="sm"
className="h-8 text-xs"
onClick={handleEnhance}
disabled={!value.trim() || isEnhancing || disabled}
loading={isEnhancing}
>
<Sparkles className="w-3 h-3 mr-1" />
Enhance
</Button>
<ModelOverrideTrigger
currentModelEntry={enhancementOverride.effectiveModelEntry}
onModelChange={enhancementOverride.setOverride}
phase="enhancementModel"
isOverridden={enhancementOverride.isOverridden}
size="sm"
variant="icon"
/>
</div>
</CollapsibleContent>
</Collapsible>
);
}

View File

@@ -0,0 +1,20 @@
/** Enhancement mode options for AI-powered prompt improvement */
export type EnhancementMode = 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer';
/** Labels for enhancement modes displayed in the UI */
export const ENHANCEMENT_MODE_LABELS: Record<EnhancementMode, string> = {
improve: 'Improve Clarity',
technical: 'Add Technical Details',
simplify: 'Simplify',
acceptance: 'Add Acceptance Criteria',
'ux-reviewer': 'User Experience',
};
/** Descriptions for enhancement modes (for tooltips/accessibility) */
export const ENHANCEMENT_MODE_DESCRIPTIONS: Record<EnhancementMode, string> = {
improve: 'Make the prompt clearer and more concise',
technical: 'Add implementation details and specifications',
simplify: 'Reduce complexity while keeping the core intent',
acceptance: 'Add specific acceptance criteria and test cases',
'ux-reviewer': 'Add user experience considerations and flows',
};

View File

@@ -0,0 +1,136 @@
import { useState, useMemo } from 'react';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { History } from 'lucide-react';
import { toast } from 'sonner';
import { EnhancementMode, ENHANCEMENT_MODE_LABELS } from './enhancement-constants';
/**
* Base interface for history entries
*/
export interface BaseHistoryEntry {
timestamp: string;
source: 'initial' | 'enhance' | 'edit';
enhancementMode?: EnhancementMode;
}
interface EnhancementHistoryButtonProps<T extends BaseHistoryEntry> {
/** Array of history entries */
history: T[];
/** Current value to compare against for highlighting */
currentValue: string;
/** Callback when a history entry is restored */
onRestore: (value: string) => void;
/** Function to extract the text value from a history entry */
valueAccessor: (entry: T) => string;
/** Title for the history popover (e.g., "Version History", "Prompt History") */
title?: string;
/** Message shown when restoring an entry */
restoreMessage?: string;
}
/**
* Reusable history button component for enhancement-related history
*
* Displays a popover with a list of historical versions that can be restored.
* Used in edit-feature-dialog and follow-up-dialog for description/prompt history.
*/
export function EnhancementHistoryButton<T extends BaseHistoryEntry>({
history,
currentValue,
onRestore,
valueAccessor,
title = 'Version History',
restoreMessage = 'Restored from history',
}: EnhancementHistoryButtonProps<T>) {
const [showHistory, setShowHistory] = useState(false);
// Memoize reversed history to avoid creating new array on every render
// NOTE: This hook MUST be called before any early returns to follow Rules of Hooks
const reversedHistory = useMemo(() => [...history].reverse(), [history]);
// Early return AFTER all hooks are called
if (history.length === 0) {
return null;
}
const getSourceLabel = (entry: T): string => {
if (entry.source === 'initial') {
return 'Original';
}
if (entry.source === 'enhance') {
const mode = entry.enhancementMode ?? 'improve';
const label = ENHANCEMENT_MODE_LABELS[mode as EnhancementMode] ?? mode;
return `Enhanced (${label})`;
}
return 'Edited';
};
const formatDate = (timestamp: string): string => {
const date = new Date(timestamp);
return date.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
return (
<Popover open={showHistory} onOpenChange={setShowHistory}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 gap-1.5 text-xs text-muted-foreground"
>
<History className="w-3.5 h-3.5" />
History ({history.length})
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="end">
<div className="p-3 border-b">
<h4 className="font-medium text-sm">{title}</h4>
<p className="text-xs text-muted-foreground mt-1">Click a version to restore it</p>
</div>
<div className="max-h-64 overflow-y-auto p-2 space-y-1">
{reversedHistory.map((entry, index) => {
const value = valueAccessor(entry);
const isCurrentVersion = value === currentValue;
const sourceLabel = getSourceLabel(entry);
const formattedDate = formatDate(entry.timestamp);
return (
<button
key={`${entry.timestamp}-${index}`}
onClick={() => {
onRestore(value);
setShowHistory(false);
toast.success(restoreMessage);
}}
className={`w-full text-left p-2 rounded-md hover:bg-muted transition-colors ${
isCurrentVersion ? 'bg-muted/50 border border-primary/20' : ''
}`}
>
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium">{sourceLabel}</span>
<span className="text-xs text-muted-foreground">{formattedDate}</span>
</div>
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
{value.slice(0, 100)}
{value.length > 100 ? '...' : ''}
</p>
{isCurrentVersion && (
<span className="text-xs text-primary font-medium mt-1 block">
Current version
</span>
)}
</button>
);
})}
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,3 @@
export * from './enhancement-constants';
export * from './enhance-with-ai';
export * from './enhancement-history-button';

View File

@@ -10,3 +10,4 @@ export * from './planning-mode-selector';
export * from './planning-mode-select'; export * from './planning-mode-select';
export * from './ancestor-context-section'; export * from './ancestor-context-section';
export * from './work-mode-selector'; export * from './work-mode-selector';
export * from './enhancement';

View File

@@ -6,13 +6,15 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { import {
Trash2, Trash2,
MoreHorizontal, MoreHorizontal,
GitCommit, GitCommit,
GitPullRequest, GitPullRequest,
ExternalLink,
Download, Download,
Upload, Upload,
Play, Play,
@@ -22,15 +24,18 @@ import {
GitMerge, GitMerge,
AlertCircle, AlertCircle,
RefreshCw, RefreshCw,
Copy,
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { WorktreeInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types'; import type { WorktreeInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types';
import { TooltipWrapper } from './tooltip-wrapper'; import { TooltipWrapper } from './tooltip-wrapper';
import { useAvailableEditors, useEffectiveDefaultEditor } from '../hooks/use-available-editors';
import { getEditorIcon } from '@/components/icons/editor-icons';
interface WorktreeActionsDropdownProps { interface WorktreeActionsDropdownProps {
worktree: WorktreeInfo; worktree: WorktreeInfo;
isSelected: boolean; isSelected: boolean;
defaultEditorName: string;
aheadCount: number; aheadCount: number;
behindCount: number; behindCount: number;
isPulling: boolean; isPulling: boolean;
@@ -42,7 +47,7 @@ interface WorktreeActionsDropdownProps {
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
onPull: (worktree: WorktreeInfo) => void; onPull: (worktree: WorktreeInfo) => void;
onPush: (worktree: WorktreeInfo) => void; onPush: (worktree: WorktreeInfo) => void;
onOpenInEditor: (worktree: WorktreeInfo) => void; onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
onCommit: (worktree: WorktreeInfo) => void; onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void; onCreatePR: (worktree: WorktreeInfo) => void;
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void; onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
@@ -58,7 +63,6 @@ interface WorktreeActionsDropdownProps {
export function WorktreeActionsDropdown({ export function WorktreeActionsDropdown({
worktree, worktree,
isSelected, isSelected,
defaultEditorName,
aheadCount, aheadCount,
behindCount, behindCount,
isPulling, isPulling,
@@ -82,6 +86,20 @@ export function WorktreeActionsDropdown({
onRunInitScript, onRunInitScript,
hasInitScript, hasInitScript,
}: WorktreeActionsDropdownProps) { }: WorktreeActionsDropdownProps) {
// Get available editors for the "Open In" submenu
const { editors } = useAvailableEditors();
// Use shared hook for effective default editor
const effectiveDefaultEditor = useEffectiveDefaultEditor(editors);
// Get other editors (excluding the default) for the submenu
const otherEditors = editors.filter((e) => e.command !== effectiveDefaultEditor?.command);
// Get icon component for the effective editor (avoid IIFE in JSX)
const DefaultEditorIcon = effectiveDefaultEditor
? getEditorIcon(effectiveDefaultEditor.command)
: null;
// Check if there's a PR associated with this worktree from stored metadata // Check if there's a PR associated with this worktree from stored metadata
const hasPR = !!worktree.pr; const hasPR = !!worktree.pr;
@@ -205,10 +223,54 @@ export function WorktreeActionsDropdown({
</TooltipWrapper> </TooltipWrapper>
)} )}
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onOpenInEditor(worktree)} className="text-xs"> {/* Open in editor - split button: click main area for default, chevron for other options */}
<ExternalLink className="w-3.5 h-3.5 mr-2" /> {effectiveDefaultEditor && (
Open in {defaultEditorName} <DropdownMenuSub>
</DropdownMenuItem> <div className="flex items-center">
{/* Main clickable area - opens in default editor */}
<DropdownMenuItem
onClick={() => onOpenInEditor(worktree, effectiveDefaultEditor.command)}
className="text-xs flex-1 pr-0 rounded-r-none"
>
{DefaultEditorIcon && <DefaultEditorIcon className="w-3.5 h-3.5 mr-2" />}
Open in {effectiveDefaultEditor.name}
</DropdownMenuItem>
{/* Chevron trigger for submenu with other editors and Copy Path */}
<DropdownMenuSubTrigger className="text-xs px-1 rounded-l-none border-l border-border/30 h-8" />
</div>
<DropdownMenuSubContent>
{/* Other editors */}
{otherEditors.map((editor) => {
const EditorIcon = getEditorIcon(editor.command);
return (
<DropdownMenuItem
key={editor.command}
onClick={() => onOpenInEditor(worktree, editor.command)}
className="text-xs"
>
<EditorIcon className="w-3.5 h-3.5 mr-2" />
{editor.name}
</DropdownMenuItem>
);
})}
{otherEditors.length > 0 && <DropdownMenuSeparator />}
<DropdownMenuItem
onClick={async () => {
try {
await navigator.clipboard.writeText(worktree.path);
toast.success('Path copied to clipboard');
} catch {
toast.error('Failed to copy path to clipboard');
}
}}
className="text-xs"
>
<Copy className="w-3.5 h-3.5 mr-2" />
Copy Path
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{!worktree.isMain && hasInitScript && ( {!worktree.isMain && hasInitScript && (
<DropdownMenuItem onClick={() => onRunInitScript(worktree)} className="text-xs"> <DropdownMenuItem onClick={() => onRunInitScript(worktree)} className="text-xs">
<RefreshCw className="w-3.5 h-3.5 mr-2" /> <RefreshCw className="w-3.5 h-3.5 mr-2" />

View File

@@ -17,7 +17,6 @@ interface WorktreeTabProps {
isActivating: boolean; isActivating: boolean;
isDevServerRunning: boolean; isDevServerRunning: boolean;
devServerInfo?: DevServerInfo; devServerInfo?: DevServerInfo;
defaultEditorName: string;
branches: BranchInfo[]; branches: BranchInfo[];
filteredBranches: BranchInfo[]; filteredBranches: BranchInfo[];
branchFilter: string; branchFilter: string;
@@ -37,7 +36,7 @@ interface WorktreeTabProps {
onCreateBranch: (worktree: WorktreeInfo) => void; onCreateBranch: (worktree: WorktreeInfo) => void;
onPull: (worktree: WorktreeInfo) => void; onPull: (worktree: WorktreeInfo) => void;
onPush: (worktree: WorktreeInfo) => void; onPush: (worktree: WorktreeInfo) => void;
onOpenInEditor: (worktree: WorktreeInfo) => void; onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
onCommit: (worktree: WorktreeInfo) => void; onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void; onCreatePR: (worktree: WorktreeInfo) => void;
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void; onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
@@ -60,7 +59,6 @@ export function WorktreeTab({
isActivating, isActivating,
isDevServerRunning, isDevServerRunning,
devServerInfo, devServerInfo,
defaultEditorName,
branches, branches,
filteredBranches, filteredBranches,
branchFilter, branchFilter,
@@ -319,7 +317,6 @@ export function WorktreeTab({
<WorktreeActionsDropdown <WorktreeActionsDropdown
worktree={worktree} worktree={worktree}
isSelected={isSelected} isSelected={isSelected}
defaultEditorName={defaultEditorName}
aheadCount={aheadCount} aheadCount={aheadCount}
behindCount={behindCount} behindCount={behindCount}
isPulling={isPulling} isPulling={isPulling}

View File

@@ -2,5 +2,5 @@ export { useWorktrees } from './use-worktrees';
export { useDevServers } from './use-dev-servers'; export { useDevServers } from './use-dev-servers';
export { useBranches } from './use-branches'; export { useBranches } from './use-branches';
export { useWorktreeActions } from './use-worktree-actions'; export { useWorktreeActions } from './use-worktree-actions';
export { useDefaultEditor } from './use-default-editor';
export { useRunningFeatures } from './use-running-features'; export { useRunningFeatures } from './use-running-features';
export { useAvailableEditors, useEffectiveDefaultEditor } from './use-available-editors';

View File

@@ -0,0 +1,101 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import type { EditorInfo } from '@automaker/types';
const logger = createLogger('AvailableEditors');
// Re-export EditorInfo for convenience
export type { EditorInfo };
export function useAvailableEditors() {
const [editors, setEditors] = useState<EditorInfo[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false);
const fetchAvailableEditors = useCallback(async () => {
try {
const api = getElectronAPI();
if (!api?.worktree?.getAvailableEditors) {
setIsLoading(false);
return;
}
const result = await api.worktree.getAvailableEditors();
if (result.success && result.result?.editors) {
setEditors(result.result.editors);
}
} catch (error) {
logger.error('Failed to fetch available editors:', error);
} finally {
setIsLoading(false);
}
}, []);
/**
* Refresh editors by clearing the server cache and re-detecting
* Use this when the user has installed/uninstalled editors
*/
const refresh = useCallback(async () => {
setIsRefreshing(true);
try {
const api = getElectronAPI();
if (!api?.worktree?.refreshEditors) {
// Fallback to regular fetch if refresh not available
await fetchAvailableEditors();
return;
}
const result = await api.worktree.refreshEditors();
if (result.success && result.result?.editors) {
setEditors(result.result.editors);
logger.info(`Editor cache refreshed, found ${result.result.editors.length} editors`);
}
} catch (error) {
logger.error('Failed to refresh editors:', error);
} finally {
setIsRefreshing(false);
}
}, [fetchAvailableEditors]);
useEffect(() => {
fetchAvailableEditors();
}, [fetchAvailableEditors]);
return {
editors,
isLoading,
isRefreshing,
refresh,
// Convenience property: has multiple editors (for deciding whether to show submenu)
hasMultipleEditors: editors.length > 1,
// The first editor is the "default" one
defaultEditor: editors[0] ?? null,
};
}
/**
* Hook to get the effective default editor based on user settings
* Falls back to: Cursor > VS Code > first available editor
*/
export function useEffectiveDefaultEditor(editors: EditorInfo[]): EditorInfo | null {
const defaultEditorCommand = useAppStore((s) => s.defaultEditorCommand);
return useMemo(() => {
if (editors.length === 0) return null;
// If user has a saved preference and it exists in available editors, use it
if (defaultEditorCommand) {
const found = editors.find((e) => e.command === defaultEditorCommand);
if (found) return found;
}
// Auto-detect: prefer Cursor, then VS Code, then first available
const cursor = editors.find((e) => e.command === 'cursor');
if (cursor) return cursor;
const vscode = editors.find((e) => e.command === 'code');
if (vscode) return vscode;
return editors[0];
}, [editors, defaultEditorCommand]);
}

View File

@@ -125,14 +125,14 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre
[isPushing, fetchBranches, fetchWorktrees] [isPushing, fetchBranches, fetchWorktrees]
); );
const handleOpenInEditor = useCallback(async (worktree: WorktreeInfo) => { const handleOpenInEditor = useCallback(async (worktree: WorktreeInfo, editorCommand?: string) => {
try { try {
const api = getElectronAPI(); const api = getElectronAPI();
if (!api?.worktree?.openInEditor) { if (!api?.worktree?.openInEditor) {
logger.warn('Open in editor API not available'); logger.warn('Open in editor API not available');
return; return;
} }
const result = await api.worktree.openInEditor(worktree.path); const result = await api.worktree.openInEditor(worktree.path, editorCommand);
if (result.success && result.result) { if (result.success && result.result) {
toast.success(result.result.message); toast.success(result.result.message);
} else if (result.error) { } else if (result.error) {

View File

@@ -10,7 +10,6 @@ import {
useDevServers, useDevServers,
useBranches, useBranches,
useWorktreeActions, useWorktreeActions,
useDefaultEditor,
useRunningFeatures, useRunningFeatures,
} from './hooks'; } from './hooks';
import { WorktreeTab } from './components'; import { WorktreeTab } from './components';
@@ -77,8 +76,6 @@ export function WorktreePanel({
fetchBranches, fetchBranches,
}); });
const { defaultEditorName } = useDefaultEditor();
const { hasRunningFeatures } = useRunningFeatures({ const { hasRunningFeatures } = useRunningFeatures({
runningFeatureIds, runningFeatureIds,
features, features,
@@ -188,7 +185,6 @@ export function WorktreePanel({
isActivating={isActivating} isActivating={isActivating}
isDevServerRunning={isDevServerRunning(mainWorktree)} isDevServerRunning={isDevServerRunning(mainWorktree)}
devServerInfo={getDevServerInfo(mainWorktree)} devServerInfo={getDevServerInfo(mainWorktree)}
defaultEditorName={defaultEditorName}
branches={branches} branches={branches}
filteredBranches={filteredBranches} filteredBranches={filteredBranches}
branchFilter={branchFilter} branchFilter={branchFilter}
@@ -245,7 +241,6 @@ export function WorktreePanel({
isActivating={isActivating} isActivating={isActivating}
isDevServerRunning={isDevServerRunning(worktree)} isDevServerRunning={isDevServerRunning(worktree)}
devServerInfo={getDevServerInfo(worktree)} devServerInfo={getDevServerInfo(worktree)}
defaultEditorName={defaultEditorName}
branches={branches} branches={branches}
filteredBranches={filteredBranches} filteredBranches={filteredBranches}
branchFilter={branchFilter} branchFilter={branchFilter}

View File

@@ -0,0 +1,318 @@
// @ts-nocheck
import { useState, useCallback, useMemo, useEffect } from 'react';
import { useAppStore, Feature } from '@/store/app-store';
import { GraphView } from './graph-view';
import { EditFeatureDialog, AddFeatureDialog, AgentOutputModal } from './board-view/dialogs';
import {
useBoardFeatures,
useBoardActions,
useBoardBackground,
useBoardPersistence,
} from './board-view/hooks';
import { useAutoMode } from '@/hooks/use-auto-mode';
import { pathsEqual } from '@/lib/utils';
import { RefreshCw } from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
import { createLogger } from '@automaker/utils/logger';
const logger = createLogger('GraphViewPage');
// Stable empty array to avoid infinite loop in selector
const EMPTY_WORKTREES: ReturnType<ReturnType<typeof useAppStore.getState>['getWorktrees']> = [];
export function GraphViewPage() {
const {
currentProject,
updateFeature,
getCurrentWorktree,
getWorktrees,
setWorktrees,
setCurrentWorktree,
defaultSkipTests,
} = useAppStore();
const worktreesByProject = useAppStore((s) => s.worktreesByProject);
const worktrees = useMemo(
() =>
currentProject
? (worktreesByProject[currentProject.path] ?? EMPTY_WORKTREES)
: EMPTY_WORKTREES,
[currentProject, worktreesByProject]
);
// Load features
const {
features: hookFeatures,
isLoading,
persistedCategories,
loadFeatures,
saveCategory,
} = useBoardFeatures({ currentProject });
// Auto mode hook
const autoMode = useAutoMode();
const runningAutoTasks = autoMode.runningTasks;
// Search state
const [searchQuery, setSearchQuery] = useState('');
// Dialog states
const [editingFeature, setEditingFeature] = useState<Feature | null>(null);
const [showAddDialog, setShowAddDialog] = useState(false);
const [spawnParentFeature, setSpawnParentFeature] = useState<Feature | null>(null);
const [showOutputModal, setShowOutputModal] = useState(false);
const [outputFeature, setOutputFeature] = useState<Feature | null>(null);
// Worktree refresh key
const [worktreeRefreshKey, setWorktreeRefreshKey] = useState(0);
// Get current worktree info
const currentWorktreeInfo = currentProject ? getCurrentWorktree(currentProject.path) : null;
const currentWorktreePath = currentWorktreeInfo?.path ?? null;
// Get the branch for the currently selected worktree
const selectedWorktree = useMemo(() => {
if (currentWorktreePath === null) {
return worktrees.find((w) => w.isMain);
} else {
return worktrees.find((w) => !w.isMain && pathsEqual(w.path, currentWorktreePath));
}
}, [worktrees, currentWorktreePath]);
const currentWorktreeBranch = selectedWorktree?.branch ?? null;
const selectedWorktreeBranch =
currentWorktreeBranch || worktrees.find((w) => w.isMain)?.branch || 'main';
// Branch suggestions
const [branchSuggestions, setBranchSuggestions] = useState<string[]>([]);
useEffect(() => {
const fetchBranches = async () => {
if (!currentProject) {
setBranchSuggestions([]);
return;
}
try {
const api = getElectronAPI();
if (!api?.worktree?.listBranches) {
setBranchSuggestions([]);
return;
}
const result = await api.worktree.listBranches(currentProject.path);
if (result.success && result.result?.branches) {
const localBranches = result.result.branches
.filter((b) => !b.isRemote)
.map((b) => b.name);
setBranchSuggestions(localBranches);
}
} catch (error) {
logger.error('Error fetching branches:', error);
setBranchSuggestions([]);
}
};
fetchBranches();
}, [currentProject, worktreeRefreshKey]);
// Branch card counts
const branchCardCounts = useMemo(() => {
return hookFeatures.reduce(
(counts, feature) => {
if (feature.status !== 'completed') {
const branch = feature.branchName ?? 'main';
counts[branch] = (counts[branch] || 0) + 1;
}
return counts;
},
{} as Record<string, number>
);
}, [hookFeatures]);
// Category suggestions
const categorySuggestions = useMemo(() => {
const featureCategories = hookFeatures.map((f) => f.category).filter(Boolean);
const allCategories = [...featureCategories, ...persistedCategories];
return [...new Set(allCategories)].sort();
}, [hookFeatures, persistedCategories]);
// Use persistence hook
const { persistFeatureCreate, persistFeatureUpdate, persistFeatureDelete } = useBoardPersistence({
currentProject,
});
// Follow-up state (simplified for graph view)
const [followUpFeature, setFollowUpFeature] = useState<Feature | null>(null);
const [followUpPrompt, setFollowUpPrompt] = useState('');
const [followUpImagePaths, setFollowUpImagePaths] = useState<any[]>([]);
const [followUpPreviewMap, setFollowUpPreviewMap] = useState<Map<string, string>>(new Map());
// In-progress features for shortcuts
const inProgressFeaturesForShortcuts = useMemo(() => {
return hookFeatures.filter((f) => {
const isRunning = runningAutoTasks.includes(f.id);
return isRunning || f.status === 'in_progress';
});
}, [hookFeatures, runningAutoTasks]);
// Board actions hook
const {
handleAddFeature,
handleUpdateFeature,
handleDeleteFeature,
handleStartImplementation,
handleResumeFeature,
handleViewOutput,
handleForceStopFeature,
handleOutputModalNumberKeyPress,
} = useBoardActions({
currentProject,
features: hookFeatures,
runningAutoTasks,
loadFeatures,
persistFeatureCreate,
persistFeatureUpdate,
persistFeatureDelete,
saveCategory,
setEditingFeature,
setShowOutputModal,
setOutputFeature,
followUpFeature,
followUpPrompt,
followUpImagePaths,
setFollowUpFeature,
setFollowUpPrompt,
setFollowUpImagePaths,
setFollowUpPreviewMap,
setShowFollowUpDialog: () => {},
inProgressFeaturesForShortcuts,
outputFeature,
projectPath: currentProject?.path || null,
onWorktreeCreated: () => setWorktreeRefreshKey((k) => k + 1),
onWorktreeAutoSelect: (newWorktree) => {
if (!currentProject) return;
const currentWorktrees = getWorktrees(currentProject.path);
const existingWorktree = currentWorktrees.find((w) => w.branch === newWorktree.branch);
if (!existingWorktree) {
const newWorktreeInfo = {
path: newWorktree.path,
branch: newWorktree.branch,
isMain: false,
isCurrent: false,
hasWorktree: true,
};
setWorktrees(currentProject.path, [...currentWorktrees, newWorktreeInfo]);
}
setCurrentWorktree(currentProject.path, newWorktree.path, newWorktree.branch);
},
currentWorktreeBranch,
});
// Handle add and start feature
const handleAddAndStartFeature = useCallback(
async (featureData: Parameters<typeof handleAddFeature>[0]) => {
const featuresBeforeIds = new Set(useAppStore.getState().features.map((f) => f.id));
await handleAddFeature(featureData);
const latestFeatures = useAppStore.getState().features;
const newFeature = latestFeatures.find((f) => !featuresBeforeIds.has(f.id));
if (newFeature) {
await handleStartImplementation(newFeature);
}
},
[handleAddFeature, handleStartImplementation]
);
if (!currentProject) {
return (
<div className="flex-1 flex items-center justify-center" data-testid="graph-view-no-project">
<p className="text-muted-foreground">No project selected</p>
</div>
);
}
if (isLoading) {
return (
<div className="flex-1 flex items-center justify-center" data-testid="graph-view-loading">
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div
className="flex-1 flex flex-col overflow-hidden content-bg relative"
data-testid="graph-view-page"
>
{/* Graph View Content */}
<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}
onUpdateFeature={updateFeature}
onSpawnTask={(feature) => {
setSpawnParentFeature(feature);
setShowAddDialog(true);
}}
onDeleteTask={(feature) => handleDeleteFeature(feature.id)}
/>
{/* Edit Feature Dialog */}
<EditFeatureDialog
feature={editingFeature}
onClose={() => setEditingFeature(null)}
onUpdate={handleUpdateFeature}
categorySuggestions={categorySuggestions}
branchSuggestions={branchSuggestions}
branchCardCounts={branchCardCounts}
currentBranch={currentWorktreeBranch || undefined}
isMaximized={false}
allFeatures={hookFeatures}
/>
{/* Add Feature Dialog (for spawning) */}
<AddFeatureDialog
open={showAddDialog}
onOpenChange={(open) => {
setShowAddDialog(open);
if (!open) {
setSpawnParentFeature(null);
}
}}
onAdd={handleAddFeature}
onAddAndStart={handleAddAndStartFeature}
categorySuggestions={categorySuggestions}
branchSuggestions={branchSuggestions}
branchCardCounts={branchCardCounts}
defaultSkipTests={defaultSkipTests}
defaultBranch={selectedWorktreeBranch}
currentBranch={currentWorktreeBranch || undefined}
isMaximized={false}
parentFeature={spawnParentFeature}
allFeatures={hookFeatures}
/>
{/* Agent Output Modal */}
<AgentOutputModal
open={showOutputModal}
onClose={() => setShowOutputModal(false)}
featureDescription={outputFeature?.description || ''}
featureId={outputFeature?.id || ''}
featureStatus={outputFeature?.status}
onNumberKeyPress={handleOutputModalNumberKeyPress}
/>
</div>
);
}

View File

@@ -31,7 +31,10 @@ export function GraphControls({
return ( return (
<Panel position="bottom-left" className="flex flex-col gap-2"> <Panel position="bottom-left" className="flex flex-col gap-2">
<TooltipProvider delayDuration={200}> <TooltipProvider delayDuration={200}>
<div className="flex flex-col gap-1 p-1.5 rounded-lg bg-popover/90 backdrop-blur-sm border border-border shadow-lg text-popover-foreground"> <div
className="flex flex-col gap-1 p-1.5 rounded-lg backdrop-blur-sm border border-border shadow-lg text-popover-foreground"
style={{ backgroundColor: 'color-mix(in oklch, var(--popover) 90%, transparent)' }}
>
{/* Zoom controls */} {/* Zoom controls */}
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>

View File

@@ -110,7 +110,10 @@ export function GraphFilterControls({
return ( return (
<Panel position="top-left" className="flex items-center gap-2"> <Panel position="top-left" className="flex items-center gap-2">
<TooltipProvider delayDuration={200}> <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"> <div
className="flex items-center gap-2 p-2 rounded-lg backdrop-blur-sm border border-border shadow-lg text-popover-foreground"
style={{ backgroundColor: 'color-mix(in oklch, var(--popover) 90%, transparent)' }}
>
{/* Category Filter Dropdown */} {/* Category Filter Dropdown */}
<Popover> <Popover>
<Tooltip> <Tooltip>

View File

@@ -44,7 +44,10 @@ const legendItems = [
export function GraphLegend() { export function GraphLegend() {
return ( return (
<Panel position="bottom-right" className="pointer-events-none"> <Panel position="bottom-right" className="pointer-events-none">
<div className="flex flex-wrap gap-3 p-2 rounded-lg bg-popover/90 backdrop-blur-sm border border-border shadow-lg pointer-events-auto text-popover-foreground"> <div
className="flex flex-wrap gap-3 p-2 rounded-lg backdrop-blur-sm border border-border shadow-lg pointer-events-auto text-popover-foreground"
style={{ backgroundColor: 'color-mix(in oklch, var(--popover) 90%, transparent)' }}
>
{legendItems.map((item) => { {legendItems.map((item) => {
const Icon = item.icon; const Icon = item.icon;
return ( return (

View File

@@ -75,6 +75,24 @@ const priorityConfig = {
3: { label: 'Low', colorClass: 'bg-[var(--status-info)] text-white' }, 3: { label: 'Low', colorClass: 'bg-[var(--status-info)] text-white' },
}; };
// Helper function to get border style with opacity (like KanbanCard does)
function getCardBorderStyle(
enabled: boolean,
opacity: number,
borderColor: string
): React.CSSProperties {
if (!enabled) {
return { borderWidth: '0px', borderColor: 'transparent' };
}
if (opacity !== 100) {
return {
borderWidth: '2px',
borderColor: `color-mix(in oklch, ${borderColor} ${opacity}%, transparent)`,
};
}
return { borderWidth: '2px' };
}
export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps) { export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps) {
// Handle pipeline statuses by treating them like in_progress // Handle pipeline statuses by treating them like in_progress
const status = data.status || 'backlog'; const status = data.status || 'backlog';
@@ -91,6 +109,28 @@ export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps
// Task is stopped if it's in_progress but not actively running // Task is stopped if it's in_progress but not actively running
const isStopped = data.status === 'in_progress' && !data.isRunning; const isStopped = data.status === 'in_progress' && !data.isRunning;
// Background/theme settings with defaults
const cardOpacity = data.cardOpacity ?? 100;
const glassmorphism = data.cardGlassmorphism ?? true;
const cardBorderEnabled = data.cardBorderEnabled ?? true;
const cardBorderOpacity = data.cardBorderOpacity ?? 100;
// Get the border color based on status and error state
const borderColor = data.error
? 'var(--status-error)'
: config.borderClass.includes('border-border')
? 'var(--border)'
: config.borderClass.includes('status-in-progress')
? 'var(--status-in-progress)'
: config.borderClass.includes('status-waiting')
? 'var(--status-waiting)'
: config.borderClass.includes('status-success')
? 'var(--status-success)'
: 'var(--border)';
// Get computed border style
const borderStyle = getCardBorderStyle(cardBorderEnabled, cardBorderOpacity, borderColor);
return ( return (
<> <>
{/* Target handle (left side - receives dependencies) */} {/* Target handle (left side - receives dependencies) */}
@@ -109,22 +149,26 @@ export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps
<div <div
className={cn( className={cn(
'min-w-[240px] max-w-[280px] rounded-xl border-2 bg-card shadow-md', 'min-w-[240px] max-w-[280px] rounded-xl shadow-md relative',
'transition-all duration-300', 'transition-all duration-300',
config.borderClass,
selected && 'ring-2 ring-brand-500 ring-offset-2 ring-offset-background', selected && 'ring-2 ring-brand-500 ring-offset-2 ring-offset-background',
data.isRunning && 'animate-pulse-subtle', data.isRunning && 'animate-pulse-subtle',
data.error && 'border-[var(--status-error)]',
// Filter highlight states // Filter highlight states
isMatched && 'graph-node-matched', isMatched && 'graph-node-matched',
isHighlighted && !isMatched && 'graph-node-highlighted', isHighlighted && !isMatched && 'graph-node-highlighted',
isDimmed && 'graph-node-dimmed' isDimmed && 'graph-node-dimmed'
)} )}
style={borderStyle}
> >
{/* Background layer with opacity control - like KanbanCard */}
<div
className={cn('absolute inset-0 rounded-xl bg-card', glassmorphism && 'backdrop-blur-sm')}
style={{ opacity: cardOpacity / 100 }}
/>
{/* Header with status and actions */} {/* Header with status and actions */}
<div <div
className={cn( className={cn(
'flex items-center justify-between px-3 py-2 rounded-t-[10px]', 'relative flex items-center justify-between px-3 py-2 rounded-t-[10px]',
config.bgClass config.bgClass
)} )}
> >
@@ -301,7 +345,7 @@ export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps
</div> </div>
{/* Content */} {/* Content */}
<div className="px-3 py-2"> <div className="relative px-3 py-2">
{/* Category */} {/* Category */}
<span className="text-[10px] text-muted-foreground font-medium uppercase tracking-wide"> <span className="text-[10px] text-muted-foreground font-medium uppercase tracking-wide">
{data.category} {data.category}

View File

@@ -15,7 +15,8 @@ import {
} from '@xyflow/react'; } from '@xyflow/react';
import '@xyflow/react/dist/style.css'; import '@xyflow/react/dist/style.css';
import { Feature } from '@/store/app-store'; import { Feature, useAppStore } from '@/store/app-store';
import { themeOptions } from '@/config/theme-options';
import { import {
TaskNode, TaskNode,
DependencyEdge, DependencyEdge,
@@ -47,6 +48,13 @@ const edgeTypes: any = {
dependency: DependencyEdge, dependency: DependencyEdge,
}; };
interface BackgroundSettings {
cardOpacity: number;
cardGlassmorphism: boolean;
cardBorderEnabled: boolean;
cardBorderOpacity: number;
}
interface GraphCanvasProps { interface GraphCanvasProps {
features: Feature[]; features: Feature[];
runningAutoTasks: string[]; runningAutoTasks: string[];
@@ -56,6 +64,7 @@ interface GraphCanvasProps {
nodeActionCallbacks?: NodeActionCallbacks; nodeActionCallbacks?: NodeActionCallbacks;
onCreateDependency?: (sourceId: string, targetId: string) => Promise<boolean>; onCreateDependency?: (sourceId: string, targetId: string) => Promise<boolean>;
backgroundStyle?: React.CSSProperties; backgroundStyle?: React.CSSProperties;
backgroundSettings?: BackgroundSettings;
className?: string; className?: string;
} }
@@ -68,11 +77,42 @@ function GraphCanvasInner({
nodeActionCallbacks, nodeActionCallbacks,
onCreateDependency, onCreateDependency,
backgroundStyle, backgroundStyle,
backgroundSettings,
className, className,
}: GraphCanvasProps) { }: GraphCanvasProps) {
const [isLocked, setIsLocked] = useState(false); const [isLocked, setIsLocked] = useState(false);
const [layoutDirection, setLayoutDirection] = useState<'LR' | 'TB'>('LR'); const [layoutDirection, setLayoutDirection] = useState<'LR' | 'TB'>('LR');
// Determine React Flow color mode based on current theme
const effectiveTheme = useAppStore((state) => state.getEffectiveTheme());
const [systemColorMode, setSystemColorMode] = useState<'dark' | 'light'>(() => {
if (typeof window === 'undefined') return 'dark';
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
});
useEffect(() => {
if (effectiveTheme !== 'system') return;
if (typeof window === 'undefined') return;
const mql = window.matchMedia('(prefers-color-scheme: dark)');
const update = () => setSystemColorMode(mql.matches ? 'dark' : 'light');
update();
// Safari < 14 fallback
if (mql.addEventListener) {
mql.addEventListener('change', update);
return () => mql.removeEventListener('change', update);
}
// eslint-disable-next-line deprecation/deprecation
mql.addListener(update);
// eslint-disable-next-line deprecation/deprecation
return () => mql.removeListener(update);
}, [effectiveTheme]);
const themeOption = themeOptions.find((t) => t.value === effectiveTheme);
const colorMode =
effectiveTheme === 'system' ? systemColorMode : themeOption?.isDark ? 'dark' : 'light';
// Filter state (category, status, and negative toggle are local to graph view) // Filter state (category, status, and negative toggle are local to graph view)
const [selectedCategories, setSelectedCategories] = useState<string[]>([]); const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
const [selectedStatuses, setSelectedStatuses] = useState<string[]>([]); const [selectedStatuses, setSelectedStatuses] = useState<string[]>([]);
@@ -98,6 +138,7 @@ function GraphCanvasInner({
runningAutoTasks, runningAutoTasks,
filterResult, filterResult,
actionCallbacks: nodeActionCallbacks, actionCallbacks: nodeActionCallbacks,
backgroundSettings,
}); });
// Apply layout // Apply layout
@@ -234,6 +275,7 @@ function GraphCanvasInner({
isValidConnection={isValidConnection} isValidConnection={isValidConnection}
nodeTypes={nodeTypes} nodeTypes={nodeTypes}
edgeTypes={edgeTypes} edgeTypes={edgeTypes}
colorMode={colorMode}
fitView fitView
fitViewOptions={{ padding: 0.2 }} fitViewOptions={{ padding: 0.2 }}
minZoom={0.1} minZoom={0.1}
@@ -256,7 +298,8 @@ function GraphCanvasInner({
nodeStrokeWidth={3} nodeStrokeWidth={3}
zoomable zoomable
pannable pannable
className="!bg-popover/90 !border-border rounded-lg shadow-lg" className="border-border! rounded-lg shadow-lg"
style={{ backgroundColor: 'color-mix(in oklch, var(--popover) 90%, transparent)' }}
/> />
<GraphControls <GraphControls
@@ -281,7 +324,10 @@ function GraphCanvasInner({
{/* Empty state when all nodes are filtered out */} {/* Empty state when all nodes are filtered out */}
{filterResult.hasActiveFilter && filterResult.matchedNodeIds.size === 0 && ( {filterResult.hasActiveFilter && filterResult.matchedNodeIds.size === 0 && (
<Panel position="top-center" className="mt-20"> <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"> <div
className="flex flex-col items-center gap-3 p-6 rounded-lg backdrop-blur-sm border border-border shadow-lg text-popover-foreground"
style={{ backgroundColor: 'color-mix(in oklch, var(--popover) 95%, transparent)' }}
>
<SearchX className="w-10 h-10 text-muted-foreground" /> <SearchX className="w-10 h-10 text-muted-foreground" />
<div className="text-center"> <div className="text-center">
<p className="text-sm font-medium">No matching tasks</p> <p className="text-sm font-medium">No matching tasks</p>

View File

@@ -44,7 +44,7 @@ export function GraphView({
const { currentProject } = useAppStore(); const { currentProject } = useAppStore();
// Use the same background hook as the board view // Use the same background hook as the board view
const { backgroundImageStyle } = useBoardBackground({ currentProject }); const { backgroundImageStyle, backgroundSettings } = useBoardBackground({ currentProject });
// Filter features by current worktree (same logic as board view) // Filter features by current worktree (same logic as board view)
const filteredFeatures = useMemo(() => { const filteredFeatures = useMemo(() => {
@@ -213,6 +213,7 @@ export function GraphView({
nodeActionCallbacks={nodeActionCallbacks} nodeActionCallbacks={nodeActionCallbacks}
onCreateDependency={handleCreateDependency} onCreateDependency={handleCreateDependency}
backgroundStyle={backgroundImageStyle} backgroundStyle={backgroundImageStyle}
backgroundSettings={backgroundSettings}
className="h-full" className="h-full"
/> />
</div> </div>

View File

@@ -18,6 +18,11 @@ export interface TaskNodeData extends Feature {
isMatched?: boolean; isMatched?: boolean;
isHighlighted?: boolean; isHighlighted?: boolean;
isDimmed?: boolean; isDimmed?: boolean;
// Background/theme settings
cardOpacity?: number;
cardGlassmorphism?: boolean;
cardBorderEnabled?: boolean;
cardBorderOpacity?: number;
// Action callbacks // Action callbacks
onViewLogs?: () => void; onViewLogs?: () => void;
onViewDetails?: () => void; onViewDetails?: () => void;
@@ -48,11 +53,19 @@ export interface NodeActionCallbacks {
onDeleteDependency?: (sourceId: string, targetId: string) => void; onDeleteDependency?: (sourceId: string, targetId: string) => void;
} }
interface BackgroundSettings {
cardOpacity: number;
cardGlassmorphism: boolean;
cardBorderEnabled: boolean;
cardBorderOpacity: number;
}
interface UseGraphNodesProps { interface UseGraphNodesProps {
features: Feature[]; features: Feature[];
runningAutoTasks: string[]; runningAutoTasks: string[];
filterResult?: GraphFilterResult; filterResult?: GraphFilterResult;
actionCallbacks?: NodeActionCallbacks; actionCallbacks?: NodeActionCallbacks;
backgroundSettings?: BackgroundSettings;
} }
/** /**
@@ -64,6 +77,7 @@ export function useGraphNodes({
runningAutoTasks, runningAutoTasks,
filterResult, filterResult,
actionCallbacks, actionCallbacks,
backgroundSettings,
}: UseGraphNodesProps) { }: UseGraphNodesProps) {
const { nodes, edges } = useMemo(() => { const { nodes, edges } = useMemo(() => {
const nodeList: TaskNode[] = []; const nodeList: TaskNode[] = [];
@@ -102,6 +116,11 @@ export function useGraphNodes({
isMatched, isMatched,
isHighlighted, isHighlighted,
isDimmed, isDimmed,
// Background/theme settings
cardOpacity: backgroundSettings?.cardOpacity,
cardGlassmorphism: backgroundSettings?.cardGlassmorphism,
cardBorderEnabled: backgroundSettings?.cardBorderEnabled,
cardBorderOpacity: backgroundSettings?.cardBorderOpacity,
// Action callbacks (bound to this feature's ID) // Action callbacks (bound to this feature's ID)
onViewLogs: actionCallbacks?.onViewLogs onViewLogs: actionCallbacks?.onViewLogs
? () => actionCallbacks.onViewLogs!(feature.id) ? () => actionCallbacks.onViewLogs!(feature.id)
@@ -163,7 +182,7 @@ export function useGraphNodes({
}); });
return { nodes: nodeList, edges: edgeList }; return { nodes: nodeList, edges: edgeList };
}, [features, runningAutoTasks, filterResult, actionCallbacks]); }, [features, runningAutoTasks, filterResult, actionCallbacks, backgroundSettings]);
return { nodes, edges }; return { nodes, edges };
} }

View File

@@ -3,7 +3,7 @@
* First page users see - shows all ideas ready for accept/reject * First page users see - shows all ideas ready for accept/reject
*/ */
import { useState, useMemo } from 'react'; import { useState, useMemo, useEffect, useCallback } from 'react';
import { Loader2, AlertCircle, Plus, X, Sparkles, Lightbulb } from 'lucide-react'; import { Loader2, AlertCircle, Plus, X, Sparkles, Lightbulb } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -17,6 +17,7 @@ import type { AnalysisSuggestion } from '@automaker/types';
interface IdeationDashboardProps { interface IdeationDashboardProps {
onGenerateIdeas: () => void; onGenerateIdeas: () => void;
onAcceptAllReady?: (isReady: boolean, count: number, handler: () => Promise<void>) => void;
} }
function SuggestionCard({ function SuggestionCard({
@@ -37,14 +38,16 @@ function SuggestionCard({
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-start gap-2 mb-1">
<h4 className="font-medium">{suggestion.title}</h4> <h4 className="font-medium shrink-0">{suggestion.title}</h4>
<Badge variant="outline" className="text-xs"> <div className="flex items-center gap-2 flex-wrap">
{suggestion.priority} <Badge variant="outline" className="text-xs whitespace-nowrap">
</Badge> {suggestion.priority}
<Badge variant="secondary" className="text-xs"> </Badge>
{job.prompt.title} <Badge variant="secondary" className="text-xs whitespace-nowrap">
</Badge> {job.prompt.title}
</Badge>
</div>
</div> </div>
<p className="text-sm text-muted-foreground">{suggestion.description}</p> <p className="text-sm text-muted-foreground">{suggestion.description}</p>
{suggestion.rationale && ( {suggestion.rationale && (
@@ -166,11 +169,12 @@ function TagFilter({
); );
} }
export function IdeationDashboard({ onGenerateIdeas }: IdeationDashboardProps) { export function IdeationDashboard({ onGenerateIdeas, onAcceptAllReady }: IdeationDashboardProps) {
const currentProject = useAppStore((s) => s.currentProject); const currentProject = useAppStore((s) => s.currentProject);
const generationJobs = useIdeationStore((s) => s.generationJobs); const generationJobs = useIdeationStore((s) => s.generationJobs);
const removeSuggestionFromJob = useIdeationStore((s) => s.removeSuggestionFromJob); const removeSuggestionFromJob = useIdeationStore((s) => s.removeSuggestionFromJob);
const [addingId, setAddingId] = useState<string | null>(null); const [addingId, setAddingId] = useState<string | null>(null);
const [isAcceptingAll, setIsAcceptingAll] = useState(false);
const [selectedTags, setSelectedTags] = useState<Set<string>>(new Set()); const [selectedTags, setSelectedTags] = useState<Set<string>>(new Set());
// Get jobs for current project only (memoized to prevent unnecessary re-renders) // Get jobs for current project only (memoized to prevent unnecessary re-renders)
@@ -270,6 +274,54 @@ export function IdeationDashboard({ onGenerateIdeas }: IdeationDashboardProps) {
toast.info('Idea removed'); toast.info('Idea removed');
}; };
// Accept all filtered suggestions
const handleAcceptAll = useCallback(async () => {
if (!currentProject?.path || filteredSuggestions.length === 0) {
return;
}
setIsAcceptingAll(true);
const api = getElectronAPI();
let successCount = 0;
let failCount = 0;
// Process all filtered suggestions
for (const { suggestion, job } of filteredSuggestions) {
try {
const result = await api.ideation?.addSuggestionToBoard(currentProject.path, suggestion);
if (result?.success) {
removeSuggestionFromJob(job.id, suggestion.id);
successCount++;
} else {
failCount++;
}
} catch (error) {
console.error('Failed to add suggestion to board:', error);
failCount++;
}
}
setIsAcceptingAll(false);
if (successCount > 0 && failCount === 0) {
toast.success(`Added ${successCount} idea${successCount > 1 ? 's' : ''} to board`);
} else if (successCount > 0 && failCount > 0) {
toast.warning(
`Added ${successCount} idea${successCount > 1 ? 's' : ''}, ${failCount} failed`
);
} else {
toast.error('Failed to add ideas to board');
}
}, [currentProject?.path, filteredSuggestions, removeSuggestionFromJob]);
// Notify parent about accept all readiness
useEffect(() => {
if (onAcceptAllReady) {
const isReady = filteredSuggestions.length > 0 && !isAcceptingAll && !addingId;
onAcceptAllReady(isReady, filteredSuggestions.length, handleAcceptAll);
}
}, [filteredSuggestions.length, isAcceptingAll, addingId, handleAcceptAll, onAcceptAllReady]);
const isEmpty = allSuggestions.length === 0 && activeJobs.length === 0; const isEmpty = allSuggestions.length === 0 && activeJobs.length === 0;
return ( return (

View File

@@ -3,7 +3,7 @@
* Dashboard-first design with Generate Ideas flow * Dashboard-first design with Generate Ideas flow
*/ */
import { useCallback } from 'react'; import { useCallback, useState } from 'react';
import { useIdeationStore } from '@/store/ideation-store'; import { useIdeationStore } from '@/store/ideation-store';
import { useAppStore } from '@/store/app-store'; import { useAppStore } from '@/store/app-store';
import { PromptCategoryGrid } from './components/prompt-category-grid'; import { PromptCategoryGrid } from './components/prompt-category-grid';
@@ -11,7 +11,7 @@ import { PromptList } from './components/prompt-list';
import { IdeationDashboard } from './components/ideation-dashboard'; import { IdeationDashboard } from './components/ideation-dashboard';
import { useGuidedPrompts } from '@/hooks/use-guided-prompts'; import { useGuidedPrompts } from '@/hooks/use-guided-prompts';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { ArrowLeft, ChevronRight, Lightbulb } from 'lucide-react'; import { ArrowLeft, ChevronRight, Lightbulb, CheckCheck, Loader2 } from 'lucide-react';
import type { IdeaCategory } from '@automaker/types'; import type { IdeaCategory } from '@automaker/types';
import type { IdeationMode } from '@/store/ideation-store'; import type { IdeationMode } from '@/store/ideation-store';
@@ -67,12 +67,20 @@ function IdeationHeader({
onNavigate, onNavigate,
onGenerateIdeas, onGenerateIdeas,
onBack, onBack,
acceptAllReady,
acceptAllCount,
onAcceptAll,
isAcceptingAll,
}: { }: {
currentMode: IdeationMode; currentMode: IdeationMode;
selectedCategory: IdeaCategory | null; selectedCategory: IdeaCategory | null;
onNavigate: (mode: IdeationMode, category?: IdeaCategory | null) => void; onNavigate: (mode: IdeationMode, category?: IdeaCategory | null) => void;
onGenerateIdeas: () => void; onGenerateIdeas: () => void;
onBack: () => void; onBack: () => void;
acceptAllReady: boolean;
acceptAllCount: number;
onAcceptAll: () => void;
isAcceptingAll: boolean;
}) { }) {
const { getCategoryById } = useGuidedPrompts(); const { getCategoryById } = useGuidedPrompts();
const showBackButton = currentMode === 'prompts'; const showBackButton = currentMode === 'prompts';
@@ -120,6 +128,21 @@ function IdeationHeader({
</div> </div>
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
{currentMode === 'dashboard' && acceptAllReady && (
<Button
onClick={onAcceptAll}
variant="outline"
className="gap-2"
disabled={isAcceptingAll}
>
{isAcceptingAll ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<CheckCheck className="w-4 h-4" />
)}
Accept All ({acceptAllCount})
</Button>
)}
<Button onClick={onGenerateIdeas} className="gap-2"> <Button onClick={onGenerateIdeas} className="gap-2">
<Lightbulb className="w-4 h-4" /> <Lightbulb className="w-4 h-4" />
Generate Ideas Generate Ideas
@@ -133,6 +156,32 @@ export function IdeationView() {
const currentProject = useAppStore((s) => s.currentProject); const currentProject = useAppStore((s) => s.currentProject);
const { currentMode, selectedCategory, setMode, setCategory } = useIdeationStore(); const { currentMode, selectedCategory, setMode, setCategory } = useIdeationStore();
// Accept all state
const [acceptAllReady, setAcceptAllReady] = useState(false);
const [acceptAllCount, setAcceptAllCount] = useState(0);
const [acceptAllHandler, setAcceptAllHandler] = useState<(() => Promise<void>) | null>(null);
const [isAcceptingAll, setIsAcceptingAll] = useState(false);
const handleAcceptAllReady = useCallback(
(isReady: boolean, count: number, handler: () => Promise<void>) => {
setAcceptAllReady(isReady);
setAcceptAllCount(count);
setAcceptAllHandler(() => handler);
},
[]
);
const handleAcceptAll = useCallback(async () => {
if (acceptAllHandler) {
setIsAcceptingAll(true);
try {
await acceptAllHandler();
} finally {
setIsAcceptingAll(false);
}
}
}, [acceptAllHandler]);
const handleNavigate = useCallback( const handleNavigate = useCallback(
(mode: IdeationMode, category?: IdeaCategory | null) => { (mode: IdeationMode, category?: IdeaCategory | null) => {
setMode(mode); setMode(mode);
@@ -192,10 +241,19 @@ export function IdeationView() {
onNavigate={handleNavigate} onNavigate={handleNavigate}
onGenerateIdeas={handleGenerateIdeas} onGenerateIdeas={handleGenerateIdeas}
onBack={handleBackFromPrompts} onBack={handleBackFromPrompts}
acceptAllReady={acceptAllReady}
acceptAllCount={acceptAllCount}
onAcceptAll={handleAcceptAll}
isAcceptingAll={isAcceptingAll}
/> />
{/* Dashboard - main view */} {/* Dashboard - main view */}
{currentMode === 'dashboard' && <IdeationDashboard onGenerateIdeas={handleGenerateIdeas} />} {currentMode === 'dashboard' && (
<IdeationDashboard
onGenerateIdeas={handleGenerateIdeas}
onAcceptAllReady={handleAcceptAllReady}
/>
)}
{/* Prompts - category selection */} {/* Prompts - category selection */}
{currentMode === 'prompts' && !selectedCategory && ( {currentMode === 'prompts' && !selectedCategory && (

View File

@@ -13,7 +13,15 @@
import { useReducer, useEffect, useRef } from 'react'; import { useReducer, useEffect, useRef } from 'react';
import { useNavigate } from '@tanstack/react-router'; import { useNavigate } from '@tanstack/react-router';
import { login, getHttpApiClient, getServerUrlSync } from '@/lib/http-api-client'; import {
login,
getHttpApiClient,
getServerUrlSync,
getApiKey,
getSessionToken,
initApiKey,
waitForApiKeyInit,
} from '@/lib/http-api-client';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { KeyRound, AlertCircle, Loader2, RefreshCw, ServerCrash } from 'lucide-react'; import { KeyRound, AlertCircle, Loader2, RefreshCw, ServerCrash } from 'lucide-react';
@@ -92,6 +100,7 @@ function reducer(state: State, action: Action): State {
const MAX_RETRIES = 5; const MAX_RETRIES = 5;
const BACKOFF_BASE_MS = 400; const BACKOFF_BASE_MS = 400;
const NO_STORE_CACHE_MODE: RequestCache = 'no-store';
// ============================================================================= // =============================================================================
// Imperative Flow Logic (runs once on mount) // Imperative Flow Logic (runs once on mount)
@@ -102,7 +111,9 @@ const BACKOFF_BASE_MS = 400;
* Unlike the httpClient methods, this does NOT call handleUnauthorized() * Unlike the httpClient methods, this does NOT call handleUnauthorized()
* which would navigate us away to /logged-out. * which would navigate us away to /logged-out.
* *
* Relies on HTTP-only session cookie being sent via credentials: 'include'. * Supports both:
* - Electron mode: Uses X-API-Key header (API key from IPC)
* - Web mode: Uses HTTP-only session cookie
* *
* Returns: { authenticated: true } or { authenticated: false } * Returns: { authenticated: true } or { authenticated: false }
* Throws: on network errors (for retry logic) * Throws: on network errors (for retry logic)
@@ -110,9 +121,31 @@ const BACKOFF_BASE_MS = 400;
async function checkAuthStatusSafe(): Promise<{ authenticated: boolean }> { async function checkAuthStatusSafe(): Promise<{ authenticated: boolean }> {
const serverUrl = getServerUrlSync(); const serverUrl = getServerUrlSync();
// Wait for API key to be initialized before checking auth
// This ensures we have a valid API key to send in the header
await waitForApiKeyInit();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
// Electron mode: use API key header
const apiKey = getApiKey();
if (apiKey) {
headers['X-API-Key'] = apiKey;
}
// Add session token header if available (web mode)
const sessionToken = getSessionToken();
if (sessionToken) {
headers['X-Session-Token'] = sessionToken;
}
const response = await fetch(`${serverUrl}/api/auth/status`, { const response = await fetch(`${serverUrl}/api/auth/status`, {
credentials: 'include', // Send HTTP-only session cookie headers,
credentials: 'include',
signal: AbortSignal.timeout(5000), signal: AbortSignal.timeout(5000),
cache: NO_STORE_CACHE_MODE,
}); });
// Any response means server is reachable // Any response means server is reachable
@@ -246,6 +279,14 @@ export function LoginView() {
const [state, dispatch] = useReducer(reducer, initialState); const [state, dispatch] = useReducer(reducer, initialState);
const retryControllerRef = useRef<AbortController | null>(null); const retryControllerRef = useRef<AbortController | null>(null);
// Initialize API key before checking session
// This ensures getApiKey() returns a valid value in checkAuthStatusSafe()
useEffect(() => {
initApiKey().catch((error) => {
console.warn('Failed to initialize API key:', error);
});
}, []);
// Run initial server/session check on mount. // Run initial server/session check on mount.
// IMPORTANT: Do not "run once" via a ref guard here. // IMPORTANT: Do not "run once" via a ref guard here.
// In React StrictMode (dev), effects mount -> cleanup -> mount. // In React StrictMode (dev), effects mount -> cleanup -> mount.

View File

@@ -0,0 +1,624 @@
import { useEffect, useState, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { useAppStore } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import {
RefreshCw,
FileText,
Trash2,
Save,
Brain,
Eye,
Pencil,
FilePlus,
MoreVertical,
} from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils';
import { Markdown } from '../ui/markdown';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
const logger = createLogger('MemoryView');
interface MemoryFile {
name: string;
content?: string;
path: string;
}
export function MemoryView() {
const { currentProject } = useAppStore();
const [memoryFiles, setMemoryFiles] = useState<MemoryFile[]>([]);
const [selectedFile, setSelectedFile] = useState<MemoryFile | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
const [editedContent, setEditedContent] = useState('');
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
const [renameFileName, setRenameFileName] = useState('');
const [isPreviewMode, setIsPreviewMode] = useState(true);
// Create Memory file modal state
const [isCreateMemoryOpen, setIsCreateMemoryOpen] = useState(false);
const [newMemoryName, setNewMemoryName] = useState('');
const [newMemoryContent, setNewMemoryContent] = useState('');
// Get memory directory path
const getMemoryPath = useCallback(() => {
if (!currentProject) return null;
return `${currentProject.path}/.automaker/memory`;
}, [currentProject]);
const isMarkdownFile = (filename: string): boolean => {
const ext = filename.toLowerCase().substring(filename.lastIndexOf('.'));
return ext === '.md' || ext === '.markdown';
};
// Load memory files
const loadMemoryFiles = useCallback(async () => {
const memoryPath = getMemoryPath();
if (!memoryPath) return;
setIsLoading(true);
try {
const api = getElectronAPI();
// Ensure memory directory exists
await api.mkdir(memoryPath);
// Read directory contents
const result = await api.readdir(memoryPath);
if (result.success && result.entries) {
const files: MemoryFile[] = result.entries
.filter((entry) => entry.isFile && isMarkdownFile(entry.name))
.map((entry) => ({
name: entry.name,
path: `${memoryPath}/${entry.name}`,
}));
setMemoryFiles(files);
}
} catch (error) {
logger.error('Failed to load memory files:', error);
} finally {
setIsLoading(false);
}
}, [getMemoryPath]);
useEffect(() => {
loadMemoryFiles();
}, [loadMemoryFiles]);
// Load selected file content
const loadFileContent = useCallback(async (file: MemoryFile) => {
try {
const api = getElectronAPI();
const result = await api.readFile(file.path);
if (result.success && result.content !== undefined) {
setEditedContent(result.content);
setSelectedFile({ ...file, content: result.content });
setHasChanges(false);
}
} catch (error) {
logger.error('Failed to load file content:', error);
}
}, []);
// Select a file
const handleSelectFile = (file: MemoryFile) => {
if (hasChanges) {
// Could add a confirmation dialog here
}
loadFileContent(file);
setIsPreviewMode(true);
};
// Save current file
const saveFile = async () => {
if (!selectedFile) return;
setIsSaving(true);
try {
const api = getElectronAPI();
await api.writeFile(selectedFile.path, editedContent);
setSelectedFile({ ...selectedFile, content: editedContent });
setHasChanges(false);
} catch (error) {
logger.error('Failed to save file:', error);
} finally {
setIsSaving(false);
}
};
// Handle content change
const handleContentChange = (value: string) => {
setEditedContent(value);
setHasChanges(true);
};
// Handle create memory file
const handleCreateMemory = async () => {
const memoryPath = getMemoryPath();
if (!memoryPath || !newMemoryName.trim()) return;
try {
const api = getElectronAPI();
let filename = newMemoryName.trim();
// Add .md extension if not provided
if (!filename.includes('.')) {
filename += '.md';
}
const filePath = `${memoryPath}/${filename}`;
// Write memory file
await api.writeFile(filePath, newMemoryContent);
// Reload files
await loadMemoryFiles();
// Reset and close modal
setIsCreateMemoryOpen(false);
setNewMemoryName('');
setNewMemoryContent('');
} catch (error) {
logger.error('Failed to create memory file:', error);
setIsCreateMemoryOpen(false);
setNewMemoryName('');
setNewMemoryContent('');
}
};
// Delete selected file
const handleDeleteFile = async () => {
if (!selectedFile) return;
try {
const api = getElectronAPI();
await api.deleteFile(selectedFile.path);
setIsDeleteDialogOpen(false);
setSelectedFile(null);
setEditedContent('');
setHasChanges(false);
await loadMemoryFiles();
} catch (error) {
logger.error('Failed to delete file:', error);
}
};
// Rename selected file
const handleRenameFile = async () => {
const memoryPath = getMemoryPath();
if (!selectedFile || !memoryPath || !renameFileName.trim()) return;
let newName = renameFileName.trim();
// Add .md extension if not provided
if (!newName.includes('.')) {
newName += '.md';
}
if (newName === selectedFile.name) {
setIsRenameDialogOpen(false);
return;
}
try {
const api = getElectronAPI();
const newPath = `${memoryPath}/${newName}`;
// Check if file with new name already exists
const exists = await api.exists(newPath);
if (exists) {
logger.error('A file with this name already exists');
return;
}
// Read current file content
const result = await api.readFile(selectedFile.path);
if (!result.success || result.content === undefined) {
logger.error('Failed to read file for rename');
return;
}
// Write to new path
await api.writeFile(newPath, result.content);
// Delete old file
await api.deleteFile(selectedFile.path);
setIsRenameDialogOpen(false);
setRenameFileName('');
// Reload files and select the renamed file
await loadMemoryFiles();
// Update selected file with new name and path
const renamedFile: MemoryFile = {
name: newName,
path: newPath,
content: result.content,
};
setSelectedFile(renamedFile);
} catch (error) {
logger.error('Failed to rename file:', error);
}
};
// Delete file from list (used by dropdown)
const handleDeleteFromList = async (file: MemoryFile) => {
try {
const api = getElectronAPI();
await api.deleteFile(file.path);
// Clear selection if this was the selected file
if (selectedFile?.path === file.path) {
setSelectedFile(null);
setEditedContent('');
setHasChanges(false);
}
await loadMemoryFiles();
} catch (error) {
logger.error('Failed to delete file:', error);
}
};
if (!currentProject) {
return (
<div className="flex-1 flex items-center justify-center" data-testid="memory-view-no-project">
<p className="text-muted-foreground">No project selected</p>
</div>
);
}
if (isLoading) {
return (
<div className="flex-1 flex items-center justify-center" data-testid="memory-view-loading">
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="flex-1 flex flex-col overflow-hidden content-bg" data-testid="memory-view">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
<div className="flex items-center gap-3">
<Brain className="w-5 h-5 text-muted-foreground" />
<div>
<h1 className="text-xl font-bold">Memory Layer</h1>
<p className="text-sm text-muted-foreground">
View and edit AI memory files for this project
</p>
</div>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={loadMemoryFiles}
data-testid="refresh-memory-button"
>
<RefreshCw className="w-4 h-4 mr-2" />
Refresh
</Button>
<Button
size="sm"
onClick={() => setIsCreateMemoryOpen(true)}
data-testid="create-memory-button"
>
<FilePlus className="w-4 h-4 mr-2" />
Create Memory File
</Button>
</div>
</div>
{/* Main content area with file list and editor */}
<div className="flex-1 flex overflow-hidden">
{/* Left Panel - File List */}
<div className="w-64 border-r border-border flex flex-col overflow-hidden">
<div className="p-3 border-b border-border">
<h2 className="text-sm font-semibold text-muted-foreground">
Memory Files ({memoryFiles.length})
</h2>
</div>
<div className="flex-1 overflow-y-auto p-2" data-testid="memory-file-list">
{memoryFiles.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center p-4">
<Brain className="w-8 h-8 text-muted-foreground mb-2" />
<p className="text-sm text-muted-foreground">
No memory files yet.
<br />
Create a memory file to get started.
</p>
</div>
) : (
<div className="space-y-1">
{memoryFiles.map((file) => (
<div
key={file.path}
onClick={() => handleSelectFile(file)}
className={cn(
'group w-full flex items-center gap-2 px-3 py-2 rounded-lg transition-colors cursor-pointer',
selectedFile?.path === file.path
? 'bg-primary/20 text-foreground border border-primary/30'
: 'text-muted-foreground hover:bg-accent hover:text-foreground'
)}
data-testid={`memory-file-${file.name}`}
>
<FileText className="w-4 h-4 flex-shrink-0" />
<div className="min-w-0 flex-1">
<span className="truncate text-sm block">{file.name}</span>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
onClick={(e) => e.stopPropagation()}
className="opacity-0 group-hover:opacity-100 p-1 hover:bg-accent rounded transition-opacity"
data-testid={`memory-file-menu-${file.name}`}
>
<MoreVertical className="w-4 h-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setRenameFileName(file.name);
setSelectedFile(file);
setIsRenameDialogOpen(true);
}}
data-testid={`rename-memory-file-${file.name}`}
>
<Pencil className="w-4 h-4 mr-2" />
Rename
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDeleteFromList(file)}
className="text-red-500 focus:text-red-500"
data-testid={`delete-memory-file-${file.name}`}
>
<Trash2 className="w-4 h-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
))}
</div>
)}
</div>
</div>
{/* Right Panel - Editor/Preview */}
<div className="flex-1 flex flex-col overflow-hidden">
{selectedFile ? (
<>
{/* File toolbar */}
<div className="flex items-center justify-between p-3 border-b border-border bg-card">
<div className="flex items-center gap-2 min-w-0">
<FileText className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<span className="text-sm font-medium truncate">{selectedFile.name}</span>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setIsPreviewMode(!isPreviewMode)}
data-testid="toggle-preview-mode"
>
{isPreviewMode ? (
<>
<Pencil className="w-4 h-4 mr-2" />
Edit
</>
) : (
<>
<Eye className="w-4 h-4 mr-2" />
Preview
</>
)}
</Button>
<Button
size="sm"
onClick={saveFile}
disabled={!hasChanges || isSaving}
data-testid="save-memory-file"
>
<Save className="w-4 h-4 mr-2" />
{isSaving ? 'Saving...' : hasChanges ? 'Save' : 'Saved'}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setIsDeleteDialogOpen(true)}
className="text-red-500 hover:text-red-400 hover:border-red-500/50"
data-testid="delete-memory-file"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
{/* Content area */}
<div className="flex-1 overflow-hidden p-4">
{isPreviewMode ? (
<Card className="h-full overflow-auto p-4" data-testid="markdown-preview">
<Markdown>{editedContent}</Markdown>
</Card>
) : (
<Card className="h-full overflow-hidden">
<textarea
className="w-full h-full p-4 font-mono text-sm bg-transparent resize-none focus:outline-none"
value={editedContent}
onChange={(e) => handleContentChange(e.target.value)}
placeholder="Enter memory content here..."
spellCheck={false}
data-testid="memory-editor"
/>
</Card>
)}
</div>
</>
) : (
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<Brain className="w-12 h-12 text-muted-foreground mx-auto mb-3" />
<p className="text-foreground-secondary">Select a file to view or edit</p>
<p className="text-muted-foreground text-sm mt-1">
Memory files help AI agents learn from past interactions
</p>
</div>
</div>
)}
</div>
</div>
{/* Create Memory Dialog */}
<Dialog open={isCreateMemoryOpen} onOpenChange={setIsCreateMemoryOpen}>
<DialogContent
data-testid="create-memory-dialog"
className="w-[60vw] max-w-[60vw] max-h-[80vh] flex flex-col"
>
<DialogHeader>
<DialogTitle>Create Memory File</DialogTitle>
<DialogDescription>
Create a new memory file to store learnings and patterns for AI agents.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4 flex-1 overflow-auto">
<div className="space-y-2">
<Label htmlFor="memory-filename">File Name</Label>
<Input
id="memory-filename"
value={newMemoryName}
onChange={(e) => setNewMemoryName(e.target.value)}
placeholder="my-learnings.md"
data-testid="new-memory-name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="memory-content">Content</Label>
<textarea
id="memory-content"
value={newMemoryContent}
onChange={(e) => setNewMemoryContent(e.target.value)}
placeholder="Enter your memory content here..."
className="w-full h-60 p-3 font-mono text-sm bg-background border border-border rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent"
spellCheck={false}
data-testid="new-memory-content"
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setIsCreateMemoryOpen(false);
setNewMemoryName('');
setNewMemoryContent('');
}}
>
Cancel
</Button>
<Button
onClick={handleCreateMemory}
disabled={!newMemoryName.trim()}
data-testid="confirm-create-memory"
>
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<DialogContent data-testid="delete-memory-dialog">
<DialogHeader>
<DialogTitle>Delete Memory File</DialogTitle>
<DialogDescription>
Are you sure you want to delete "{selectedFile?.name}"? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setIsDeleteDialogOpen(false)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDeleteFile}
className="bg-red-600 hover:bg-red-700"
data-testid="confirm-delete-file"
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Rename Dialog */}
<Dialog open={isRenameDialogOpen} onOpenChange={setIsRenameDialogOpen}>
<DialogContent data-testid="rename-memory-dialog">
<DialogHeader>
<DialogTitle>Rename Memory File</DialogTitle>
<DialogDescription>Enter a new name for "{selectedFile?.name}".</DialogDescription>
</DialogHeader>
<div className="py-4">
<div className="space-y-2">
<Label htmlFor="rename-filename">File Name</Label>
<Input
id="rename-filename"
value={renameFileName}
onChange={(e) => setRenameFileName(e.target.value)}
placeholder="Enter new filename"
data-testid="rename-file-input"
onKeyDown={(e) => {
if (e.key === 'Enter' && renameFileName.trim()) {
handleRenameFile();
}
}}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setIsRenameDialogOpen(false);
setRenameFileName('');
}}
>
Cancel
</Button>
<Button
onClick={handleRenameFile}
disabled={!renameFileName.trim() || renameFileName === selectedFile?.name}
data-testid="confirm-rename-file"
>
Rename
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -1,15 +1,51 @@
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate } from '@tanstack/react-router'; import { useNavigate } from '@tanstack/react-router';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { LogOut, User } from 'lucide-react'; import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { toast } from 'sonner';
import { LogOut, User, Code2, RefreshCw } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { logout } from '@/lib/http-api-client'; import { logout } from '@/lib/http-api-client';
import { useAuthStore } from '@/store/auth-store'; import { useAuthStore } from '@/store/auth-store';
import { useAppStore } from '@/store/app-store';
import {
useAvailableEditors,
useEffectiveDefaultEditor,
} from '@/components/views/board-view/worktree-panel/hooks/use-available-editors';
import { getEditorIcon } from '@/components/icons/editor-icons';
export function AccountSection() { export function AccountSection() {
const navigate = useNavigate(); const navigate = useNavigate();
const [isLoggingOut, setIsLoggingOut] = useState(false); const [isLoggingOut, setIsLoggingOut] = useState(false);
// Editor settings
const { editors, isLoading: isLoadingEditors, isRefreshing, refresh } = useAvailableEditors();
const defaultEditorCommand = useAppStore((s) => s.defaultEditorCommand);
const setDefaultEditorCommand = useAppStore((s) => s.setDefaultEditorCommand);
// Use shared hook for effective default editor
const effectiveEditor = useEffectiveDefaultEditor(editors);
// Normalize Select value: if saved editor isn't found, show 'auto'
const hasSavedEditor =
!!defaultEditorCommand && editors.some((e) => e.command === defaultEditorCommand);
const selectValue = hasSavedEditor ? defaultEditorCommand : 'auto';
// Get icon component for the effective editor
const EffectiveEditorIcon = effectiveEditor ? getEditorIcon(effectiveEditor.command) : null;
const handleRefreshEditors = async () => {
await refresh();
toast.success('Editor list refreshed');
};
const handleLogout = async () => { const handleLogout = async () => {
setIsLoggingOut(true); setIsLoggingOut(true);
try { try {
@@ -43,6 +79,81 @@ export function AccountSection() {
<p className="text-sm text-muted-foreground/80 ml-12">Manage your session and account.</p> <p className="text-sm text-muted-foreground/80 ml-12">Manage your session and account.</p>
</div> </div>
<div className="p-6 space-y-4"> <div className="p-6 space-y-4">
{/* Default IDE */}
<div className="flex items-center justify-between gap-4 p-4 rounded-xl bg-muted/30 border border-border/30">
<div className="flex items-center gap-3.5 min-w-0">
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-muted/50 to-muted/30 border border-border/30 flex items-center justify-center shrink-0">
<Code2 className="w-5 h-5 text-muted-foreground" />
</div>
<div className="min-w-0">
<p className="font-medium text-foreground">Default IDE</p>
<p className="text-xs text-muted-foreground/70 mt-0.5">
Default IDE to use when opening branches or worktrees
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Select
value={selectValue}
onValueChange={(value) => setDefaultEditorCommand(value === 'auto' ? null : value)}
disabled={isLoadingEditors || isRefreshing || editors.length === 0}
>
<SelectTrigger className="w-[180px] shrink-0">
<SelectValue placeholder="Select editor">
{effectiveEditor ? (
<span className="flex items-center gap-2">
{EffectiveEditorIcon && <EffectiveEditorIcon className="w-4 h-4" />}
{effectiveEditor.name}
{selectValue === 'auto' && (
<span className="text-muted-foreground text-xs">(Auto)</span>
)}
</span>
) : (
'Select editor'
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">
<span className="flex items-center gap-2">
<Code2 className="w-4 h-4" />
Auto-detect
</span>
</SelectItem>
{editors.map((editor) => {
const Icon = getEditorIcon(editor.command);
return (
<SelectItem key={editor.command} value={editor.command}>
<span className="flex items-center gap-2">
<Icon className="w-4 h-4" />
{editor.name}
</span>
</SelectItem>
);
})}
</SelectContent>
</Select>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={handleRefreshEditors}
disabled={isRefreshing || isLoadingEditors}
className="shrink-0 h-9 w-9"
>
<RefreshCw className={cn('w-4 h-4', isRefreshing && 'animate-spin')} />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Refresh available editors</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
{/* Logout */} {/* Logout */}
<div className="flex items-center justify-between gap-4 p-4 rounded-xl bg-muted/30 border border-border/30"> <div className="flex items-center justify-between gap-4 p-4 rounded-xl bg-muted/30 border border-border/30">
<div className="flex items-center gap-3.5 min-w-0"> <div className="flex items-center gap-3.5 min-w-0">

View File

@@ -37,6 +37,7 @@ import {
QwenIcon, QwenIcon,
MistralIcon, MistralIcon,
MetaIcon, MetaIcon,
getProviderIconForModel,
} from '@/components/ui/provider-icon'; } from '@/components/ui/provider-icon';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
@@ -513,27 +514,8 @@ export function PhaseModelSelector({
const isSelected = selectedModel === model.id; const isSelected = selectedModel === model.id;
const isFavorite = favoriteModels.includes(model.id); const isFavorite = favoriteModels.includes(model.id);
// Get the appropriate icon based on provider // Get the appropriate icon based on the specific model ID
const ProviderIcon = (() => { const ProviderIcon = getProviderIconForModel(model.id);
switch (model.provider) {
case 'opencode':
return OpenCodeIcon;
case 'amazon-bedrock-anthropic':
return AnthropicIcon;
case 'amazon-bedrock-deepseek':
return DeepSeekIcon;
case 'amazon-bedrock-amazon':
return NovaIcon;
case 'amazon-bedrock-meta':
return MetaIcon;
case 'amazon-bedrock-mistral':
return MistralIcon;
case 'amazon-bedrock-qwen':
return QwenIcon;
default:
return OpenCodeIcon;
}
})();
return ( return (
<CommandItem <CommandItem

View File

@@ -431,6 +431,16 @@ export function PromptCustomizationSection({
updatePrompt('enhancement', 'acceptanceSystemPrompt', value) updatePrompt('enhancement', 'acceptanceSystemPrompt', value)
} }
/> />
<PromptField
label="User Experience Mode"
description="Review and enhance from a user experience and design perspective"
defaultValue={DEFAULT_ENHANCEMENT_PROMPTS.uxReviewerSystemPrompt}
customValue={promptCustomization?.enhancement?.uxReviewerSystemPrompt}
onCustomValueChange={(value) =>
updatePrompt('enhancement', 'uxReviewerSystemPrompt', value)
}
/>
</div> </div>
</TabsContent> </TabsContent>
</Tabs> </Tabs>

View File

@@ -19,6 +19,7 @@ import {
AnthropicIcon, AnthropicIcon,
MistralIcon, MistralIcon,
MetaIcon, MetaIcon,
getProviderIconForModel,
} from '@/components/ui/provider-icon'; } from '@/components/ui/provider-icon';
import type { ComponentType } from 'react'; import type { ComponentType } from 'react';
@@ -31,27 +32,10 @@ interface OpencodeModelConfigurationProps {
} }
/** /**
* Returns the appropriate icon component for a given OpenCode provider * Returns the appropriate icon component for a given OpenCode model ID
*/ */
function getProviderIcon(provider: OpencodeProvider): ComponentType<{ className?: string }> { function getModelIcon(modelId: OpencodeModelId): ComponentType<{ className?: string }> {
switch (provider) { return getProviderIconForModel(modelId);
case 'opencode':
return OpenCodeIcon;
case 'amazon-bedrock-anthropic':
return AnthropicIcon;
case 'amazon-bedrock-deepseek':
return DeepSeekIcon;
case 'amazon-bedrock-amazon':
return NovaIcon;
case 'amazon-bedrock-meta':
return MetaIcon;
case 'amazon-bedrock-mistral':
return MistralIcon;
case 'amazon-bedrock-qwen':
return QwenIcon;
default:
return OpenCodeIcon;
}
} }
/** /**
@@ -146,11 +130,11 @@ export function OpencodeModelConfiguration({
{enabledOpencodeModels.map((modelId) => { {enabledOpencodeModels.map((modelId) => {
const model = OPENCODE_MODEL_CONFIG_MAP[modelId]; const model = OPENCODE_MODEL_CONFIG_MAP[modelId];
if (!model) return null; if (!model) return null;
const ProviderIconComponent = getProviderIcon(model.provider); const ModelIconComponent = getModelIcon(modelId);
return ( return (
<SelectItem key={modelId} value={modelId}> <SelectItem key={modelId} value={modelId}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ProviderIconComponent className="w-4 h-4" /> <ModelIconComponent className="w-4 h-4" />
<span>{model.label}</span> <span>{model.label}</span>
</div> </div>
</SelectItem> </SelectItem>
@@ -167,7 +151,9 @@ export function OpencodeModelConfiguration({
const models = modelsByProvider[provider]; const models = modelsByProvider[provider];
if (!models || models.length === 0) return null; if (!models || models.length === 0) return null;
const ProviderIconComponent = getProviderIcon(provider); // Use the first model's icon as the provider icon
const ProviderIconComponent =
models.length > 0 ? getModelIcon(models[0].id) : OpenCodeIcon;
return ( return (
<div key={provider} className="space-y-2"> <div key={provider} className="space-y-2">

View File

@@ -81,7 +81,7 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
return; return;
} }
const status = await api.specRegeneration.status(); const status = await api.specRegeneration.status(currentProject.path);
logger.debug( logger.debug(
'[useSpecGeneration] Status check on mount:', '[useSpecGeneration] Status check on mount:',
status, status,
@@ -90,9 +90,7 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
); );
if (status.success && status.isRunning) { if (status.success && status.isRunning) {
logger.debug( logger.debug('[useSpecGeneration] Spec generation is running for this project.');
'[useSpecGeneration] Spec generation is running globally. Tentatively showing loader.'
);
setIsCreating(true); setIsCreating(true);
setIsRegenerating(true); setIsRegenerating(true);
@@ -143,7 +141,7 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
const api = getElectronAPI(); const api = getElectronAPI();
if (!api.specRegeneration) return; if (!api.specRegeneration) return;
const status = await api.specRegeneration.status(); const status = await api.specRegeneration.status(currentProject.path);
logger.debug('[useSpecGeneration] Visibility change - status check:', status); logger.debug('[useSpecGeneration] Visibility change - status check:', status);
if (!status.isRunning) { if (!status.isRunning) {
@@ -180,7 +178,7 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
const api = getElectronAPI(); const api = getElectronAPI();
if (!api.specRegeneration) return; if (!api.specRegeneration) return;
const status = await api.specRegeneration.status(); const status = await api.specRegeneration.status(currentProject.path);
if (!status.isRunning) { if (!status.isRunning) {
logger.debug( logger.debug(

View File

@@ -21,9 +21,9 @@ export function useSpecLoading() {
// Check if spec generation is running before trying to load // Check if spec generation is running before trying to load
// This prevents showing "No App Specification Found" during generation // This prevents showing "No App Specification Found" during generation
if (api.specRegeneration) { if (api.specRegeneration) {
const status = await api.specRegeneration.status(); const status = await api.specRegeneration.status(currentProject.path);
if (status.success && status.isRunning) { if (status.success && status.isRunning) {
logger.debug('Spec generation is running, skipping load'); logger.debug('Spec generation is running for this project, skipping load');
setIsGenerationRunning(true); setIsGenerationRunning(true);
setIsLoading(false); setIsLoading(false);
return; return;

View File

@@ -44,6 +44,7 @@ import { getElectronAPI } from '@/lib/electron';
import { getApiKey, getSessionToken, getServerUrlSync } from '@/lib/http-api-client'; import { getApiKey, getSessionToken, getServerUrlSync } from '@/lib/http-api-client';
const logger = createLogger('Terminal'); const logger = createLogger('Terminal');
const NO_STORE_CACHE_MODE: RequestCache = 'no-store';
// Font size constraints // Font size constraints
const MIN_FONT_SIZE = 8; const MIN_FONT_SIZE = 8;
@@ -504,6 +505,7 @@ export function TerminalPanel({
const response = await fetch(`${serverUrl}/api/auth/token`, { const response = await fetch(`${serverUrl}/api/auth/token`, {
headers, headers,
credentials: 'include', credentials: 'include',
cache: NO_STORE_CACHE_MODE,
}); });
if (!response.ok) { if (!response.ok) {

View File

@@ -517,8 +517,9 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
} }
// Save theme to localStorage for fallback when server settings aren't available // Save theme to localStorage for fallback when server settings aren't available
if (settings.theme) { const storedTheme = (currentProject?.theme as string | undefined) || settings.theme;
setItem(THEME_STORAGE_KEY, settings.theme); if (storedTheme) {
setItem(THEME_STORAGE_KEY, storedTheme);
} }
useAppStore.setState({ useAppStore.setState({

View File

@@ -47,6 +47,7 @@ const SETTINGS_FIELDS_TO_SYNC = [
'autoLoadClaudeMd', 'autoLoadClaudeMd',
'keyboardShortcuts', 'keyboardShortcuts',
'mcpServers', 'mcpServers',
'defaultEditorCommand',
'promptCustomization', 'promptCustomization',
'projects', 'projects',
'trashedProjects', 'trashedProjects',
@@ -89,6 +90,7 @@ export function useSettingsSync(): SettingsSyncState {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated); const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const authChecked = useAuthStore((s) => s.authChecked); const authChecked = useAuthStore((s) => s.authChecked);
const settingsLoaded = useAuthStore((s) => s.settingsLoaded);
const syncTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const syncTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastSyncedRef = useRef<string>(''); const lastSyncedRef = useRef<string>('');
@@ -117,9 +119,17 @@ export function useSettingsSync(): SettingsSyncState {
// Debounced sync function // Debounced sync function
const syncToServer = useCallback(async () => { const syncToServer = useCallback(async () => {
try { try {
// Never sync when not authenticated (prevents overwriting server settings during logout/login transitions) // Never sync when not authenticated or settings not loaded
// The settingsLoaded flag ensures we don't sync default empty state before hydration
const auth = useAuthStore.getState(); const auth = useAuthStore.getState();
if (!auth.authChecked || !auth.isAuthenticated) { logger.debug('syncToServer check:', {
authChecked: auth.authChecked,
isAuthenticated: auth.isAuthenticated,
settingsLoaded: auth.settingsLoaded,
projectsCount: useAppStore.getState().projects?.length ?? 0,
});
if (!auth.authChecked || !auth.isAuthenticated || !auth.settingsLoaded) {
logger.debug('Sync skipped: not authenticated or settings not loaded');
return; return;
} }
@@ -127,6 +137,8 @@ export function useSettingsSync(): SettingsSyncState {
const api = getHttpApiClient(); const api = getHttpApiClient();
const appState = useAppStore.getState(); const appState = useAppStore.getState();
logger.debug('Syncing to server:', { projectsCount: appState.projects?.length ?? 0 });
// Build updates object from current state // Build updates object from current state
const updates: Record<string, unknown> = {}; const updates: Record<string, unknown> = {};
for (const field of SETTINGS_FIELDS_TO_SYNC) { for (const field of SETTINGS_FIELDS_TO_SYNC) {
@@ -147,10 +159,13 @@ export function useSettingsSync(): SettingsSyncState {
// Create a hash of the updates to avoid redundant syncs // Create a hash of the updates to avoid redundant syncs
const updateHash = JSON.stringify(updates); const updateHash = JSON.stringify(updates);
if (updateHash === lastSyncedRef.current) { if (updateHash === lastSyncedRef.current) {
logger.debug('Sync skipped: no changes');
setState((s) => ({ ...s, syncing: false })); setState((s) => ({ ...s, syncing: false }));
return; return;
} }
logger.info('Sending settings update:', { projects: updates.projects });
const result = await api.settings.updateGlobal(updates); const result = await api.settings.updateGlobal(updates);
if (result.success) { if (result.success) {
lastSyncedRef.current = updateHash; lastSyncedRef.current = updateHash;
@@ -184,11 +199,20 @@ export function useSettingsSync(): SettingsSyncState {
void syncToServer(); void syncToServer();
}, [syncToServer]); }, [syncToServer]);
// Initialize sync - WAIT for migration to complete first // Initialize sync - WAIT for settings to be loaded and migration to complete
useEffect(() => { useEffect(() => {
// Don't initialize syncing until we know auth status and are authenticated. // Don't initialize syncing until:
// Prevents accidental overwrites when the app boots before settings are hydrated. // 1. Auth has been checked
if (!authChecked || !isAuthenticated) return; // 2. User is authenticated
// 3. Settings have been loaded from server (settingsLoaded flag)
// This prevents syncing empty/default state before hydration completes.
logger.debug('useSettingsSync initialization check:', {
authChecked,
isAuthenticated,
settingsLoaded,
stateLoaded: state.loaded,
});
if (!authChecked || !isAuthenticated || !settingsLoaded) return;
if (isInitializedRef.current) return; if (isInitializedRef.current) return;
isInitializedRef.current = true; isInitializedRef.current = true;
@@ -198,14 +222,26 @@ export function useSettingsSync(): SettingsSyncState {
await waitForApiKeyInit(); await waitForApiKeyInit();
// CRITICAL: Wait for migration/hydration to complete before we start syncing // CRITICAL: Wait for migration/hydration to complete before we start syncing
// This prevents overwriting server data with empty/default state // This is a backup to the settingsLoaded flag for extra safety
logger.info('Waiting for migration to complete before starting sync...'); logger.info('Waiting for migration to complete before starting sync...');
await waitForMigrationComplete(); await waitForMigrationComplete();
// Wait for React to finish rendering after store hydration.
// Zustand's subscribe() fires during setState(), which happens BEFORE React's
// render completes. Use a small delay to ensure all pending state updates
// have propagated through the React tree before we read state.
await new Promise((resolve) => setTimeout(resolve, 50));
logger.info('Migration complete, initializing sync'); logger.info('Migration complete, initializing sync');
// Read state - at this point React has processed the store update
const appState = useAppStore.getState();
const setupState = useSetupStore.getState();
logger.info('Initial state read:', { projectsCount: appState.projects?.length ?? 0 });
// Store the initial state hash to avoid immediate re-sync // Store the initial state hash to avoid immediate re-sync
// (migration has already hydrated the store from server/localStorage) // (migration has already hydrated the store from server/localStorage)
const appState = useAppStore.getState();
const updates: Record<string, unknown> = {}; const updates: Record<string, unknown> = {};
for (const field of SETTINGS_FIELDS_TO_SYNC) { for (const field of SETTINGS_FIELDS_TO_SYNC) {
if (field === 'currentProjectId') { if (field === 'currentProjectId') {
@@ -214,7 +250,6 @@ export function useSettingsSync(): SettingsSyncState {
updates[field] = appState[field as keyof typeof appState]; updates[field] = appState[field as keyof typeof appState];
} }
} }
const setupState = useSetupStore.getState();
for (const field of SETUP_FIELDS_TO_SYNC) { for (const field of SETUP_FIELDS_TO_SYNC) {
updates[field] = setupState[field as keyof typeof setupState]; updates[field] = setupState[field as keyof typeof setupState];
} }
@@ -233,16 +268,33 @@ export function useSettingsSync(): SettingsSyncState {
} }
initializeSync(); initializeSync();
}, [authChecked, isAuthenticated]); }, [authChecked, isAuthenticated, settingsLoaded]);
// Subscribe to store changes and sync to server // Subscribe to store changes and sync to server
useEffect(() => { useEffect(() => {
if (!state.loaded || !authChecked || !isAuthenticated) return; if (!state.loaded || !authChecked || !isAuthenticated || !settingsLoaded) return;
// Subscribe to app store changes // Subscribe to app store changes
const unsubscribeApp = useAppStore.subscribe((newState, prevState) => { const unsubscribeApp = useAppStore.subscribe((newState, prevState) => {
const auth = useAuthStore.getState();
logger.debug('Store subscription fired:', {
prevProjects: prevState.projects?.length ?? 0,
newProjects: newState.projects?.length ?? 0,
authChecked: auth.authChecked,
isAuthenticated: auth.isAuthenticated,
settingsLoaded: auth.settingsLoaded,
loaded: state.loaded,
});
// Don't sync if settings not loaded yet
if (!auth.settingsLoaded) {
logger.debug('Store changed but settings not loaded, skipping sync');
return;
}
// If the current project changed, sync immediately so we can restore on next launch // If the current project changed, sync immediately so we can restore on next launch
if (newState.currentProject?.id !== prevState.currentProject?.id) { if (newState.currentProject?.id !== prevState.currentProject?.id) {
logger.debug('Current project changed, syncing immediately');
syncNow(); syncNow();
return; return;
} }
@@ -266,6 +318,7 @@ export function useSettingsSync(): SettingsSyncState {
} }
if (changed) { if (changed) {
logger.debug('Store changed, scheduling sync');
scheduleSyncToServer(); scheduleSyncToServer();
} }
}); });
@@ -294,11 +347,11 @@ export function useSettingsSync(): SettingsSyncState {
clearTimeout(syncTimeoutRef.current); clearTimeout(syncTimeoutRef.current);
} }
}; };
}, [state.loaded, authChecked, isAuthenticated, scheduleSyncToServer, syncNow]); }, [state.loaded, authChecked, isAuthenticated, settingsLoaded, scheduleSyncToServer, syncNow]);
// Best-effort flush on tab close / backgrounding // Best-effort flush on tab close / backgrounding
useEffect(() => { useEffect(() => {
if (!state.loaded || !authChecked || !isAuthenticated) return; if (!state.loaded || !authChecked || !isAuthenticated || !settingsLoaded) return;
const handleBeforeUnload = () => { const handleBeforeUnload = () => {
// Fire-and-forget; may not complete in all browsers, but helps in Electron/webview // Fire-and-forget; may not complete in all browsers, but helps in Electron/webview
@@ -318,7 +371,7 @@ export function useSettingsSync(): SettingsSyncState {
window.removeEventListener('beforeunload', handleBeforeUnload); window.removeEventListener('beforeunload', handleBeforeUnload);
document.removeEventListener('visibilitychange', handleVisibilityChange); document.removeEventListener('visibilitychange', handleVisibilityChange);
}; };
}, [state.loaded, authChecked, isAuthenticated, syncNow]); }, [state.loaded, authChecked, isAuthenticated, settingsLoaded, syncNow]);
return state; return state;
} }
@@ -399,6 +452,7 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
>), >),
}, },
mcpServers: serverSettings.mcpServers, mcpServers: serverSettings.mcpServers,
defaultEditorCommand: serverSettings.defaultEditorCommand ?? null,
promptCustomization: serverSettings.promptCustomization ?? {}, promptCustomization: serverSettings.promptCustomization ?? {},
projects: serverSettings.projects, projects: serverSettings.projects,
trashedProjects: serverSettings.trashedProjects, trashedProjects: serverSettings.trashedProjects,

View File

@@ -13,6 +13,7 @@ import { getApiKey, getSessionToken, getServerUrlSync } from './http-api-client'
// Server URL - uses shared cached URL from http-api-client // Server URL - uses shared cached URL from http-api-client
const getServerUrl = (): string => getServerUrlSync(); const getServerUrl = (): string => getServerUrlSync();
const DEFAULT_CACHE_MODE: RequestCache = 'no-store';
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
@@ -80,7 +81,7 @@ export async function apiFetch(
method: HttpMethod = 'GET', method: HttpMethod = 'GET',
options: ApiFetchOptions = {} options: ApiFetchOptions = {}
): Promise<Response> { ): Promise<Response> {
const { headers: additionalHeaders, body, skipAuth, ...restOptions } = options; const { headers: additionalHeaders, body, skipAuth, cache, ...restOptions } = options;
const headers = skipAuth const headers = skipAuth
? { 'Content-Type': 'application/json', ...additionalHeaders } ? { 'Content-Type': 'application/json', ...additionalHeaders }
@@ -90,6 +91,7 @@ export async function apiFetch(
method, method,
headers, headers,
credentials: 'include', credentials: 'include',
cache: cache ?? DEFAULT_CACHE_MODE,
...restOptions, ...restOptions,
}; };

View File

@@ -433,11 +433,12 @@ export interface SpecRegenerationAPI {
success: boolean; success: boolean;
error?: string; error?: string;
}>; }>;
stop: () => Promise<{ success: boolean; error?: string }>; stop: (projectPath?: string) => Promise<{ success: boolean; error?: string }>;
status: () => Promise<{ status: (projectPath?: string) => Promise<{
success: boolean; success: boolean;
isRunning?: boolean; isRunning?: boolean;
currentPhase?: string; currentPhase?: string;
projectPath?: string;
error?: string; error?: string;
}>; }>;
onEvent: (callback: (event: SpecRegenerationEvent) => void) => () => void; onEvent: (callback: (event: SpecRegenerationEvent) => void) => () => void;
@@ -461,7 +462,8 @@ export interface FeaturesAPI {
featureId: string, featureId: string,
updates: Partial<Feature>, updates: Partial<Feature>,
descriptionHistorySource?: 'enhance' | 'edit', descriptionHistorySource?: 'enhance' | 'edit',
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer',
preEnhancementDescription?: string
) => Promise<{ success: boolean; feature?: Feature; error?: string }>; ) => Promise<{ success: boolean; feature?: Feature; error?: string }>;
delete: (projectPath: string, featureId: string) => Promise<{ success: boolean; error?: string }>; delete: (projectPath: string, featureId: string) => Promise<{ success: boolean; error?: string }>;
getAgentOutput: ( getAgentOutput: (
@@ -532,6 +534,9 @@ export interface AutoModeAPI {
editedPlan?: string, editedPlan?: string,
feedback?: string feedback?: string
) => Promise<{ success: boolean; error?: string }>; ) => Promise<{ success: boolean; error?: string }>;
resumeInterrupted: (
projectPath: string
) => Promise<{ success: boolean; message?: string; error?: string }>;
onEvent: (callback: (event: AutoModeEvent) => void) => () => void; onEvent: (callback: (event: AutoModeEvent) => void) => () => void;
} }
@@ -608,7 +613,8 @@ export interface ElectronAPI {
enhance: ( enhance: (
originalText: string, originalText: string,
enhancementMode: string, enhancementMode: string,
model?: string model?: string,
thinkingLevel?: string
) => Promise<{ ) => Promise<{
success: boolean; success: boolean;
enhancedText?: string; enhancedText?: string;
@@ -1639,13 +1645,34 @@ function createMockWorktreeAPI(): WorktreeAPI {
}; };
}, },
openInEditor: async (worktreePath: string) => { openInEditor: async (worktreePath: string, editorCommand?: string) => {
console.log('[Mock] Opening in editor:', worktreePath); const ANTIGRAVITY_EDITOR_COMMAND = 'antigravity';
const ANTIGRAVITY_LEGACY_COMMAND = 'agy';
// Map editor commands to display names
const editorNameMap: Record<string, string> = {
cursor: 'Cursor',
code: 'VS Code',
zed: 'Zed',
subl: 'Sublime Text',
windsurf: 'Windsurf',
trae: 'Trae',
rider: 'Rider',
webstorm: 'WebStorm',
xed: 'Xcode',
studio: 'Android Studio',
[ANTIGRAVITY_EDITOR_COMMAND]: 'Antigravity',
[ANTIGRAVITY_LEGACY_COMMAND]: 'Antigravity',
open: 'Finder',
explorer: 'Explorer',
'xdg-open': 'File Manager',
};
const editorName = editorCommand ? (editorNameMap[editorCommand] ?? 'Editor') : 'VS Code';
console.log('[Mock] Opening in editor:', worktreePath, 'using:', editorName);
return { return {
success: true, success: true,
result: { result: {
message: `Opened ${worktreePath} in VS Code`, message: `Opened ${worktreePath} in ${editorName}`,
editorName: 'VS Code', editorName,
}, },
}; };
}, },
@@ -1661,6 +1688,32 @@ function createMockWorktreeAPI(): WorktreeAPI {
}; };
}, },
getAvailableEditors: async () => {
console.log('[Mock] Getting available editors');
return {
success: true,
result: {
editors: [
{ name: 'VS Code', command: 'code' },
{ name: 'Finder', command: 'open' },
],
},
};
},
refreshEditors: async () => {
console.log('[Mock] Refreshing available editors');
return {
success: true,
result: {
editors: [
{ name: 'VS Code', command: 'code' },
{ name: 'Finder', command: 'open' },
],
message: 'Found 2 available editors',
},
};
},
initGit: async (projectPath: string) => { initGit: async (projectPath: string) => {
console.log('[Mock] Initializing git:', projectPath); console.log('[Mock] Initializing git:', projectPath);
return { return {
@@ -2110,6 +2163,11 @@ function createMockAutoModeAPI(): AutoModeAPI {
return { success: true }; return { success: true };
}, },
resumeInterrupted: async (projectPath: string) => {
console.log('[Mock] Resume interrupted features for:', projectPath);
return { success: true, message: 'Mock: no interrupted features' };
},
onEvent: (callback: (event: AutoModeEvent) => void) => { onEvent: (callback: (event: AutoModeEvent) => void) => {
mockAutoModeCallbacks.push(callback); mockAutoModeCallbacks.push(callback);
return () => { return () => {
@@ -2539,7 +2597,7 @@ function createMockSpecRegenerationAPI(): SpecRegenerationAPI {
return { success: true }; return { success: true };
}, },
stop: async () => { stop: async (_projectPath?: string) => {
mockSpecRegenerationRunning = false; mockSpecRegenerationRunning = false;
mockSpecRegenerationPhase = ''; mockSpecRegenerationPhase = '';
if (mockSpecRegenerationTimeout) { if (mockSpecRegenerationTimeout) {
@@ -2549,7 +2607,7 @@ function createMockSpecRegenerationAPI(): SpecRegenerationAPI {
return { success: true }; return { success: true };
}, },
status: async () => { status: async (_projectPath?: string) => {
return { return {
success: true, success: true,
isRunning: mockSpecRegenerationRunning, isRunning: mockSpecRegenerationRunning,

View File

@@ -39,6 +39,7 @@ import type { WorktreeAPI, GitAPI, ModelDefinition, ProviderStatus } from '@/typ
import { getGlobalFileBrowser } from '@/contexts/file-browser-context'; import { getGlobalFileBrowser } from '@/contexts/file-browser-context';
const logger = createLogger('HttpClient'); const logger = createLogger('HttpClient');
const NO_STORE_CACHE_MODE: RequestCache = 'no-store';
// Cached server URL (set during initialization in Electron mode) // Cached server URL (set during initialization in Electron mode)
let cachedServerUrl: string | null = null; let cachedServerUrl: string | null = null;
@@ -69,6 +70,7 @@ const handleUnauthorized = (): void => {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
credentials: 'include', credentials: 'include',
body: '{}', body: '{}',
cache: NO_STORE_CACHE_MODE,
}).catch(() => {}); }).catch(() => {});
notifyLoggedOut(); notifyLoggedOut();
}; };
@@ -296,6 +298,7 @@ export const checkAuthStatus = async (): Promise<{
const response = await fetch(`${getServerUrl()}/api/auth/status`, { const response = await fetch(`${getServerUrl()}/api/auth/status`, {
credentials: 'include', credentials: 'include',
headers: getApiKey() ? { 'X-API-Key': getApiKey()! } : undefined, headers: getApiKey() ? { 'X-API-Key': getApiKey()! } : undefined,
cache: NO_STORE_CACHE_MODE,
}); });
const data = await response.json(); const data = await response.json();
return { return {
@@ -322,6 +325,7 @@ export const login = async (
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
credentials: 'include', credentials: 'include',
body: JSON.stringify({ apiKey }), body: JSON.stringify({ apiKey }),
cache: NO_STORE_CACHE_MODE,
}); });
const data = await response.json(); const data = await response.json();
@@ -361,6 +365,7 @@ export const fetchSessionToken = async (): Promise<boolean> => {
try { try {
const response = await fetch(`${getServerUrl()}/api/auth/status`, { const response = await fetch(`${getServerUrl()}/api/auth/status`, {
credentials: 'include', // Send the session cookie credentials: 'include', // Send the session cookie
cache: NO_STORE_CACHE_MODE,
}); });
if (!response.ok) { if (!response.ok) {
@@ -391,6 +396,7 @@ export const logout = async (): Promise<{ success: boolean }> => {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
credentials: 'include', credentials: 'include',
cache: NO_STORE_CACHE_MODE,
}); });
// Clear the cached session token // Clear the cached session token
@@ -439,6 +445,7 @@ export const verifySession = async (): Promise<boolean> => {
const response = await fetch(`${getServerUrl()}/api/settings/status`, { const response = await fetch(`${getServerUrl()}/api/settings/status`, {
headers, headers,
credentials: 'include', credentials: 'include',
cache: NO_STORE_CACHE_MODE,
// Avoid hanging indefinitely during backend reloads or network issues // Avoid hanging indefinitely during backend reloads or network issues
signal: AbortSignal.timeout(2500), signal: AbortSignal.timeout(2500),
}); });
@@ -475,6 +482,7 @@ export const checkSandboxEnvironment = async (): Promise<{
try { try {
const response = await fetch(`${getServerUrl()}/api/health/environment`, { const response = await fetch(`${getServerUrl()}/api/health/environment`, {
method: 'GET', method: 'GET',
cache: NO_STORE_CACHE_MODE,
signal: AbortSignal.timeout(5000), signal: AbortSignal.timeout(5000),
}); });
@@ -559,6 +567,7 @@ export class HttpApiClient implements ElectronAPI {
const response = await fetch(`${this.serverUrl}/api/auth/token`, { const response = await fetch(`${this.serverUrl}/api/auth/token`, {
headers, headers,
credentials: 'include', credentials: 'include',
cache: NO_STORE_CACHE_MODE,
}); });
if (response.status === 401 || response.status === 403) { if (response.status === 401 || response.status === 403) {
@@ -590,6 +599,17 @@ export class HttpApiClient implements ElectronAPI {
this.isConnecting = true; this.isConnecting = true;
// Wait for API key initialization to complete before attempting connection
// This prevents race conditions during app startup
waitForApiKeyInit()
.then(() => this.doConnectWebSocketInternal())
.catch((error) => {
logger.error('Failed to initialize for WebSocket connection:', error);
this.isConnecting = false;
});
}
private doConnectWebSocketInternal(): void {
// Electron mode typically authenticates with the injected API key. // Electron mode typically authenticates with the injected API key.
// However, in external-server/cookie-auth flows, the API key may be unavailable. // However, in external-server/cookie-auth flows, the API key may be unavailable.
// In that case, fall back to the same wsToken/cookie authentication used in web mode // In that case, fall back to the same wsToken/cookie authentication used in web mode
@@ -774,6 +794,7 @@ export class HttpApiClient implements ElectronAPI {
const response = await fetch(`${this.serverUrl}${endpoint}`, { const response = await fetch(`${this.serverUrl}${endpoint}`, {
headers: this.getHeaders(), headers: this.getHeaders(),
credentials: 'include', // Include cookies for session auth credentials: 'include', // Include cookies for session auth
cache: NO_STORE_CACHE_MODE,
}); });
if (response.status === 401 || response.status === 403) { if (response.status === 401 || response.status === 403) {
@@ -1442,6 +1463,16 @@ export class HttpApiClient implements ElectronAPI {
features?: Feature[]; features?: Feature[];
error?: string; error?: string;
}>; }>;
bulkDelete: (
projectPath: string,
featureIds: string[]
) => Promise<{
success: boolean;
deletedCount?: number;
failedCount?: number;
results?: Array<{ featureId: string; success: boolean; error?: string }>;
error?: string;
}>;
} = { } = {
getAll: (projectPath: string) => this.post('/api/features/list', { projectPath }), getAll: (projectPath: string) => this.post('/api/features/list', { projectPath }),
get: (projectPath: string, featureId: string) => get: (projectPath: string, featureId: string) =>
@@ -1453,7 +1484,8 @@ export class HttpApiClient implements ElectronAPI {
featureId: string, featureId: string,
updates: Partial<Feature>, updates: Partial<Feature>,
descriptionHistorySource?: 'enhance' | 'edit', descriptionHistorySource?: 'enhance' | 'edit',
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer',
preEnhancementDescription?: string
) => ) =>
this.post('/api/features/update', { this.post('/api/features/update', {
projectPath, projectPath,
@@ -1461,6 +1493,7 @@ export class HttpApiClient implements ElectronAPI {
updates, updates,
descriptionHistorySource, descriptionHistorySource,
enhancementMode, enhancementMode,
preEnhancementDescription,
}), }),
delete: (projectPath: string, featureId: string) => delete: (projectPath: string, featureId: string) =>
this.post('/api/features/delete', { projectPath, featureId }), this.post('/api/features/delete', { projectPath, featureId }),
@@ -1470,6 +1503,8 @@ export class HttpApiClient implements ElectronAPI {
this.post('/api/features/generate-title', { description }), this.post('/api/features/generate-title', { description }),
bulkUpdate: (projectPath: string, featureIds: string[], updates: Partial<Feature>) => bulkUpdate: (projectPath: string, featureIds: string[], updates: Partial<Feature>) =>
this.post('/api/features/bulk-update', { projectPath, featureIds, updates }), this.post('/api/features/bulk-update', { projectPath, featureIds, updates }),
bulkDelete: (projectPath: string, featureIds: string[]) =>
this.post('/api/features/bulk-delete', { projectPath, featureIds }),
}; };
// Auto Mode API // Auto Mode API
@@ -1537,6 +1572,8 @@ export class HttpApiClient implements ElectronAPI {
editedPlan, editedPlan,
feedback, feedback,
}), }),
resumeInterrupted: (projectPath: string) =>
this.post('/api/auto-mode/resume-interrupted', { projectPath }),
onEvent: (callback: (event: AutoModeEvent) => void) => { onEvent: (callback: (event: AutoModeEvent) => void) => {
return this.subscribeToEvent('auto-mode:event', callback as EventCallback); return this.subscribeToEvent('auto-mode:event', callback as EventCallback);
}, },
@@ -1602,9 +1639,11 @@ export class HttpApiClient implements ElectronAPI {
this.post('/api/worktree/list-branches', { worktreePath }), this.post('/api/worktree/list-branches', { worktreePath }),
switchBranch: (worktreePath: string, branchName: string) => switchBranch: (worktreePath: string, branchName: string) =>
this.post('/api/worktree/switch-branch', { worktreePath, branchName }), this.post('/api/worktree/switch-branch', { worktreePath, branchName }),
openInEditor: (worktreePath: string) => openInEditor: (worktreePath: string, editorCommand?: string) =>
this.post('/api/worktree/open-in-editor', { worktreePath }), this.post('/api/worktree/open-in-editor', { worktreePath, editorCommand }),
getDefaultEditor: () => this.get('/api/worktree/default-editor'), getDefaultEditor: () => this.get('/api/worktree/default-editor'),
getAvailableEditors: () => this.get('/api/worktree/available-editors'),
refreshEditors: () => this.post('/api/worktree/refresh-editors', {}),
initGit: (projectPath: string) => this.post('/api/worktree/init-git', { projectPath }), initGit: (projectPath: string) => this.post('/api/worktree/init-git', { projectPath }),
startDevServer: (projectPath: string, worktreePath: string) => startDevServer: (projectPath: string, worktreePath: string) =>
this.post('/api/worktree/start-dev', { projectPath, worktreePath }), this.post('/api/worktree/start-dev', { projectPath, worktreePath }),
@@ -1703,8 +1742,13 @@ export class HttpApiClient implements ElectronAPI {
projectPath, projectPath,
maxFeatures, maxFeatures,
}), }),
stop: () => this.post('/api/spec-regeneration/stop'), stop: (projectPath?: string) => this.post('/api/spec-regeneration/stop', { projectPath }),
status: () => this.get('/api/spec-regeneration/status'), status: (projectPath?: string) =>
this.get(
projectPath
? `/api/spec-regeneration/status?projectPath=${encodeURIComponent(projectPath)}`
: '/api/spec-regeneration/status'
),
onEvent: (callback: (event: SpecRegenerationEvent) => void) => { onEvent: (callback: (event: SpecRegenerationEvent) => void) => {
return this.subscribeToEvent('spec-regeneration:event', callback as EventCallback); return this.subscribeToEvent('spec-regeneration:event', callback as EventCallback);
}, },

View File

@@ -12,12 +12,14 @@ import { useSetupStore } from '@/store/setup-store';
import { useAuthStore } from '@/store/auth-store'; import { useAuthStore } from '@/store/auth-store';
import { getElectronAPI, isElectron } from '@/lib/electron'; import { getElectronAPI, isElectron } from '@/lib/electron';
import { isMac } from '@/lib/utils'; import { isMac } from '@/lib/utils';
import { initializeProject } from '@/lib/project-init';
import { import {
initApiKey, initApiKey,
verifySession, verifySession,
checkSandboxEnvironment, checkSandboxEnvironment,
getServerUrlSync, getServerUrlSync,
getHttpApiClient, getHttpApiClient,
handleServerOffline,
} from '@/lib/http-api-client'; } from '@/lib/http-api-client';
import { import {
hydrateStoreFromSettings, hydrateStoreFromSettings,
@@ -30,8 +32,23 @@ import { SandboxRiskDialog } from '@/components/dialogs/sandbox-risk-dialog';
import { SandboxRejectionScreen } from '@/components/dialogs/sandbox-rejection-screen'; import { SandboxRejectionScreen } from '@/components/dialogs/sandbox-rejection-screen';
import { LoadingState } from '@/components/ui/loading-state'; import { LoadingState } from '@/components/ui/loading-state';
import { useProjectSettingsLoader } from '@/hooks/use-project-settings-loader'; import { useProjectSettingsLoader } from '@/hooks/use-project-settings-loader';
import type { Project } from '@/lib/electron';
const logger = createLogger('RootLayout'); const logger = createLogger('RootLayout');
const SERVER_READY_MAX_ATTEMPTS = 8;
const SERVER_READY_BACKOFF_BASE_MS = 250;
const SERVER_READY_MAX_DELAY_MS = 1500;
const SERVER_READY_TIMEOUT_MS = 2000;
const NO_STORE_CACHE_MODE: RequestCache = 'no-store';
const AUTO_OPEN_HISTORY_INDEX = 0;
const SINGLE_PROJECT_COUNT = 1;
const DEFAULT_LAST_OPENED_TIME_MS = 0;
const AUTO_OPEN_STATUS = {
idle: 'idle',
opening: 'opening',
done: 'done',
} as const;
type AutoOpenStatus = (typeof AUTO_OPEN_STATUS)[keyof typeof AUTO_OPEN_STATUS];
// Apply stored theme immediately on page load (before React hydration) // Apply stored theme immediately on page load (before React hydration)
// This prevents flash of default theme on login/setup pages // This prevents flash of default theme on login/setup pages
@@ -60,11 +77,84 @@ function applyStoredTheme(): void {
// Apply stored theme immediately (runs synchronously before render) // Apply stored theme immediately (runs synchronously before render)
applyStoredTheme(); applyStoredTheme();
async function waitForServerReady(): Promise<boolean> {
const serverUrl = getServerUrlSync();
for (let attempt = 1; attempt <= SERVER_READY_MAX_ATTEMPTS; attempt++) {
try {
const response = await fetch(`${serverUrl}/api/health`, {
method: 'GET',
signal: AbortSignal.timeout(SERVER_READY_TIMEOUT_MS),
cache: NO_STORE_CACHE_MODE,
});
if (response.ok) {
return true;
}
} catch (error) {
logger.warn(`Server readiness check failed (attempt ${attempt})`, error);
}
const delayMs = Math.min(SERVER_READY_MAX_DELAY_MS, SERVER_READY_BACKOFF_BASE_MS * attempt);
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
return false;
}
function getProjectLastOpenedMs(project: Project): number {
if (!project.lastOpened) return DEFAULT_LAST_OPENED_TIME_MS;
const parsed = Date.parse(project.lastOpened);
return Number.isNaN(parsed) ? DEFAULT_LAST_OPENED_TIME_MS : parsed;
}
function selectAutoOpenProject(
currentProject: Project | null,
projects: Project[],
projectHistory: string[]
): Project | null {
if (currentProject) return currentProject;
if (projectHistory.length > 0) {
const historyProjectId = projectHistory[AUTO_OPEN_HISTORY_INDEX];
const historyProject = projects.find((project) => project.id === historyProjectId);
if (historyProject) {
return historyProject;
}
}
if (projects.length === SINGLE_PROJECT_COUNT) {
return projects[AUTO_OPEN_HISTORY_INDEX] ?? null;
}
if (projects.length > SINGLE_PROJECT_COUNT) {
let latestProject: Project | null = projects[AUTO_OPEN_HISTORY_INDEX] ?? null;
let latestTimestamp = latestProject
? getProjectLastOpenedMs(latestProject)
: DEFAULT_LAST_OPENED_TIME_MS;
for (const project of projects) {
const openedAt = getProjectLastOpenedMs(project);
if (openedAt > latestTimestamp) {
latestTimestamp = openedAt;
latestProject = project;
}
}
return latestProject;
}
return null;
}
function RootLayoutContent() { function RootLayoutContent() {
const location = useLocation(); const location = useLocation();
const { const {
setIpcConnected, setIpcConnected,
projects,
currentProject, currentProject,
projectHistory,
upsertAndSetCurrentProject,
getEffectiveTheme, getEffectiveTheme,
skipSandboxWarning, skipSandboxWarning,
setSkipSandboxWarning, setSkipSandboxWarning,
@@ -76,6 +166,7 @@ function RootLayoutContent() {
const [streamerPanelOpen, setStreamerPanelOpen] = useState(false); const [streamerPanelOpen, setStreamerPanelOpen] = useState(false);
const authChecked = useAuthStore((s) => s.authChecked); const authChecked = useAuthStore((s) => s.authChecked);
const isAuthenticated = useAuthStore((s) => s.isAuthenticated); const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const settingsLoaded = useAuthStore((s) => s.settingsLoaded);
const { openFileBrowser } = useFileBrowser(); const { openFileBrowser } = useFileBrowser();
// Load project settings when switching projects // Load project settings when switching projects
@@ -85,6 +176,22 @@ function RootLayoutContent() {
const isLoginRoute = location.pathname === '/login'; const isLoginRoute = location.pathname === '/login';
const isLoggedOutRoute = location.pathname === '/logged-out'; const isLoggedOutRoute = location.pathname === '/logged-out';
const isDashboardRoute = location.pathname === '/dashboard'; const isDashboardRoute = location.pathname === '/dashboard';
const isBoardRoute = location.pathname === '/board';
const isRootRoute = location.pathname === '/';
const [autoOpenStatus, setAutoOpenStatus] = useState<AutoOpenStatus>(AUTO_OPEN_STATUS.idle);
const autoOpenCandidate = selectAutoOpenProject(currentProject, projects, projectHistory);
const canAutoOpen =
authChecked &&
isAuthenticated &&
settingsLoaded &&
setupComplete &&
!isLoginRoute &&
!isLoggedOutRoute &&
!isSetupRoute &&
!!autoOpenCandidate;
const shouldAutoOpen = canAutoOpen && autoOpenStatus !== AUTO_OPEN_STATUS.done;
const shouldBlockForSettings =
authChecked && isAuthenticated && !settingsLoaded && !isLoginRoute && !isLoggedOutRoute;
// Sandbox environment check state // Sandbox environment check state
type SandboxStatus = 'pending' | 'containerized' | 'needs-confirmation' | 'denied' | 'confirmed'; type SandboxStatus = 'pending' | 'containerized' | 'needs-confirmation' | 'denied' | 'confirmed';
@@ -218,9 +325,11 @@ function RootLayoutContent() {
// Works for ALL modes (unified flow) // Works for ALL modes (unified flow)
useEffect(() => { useEffect(() => {
const handleLoggedOut = () => { const handleLoggedOut = () => {
logger.warn('automaker:logged-out event received!');
useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true }); useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
if (location.pathname !== '/logged-out') { if (location.pathname !== '/logged-out') {
logger.warn('Navigating to /logged-out due to logged-out event');
navigate({ to: '/logged-out' }); navigate({ to: '/logged-out' });
} }
}; };
@@ -236,6 +345,7 @@ function RootLayoutContent() {
// Redirects to login page which will detect server is offline and show error UI. // Redirects to login page which will detect server is offline and show error UI.
useEffect(() => { useEffect(() => {
const handleServerOffline = () => { const handleServerOffline = () => {
logger.warn('automaker:server-offline event received!');
useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true }); useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
// Navigate to login - the login page will detect server is offline and show appropriate UI // Navigate to login - the login page will detect server is offline and show appropriate UI
@@ -266,6 +376,12 @@ function RootLayoutContent() {
// Initialize API key for Electron mode // Initialize API key for Electron mode
await initApiKey(); await initApiKey();
const serverReady = await waitForServerReady();
if (!serverReady) {
handleServerOffline();
return;
}
// 1. Verify session (Single Request, ALL modes) // 1. Verify session (Single Request, ALL modes)
let isValid = false; let isValid = false;
try { try {
@@ -302,13 +418,28 @@ function RootLayoutContent() {
// Hydrate store with the final settings (merged if migration occurred) // Hydrate store with the final settings (merged if migration occurred)
hydrateStoreFromSettings(finalSettings); hydrateStoreFromSettings(finalSettings);
// Signal that settings hydration is complete so useSettingsSync can start // CRITICAL: Wait for React to render the hydrated state before
// signaling completion. Zustand updates are synchronous, but React
// hasn't necessarily re-rendered yet. This prevents race conditions
// where useSettingsSync reads state before the UI has updated.
await new Promise((resolve) => setTimeout(resolve, 0));
// Signal that settings hydration is complete FIRST.
// This ensures useSettingsSync's waitForMigrationComplete() will resolve
// immediately when it starts after auth state change, preventing it from
// syncing default empty state to the server.
signalMigrationComplete(); signalMigrationComplete();
// Mark auth as checked only after settings hydration succeeded. // Now mark auth as checked AND settings as loaded.
useAuthStore // The settingsLoaded flag ensures useSettingsSync won't start syncing
.getState() // until settings have been properly hydrated, even if authChecked was
.setAuthState({ isAuthenticated: true, authChecked: true }); // set earlier by login-view.
useAuthStore.getState().setAuthState({
isAuthenticated: true,
authChecked: true,
settingsLoaded: true,
});
return; return;
} }
@@ -373,17 +504,38 @@ function RootLayoutContent() {
// - If authenticated but setup incomplete: force /setup // - If authenticated but setup incomplete: force /setup
// - If authenticated and setup complete: allow access to app // - If authenticated and setup complete: allow access to app
useEffect(() => { useEffect(() => {
logger.debug('Routing effect triggered:', {
authChecked,
isAuthenticated,
settingsLoaded,
setupComplete,
pathname: location.pathname,
});
// Wait for auth check to complete before enforcing any redirects // Wait for auth check to complete before enforcing any redirects
if (!authChecked) return; if (!authChecked) {
logger.debug('Auth not checked yet, skipping routing');
return;
}
// Unauthenticated -> force /logged-out (but allow /login so user can authenticate) // Unauthenticated -> force /logged-out (but allow /login so user can authenticate)
if (!isAuthenticated) { if (!isAuthenticated) {
logger.warn('Not authenticated, redirecting to /logged-out. Auth state:', {
authChecked,
isAuthenticated,
settingsLoaded,
currentPath: location.pathname,
});
if (location.pathname !== '/logged-out' && location.pathname !== '/login') { if (location.pathname !== '/logged-out' && location.pathname !== '/login') {
navigate({ to: '/logged-out' }); navigate({ to: '/logged-out' });
} }
return; return;
} }
// Wait for settings to be loaded before making setupComplete-based routing decisions
// This prevents redirecting to /setup before we know the actual setupComplete value
if (!settingsLoaded) return;
// Authenticated -> determine whether setup is required // Authenticated -> determine whether setup is required
if (!setupComplete && location.pathname !== '/setup') { if (!setupComplete && location.pathname !== '/setup') {
navigate({ to: '/setup' }); navigate({ to: '/setup' });
@@ -394,7 +546,46 @@ function RootLayoutContent() {
if (setupComplete && location.pathname === '/setup') { if (setupComplete && location.pathname === '/setup') {
navigate({ to: '/dashboard' }); navigate({ to: '/dashboard' });
} }
}, [authChecked, isAuthenticated, setupComplete, location.pathname, navigate]); }, [authChecked, isAuthenticated, settingsLoaded, setupComplete, location.pathname, navigate]);
// Fallback: If auth is checked and authenticated but settings not loaded,
// it means login-view or another component set auth state before __root.tsx's
// auth flow completed. Load settings now to prevent sync with empty state.
useEffect(() => {
// Only trigger if auth is valid but settings aren't loaded yet
// This handles the case where login-view sets authChecked=true before we finish our auth flow
if (!authChecked || !isAuthenticated || settingsLoaded) {
logger.debug('Fallback skipped:', { authChecked, isAuthenticated, settingsLoaded });
return;
}
logger.info('Auth valid but settings not loaded - triggering fallback load');
const loadSettings = async () => {
const api = getHttpApiClient();
try {
logger.debug('Fetching settings in fallback...');
const settingsResult = await api.settings.getGlobal();
logger.debug('Settings fetched:', settingsResult.success ? 'success' : 'failed');
if (settingsResult.success && settingsResult.settings) {
const { settings: finalSettings } = await performSettingsMigration(
settingsResult.settings as unknown as Parameters<typeof performSettingsMigration>[0]
);
logger.debug('Settings migrated, hydrating stores...');
hydrateStoreFromSettings(finalSettings);
await new Promise((resolve) => setTimeout(resolve, 0));
signalMigrationComplete();
logger.debug('Setting settingsLoaded=true');
useAuthStore.getState().setAuthState({ settingsLoaded: true });
logger.info('Fallback settings load completed successfully');
}
} catch (error) {
logger.error('Failed to load settings in fallback:', error);
}
};
loadSettings();
}, [authChecked, isAuthenticated, settingsLoaded]);
useEffect(() => { useEffect(() => {
setGlobalFileBrowser(openFileBrowser); setGlobalFileBrowser(openFileBrowser);
@@ -428,7 +619,10 @@ function RootLayoutContent() {
// Redirect from welcome page based on project state // Redirect from welcome page based on project state
useEffect(() => { useEffect(() => {
if (isMounted && location.pathname === '/') { if (isMounted && isRootRoute) {
if (!settingsLoaded || shouldAutoOpen) {
return;
}
if (currentProject) { if (currentProject) {
// Project is selected, go to board // Project is selected, go to board
navigate({ to: '/board' }); navigate({ to: '/board' });
@@ -437,14 +631,66 @@ function RootLayoutContent() {
navigate({ to: '/dashboard' }); navigate({ to: '/dashboard' });
} }
} }
}, [isMounted, currentProject, location.pathname, navigate]); }, [isMounted, currentProject, isRootRoute, navigate, shouldAutoOpen, settingsLoaded]);
// Auto-open the most recent project on startup
useEffect(() => {
if (!canAutoOpen) return;
if (autoOpenStatus !== AUTO_OPEN_STATUS.idle) return;
if (!autoOpenCandidate) return;
setAutoOpenStatus(AUTO_OPEN_STATUS.opening);
const openProject = async () => {
try {
const initResult = await initializeProject(autoOpenCandidate.path);
if (!initResult.success) {
logger.warn('Auto-open project failed:', initResult.error);
if (isRootRoute) {
navigate({ to: '/dashboard' });
}
return;
}
if (!currentProject || currentProject.id !== autoOpenCandidate.id) {
upsertAndSetCurrentProject(
autoOpenCandidate.path,
autoOpenCandidate.name,
autoOpenCandidate.theme
);
}
if (isRootRoute) {
navigate({ to: '/board' });
}
} catch (error) {
logger.error('Auto-open project crashed:', error);
if (isRootRoute) {
navigate({ to: '/dashboard' });
}
} finally {
setAutoOpenStatus(AUTO_OPEN_STATUS.done);
}
};
void openProject();
}, [
canAutoOpen,
autoOpenStatus,
autoOpenCandidate,
currentProject,
navigate,
upsertAndSetCurrentProject,
isRootRoute,
]);
// Bootstrap Codex models on app startup (after auth completes) // Bootstrap Codex models on app startup (after auth completes)
useEffect(() => { useEffect(() => {
// Only fetch if authenticated and Codex CLI is available // Only fetch if authenticated and Codex CLI is available
if (!authChecked || !isAuthenticated) return; if (!authChecked || !isAuthenticated) return;
const isCodexAvailable = codexCliStatus?.installed && codexCliStatus?.auth?.authenticated; const isCodexAvailable = codexCliStatus?.installed && codexCliStatus?.hasApiKey;
if (!isCodexAvailable) return; if (!isCodexAvailable) return;
// Fetch models in the background // Fetch models in the background
@@ -512,6 +758,22 @@ function RootLayoutContent() {
); );
} }
if (shouldBlockForSettings) {
return (
<main className="flex h-screen items-center justify-center" data-testid="app-container">
<LoadingState message="Loading settings..." />
</main>
);
}
if (shouldAutoOpen) {
return (
<main className="flex h-screen items-center justify-center" data-testid="app-container">
<LoadingState message="Opening project..." />
</main>
);
}
// Show setup page (full screen, no sidebar) - authenticated only // Show setup page (full screen, no sidebar) - authenticated only
if (isSetupRoute) { if (isSetupRoute) {
return ( return (

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from '@tanstack/react-router';
import { GraphViewPage } from '@/components/views/graph-view-page';
export const Route = createFileRoute('/graph')({
component: GraphViewPage,
});

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from '@tanstack/react-router';
import { MemoryView } from '@/components/views/memory-view';
export const Route = createFileRoute('/memory')({
component: MemoryView,
});

View File

@@ -114,6 +114,12 @@ function saveThemeToStorage(theme: ThemeMode): void {
setItem(THEME_STORAGE_KEY, theme); setItem(THEME_STORAGE_KEY, theme);
} }
function persistEffectiveThemeForProject(project: Project | null, fallbackTheme: ThemeMode): void {
const projectTheme = project?.theme as ThemeMode | undefined;
const themeToStore = projectTheme ?? fallbackTheme;
saveThemeToStorage(themeToStore);
}
export type BoardViewMode = 'kanban' | 'graph'; export type BoardViewMode = 'kanban' | 'graph';
export interface ApiKeys { export interface ApiKeys {
@@ -210,9 +216,11 @@ export function formatShortcut(shortcut: string | undefined | null, forDisplay =
export interface KeyboardShortcuts { export interface KeyboardShortcuts {
// Navigation shortcuts // Navigation shortcuts
board: string; board: string;
graph: string;
agent: string; agent: string;
spec: string; spec: string;
context: string; context: string;
memory: string;
settings: string; settings: string;
terminal: string; terminal: string;
ideation: string; ideation: string;
@@ -243,9 +251,11 @@ export interface KeyboardShortcuts {
export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = { export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = {
// Navigation // Navigation
board: 'K', board: 'K',
graph: 'H',
agent: 'A', agent: 'A',
spec: 'D', spec: 'D',
context: 'C', context: 'C',
memory: 'Y',
settings: 'S', settings: 'S',
terminal: 'T', terminal: 'T',
ideation: 'I', ideation: 'I',
@@ -578,6 +588,9 @@ export interface AppState {
// MCP Servers // MCP Servers
mcpServers: MCPServerConfig[]; // List of configured MCP servers for agent use mcpServers: MCPServerConfig[]; // List of configured MCP servers for agent use
// Editor Configuration
defaultEditorCommand: string | null; // Default editor for "Open In" action
// Skills Configuration // Skills Configuration
enableSkills: boolean; // Enable Skills functionality (loads from .claude/skills/ directories) enableSkills: boolean; // Enable Skills functionality (loads from .claude/skills/ directories)
skillsSources: Array<'user' | 'project'>; // Which directories to load Skills from skillsSources: Array<'user' | 'project'>; // Which directories to load Skills from
@@ -973,6 +986,9 @@ export interface AppActions {
setAutoLoadClaudeMd: (enabled: boolean) => Promise<void>; setAutoLoadClaudeMd: (enabled: boolean) => Promise<void>;
setSkipSandboxWarning: (skip: boolean) => Promise<void>; setSkipSandboxWarning: (skip: boolean) => Promise<void>;
// Editor Configuration actions
setDefaultEditorCommand: (command: string | null) => void;
// Prompt Customization actions // Prompt Customization actions
setPromptCustomization: (customization: PromptCustomization) => Promise<void>; setPromptCustomization: (customization: PromptCustomization) => Promise<void>;
@@ -1198,6 +1214,7 @@ const initialState: AppState = {
autoLoadClaudeMd: false, // Default to disabled (user must opt-in) autoLoadClaudeMd: false, // Default to disabled (user must opt-in)
skipSandboxWarning: false, // Default to disabled (show sandbox warning dialog) skipSandboxWarning: false, // Default to disabled (show sandbox warning dialog)
mcpServers: [], // No MCP servers configured by default mcpServers: [], // No MCP servers configured by default
defaultEditorCommand: null, // Auto-detect: Cursor > VS Code > first available
enableSkills: true, // Skills enabled by default enableSkills: true, // Skills enabled by default
skillsSources: ['user', 'project'] as Array<'user' | 'project'>, // Load from both sources by default skillsSources: ['user', 'project'] as Array<'user' | 'project'>, // Load from both sources by default
enableSubagents: true, // Subagents enabled by default enableSubagents: true, // Subagents enabled by default
@@ -1289,13 +1306,16 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
}; };
const isCurrent = get().currentProject?.id === projectId; const isCurrent = get().currentProject?.id === projectId;
const nextCurrentProject = isCurrent ? null : get().currentProject;
set({ set({
projects: remainingProjects, projects: remainingProjects,
trashedProjects: [trashedProject, ...existingTrash], trashedProjects: [trashedProject, ...existingTrash],
currentProject: isCurrent ? null : get().currentProject, currentProject: nextCurrentProject,
currentView: isCurrent ? 'welcome' : get().currentView, currentView: isCurrent ? 'welcome' : get().currentView,
}); });
persistEffectiveThemeForProject(nextCurrentProject, get().theme);
}, },
restoreTrashedProject: (projectId) => { restoreTrashedProject: (projectId) => {
@@ -1314,6 +1334,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
currentProject: samePathProject, currentProject: samePathProject,
currentView: 'board', currentView: 'board',
}); });
persistEffectiveThemeForProject(samePathProject, get().theme);
return; return;
} }
@@ -1331,6 +1352,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
currentProject: restoredProject, currentProject: restoredProject,
currentView: 'board', currentView: 'board',
}); });
persistEffectiveThemeForProject(restoredProject, get().theme);
}, },
deleteTrashedProject: (projectId) => { deleteTrashedProject: (projectId) => {
@@ -1350,6 +1372,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
setCurrentProject: (project) => { setCurrentProject: (project) => {
set({ currentProject: project }); set({ currentProject: project });
persistEffectiveThemeForProject(project, get().theme);
if (project) { if (project) {
set({ currentView: 'board' }); set({ currentView: 'board' });
// Add to project history (MRU order) // Add to project history (MRU order)
@@ -1433,6 +1456,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
projectHistoryIndex: newIndex, projectHistoryIndex: newIndex,
currentView: 'board', currentView: 'board',
}); });
persistEffectiveThemeForProject(targetProject, get().theme);
} }
}, },
@@ -1466,6 +1490,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
projectHistoryIndex: newIndex, projectHistoryIndex: newIndex,
currentView: 'board', currentView: 'board',
}); });
persistEffectiveThemeForProject(targetProject, get().theme);
} }
}, },
@@ -1525,12 +1550,14 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
// Also update currentProject if it's the same project // Also update currentProject if it's the same project
const currentProject = get().currentProject; const currentProject = get().currentProject;
if (currentProject?.id === projectId) { if (currentProject?.id === projectId) {
const updatedTheme = theme === null ? undefined : theme;
set({ set({
currentProject: { currentProject: {
...currentProject, ...currentProject,
theme: theme === null ? undefined : theme, theme: updatedTheme,
}, },
}); });
persistEffectiveThemeForProject({ ...currentProject, theme: updatedTheme }, get().theme);
} }
}, },
@@ -1981,6 +2008,9 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
set({ skipSandboxWarning: previous }); set({ skipSandboxWarning: previous });
} }
}, },
// Editor Configuration actions
setDefaultEditorCommand: (command) => set({ defaultEditorCommand: command }),
// Prompt Customization actions // Prompt Customization actions
setPromptCustomization: async (customization) => { setPromptCustomization: async (customization) => {
set({ promptCustomization: customization }); set({ promptCustomization: customization });

View File

@@ -5,6 +5,8 @@ interface AuthState {
authChecked: boolean; authChecked: boolean;
/** Whether the user is currently authenticated (web mode: valid session cookie) */ /** Whether the user is currently authenticated (web mode: valid session cookie) */
isAuthenticated: boolean; isAuthenticated: boolean;
/** Whether settings have been loaded and hydrated from server */
settingsLoaded: boolean;
} }
interface AuthActions { interface AuthActions {
@@ -15,15 +17,18 @@ interface AuthActions {
const initialState: AuthState = { const initialState: AuthState = {
authChecked: false, authChecked: false,
isAuthenticated: false, isAuthenticated: false,
settingsLoaded: false,
}; };
/** /**
* Web authentication state. * Web authentication state.
* *
* Intentionally NOT persisted: source of truth is the server session cookie. * Intentionally NOT persisted: source of truth is server session cookie.
*/ */
export const useAuthStore = create<AuthState & AuthActions>((set) => ({ export const useAuthStore = create<AuthState & AuthActions>((set) => ({
...initialState, ...initialState,
setAuthState: (state) => set(state), setAuthState: (state) => {
set({ ...state });
},
resetAuth: () => set(initialState), resetAuth: () => set(initialState),
})); }));

View File

@@ -7,6 +7,7 @@ export interface CliStatus {
path: string | null; path: string | null;
version: string | null; version: string | null;
method: string; method: string;
hasApiKey?: boolean;
error?: string; error?: string;
} }

View File

@@ -298,7 +298,14 @@
} }
} }
.light { /* IMPORTANT:
* Theme classes like `.light` are applied to `:root` (html).
* Some third-party libraries (e.g. React Flow) also add `.light`/`.dark` classes
* to nested containers. If we define CSS variables on `.light` broadly, those
* nested containers will override the app theme and cause "white cards" in dark themes.
* Scoping to `:root.light` ensures only the root theme toggle controls variables.
*/
:root.light {
/* Explicit light mode - same as root but ensures it overrides any dark defaults */ /* Explicit light mode - same as root but ensures it overrides any dark defaults */
--background: oklch(1 0 0); /* White */ --background: oklch(1 0 0); /* White */
--background-50: oklch(1 0 0 / 0.5); --background-50: oklch(1 0 0 / 0.5);

View File

@@ -1,6 +1,6 @@
/* Dark Theme */ /* Dark Theme */
.dark { :root.dark {
/* Deep dark backgrounds - zinc-950 family */ /* Deep dark backgrounds - zinc-950 family */
--background: oklch(0.04 0 0); /* zinc-950 */ --background: oklch(0.04 0 0); /* zinc-950 */
--background-50: oklch(0.04 0 0 / 0.5); /* zinc-950/50 */ --background-50: oklch(0.04 0 0 / 0.5); /* zinc-950/50 */

View File

@@ -300,6 +300,17 @@ export type AutoModeEvent =
featureId: string; featureId: string;
projectPath?: string; projectPath?: string;
phaseNumber: number; phaseNumber: number;
}
| {
type: 'auto_mode_resuming_features';
message: string;
projectPath?: string;
featureIds: string[];
features: Array<{
id: string;
title?: string;
status?: string;
}>;
}; };
export type SpecRegenerationEvent = export type SpecRegenerationEvent =
@@ -356,15 +367,16 @@ export interface SpecRegenerationAPI {
error?: string; error?: string;
}>; }>;
stop: () => Promise<{ stop: (projectPath?: string) => Promise<{
success: boolean; success: boolean;
error?: string; error?: string;
}>; }>;
status: () => Promise<{ status: (projectPath?: string) => Promise<{
success: boolean; success: boolean;
isRunning?: boolean; isRunning?: boolean;
currentPhase?: string; currentPhase?: string;
projectPath?: string;
error?: string; error?: string;
}>; }>;
@@ -872,7 +884,10 @@ export interface WorktreeAPI {
}>; }>;
// Open a worktree directory in the editor // Open a worktree directory in the editor
openInEditor: (worktreePath: string) => Promise<{ openInEditor: (
worktreePath: string,
editorCommand?: string
) => Promise<{
success: boolean; success: boolean;
result?: { result?: {
message: string; message: string;
@@ -891,6 +906,30 @@ export interface WorktreeAPI {
error?: string; error?: string;
}>; }>;
// Get all available code editors
getAvailableEditors: () => Promise<{
success: boolean;
result?: {
editors: Array<{
name: string;
command: string;
}>;
};
error?: string;
}>;
// Refresh editor cache and re-detect available editors
refreshEditors: () => Promise<{
success: boolean;
result?: {
editors: Array<{
name: string;
command: string;
}>;
message: string;
};
error?: string;
}>;
// Initialize git repository in a project // Initialize git repository in a project
initGit: (projectPath: string) => Promise<{ initGit: (projectPath: string) => Promise<{
success: boolean; success: boolean;

View File

@@ -3,9 +3,10 @@ import { routeTree } from '../routeTree.gen';
// Use browser history in web mode (for e2e tests and dev), memory history in Electron // Use browser history in web mode (for e2e tests and dev), memory history in Electron
const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined; const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined;
const BOARD_ROUTE_PATH = '/board';
const history = isElectron const history = isElectron
? createMemoryHistory({ initialEntries: [window.location.pathname || '/'] }) ? createMemoryHistory({ initialEntries: [BOARD_ROUTE_PATH] })
: createBrowserHistory(); : createBrowserHistory();
export const router = createRouter({ export const router = createRouter({

View File

@@ -368,3 +368,42 @@ export async function authenticateForTests(page: Page): Promise<boolean> {
const apiKey = process.env.AUTOMAKER_API_KEY || 'test-api-key-for-e2e-tests'; const apiKey = process.env.AUTOMAKER_API_KEY || 'test-api-key-for-e2e-tests';
return authenticateWithApiKey(page, apiKey); return authenticateWithApiKey(page, apiKey);
} }
/**
* Check if the backend server is healthy
* Returns true if the server responds with status 200, false otherwise
*/
export async function checkBackendHealth(page: Page, timeout = 5000): Promise<boolean> {
try {
const response = await page.request.get(`${API_BASE_URL}/api/health`, {
timeout,
});
return response.ok();
} catch {
return false;
}
}
/**
* Wait for the backend to be healthy, with retry logic
* Throws an error if the backend doesn't become healthy within the timeout
*/
export async function waitForBackendHealth(
page: Page,
maxWaitMs = 30000,
checkIntervalMs = 500
): Promise<void> {
const startTime = Date.now();
while (Date.now() - startTime < maxWaitMs) {
if (await checkBackendHealth(page, checkIntervalMs)) {
return;
}
await page.waitForTimeout(checkIntervalMs);
}
throw new Error(
`Backend did not become healthy within ${maxWaitMs}ms. ` +
`Last health check failed or timed out.`
);
}

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