mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
Merge branch: resolve conflict in worktree-actions-dropdown.tsx
This commit is contained in:
88
.github/workflows/e2e-tests.yml
vendored
88
.github/workflows/e2e-tests.yml
vendored
@@ -37,7 +37,14 @@ jobs:
|
||||
git config --global user.email "ci@example.com"
|
||||
|
||||
- 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:
|
||||
PORT: 3008
|
||||
NODE_ENV: test
|
||||
@@ -53,21 +60,70 @@ jobs:
|
||||
- name: Wait for backend server
|
||||
run: |
|
||||
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
|
||||
if curl -s -f http://localhost:3008/api/health > /dev/null 2>&1; then
|
||||
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
|
||||
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)"
|
||||
sleep 1
|
||||
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"
|
||||
echo ""
|
||||
echo "=== Port status ==="
|
||||
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"
|
||||
|
||||
# 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
|
||||
|
||||
- name: Run E2E tests
|
||||
@@ -81,6 +137,18 @@ jobs:
|
||||
# Keep UI-side login/defaults consistent
|
||||
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
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
@@ -98,3 +166,13 @@ jobs:
|
||||
apps/ui/test-results/
|
||||
retention-days: 7
|
||||
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
|
||||
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -87,4 +87,12 @@ docker-compose.override.yml
|
||||
.claude/hans/
|
||||
|
||||
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/
|
||||
|
||||
@@ -597,6 +597,26 @@ const startServer = (port: number) => {
|
||||
|
||||
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
|
||||
process.on('SIGTERM', () => {
|
||||
logger.info('SIGTERM received, shutting down...');
|
||||
|
||||
@@ -6,26 +6,57 @@ import { createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('SpecRegeneration');
|
||||
|
||||
// Shared state for tracking generation status - private
|
||||
let isRunning = false;
|
||||
let currentAbortController: AbortController | null = null;
|
||||
// Shared state for tracking generation status - scoped by project path
|
||||
const runningProjects = new Map<string, boolean>();
|
||||
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;
|
||||
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 {
|
||||
isRunning = running;
|
||||
currentAbortController = controller;
|
||||
export function getRunningProjectPath(): string | null {
|
||||
for (const [path, running] of runningProjects.entries()) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -47,17 +47,17 @@ export function createCreateHandler(events: EventEmitter) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { isRunning } = getSpecRegenerationStatus();
|
||||
const { isRunning } = getSpecRegenerationStatus(projectPath);
|
||||
if (isRunning) {
|
||||
logger.warn('Generation already running, rejecting request');
|
||||
res.json({ success: false, error: 'Spec generation already running' });
|
||||
logger.warn('Generation already running for project:', projectPath);
|
||||
res.json({ success: false, error: 'Spec generation already running for this project' });
|
||||
return;
|
||||
}
|
||||
|
||||
logAuthStatus('Before starting generation');
|
||||
|
||||
const abortController = new AbortController();
|
||||
setRunningState(true, abortController);
|
||||
setRunningState(projectPath, true, abortController);
|
||||
logger.info('Starting background generation task...');
|
||||
|
||||
// Start generation in background
|
||||
@@ -80,7 +80,7 @@ export function createCreateHandler(events: EventEmitter) {
|
||||
})
|
||||
.finally(() => {
|
||||
logger.info('Generation task finished (success or error)');
|
||||
setRunningState(false, null);
|
||||
setRunningState(projectPath, false, null);
|
||||
});
|
||||
|
||||
logger.info('Returning success response (generation running in background)');
|
||||
|
||||
@@ -40,17 +40,17 @@ export function createGenerateFeaturesHandler(
|
||||
return;
|
||||
}
|
||||
|
||||
const { isRunning } = getSpecRegenerationStatus();
|
||||
const { isRunning } = getSpecRegenerationStatus(projectPath);
|
||||
if (isRunning) {
|
||||
logger.warn('Generation already running, rejecting request');
|
||||
res.json({ success: false, error: 'Generation already running' });
|
||||
logger.warn('Generation already running for project:', projectPath);
|
||||
res.json({ success: false, error: 'Generation already running for this project' });
|
||||
return;
|
||||
}
|
||||
|
||||
logAuthStatus('Before starting feature generation');
|
||||
|
||||
const abortController = new AbortController();
|
||||
setRunningState(true, abortController);
|
||||
setRunningState(projectPath, true, abortController);
|
||||
logger.info('Starting background feature generation task...');
|
||||
|
||||
generateFeaturesFromSpec(projectPath, events, abortController, maxFeatures, settingsService)
|
||||
@@ -63,7 +63,7 @@ export function createGenerateFeaturesHandler(
|
||||
})
|
||||
.finally(() => {
|
||||
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)');
|
||||
|
||||
@@ -48,17 +48,17 @@ export function createGenerateHandler(events: EventEmitter, settingsService?: Se
|
||||
return;
|
||||
}
|
||||
|
||||
const { isRunning } = getSpecRegenerationStatus();
|
||||
const { isRunning } = getSpecRegenerationStatus(projectPath);
|
||||
if (isRunning) {
|
||||
logger.warn('Generation already running, rejecting request');
|
||||
res.json({ success: false, error: 'Spec generation already running' });
|
||||
logger.warn('Generation already running for project:', projectPath);
|
||||
res.json({ success: false, error: 'Spec generation already running for this project' });
|
||||
return;
|
||||
}
|
||||
|
||||
logAuthStatus('Before starting generation');
|
||||
|
||||
const abortController = new AbortController();
|
||||
setRunningState(true, abortController);
|
||||
setRunningState(projectPath, true, abortController);
|
||||
logger.info('Starting background generation task...');
|
||||
|
||||
generateSpec(
|
||||
@@ -81,7 +81,7 @@ export function createGenerateHandler(events: EventEmitter, settingsService?: Se
|
||||
})
|
||||
.finally(() => {
|
||||
logger.info('Generation task finished (success or error)');
|
||||
setRunningState(false, null);
|
||||
setRunningState(projectPath, false, null);
|
||||
});
|
||||
|
||||
logger.info('Returning success response (generation running in background)');
|
||||
|
||||
@@ -6,10 +6,11 @@ import type { Request, Response } from 'express';
|
||||
import { getSpecRegenerationStatus, getErrorMessage } from '../common.js';
|
||||
|
||||
export function createStatusHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { isRunning } = getSpecRegenerationStatus();
|
||||
res.json({ success: true, isRunning });
|
||||
const projectPath = req.query.projectPath as string | undefined;
|
||||
const { isRunning } = getSpecRegenerationStatus(projectPath);
|
||||
res.json({ success: true, isRunning, projectPath });
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
|
||||
@@ -6,13 +6,16 @@ import type { Request, Response } from 'express';
|
||||
import { getSpecRegenerationStatus, setRunningState, getErrorMessage } from '../common.js';
|
||||
|
||||
export function createStopHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { currentAbortController } = getSpecRegenerationStatus();
|
||||
const { projectPath } = req.body as { projectPath?: string };
|
||||
const { currentAbortController } = getSpecRegenerationStatus(projectPath);
|
||||
if (currentAbortController) {
|
||||
currentAbortController.abort();
|
||||
}
|
||||
setRunningState(false, null);
|
||||
if (projectPath) {
|
||||
setRunningState(projectPath, false, null);
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
|
||||
@@ -17,6 +17,7 @@ import { createAnalyzeProjectHandler } from './routes/analyze-project.js';
|
||||
import { createFollowUpFeatureHandler } from './routes/follow-up-feature.js';
|
||||
import { createCommitFeatureHandler } from './routes/commit-feature.js';
|
||||
import { createApprovePlanHandler } from './routes/approve-plan.js';
|
||||
import { createResumeInterruptedHandler } from './routes/resume-interrupted.js';
|
||||
|
||||
export function createAutoModeRoutes(autoModeService: AutoModeService): Router {
|
||||
const router = Router();
|
||||
@@ -63,6 +64,11 @@ export function createAutoModeRoutes(autoModeService: AutoModeService): Router {
|
||||
validatePathParams('projectPath'),
|
||||
createApprovePlanHandler(autoModeService)
|
||||
);
|
||||
router.post(
|
||||
'/resume-interrupted',
|
||||
validatePathParams('projectPath'),
|
||||
createResumeInterruptedHandler(autoModeService)
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -188,6 +188,7 @@ export function createEnhanceHandler(
|
||||
technical: prompts.enhancement.technicalSystemPrompt,
|
||||
simplify: prompts.enhancement.simplifySystemPrompt,
|
||||
acceptance: prompts.enhancement.acceptanceSystemPrompt,
|
||||
'ux-reviewer': prompts.enhancement.uxReviewerSystemPrompt,
|
||||
};
|
||||
const systemPrompt = systemPromptMap[validMode];
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { createGetHandler } from './routes/get.js';
|
||||
import { createCreateHandler } from './routes/create.js';
|
||||
import { createUpdateHandler } from './routes/update.js';
|
||||
import { createBulkUpdateHandler } from './routes/bulk-update.js';
|
||||
import { createBulkDeleteHandler } from './routes/bulk-delete.js';
|
||||
import { createDeleteHandler } from './routes/delete.js';
|
||||
import { createAgentOutputHandler, createRawOutputHandler } from './routes/agent-output.js';
|
||||
import { createGenerateTitleHandler } from './routes/generate-title.js';
|
||||
@@ -26,6 +27,11 @@ export function createFeaturesRoutes(featureLoader: FeatureLoader): Router {
|
||||
validatePathParams('projectPath'),
|
||||
createBulkUpdateHandler(featureLoader)
|
||||
);
|
||||
router.post(
|
||||
'/bulk-delete',
|
||||
validatePathParams('projectPath'),
|
||||
createBulkDeleteHandler(featureLoader)
|
||||
);
|
||||
router.post('/delete', validatePathParams('projectPath'), createDeleteHandler(featureLoader));
|
||||
router.post('/agent-output', createAgentOutputHandler(featureLoader));
|
||||
router.post('/raw-output', createRawOutputHandler(featureLoader));
|
||||
|
||||
61
apps/server/src/routes/features/routes/bulk-delete.ts
Normal file
61
apps/server/src/routes/features/routes/bulk-delete.ts
Normal 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) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -10,14 +10,21 @@ import { getErrorMessage, logError } from '../common.js';
|
||||
export function createUpdateHandler(featureLoader: FeatureLoader) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, featureId, updates, descriptionHistorySource, enhancementMode } =
|
||||
req.body as {
|
||||
projectPath: string;
|
||||
featureId: string;
|
||||
updates: Partial<Feature>;
|
||||
descriptionHistorySource?: 'enhance' | 'edit';
|
||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance';
|
||||
};
|
||||
const {
|
||||
projectPath,
|
||||
featureId,
|
||||
updates,
|
||||
descriptionHistorySource,
|
||||
enhancementMode,
|
||||
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) {
|
||||
res.status(400).json({
|
||||
@@ -32,7 +39,8 @@ export function createUpdateHandler(featureLoader: FeatureLoader) {
|
||||
featureId,
|
||||
updates,
|
||||
descriptionHistorySource,
|
||||
enhancementMode
|
||||
enhancementMode,
|
||||
preEnhancementDescription
|
||||
);
|
||||
res.json({ success: true, feature: updated });
|
||||
} catch (error) {
|
||||
|
||||
@@ -25,6 +25,8 @@ import { createSwitchBranchHandler } from './routes/switch-branch.js';
|
||||
import {
|
||||
createOpenInEditorHandler,
|
||||
createGetDefaultEditorHandler,
|
||||
createGetAvailableEditorsHandler,
|
||||
createRefreshEditorsHandler,
|
||||
} from './routes/open-in-editor.js';
|
||||
import { createInitGitHandler } from './routes/init-git.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('/open-in-editor', validatePathParams('worktreePath'), createOpenInEditorHandler());
|
||||
router.get('/default-editor', createGetDefaultEditorHandler());
|
||||
router.get('/available-editors', createGetAvailableEditorsHandler());
|
||||
router.post('/refresh-editors', createRefreshEditorsHandler());
|
||||
router.post('/init-git', validatePathParams('projectPath'), createInitGitHandler());
|
||||
router.post('/migrate', createMigrateHandler());
|
||||
router.post(
|
||||
|
||||
@@ -2,18 +2,23 @@
|
||||
* POST /list endpoint - List all git worktrees
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import path from 'path';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { isGitRepo } from '@automaker/git-utils';
|
||||
import { getErrorMessage, logError, normalizePath } from '../common.js';
|
||||
import { readAllWorktreeMetadata, type WorktreePRInfo } from '../../../lib/worktree-metadata.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const logger = createLogger('Worktree');
|
||||
|
||||
interface WorktreeInfo {
|
||||
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() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
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
|
||||
const allMetadata = await readAllWorktreeMetadata(projectPath);
|
||||
|
||||
|
||||
@@ -1,78 +1,40 @@
|
||||
/**
|
||||
* 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
|
||||
* 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 { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { isAbsolute } from 'path';
|
||||
import {
|
||||
clearEditorCache,
|
||||
detectAllEditors,
|
||||
detectDefaultEditor,
|
||||
openInEditor,
|
||||
openInFileManager,
|
||||
} from '@automaker/platform';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const logger = createLogger('open-in-editor');
|
||||
|
||||
// Editor detection with caching
|
||||
interface EditorInfo {
|
||||
name: string;
|
||||
command: string;
|
||||
}
|
||||
|
||||
let cachedEditor: EditorInfo | null = null;
|
||||
|
||||
/**
|
||||
* Detect which code editor is available on the system
|
||||
*/
|
||||
async function detectDefaultEditor(): Promise<EditorInfo> {
|
||||
// Return cached result if available
|
||||
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 createGetAvailableEditorsHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const editors = await detectAllEditors();
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
editors,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Get available editors failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { worktreePath } = req.body as {
|
||||
const { worktreePath, editorCommand } = req.body as {
|
||||
worktreePath: string;
|
||||
editorCommand?: string;
|
||||
};
|
||||
|
||||
if (!worktreePath) {
|
||||
@@ -108,42 +100,44 @@ export function createOpenInEditorHandler() {
|
||||
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 {
|
||||
await execAsync(`${editor.command} "${worktreePath}"`);
|
||||
// Use the platform utility to open in editor
|
||||
const result = await openInEditor(worktreePath, editorCommand);
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
message: `Opened ${worktreePath} in ${editor.name}`,
|
||||
editorName: editor.name,
|
||||
message: `Opened ${worktreePath} in ${result.editorName}`,
|
||||
editorName: result.editorName,
|
||||
},
|
||||
});
|
||||
} catch (editorError) {
|
||||
// If the detected editor fails, try opening in default file manager as fallback
|
||||
const platform = process.platform;
|
||||
let openCommand: string;
|
||||
let fallbackName: string;
|
||||
// If the specified editor fails, try opening in default file manager as fallback
|
||||
logger.warn(
|
||||
`Failed to open in editor, falling back to file manager: ${getErrorMessage(editorError)}`
|
||||
);
|
||||
|
||||
if (platform === 'darwin') {
|
||||
openCommand = `open "${worktreePath}"`;
|
||||
fallbackName = 'Finder';
|
||||
} else if (platform === 'win32') {
|
||||
openCommand = `explorer "${worktreePath}"`;
|
||||
fallbackName = 'Explorer';
|
||||
} else {
|
||||
openCommand = `xdg-open "${worktreePath}"`;
|
||||
fallbackName = 'File Manager';
|
||||
try {
|
||||
const result = await openInFileManager(worktreePath);
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
message: `Opened ${worktreePath} in ${result.editorName}`,
|
||||
editorName: result.editorName,
|
||||
},
|
||||
});
|
||||
} 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) {
|
||||
logError(error, 'Open in editor failed');
|
||||
|
||||
@@ -31,7 +31,13 @@ import {
|
||||
const logger = createLogger('AutoMode');
|
||||
import { resolveModelString, resolvePhaseModel, DEFAULT_MODELS } from '@automaker/model-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 { promisify } from 'util';
|
||||
import path from 'path';
|
||||
@@ -201,6 +207,29 @@ interface AutoModeConfig {
|
||||
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
|
||||
const CONSECUTIVE_FAILURE_THRESHOLD = 3; // Pause after 3 consecutive failures
|
||||
const FAILURE_WINDOW_MS = 60000; // Failures within 1 minute count as consecutive
|
||||
@@ -322,6 +351,9 @@ export class AutoModeService {
|
||||
projectPath,
|
||||
});
|
||||
|
||||
// Save execution state for recovery after restart
|
||||
await this.saveExecutionState(projectPath);
|
||||
|
||||
// Note: Memory folder initialization is now handled by loadContextFiles
|
||||
|
||||
// Run the loop in the background
|
||||
@@ -390,17 +422,23 @@ export class AutoModeService {
|
||||
*/
|
||||
async stopAutoLoop(): Promise<number> {
|
||||
const wasRunning = this.autoLoopRunning;
|
||||
const projectPath = this.config?.projectPath;
|
||||
this.autoLoopRunning = false;
|
||||
if (this.autoLoopAbortController) {
|
||||
this.autoLoopAbortController.abort();
|
||||
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
|
||||
if (wasRunning) {
|
||||
this.emitAutoModeEvent('auto_mode_stopped', {
|
||||
message: 'Auto mode stopped',
|
||||
projectPath: this.config?.projectPath,
|
||||
projectPath,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -441,6 +479,11 @@ export class AutoModeService {
|
||||
};
|
||||
this.runningFeatures.set(featureId, tempRunningFeature);
|
||||
|
||||
// Save execution state when feature starts
|
||||
if (isAutoMode) {
|
||||
await this.saveExecutionState(projectPath);
|
||||
}
|
||||
|
||||
try {
|
||||
// Validate that project path is allowed using centralized validation
|
||||
validateWorkingDirectory(projectPath);
|
||||
@@ -695,6 +738,11 @@ export class AutoModeService {
|
||||
`Pending approvals at cleanup: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}`
|
||||
);
|
||||
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
|
||||
* Uses a quick Claude call to identify important decisions and patterns
|
||||
|
||||
@@ -2,6 +2,7 @@ import { spawn } from 'child_process';
|
||||
import * as os from 'os';
|
||||
import * as pty from 'node-pty';
|
||||
import { ClaudeUsage } from '../routes/claude/types.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
/**
|
||||
* Claude Usage Service
|
||||
@@ -14,6 +15,8 @@ import { ClaudeUsage } from '../routes/claude/types.js';
|
||||
* - macOS: Uses 'expect' command for PTY
|
||||
* - Windows/Linux: Uses node-pty for PTY
|
||||
*/
|
||||
const logger = createLogger('ClaudeUsage');
|
||||
|
||||
export class ClaudeUsageService {
|
||||
private claudeBinary = 'claude';
|
||||
private timeout = 30000; // 30 second timeout
|
||||
@@ -164,21 +167,40 @@ export class ClaudeUsageService {
|
||||
const shell = this.isWindows ? 'cmd.exe' : '/bin/sh';
|
||||
const args = this.isWindows ? ['/c', 'claude', '/usage'] : ['-c', 'claude /usage'];
|
||||
|
||||
const ptyProcess = pty.spawn(shell, args, {
|
||||
name: 'xterm-256color',
|
||||
cols: 120,
|
||||
rows: 30,
|
||||
cwd: workingDirectory,
|
||||
env: {
|
||||
...process.env,
|
||||
TERM: 'xterm-256color',
|
||||
} as Record<string, string>,
|
||||
});
|
||||
let ptyProcess: any = null;
|
||||
|
||||
try {
|
||||
ptyProcess = pty.spawn(shell, args, {
|
||||
name: 'xterm-256color',
|
||||
cols: 120,
|
||||
rows: 30,
|
||||
cwd: workingDirectory,
|
||||
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(() => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
ptyProcess.kill();
|
||||
if (ptyProcess && !ptyProcess.killed) {
|
||||
ptyProcess.kill();
|
||||
}
|
||||
// Don't fail if we have data - return it instead
|
||||
if (output.includes('Current session')) {
|
||||
resolve(output);
|
||||
@@ -188,7 +210,7 @@ export class ClaudeUsageService {
|
||||
}
|
||||
}, this.timeout);
|
||||
|
||||
ptyProcess.onData((data) => {
|
||||
ptyProcess.onData((data: string) => {
|
||||
output += data;
|
||||
|
||||
// Check if we've seen the usage data (look for "Current session")
|
||||
@@ -196,12 +218,12 @@ export class ClaudeUsageService {
|
||||
hasSeenUsageData = true;
|
||||
// Wait for full output, then send escape to exit
|
||||
setTimeout(() => {
|
||||
if (!settled) {
|
||||
if (!settled && ptyProcess && !ptyProcess.killed) {
|
||||
ptyProcess.write('\x1b'); // Send escape key
|
||||
|
||||
// Fallback: if ESC doesn't exit (Linux), use SIGTERM after 2s
|
||||
setTimeout(() => {
|
||||
if (!settled) {
|
||||
if (!settled && ptyProcess && !ptyProcess.killed) {
|
||||
ptyProcess.kill('SIGTERM');
|
||||
}
|
||||
}, 2000);
|
||||
@@ -212,14 +234,14 @@ export class ClaudeUsageService {
|
||||
// Fallback: if we see "Esc to cancel" but haven't seen usage data yet
|
||||
if (!hasSeenUsageData && output.includes('Esc to cancel')) {
|
||||
setTimeout(() => {
|
||||
if (!settled) {
|
||||
if (!settled && ptyProcess && !ptyProcess.killed) {
|
||||
ptyProcess.write('\x1b'); // Send escape key
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
});
|
||||
|
||||
ptyProcess.onExit(({ exitCode }) => {
|
||||
ptyProcess.onExit(({ exitCode }: { exitCode: number }) => {
|
||||
clearTimeout(timeoutId);
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
|
||||
@@ -308,13 +308,15 @@ export class FeatureLoader {
|
||||
* @param updates - Partial feature updates
|
||||
* @param descriptionHistorySource - Source of description change ('enhance' or 'edit')
|
||||
* @param enhancementMode - Enhancement mode if source is 'enhance'
|
||||
* @param preEnhancementDescription - Description before enhancement (for restoring original)
|
||||
*/
|
||||
async update(
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
updates: Partial<Feature>,
|
||||
descriptionHistorySource?: 'enhance' | 'edit',
|
||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance'
|
||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer',
|
||||
preEnhancementDescription?: string
|
||||
): Promise<Feature> {
|
||||
const feature = await this.get(projectPath, featureId);
|
||||
if (!feature) {
|
||||
@@ -338,9 +340,31 @@ export class FeatureLoader {
|
||||
updates.description !== feature.description &&
|
||||
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 = {
|
||||
description: updates.description,
|
||||
timestamp: new Date().toISOString(),
|
||||
timestamp,
|
||||
source: descriptionHistorySource || 'edit',
|
||||
...(descriptionHistorySource === 'enhance' && enhancementMode ? { enhancementMode } : {}),
|
||||
};
|
||||
|
||||
220
apps/ui/src/components/icons/editor-icons.tsx
Normal file
220
apps/ui/src/components/icons/editor-icons.tsx
Normal 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;
|
||||
}
|
||||
@@ -126,6 +126,9 @@ export function Sidebar() {
|
||||
// Derive isCreatingSpec from store state
|
||||
const isCreatingSpec = specCreatingForProject !== null;
|
||||
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
|
||||
useSidebarAutoCollapse({ sidebarOpen, toggleSidebar });
|
||||
@@ -241,6 +244,7 @@ export function Sidebar() {
|
||||
cyclePrevProject,
|
||||
cycleNextProject,
|
||||
unviewedValidationsCount,
|
||||
isSpecGenerating: isCurrentProjectGeneratingSpec,
|
||||
});
|
||||
|
||||
// Register keyboard shortcuts
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { NavigateOptions } from '@tanstack/react-router';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatShortcut } from '@/store/app-store';
|
||||
import type { NavSection } from '../types';
|
||||
@@ -80,14 +81,23 @@ export function SidebarNavigation({
|
||||
data-testid={`nav-${item.id}`}
|
||||
>
|
||||
<div className="relative">
|
||||
<Icon
|
||||
className={cn(
|
||||
'w-[18px] h-[18px] shrink-0 transition-all duration-200',
|
||||
isActive
|
||||
? 'text-brand-500 drop-shadow-sm'
|
||||
: 'group-hover:text-brand-400 group-hover:scale-110'
|
||||
)}
|
||||
/>
|
||||
{item.isLoading ? (
|
||||
<Loader2
|
||||
className={cn(
|
||||
'w-[18px] h-[18px] shrink-0 animate-spin',
|
||||
isActive ? 'text-brand-500' : 'text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Icon
|
||||
className={cn(
|
||||
'w-[18px] h-[18px] shrink-0 transition-all duration-200',
|
||||
isActive
|
||||
? 'text-brand-500 drop-shadow-sm'
|
||||
: 'group-hover:text-brand-400 group-hover:scale-110'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{/* Count badge for collapsed state */}
|
||||
{!sidebarOpen && item.count !== undefined && item.count > 0 && (
|
||||
<span
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useRef } from 'react';
|
||||
import { Rocket, CheckCircle2, Zap, FileText, Sparkles, ArrowRight } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -24,13 +25,25 @@ export function OnboardingDialog({
|
||||
onSkip,
|
||||
onGenerateSpec,
|
||||
}: 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 (
|
||||
<Dialog
|
||||
open={open}
|
||||
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();
|
||||
}
|
||||
isGeneratingRef.current = false;
|
||||
onOpenChange(isOpen);
|
||||
}}
|
||||
>
|
||||
@@ -108,7 +121,7 @@ export function OnboardingDialog({
|
||||
Skip for now
|
||||
</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"
|
||||
>
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
CircleDot,
|
||||
GitPullRequest,
|
||||
Lightbulb,
|
||||
Brain,
|
||||
Network,
|
||||
} from 'lucide-react';
|
||||
import type { NavSection, NavItem } from '../types';
|
||||
import type { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts';
|
||||
@@ -24,7 +26,9 @@ interface UseNavigationProps {
|
||||
cycleNextProject: string;
|
||||
spec: string;
|
||||
context: string;
|
||||
memory: string;
|
||||
board: string;
|
||||
graph: string;
|
||||
agent: string;
|
||||
terminal: string;
|
||||
settings: string;
|
||||
@@ -46,6 +50,8 @@ interface UseNavigationProps {
|
||||
cycleNextProject: () => void;
|
||||
/** Count of unviewed validations to show on GitHub Issues nav item */
|
||||
unviewedValidationsCount?: number;
|
||||
/** Whether spec generation is currently running for the current project */
|
||||
isSpecGenerating?: boolean;
|
||||
}
|
||||
|
||||
export function useNavigation({
|
||||
@@ -63,6 +69,7 @@ export function useNavigation({
|
||||
cyclePrevProject,
|
||||
cycleNextProject,
|
||||
unviewedValidationsCount,
|
||||
isSpecGenerating,
|
||||
}: UseNavigationProps) {
|
||||
// Track if current project has a GitHub remote
|
||||
const [hasGitHubRemote, setHasGitHubRemote] = useState(false);
|
||||
@@ -102,6 +109,7 @@ export function useNavigation({
|
||||
label: 'Spec Editor',
|
||||
icon: FileText,
|
||||
shortcut: shortcuts.spec,
|
||||
isLoading: isSpecGenerating,
|
||||
},
|
||||
{
|
||||
id: 'context',
|
||||
@@ -109,6 +117,12 @@ export function useNavigation({
|
||||
icon: BookOpen,
|
||||
shortcut: shortcuts.context,
|
||||
},
|
||||
{
|
||||
id: 'memory',
|
||||
label: 'Memory',
|
||||
icon: Brain,
|
||||
shortcut: shortcuts.memory,
|
||||
},
|
||||
];
|
||||
|
||||
// Filter out hidden items
|
||||
@@ -130,6 +144,12 @@ export function useNavigation({
|
||||
icon: LayoutGrid,
|
||||
shortcut: shortcuts.board,
|
||||
},
|
||||
{
|
||||
id: 'graph',
|
||||
label: 'Graph View',
|
||||
icon: Network,
|
||||
shortcut: shortcuts.graph,
|
||||
},
|
||||
{
|
||||
id: 'agent',
|
||||
label: 'Agent Runner',
|
||||
@@ -189,6 +209,7 @@ export function useNavigation({
|
||||
hideTerminal,
|
||||
hasGitHubRemote,
|
||||
unviewedValidationsCount,
|
||||
isSpecGenerating,
|
||||
]);
|
||||
|
||||
// Build keyboard shortcuts for navigation
|
||||
|
||||
@@ -13,6 +13,8 @@ export interface NavItem {
|
||||
shortcut?: string;
|
||||
/** Optional count badge to display next to the nav item */
|
||||
count?: number;
|
||||
/** Whether this nav item is in a loading state (shows spinner) */
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export interface SortableProjectItemProps {
|
||||
|
||||
@@ -84,9 +84,11 @@ const KEYBOARD_ROWS = [
|
||||
// Map shortcut names to human-readable labels
|
||||
const SHORTCUT_LABELS: Record<keyof KeyboardShortcuts, string> = {
|
||||
board: 'Kanban Board',
|
||||
graph: 'Graph View',
|
||||
agent: 'Agent Runner',
|
||||
spec: 'Spec Editor',
|
||||
context: 'Context',
|
||||
memory: 'Memory',
|
||||
settings: 'Settings',
|
||||
terminal: 'Terminal',
|
||||
ideation: 'Ideation',
|
||||
@@ -110,9 +112,11 @@ const SHORTCUT_LABELS: Record<keyof KeyboardShortcuts, string> = {
|
||||
// Categorize shortcuts for color coding
|
||||
const SHORTCUT_CATEGORIES: Record<keyof KeyboardShortcuts, 'navigation' | 'ui' | 'action'> = {
|
||||
board: 'navigation',
|
||||
graph: 'navigation',
|
||||
agent: 'navigation',
|
||||
spec: 'navigation',
|
||||
context: 'navigation',
|
||||
memory: 'navigation',
|
||||
settings: 'navigation',
|
||||
terminal: 'navigation',
|
||||
ideation: 'navigation',
|
||||
|
||||
@@ -15,6 +15,9 @@ const PROVIDER_ICON_KEYS = {
|
||||
nova: 'nova',
|
||||
meta: 'meta',
|
||||
mistral: 'mistral',
|
||||
minimax: 'minimax',
|
||||
glm: 'glm',
|
||||
bigpickle: 'bigpickle',
|
||||
} as const;
|
||||
|
||||
type ProviderIconKey = keyof typeof PROVIDER_ICON_KEYS;
|
||||
@@ -87,6 +90,22 @@ const PROVIDER_ICON_DEFINITIONS: Record<ProviderIconKey, ProviderIconDefinition>
|
||||
viewBox: '0 0 24 24',
|
||||
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'> {
|
||||
@@ -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<
|
||||
ModelProvider,
|
||||
ComponentType<{ className?: string }>
|
||||
@@ -299,33 +395,50 @@ function getUnderlyingModelIcon(model?: AgentModel | string): ProviderIconKey {
|
||||
|
||||
const modelStr = typeof model === 'string' ? model.toLowerCase() : model;
|
||||
|
||||
// Check for OpenCode models (opencode/, amazon-bedrock/, opencode-*)
|
||||
if (modelStr.includes('opencode')) {
|
||||
// For OpenCode models, check which specific provider
|
||||
if (modelStr.includes('amazon-bedrock')) {
|
||||
// Bedrock-hosted models - detect the specific provider
|
||||
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';
|
||||
// Check for Amazon Bedrock models first (amazon-bedrock/...)
|
||||
if (modelStr.startsWith('amazon-bedrock/')) {
|
||||
// Bedrock-hosted models - detect the specific provider
|
||||
if (modelStr.includes('anthropic') || modelStr.includes('claude')) {
|
||||
return 'anthropic';
|
||||
}
|
||||
// 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';
|
||||
}
|
||||
|
||||
@@ -371,6 +484,9 @@ export function getProviderIconForModel(
|
||||
nova: NovaIcon,
|
||||
meta: MetaIcon,
|
||||
mistral: MistralIcon,
|
||||
minimax: MiniMaxIcon,
|
||||
glm: GlmIcon,
|
||||
bigpickle: BigPickleIcon,
|
||||
};
|
||||
|
||||
return iconMap[iconKey] || AnthropicIcon;
|
||||
|
||||
@@ -40,10 +40,7 @@ import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
|
||||
import { useWindowState } from '@/hooks/use-window-state';
|
||||
// Board-view specific imports
|
||||
import { BoardHeader } from './board-view/board-header';
|
||||
import { BoardSearchBar } from './board-view/board-search-bar';
|
||||
import { BoardControls } from './board-view/board-controls';
|
||||
import { KanbanBoard } from './board-view/kanban-board';
|
||||
import { GraphView } from './graph-view';
|
||||
import {
|
||||
AddFeatureDialog,
|
||||
AgentOutputModal,
|
||||
@@ -92,8 +89,6 @@ export function BoardView() {
|
||||
maxConcurrency,
|
||||
setMaxConcurrency,
|
||||
defaultSkipTests,
|
||||
boardViewMode,
|
||||
setBoardViewMode,
|
||||
specCreatingForProject,
|
||||
setSpecCreatingForProject,
|
||||
pendingPlanApproval,
|
||||
@@ -174,12 +169,14 @@ export function BoardView() {
|
||||
followUpPrompt,
|
||||
followUpImagePaths,
|
||||
followUpPreviewMap,
|
||||
followUpPromptHistory,
|
||||
setShowFollowUpDialog,
|
||||
setFollowUpFeature,
|
||||
setFollowUpPrompt,
|
||||
setFollowUpImagePaths,
|
||||
setFollowUpPreviewMap,
|
||||
handleFollowUpDialogChange,
|
||||
addToPromptHistory,
|
||||
} = useFollowUpState();
|
||||
|
||||
// Selection mode hook for mass editing
|
||||
@@ -521,6 +518,45 @@ export function BoardView() {
|
||||
[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
|
||||
const selectedFeatures = useMemo(() => {
|
||||
return hookFeatures.filter((f) => selectedFeatureIds.has(f.id));
|
||||
@@ -1166,7 +1202,6 @@ export function BoardView() {
|
||||
>
|
||||
{/* Header */}
|
||||
<BoardHeader
|
||||
projectName={currentProject.name}
|
||||
projectPath={currentProject.path}
|
||||
maxConcurrency={maxConcurrency}
|
||||
runningAgentsCount={runningAutoTasks.length}
|
||||
@@ -1181,6 +1216,13 @@ export function BoardView() {
|
||||
}}
|
||||
onOpenPlanDialog={() => setShowPlanDialog(true)}
|
||||
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 */}
|
||||
@@ -1219,89 +1261,46 @@ export function BoardView() {
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Search Bar Row */}
|
||||
<div className="px-4 pt-4 pb-2 flex items-center justify-between">
|
||||
<BoardSearchBar
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
isCreatingSpec={isCreatingSpec}
|
||||
creatingSpecProjectPath={creatingSpecProjectPath ?? undefined}
|
||||
currentProjectPath={currentProject?.path}
|
||||
/>
|
||||
|
||||
{/* Board Background & Detail Level Controls */}
|
||||
<BoardControls
|
||||
isMounted={isMounted}
|
||||
onShowBoardBackground={() => setShowBoardBackgroundModal(true)}
|
||||
onShowCompletedModal={() => setShowCompletedModal(true)}
|
||||
completedCount={completedFeatures.length}
|
||||
boardViewMode={boardViewMode}
|
||||
onBoardViewModeChange={setBoardViewMode}
|
||||
/>
|
||||
</div>
|
||||
{/* View Content - Kanban or Graph */}
|
||||
{boardViewMode === 'kanban' ? (
|
||||
<KanbanBoard
|
||||
sensors={sensors}
|
||||
collisionDetectionStrategy={collisionDetectionStrategy}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
activeFeature={activeFeature}
|
||||
getColumnFeatures={getColumnFeatures}
|
||||
backgroundImageStyle={backgroundImageStyle}
|
||||
backgroundSettings={backgroundSettings}
|
||||
onEdit={(feature) => setEditingFeature(feature)}
|
||||
onDelete={(featureId) => handleDeleteFeature(featureId)}
|
||||
onViewOutput={handleViewOutput}
|
||||
onVerify={handleVerifyFeature}
|
||||
onResume={handleResumeFeature}
|
||||
onForceStop={handleForceStopFeature}
|
||||
onManualVerify={handleManualVerify}
|
||||
onMoveBackToInProgress={handleMoveBackToInProgress}
|
||||
onFollowUp={handleOpenFollowUp}
|
||||
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)}
|
||||
/>
|
||||
)}
|
||||
{/* View Content - Kanban Board */}
|
||||
<KanbanBoard
|
||||
sensors={sensors}
|
||||
collisionDetectionStrategy={collisionDetectionStrategy}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
activeFeature={activeFeature}
|
||||
getColumnFeatures={getColumnFeatures}
|
||||
backgroundImageStyle={backgroundImageStyle}
|
||||
backgroundSettings={backgroundSettings}
|
||||
onEdit={(feature) => setEditingFeature(feature)}
|
||||
onDelete={(featureId) => handleDeleteFeature(featureId)}
|
||||
onViewOutput={handleViewOutput}
|
||||
onVerify={handleVerifyFeature}
|
||||
onResume={handleResumeFeature}
|
||||
onForceStop={handleForceStopFeature}
|
||||
onManualVerify={handleManualVerify}
|
||||
onMoveBackToInProgress={handleMoveBackToInProgress}
|
||||
onFollowUp={handleOpenFollowUp}
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Selection Action Bar */}
|
||||
@@ -1310,6 +1309,7 @@ export function BoardView() {
|
||||
selectedCount={selectedCount}
|
||||
totalCount={allSelectableFeatureIds.length}
|
||||
onEdit={() => setShowMassEditDialog(true)}
|
||||
onDelete={handleBulkDelete}
|
||||
onClear={clearSelection}
|
||||
onSelectAll={() => selectAll(allSelectableFeatureIds)}
|
||||
/>
|
||||
@@ -1435,6 +1435,8 @@ export function BoardView() {
|
||||
onPreviewMapChange={setFollowUpPreviewMap}
|
||||
onSend={handleSendFollowUp}
|
||||
isMaximized={isMaximized}
|
||||
promptHistory={followUpPromptHistory}
|
||||
onHistoryAdd={addToPromptHistory}
|
||||
/>
|
||||
|
||||
{/* Backlog Plan Dialog */}
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { ImageIcon, Archive, Columns3, Network } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { BoardViewMode } from '@/store/app-store';
|
||||
import { ImageIcon, Archive } from 'lucide-react';
|
||||
|
||||
interface BoardControlsProps {
|
||||
isMounted: boolean;
|
||||
onShowBoardBackground: () => void;
|
||||
onShowCompletedModal: () => void;
|
||||
completedCount: number;
|
||||
boardViewMode: BoardViewMode;
|
||||
onBoardViewModeChange: (mode: BoardViewMode) => void;
|
||||
}
|
||||
|
||||
export function BoardControls({
|
||||
@@ -18,59 +14,12 @@ export function BoardControls({
|
||||
onShowBoardBackground,
|
||||
onShowCompletedModal,
|
||||
completedCount,
|
||||
boardViewMode,
|
||||
onBoardViewModeChange,
|
||||
}: BoardControlsProps) {
|
||||
if (!isMounted) return null;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
{/* View Mode Toggle - Kanban / Graph */}
|
||||
<div
|
||||
className="flex items-center rounded-lg bg-secondary border border-border"
|
||||
data-testid="view-mode-toggle"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => onBoardViewModeChange('kanban')}
|
||||
className={cn(
|
||||
'p-2 rounded-l-lg transition-colors',
|
||||
boardViewMode === 'kanban'
|
||||
? 'bg-brand-500/20 text-brand-500'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||
)}
|
||||
data-testid="view-mode-kanban"
|
||||
>
|
||||
<Columns3 className="w-4 h-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Kanban Board View</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => onBoardViewModeChange('graph')}
|
||||
className={cn(
|
||||
'p-2 rounded-r-lg transition-colors',
|
||||
boardViewMode === 'graph'
|
||||
? 'bg-brand-500/20 text-brand-500'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||
)}
|
||||
data-testid="view-mode-graph"
|
||||
>
|
||||
<Network className="w-4 h-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Dependency Graph View</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Board Background Button */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -10,9 +10,10 @@ import { useAppStore } from '@/store/app-store';
|
||||
import { useSetupStore } from '@/store/setup-store';
|
||||
import { AutoModeSettingsDialog } from './dialogs/auto-mode-settings-dialog';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import { BoardSearchBar } from './board-search-bar';
|
||||
import { BoardControls } from './board-controls';
|
||||
|
||||
interface BoardHeaderProps {
|
||||
projectName: string;
|
||||
projectPath: string;
|
||||
maxConcurrency: number;
|
||||
runningAgentsCount: number;
|
||||
@@ -21,6 +22,15 @@ interface BoardHeaderProps {
|
||||
onAutoModeToggle: (enabled: boolean) => void;
|
||||
onOpenPlanDialog: () => void;
|
||||
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
|
||||
@@ -28,7 +38,6 @@ const controlContainerClass =
|
||||
'flex items-center gap-1.5 px-3 h-8 rounded-md bg-secondary border border-border';
|
||||
|
||||
export function BoardHeader({
|
||||
projectName,
|
||||
projectPath,
|
||||
maxConcurrency,
|
||||
runningAgentsCount,
|
||||
@@ -37,6 +46,13 @@ export function BoardHeader({
|
||||
onAutoModeToggle,
|
||||
onOpenPlanDialog,
|
||||
isMounted,
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
isCreatingSpec,
|
||||
creatingSpecProjectPath,
|
||||
onShowBoardBackground,
|
||||
onShowCompletedModal,
|
||||
completedCount,
|
||||
}: BoardHeaderProps) {
|
||||
const [showAutoModeSettings, setShowAutoModeSettings] = useState(false);
|
||||
const apiKeys = useAppStore((state) => state.apiKeys);
|
||||
@@ -84,9 +100,20 @@ export function BoardHeader({
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Kanban Board</h1>
|
||||
<p className="text-sm text-muted-foreground">{projectName}</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<BoardSearchBar
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={onSearchChange}
|
||||
isCreatingSpec={isCreatingSpec}
|
||||
creatingSpecProjectPath={creatingSpecProjectPath}
|
||||
currentProjectPath={projectPath}
|
||||
/>
|
||||
<BoardControls
|
||||
isMounted={isMounted}
|
||||
onShowBoardBackground={onShowBoardBackground}
|
||||
onShowCompletedModal={onShowCompletedModal}
|
||||
completedCount={completedCount}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
{/* Usage Popover - show if either provider is authenticated */}
|
||||
|
||||
@@ -70,6 +70,7 @@ export function AgentInfoPanel({
|
||||
}: AgentInfoPanelProps) {
|
||||
const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null);
|
||||
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
|
||||
const [isTodosExpanded, setIsTodosExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadContext = async () => {
|
||||
@@ -197,32 +198,47 @@ export function AgentInfoPanel({
|
||||
{agentInfo.todos.length} tasks
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-0.5 max-h-16 overflow-y-auto">
|
||||
{agentInfo.todos.slice(0, 3).map((todo, idx) => (
|
||||
<div key={idx} className="flex items-center gap-1.5 text-[10px]">
|
||||
{todo.status === 'completed' ? (
|
||||
<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" />
|
||||
) : (
|
||||
<Circle className="w-2.5 h-2.5 text-muted-foreground/50 shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
'break-words hyphens-auto line-clamp-2 leading-relaxed',
|
||||
todo.status === 'completed' && 'text-muted-foreground/60 line-through',
|
||||
todo.status === 'in_progress' && 'text-[var(--status-warning)]',
|
||||
todo.status === 'pending' && 'text-muted-foreground/80'
|
||||
<div
|
||||
className={cn(
|
||||
'space-y-0.5 overflow-y-auto',
|
||||
isTodosExpanded ? 'max-h-40' : 'max-h-16'
|
||||
)}
|
||||
>
|
||||
{(isTodosExpanded ? agentInfo.todos : agentInfo.todos.slice(0, 3)).map(
|
||||
(todo, idx) => (
|
||||
<div key={idx} className="flex items-center gap-1.5 text-[10px]">
|
||||
{todo.status === 'completed' ? (
|
||||
<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" />
|
||||
) : (
|
||||
<Circle className="w-2.5 h-2.5 text-muted-foreground/50 shrink-0" />
|
||||
)}
|
||||
>
|
||||
{todo.content}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<span
|
||||
className={cn(
|
||||
'break-words hyphens-auto line-clamp-2 leading-relaxed',
|
||||
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 && (
|
||||
<p className="text-[10px] text-muted-foreground/60 pl-4">
|
||||
+{agentInfo.todos.length - 3} more
|
||||
</p>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
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>
|
||||
|
||||
@@ -35,6 +35,7 @@ export function SummaryDialog({
|
||||
data-testid={`summary-dialog-${feature.id}`}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import { useState } from 'react';
|
||||
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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
interface SelectionActionBarProps {
|
||||
selectedCount: number;
|
||||
totalCount: number;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onClear: () => void;
|
||||
onSelectAll: () => void;
|
||||
}
|
||||
@@ -14,65 +24,126 @@ export function SelectionActionBar({
|
||||
selectedCount,
|
||||
totalCount,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onClear,
|
||||
onSelectAll,
|
||||
}: SelectionActionBarProps) {
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
|
||||
if (selectedCount === 0) return null;
|
||||
|
||||
const allSelected = selectedCount === totalCount;
|
||||
|
||||
const handleDeleteClick = () => {
|
||||
setShowDeleteDialog(true);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
setShowDeleteDialog(false);
|
||||
onDelete();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'fixed bottom-6 left-1/2 -translate-x-1/2 z-50',
|
||||
'flex items-center gap-3 px-4 py-3 rounded-xl',
|
||||
'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"
|
||||
>
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{selectedCount} feature{selectedCount !== 1 ? 's' : ''} selected
|
||||
</span>
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'fixed bottom-6 left-1/2 -translate-x-1/2 z-50',
|
||||
'flex items-center gap-3 px-4 py-3 rounded-xl',
|
||||
'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"
|
||||
>
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{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">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={onEdit}
|
||||
className="h-8 bg-brand-500 hover:bg-brand-600"
|
||||
data-testid="selection-edit-button"
|
||||
>
|
||||
<Pencil className="w-4 h-4 mr-1.5" />
|
||||
Edit Selected
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={onEdit}
|
||||
className="h-8 bg-brand-500 hover:bg-brand-600"
|
||||
data-testid="selection-edit-button"
|
||||
>
|
||||
<Pencil className="w-4 h-4 mr-1.5" />
|
||||
Edit Selected
|
||||
</Button>
|
||||
|
||||
{!allSelected && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onSelectAll}
|
||||
className="h-8"
|
||||
data-testid="selection-select-all-button"
|
||||
onClick={handleDeleteClick}
|
||||
className="h-8 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
data-testid="selection-delete-button"
|
||||
>
|
||||
<CheckSquare className="w-4 h-4 mr-1.5" />
|
||||
Select All ({totalCount})
|
||||
<Trash2 className="w-4 h-4 mr-1.5" />
|
||||
Delete
|
||||
</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>
|
||||
{!allSelected && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onSelectAll}
|
||||
className="h-8"
|
||||
data-testid="selection-select-all-button"
|
||||
>
|
||||
<CheckSquare className="w-4 h-4 mr-1.5" />
|
||||
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>
|
||||
|
||||
{/* 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -21,11 +21,9 @@ import {
|
||||
FeatureTextFilePath as DescriptionTextFilePath,
|
||||
ImagePreviewMap,
|
||||
} from '@/components/ui/description-image-dropzone';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { Sparkles, ChevronDown, ChevronRight, Play, Cpu, FolderKanban } from 'lucide-react';
|
||||
import { Play, Cpu, FolderKanban } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { modelSupportsThinking } from '@/lib/utils';
|
||||
import {
|
||||
useAppStore,
|
||||
@@ -43,16 +41,12 @@ import {
|
||||
WorkModeSelector,
|
||||
PlanningModeSelect,
|
||||
AncestorContextSection,
|
||||
EnhanceWithAI,
|
||||
EnhancementHistoryButton,
|
||||
type BaseHistoryEntry,
|
||||
} from '../shared';
|
||||
import type { WorkMode } from '../shared';
|
||||
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 {
|
||||
getAncestors,
|
||||
@@ -97,6 +91,13 @@ interface AddFeatureDialogProps {
|
||||
allFeatures?: Feature[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A single entry in the description history
|
||||
*/
|
||||
interface DescriptionHistoryEntry extends BaseHistoryEntry {
|
||||
description: string;
|
||||
}
|
||||
|
||||
export function AddFeatureDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -139,11 +140,9 @@ export function AddFeatureDialog({
|
||||
// UI state
|
||||
const [previewMap, setPreviewMap] = useState<ImagePreviewMap>(() => new Map());
|
||||
const [descriptionError, setDescriptionError] = useState(false);
|
||||
const [isEnhancing, setIsEnhancing] = useState(false);
|
||||
const [enhancementMode, setEnhancementMode] = useState<
|
||||
'improve' | 'technical' | 'simplify' | 'acceptance'
|
||||
>('improve');
|
||||
const [enhanceOpen, setEnhanceOpen] = useState(false);
|
||||
|
||||
// Description history state
|
||||
const [descriptionHistory, setDescriptionHistory] = useState<DescriptionHistoryEntry[]>([]);
|
||||
|
||||
// Spawn mode state
|
||||
const [ancestors, setAncestors] = useState<AncestorContext[]>([]);
|
||||
@@ -152,9 +151,6 @@ export function AddFeatureDialog({
|
||||
// Get defaults from store
|
||||
const { defaultPlanningMode, defaultRequirePlanApproval } = useAppStore();
|
||||
|
||||
// Enhancement model override
|
||||
const enhancementOverride = useModelOverride({ phase: 'enhancementModel' });
|
||||
|
||||
// Track previous open state to detect when dialog opens
|
||||
const wasOpenRef = useRef(false);
|
||||
|
||||
@@ -171,6 +167,9 @@ export function AddFeatureDialog({
|
||||
setRequirePlanApproval(defaultRequirePlanApproval);
|
||||
setModelEntry({ model: 'opus' });
|
||||
|
||||
// Initialize description history (empty for new feature)
|
||||
setDescriptionHistory([]);
|
||||
|
||||
// Initialize ancestors for spawn mode
|
||||
if (parentFeature) {
|
||||
const ancestorList = getAncestors(parentFeature, allFeatures);
|
||||
@@ -279,7 +278,7 @@ export function AddFeatureDialog({
|
||||
setRequirePlanApproval(defaultRequirePlanApproval);
|
||||
setPreviewMap(new Map());
|
||||
setDescriptionError(false);
|
||||
setEnhanceOpen(false);
|
||||
setDescriptionHistory([]);
|
||||
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
|
||||
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';
|
||||
@@ -380,7 +352,18 @@ export function AddFeatureDialog({
|
||||
{/* Task Details Section */}
|
||||
<div className={cardClass}>
|
||||
<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
|
||||
value={description}
|
||||
onChange={(value) => {
|
||||
@@ -409,71 +392,35 @@ export function AddFeatureDialog({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Collapsible Enhancement Section */}
|
||||
<Collapsible open={enhanceOpen} onOpenChange={setEnhanceOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors w-full py-1">
|
||||
{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">
|
||||
{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
|
||||
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>
|
||||
{/* Enhancement Section */}
|
||||
<EnhanceWithAI
|
||||
value={description}
|
||||
onChange={setDescription}
|
||||
onHistoryAdd={({ mode, originalText, enhancedText }) => {
|
||||
const timestamp = new Date().toISOString();
|
||||
setDescriptionHistory((prev) => {
|
||||
const newHistory = [...prev];
|
||||
// Add original text first (so user can restore to pre-enhancement state)
|
||||
// Only add if it's different from the last entry to avoid duplicates
|
||||
const lastEntry = prev[prev.length - 1];
|
||||
if (!lastEntry || lastEntry.description !== originalText) {
|
||||
newHistory.push({
|
||||
description: originalText,
|
||||
timestamp,
|
||||
source: prev.length === 0 ? 'initial' : 'edit',
|
||||
});
|
||||
}
|
||||
// Add enhanced text
|
||||
newHistory.push({
|
||||
description: enhancedText,
|
||||
timestamp,
|
||||
source: 'enhance',
|
||||
enhancementMode: mode,
|
||||
});
|
||||
return newHistory;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* AI & Execution Section */}
|
||||
|
||||
@@ -21,18 +21,8 @@ import {
|
||||
FeatureTextFilePath as DescriptionTextFilePath,
|
||||
ImagePreviewMap,
|
||||
} from '@/components/ui/description-image-dropzone';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import {
|
||||
Sparkles,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
GitBranch,
|
||||
History,
|
||||
Cpu,
|
||||
FolderKanban,
|
||||
} from 'lucide-react';
|
||||
import { GitBranch, Cpu, FolderKanban } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { cn, modelSupportsThinking } from '@/lib/utils';
|
||||
import { Feature, ModelAlias, ThinkingLevel, useAppStore, PlanningMode } from '@/store/app-store';
|
||||
import type { ReasoningEffort, PhaseModelEntry, DescriptionHistoryEntry } from '@automaker/types';
|
||||
@@ -41,17 +31,12 @@ import {
|
||||
PrioritySelector,
|
||||
WorkModeSelector,
|
||||
PlanningModeSelect,
|
||||
EnhanceWithAI,
|
||||
EnhancementHistoryButton,
|
||||
type EnhancementMode,
|
||||
} from '../shared';
|
||||
import type { WorkMode } from '../shared';
|
||||
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 { DependencyTreeDialog } from './dependency-tree-dialog';
|
||||
import { isClaudeModel, supportsReasoningEffort } from '@automaker/types';
|
||||
@@ -79,7 +64,8 @@ interface EditFeatureDialogProps {
|
||||
requirePlanApproval: boolean;
|
||||
},
|
||||
descriptionHistorySource?: 'enhance' | 'edit',
|
||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance'
|
||||
enhancementMode?: EnhancementMode,
|
||||
preEnhancementDescription?: string
|
||||
) => void;
|
||||
categorySuggestions: string[];
|
||||
branchSuggestions: string[];
|
||||
@@ -110,11 +96,6 @@ export function EditFeatureDialog({
|
||||
const [editFeaturePreviewMap, setEditFeaturePreviewMap] = useState<ImagePreviewMap>(
|
||||
() => 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 [planningMode, setPlanningMode] = useState<PlanningMode>(feature?.planningMode ?? 'skip');
|
||||
const [requirePlanApproval, setRequirePlanApproval] = useState(
|
||||
@@ -133,15 +114,16 @@ export function EditFeatureDialog({
|
||||
|
||||
// Track the source of description changes for history
|
||||
const [descriptionChangeSource, setDescriptionChangeSource] = useState<
|
||||
{ source: 'enhance'; mode: 'improve' | 'technical' | 'simplify' | 'acceptance' } | 'edit' | null
|
||||
{ source: 'enhance'; mode: EnhancementMode } | 'edit' | null
|
||||
>(null);
|
||||
// Track the original description when the dialog opened for comparison
|
||||
const [originalDescription, setOriginalDescription] = useState(feature?.description ?? '');
|
||||
// Track if history dropdown is open
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
|
||||
// Enhancement model override
|
||||
const enhancementOverride = useModelOverride({ phase: 'enhancementModel' });
|
||||
// Track the description before enhancement (so it can be restored)
|
||||
const [preEnhancementDescription, setPreEnhancementDescription] = useState<string | null>(null);
|
||||
// Local history state for real-time display (combines persisted + session history)
|
||||
const [localHistory, setLocalHistory] = useState<DescriptionHistoryEntry[]>(
|
||||
feature?.descriptionHistory ?? []
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setEditingFeature(feature);
|
||||
@@ -153,8 +135,8 @@ export function EditFeatureDialog({
|
||||
// Reset history tracking state
|
||||
setOriginalDescription(feature.description ?? '');
|
||||
setDescriptionChangeSource(null);
|
||||
setShowHistory(false);
|
||||
setEnhanceOpen(false);
|
||||
setPreEnhancementDescription(null);
|
||||
setLocalHistory(feature.descriptionHistory ?? []);
|
||||
// Reset model entry
|
||||
setModelEntry({
|
||||
model: (feature.model as ModelAlias) || 'opus',
|
||||
@@ -164,7 +146,8 @@ export function EditFeatureDialog({
|
||||
} else {
|
||||
setEditFeaturePreviewMap(new Map());
|
||||
setDescriptionChangeSource(null);
|
||||
setShowHistory(false);
|
||||
setPreEnhancementDescription(null);
|
||||
setLocalHistory([]);
|
||||
}
|
||||
}, [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());
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
@@ -304,85 +263,18 @@ export function EditFeatureDialog({
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="edit-description">Description</Label>
|
||||
{/* Version History Button */}
|
||||
{feature?.descriptionHistory && feature.descriptionHistory.length > 0 && (
|
||||
<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 ({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>
|
||||
)}
|
||||
{/* Version History Button - uses local history for real-time updates */}
|
||||
<EnhancementHistoryButton
|
||||
history={localHistory}
|
||||
currentValue={editingFeature.description}
|
||||
onRestore={(description) => {
|
||||
setEditingFeature((prev) => (prev ? { ...prev, description } : prev));
|
||||
setDescriptionChangeSource('edit');
|
||||
}}
|
||||
valueAccessor={(entry) => entry.description}
|
||||
title="Version History"
|
||||
restoreMessage="Description restored from history"
|
||||
/>
|
||||
</div>
|
||||
<DescriptionImageDropZone
|
||||
value={editingFeature.description}
|
||||
@@ -433,71 +325,40 @@ export function EditFeatureDialog({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Collapsible Enhancement Section */}
|
||||
<Collapsible open={enhanceOpen} onOpenChange={setEnhanceOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors w-full py-1">
|
||||
{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">
|
||||
{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>
|
||||
{/* Enhancement Section */}
|
||||
<EnhanceWithAI
|
||||
value={editingFeature.description}
|
||||
onChange={(enhanced) =>
|
||||
setEditingFeature((prev) => (prev ? { ...prev, description: enhanced } : prev))
|
||||
}
|
||||
onHistoryAdd={({ mode, originalText, enhancedText }) => {
|
||||
setDescriptionChangeSource({ source: 'enhance', mode });
|
||||
setPreEnhancementDescription(originalText);
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
onClick={handleEnhanceDescription}
|
||||
disabled={!editingFeature.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>
|
||||
// Update local history for real-time display
|
||||
const timestamp = new Date().toISOString();
|
||||
setLocalHistory((prev) => {
|
||||
const newHistory = [...prev];
|
||||
// Add original text first (so user can restore to pre-enhancement state)
|
||||
const lastEntry = prev[prev.length - 1];
|
||||
if (!lastEntry || lastEntry.description !== originalText) {
|
||||
newHistory.push({
|
||||
description: originalText,
|
||||
timestamp,
|
||||
source: prev.length === 0 ? 'initial' : 'edit',
|
||||
});
|
||||
}
|
||||
// Add enhanced text
|
||||
newHistory.push({
|
||||
description: enhancedText,
|
||||
timestamp,
|
||||
source: 'enhance',
|
||||
enhancementMode: mode,
|
||||
});
|
||||
return newHistory;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* AI & Execution Section */}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -17,6 +18,21 @@ import {
|
||||
} from '@/components/ui/description-image-dropzone';
|
||||
import { MessageSquare } from 'lucide-react';
|
||||
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 {
|
||||
open: boolean;
|
||||
@@ -30,6 +46,10 @@ interface FollowUpDialogProps {
|
||||
onPreviewMapChange: (map: ImagePreviewMap) => void;
|
||||
onSend: () => void;
|
||||
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({
|
||||
@@ -44,9 +64,11 @@ export function FollowUpDialog({
|
||||
onPreviewMapChange,
|
||||
onSend,
|
||||
isMaximized,
|
||||
promptHistory = [],
|
||||
onHistoryAdd,
|
||||
}: FollowUpDialogProps) {
|
||||
const handleClose = (open: boolean) => {
|
||||
if (!open) {
|
||||
const handleClose = (openState: boolean) => {
|
||||
if (!openState) {
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
@@ -77,7 +99,18 @@ export function FollowUpDialog({
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4 overflow-y-auto flex-1 min-h-0">
|
||||
<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
|
||||
value={prompt}
|
||||
onChange={onPromptChange}
|
||||
@@ -88,6 +121,33 @@ export function FollowUpDialog({
|
||||
onPreviewMapChange={onPreviewMapChange}
|
||||
/>
|
||||
</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">
|
||||
The agent will continue from where it left off, using the existing context. You can
|
||||
attach screenshots to help explain the issue.
|
||||
|
||||
@@ -5,6 +5,6 @@ export { CompletedFeaturesModal } from './completed-features-modal';
|
||||
export { ArchiveAllVerifiedDialog } from './archive-all-verified-dialog';
|
||||
export { DeleteCompletedFeatureDialog } from './delete-completed-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 { MassEditDialog } from './mass-edit-dialog';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -8,223 +8,11 @@ import {
|
||||
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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Plus, Trash2, ChevronUp, ChevronDown, Upload, Pencil, X, FileText } from 'lucide-react';
|
||||
import { Plus, Trash2, ChevronUp, ChevronDown, Pencil } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import type { PipelineConfig, PipelineStep } from '@automaker/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// 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;
|
||||
};
|
||||
import { AddEditPipelineStepDialog } from './add-edit-pipeline-step-dialog';
|
||||
|
||||
interface PipelineSettingsDialogProps {
|
||||
open: boolean;
|
||||
@@ -234,18 +22,10 @@ interface PipelineSettingsDialogProps {
|
||||
onSave: (config: PipelineConfig) => Promise<void>;
|
||||
}
|
||||
|
||||
interface EditingStep {
|
||||
id?: string;
|
||||
name: string;
|
||||
instructions: string;
|
||||
colorClass: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export function PipelineSettingsDialog({
|
||||
open,
|
||||
onClose,
|
||||
projectPath,
|
||||
projectPath: _projectPath,
|
||||
pipelineConfig,
|
||||
onSave,
|
||||
}: PipelineSettingsDialogProps) {
|
||||
@@ -262,9 +42,11 @@ export function PipelineSettingsDialog({
|
||||
};
|
||||
|
||||
const [steps, setSteps] = useState<PipelineStep[]>(() => validateSteps(pipelineConfig?.steps));
|
||||
const [editingStep, setEditingStep] = useState<EditingStep | null>(null);
|
||||
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
|
||||
useEffect(() => {
|
||||
@@ -276,22 +58,13 @@ export function PipelineSettingsDialog({
|
||||
const sortedSteps = [...steps].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||
|
||||
const handleAddStep = () => {
|
||||
setEditingStep({
|
||||
name: '',
|
||||
instructions: '',
|
||||
colorClass: COLOR_OPTIONS[steps.length % COLOR_OPTIONS.length].value,
|
||||
order: steps.length,
|
||||
});
|
||||
setEditingStep(null);
|
||||
setAddEditDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleEditStep = (step: PipelineStep) => {
|
||||
setEditingStep({
|
||||
id: step.id,
|
||||
name: step.name,
|
||||
instructions: step.instructions,
|
||||
colorClass: step.colorClass,
|
||||
order: step.order,
|
||||
});
|
||||
setEditingStep(step);
|
||||
setAddEditDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteStep = (stepId: string) => {
|
||||
@@ -323,53 +96,21 @@ export function PipelineSettingsDialog({
|
||||
setSteps(newSteps);
|
||||
};
|
||||
|
||||
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();
|
||||
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 handleSaveStep = (
|
||||
stepData: Omit<PipelineStep, 'id' | 'createdAt' | 'updatedAt'> & { id?: string }
|
||||
) => {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
if (editingStep.id) {
|
||||
if (stepData.id) {
|
||||
// Update existing step
|
||||
setSteps((prev) =>
|
||||
prev.map((s) =>
|
||||
s.id === editingStep.id
|
||||
s.id === stepData.id
|
||||
? {
|
||||
...s,
|
||||
name: editingStep.name,
|
||||
instructions: editingStep.instructions,
|
||||
colorClass: editingStep.colorClass,
|
||||
name: stepData.name,
|
||||
instructions: stepData.instructions,
|
||||
colorClass: stepData.colorClass,
|
||||
updatedAt: now,
|
||||
}
|
||||
: s
|
||||
@@ -379,90 +120,21 @@ export function PipelineSettingsDialog({
|
||||
// Add new step
|
||||
const newStep: PipelineStep = {
|
||||
id: `step_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 8)}`,
|
||||
name: editingStep.name,
|
||||
instructions: editingStep.instructions,
|
||||
colorClass: editingStep.colorClass,
|
||||
name: stepData.name,
|
||||
instructions: stepData.instructions,
|
||||
colorClass: stepData.colorClass,
|
||||
order: steps.length,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
setSteps((prev) => [...prev, newStep]);
|
||||
}
|
||||
|
||||
setEditingStep(null);
|
||||
};
|
||||
|
||||
const handleSaveConfig = async () => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
// If the user is currently editing a step and clicks "Save Configuration",
|
||||
// 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 sortedEffectiveSteps = [...steps].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||
const config: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: sortedEffectiveSteps.map((s, index) => ({ ...s, order: index })),
|
||||
@@ -470,7 +142,7 @@ export function PipelineSettingsDialog({
|
||||
await onSave(config);
|
||||
toast.success('Pipeline configuration saved');
|
||||
onClose();
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toast.error('Failed to save pipeline configuration');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
@@ -478,259 +150,121 @@ export function PipelineSettingsDialog({
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(open) => !open && 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>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>
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] overflow-hidden flex flex-col">
|
||||
<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
|
||||
className={cn(
|
||||
'w-3 h-8 rounded',
|
||||
(step.colorClass || 'bg-blue-500/20').replace('/20', '')
|
||||
)}
|
||||
/>
|
||||
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 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
|
||||
className={cn(
|
||||
'w-3 h-8 rounded',
|
||||
(step.colorClass || 'bg-blue-500/20').replace('/20', '')
|
||||
)}
|
||||
/>
|
||||
|
||||
<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 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">
|
||||
<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 && (
|
||||
{/* Add Step Button */}
|
||||
<Button variant="outline" className="w-full" onClick={handleAddStep}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Pipeline Step
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Edit/Add Step Form */}
|
||||
{editingStep && (
|
||||
<div className="border rounded-lg p-4 space-y-4 bg-muted/20">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium">{editingStep.id ? 'Edit Step' : 'New Step'}</h4>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setEditingStep(null)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSaveConfig} disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Saving...' : 'Save Pipeline'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Template Selector - only show for new steps */}
|
||||
{!editingStep.id && (
|
||||
<div className="space-y-2">
|
||||
<Label>Start from Template</Label>
|
||||
<Select
|
||||
onValueChange={(templateId) => {
|
||||
const template = STEP_TEMPLATES.find((t) => t.id === templateId);
|
||||
if (template) {
|
||||
setEditingStep((prev) =>
|
||||
prev
|
||||
? {
|
||||
...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>
|
||||
{/* Sub-dialog for adding/editing steps */}
|
||||
<AddEditPipelineStepDialog
|
||||
open={addEditDialogOpen}
|
||||
onClose={() => {
|
||||
setAddEditDialogOpen(false);
|
||||
setEditingStep(null);
|
||||
}}
|
||||
onSave={handleSaveStep}
|
||||
existingStep={editingStep}
|
||||
defaultOrder={steps.length}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.**`,
|
||||
};
|
||||
@@ -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.**`,
|
||||
};
|
||||
@@ -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';
|
||||
};
|
||||
@@ -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.**`,
|
||||
};
|
||||
@@ -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.**`,
|
||||
};
|
||||
@@ -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.**`,
|
||||
};
|
||||
@@ -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.**`,
|
||||
};
|
||||
@@ -30,7 +30,8 @@ interface UseBoardActionsProps {
|
||||
featureId: string,
|
||||
updates: Partial<Feature>,
|
||||
descriptionHistorySource?: 'enhance' | 'edit',
|
||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance'
|
||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer',
|
||||
preEnhancementDescription?: string
|
||||
) => Promise<void>;
|
||||
persistFeatureDelete: (featureId: string) => Promise<void>;
|
||||
saveCategory: (category: string) => Promise<void>;
|
||||
@@ -251,7 +252,8 @@ export function useBoardActions({
|
||||
workMode?: 'current' | 'auto' | 'custom';
|
||||
},
|
||||
descriptionHistorySource?: 'enhance' | 'edit',
|
||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance'
|
||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer',
|
||||
preEnhancementDescription?: string
|
||||
) => {
|
||||
const workMode = updates.workMode || 'current';
|
||||
|
||||
@@ -308,7 +310,13 @@ export function useBoardActions({
|
||||
};
|
||||
|
||||
updateFeature(featureId, finalUpdates);
|
||||
persistFeatureUpdate(featureId, finalUpdates, descriptionHistorySource, enhancementMode);
|
||||
persistFeatureUpdate(
|
||||
featureId,
|
||||
finalUpdates,
|
||||
descriptionHistorySource,
|
||||
enhancementMode,
|
||||
preEnhancementDescription
|
||||
);
|
||||
if (updates.category) {
|
||||
saveCategory(updates.category);
|
||||
}
|
||||
|
||||
@@ -70,9 +70,21 @@ export function useBoardColumnFeatures({
|
||||
// We're viewing main but branch hasn't been initialized yet
|
||||
// (worktrees disabled or haven't loaded yet).
|
||||
// Show features assigned to primary worktree's branch.
|
||||
matchesWorktree = projectPath
|
||||
? useAppStore.getState().isPrimaryWorktreeBranch(projectPath, featureBranch)
|
||||
: false;
|
||||
if (projectPath) {
|
||||
const worktrees = useAppStore.getState().worktreesByProject[projectPath] ?? [];
|
||||
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 {
|
||||
// Match by branch name
|
||||
matchesWorktree = featureBranch === effectiveBranch;
|
||||
|
||||
@@ -75,6 +75,17 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
|
||||
if (isProjectSwitch) {
|
||||
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) {
|
||||
logger.error('API returned error:', result.error);
|
||||
// If it's a new project or the error indicates no features found,
|
||||
|
||||
@@ -19,7 +19,8 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
|
||||
featureId: string,
|
||||
updates: Partial<Feature>,
|
||||
descriptionHistorySource?: 'enhance' | 'edit',
|
||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance'
|
||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer',
|
||||
preEnhancementDescription?: string
|
||||
) => {
|
||||
if (!currentProject) return;
|
||||
|
||||
@@ -35,7 +36,8 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
|
||||
featureId,
|
||||
updates,
|
||||
descriptionHistorySource,
|
||||
enhancementMode
|
||||
enhancementMode,
|
||||
preEnhancementDescription
|
||||
);
|
||||
if (result.success && result.feature) {
|
||||
updateFeature(result.feature.id, result.feature);
|
||||
|
||||
@@ -4,13 +4,18 @@ import {
|
||||
FeatureImagePath as DescriptionImagePath,
|
||||
ImagePreviewMap,
|
||||
} 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() {
|
||||
const [showFollowUpDialog, setShowFollowUpDialog] = useState(false);
|
||||
const [followUpFeature, setFollowUpFeature] = useState<Feature | null>(null);
|
||||
const [followUpPrompt, setFollowUpPrompt] = useState('');
|
||||
const [followUpImagePaths, setFollowUpImagePaths] = useState<DescriptionImagePath[]>([]);
|
||||
const [followUpPreviewMap, setFollowUpPreviewMap] = useState<ImagePreviewMap>(() => new Map());
|
||||
const [followUpPromptHistory, setFollowUpPromptHistory] = useState<FollowUpHistoryEntry[]>([]);
|
||||
|
||||
const resetFollowUpState = useCallback(() => {
|
||||
setShowFollowUpDialog(false);
|
||||
@@ -18,6 +23,7 @@ export function useFollowUpState() {
|
||||
setFollowUpPrompt('');
|
||||
setFollowUpImagePaths([]);
|
||||
setFollowUpPreviewMap(new Map());
|
||||
setFollowUpPromptHistory([]);
|
||||
}, []);
|
||||
|
||||
const handleFollowUpDialogChange = useCallback(
|
||||
@@ -31,6 +37,13 @@ export function useFollowUpState() {
|
||||
[resetFollowUpState]
|
||||
);
|
||||
|
||||
/**
|
||||
* Adds a new entry to the prompt history
|
||||
*/
|
||||
const addToPromptHistory = useCallback((entry: FollowUpHistoryEntry) => {
|
||||
setFollowUpPromptHistory((prev) => [...prev, entry]);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// State
|
||||
showFollowUpDialog,
|
||||
@@ -38,14 +51,17 @@ export function useFollowUpState() {
|
||||
followUpPrompt,
|
||||
followUpImagePaths,
|
||||
followUpPreviewMap,
|
||||
followUpPromptHistory,
|
||||
// Setters
|
||||
setShowFollowUpDialog,
|
||||
setFollowUpFeature,
|
||||
setFollowUpPrompt,
|
||||
setFollowUpImagePaths,
|
||||
setFollowUpPreviewMap,
|
||||
setFollowUpPromptHistory,
|
||||
// Helpers
|
||||
resetFollowUpState,
|
||||
handleFollowUpDialogChange,
|
||||
addToPromptHistory,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ export function KanbanBoard({
|
||||
const { columnWidth, containerStyle } = useResponsiveKanban(columns.length);
|
||||
|
||||
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
|
||||
sensors={sensors}
|
||||
collisionDetection={collisionDetectionStrategy}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './enhancement-constants';
|
||||
export * from './enhance-with-ai';
|
||||
export * from './enhancement-history-button';
|
||||
@@ -10,3 +10,4 @@ export * from './planning-mode-selector';
|
||||
export * from './planning-mode-select';
|
||||
export * from './ancestor-context-section';
|
||||
export * from './work-mode-selector';
|
||||
export * from './enhancement';
|
||||
|
||||
@@ -6,13 +6,15 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Trash2,
|
||||
MoreHorizontal,
|
||||
GitCommit,
|
||||
GitPullRequest,
|
||||
ExternalLink,
|
||||
Download,
|
||||
Upload,
|
||||
Play,
|
||||
@@ -22,15 +24,18 @@ import {
|
||||
GitMerge,
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
Copy,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { WorktreeInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types';
|
||||
import { TooltipWrapper } from './tooltip-wrapper';
|
||||
import { useAvailableEditors, useEffectiveDefaultEditor } from '../hooks/use-available-editors';
|
||||
import { getEditorIcon } from '@/components/icons/editor-icons';
|
||||
|
||||
interface WorktreeActionsDropdownProps {
|
||||
worktree: WorktreeInfo;
|
||||
isSelected: boolean;
|
||||
defaultEditorName: string;
|
||||
aheadCount: number;
|
||||
behindCount: number;
|
||||
isPulling: boolean;
|
||||
@@ -42,7 +47,7 @@ interface WorktreeActionsDropdownProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onPull: (worktree: WorktreeInfo) => void;
|
||||
onPush: (worktree: WorktreeInfo) => void;
|
||||
onOpenInEditor: (worktree: WorktreeInfo) => void;
|
||||
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
|
||||
onCommit: (worktree: WorktreeInfo) => void;
|
||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||
@@ -58,7 +63,6 @@ interface WorktreeActionsDropdownProps {
|
||||
export function WorktreeActionsDropdown({
|
||||
worktree,
|
||||
isSelected,
|
||||
defaultEditorName,
|
||||
aheadCount,
|
||||
behindCount,
|
||||
isPulling,
|
||||
@@ -82,6 +86,20 @@ export function WorktreeActionsDropdown({
|
||||
onRunInitScript,
|
||||
hasInitScript,
|
||||
}: 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
|
||||
const hasPR = !!worktree.pr;
|
||||
|
||||
@@ -205,10 +223,54 @@ export function WorktreeActionsDropdown({
|
||||
</TooltipWrapper>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => onOpenInEditor(worktree)} className="text-xs">
|
||||
<ExternalLink className="w-3.5 h-3.5 mr-2" />
|
||||
Open in {defaultEditorName}
|
||||
</DropdownMenuItem>
|
||||
{/* Open in editor - split button: click main area for default, chevron for other options */}
|
||||
{effectiveDefaultEditor && (
|
||||
<DropdownMenuSub>
|
||||
<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 && (
|
||||
<DropdownMenuItem onClick={() => onRunInitScript(worktree)} className="text-xs">
|
||||
<RefreshCw className="w-3.5 h-3.5 mr-2" />
|
||||
|
||||
@@ -17,7 +17,6 @@ interface WorktreeTabProps {
|
||||
isActivating: boolean;
|
||||
isDevServerRunning: boolean;
|
||||
devServerInfo?: DevServerInfo;
|
||||
defaultEditorName: string;
|
||||
branches: BranchInfo[];
|
||||
filteredBranches: BranchInfo[];
|
||||
branchFilter: string;
|
||||
@@ -37,7 +36,7 @@ interface WorktreeTabProps {
|
||||
onCreateBranch: (worktree: WorktreeInfo) => void;
|
||||
onPull: (worktree: WorktreeInfo) => void;
|
||||
onPush: (worktree: WorktreeInfo) => void;
|
||||
onOpenInEditor: (worktree: WorktreeInfo) => void;
|
||||
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
|
||||
onCommit: (worktree: WorktreeInfo) => void;
|
||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||
@@ -60,7 +59,6 @@ export function WorktreeTab({
|
||||
isActivating,
|
||||
isDevServerRunning,
|
||||
devServerInfo,
|
||||
defaultEditorName,
|
||||
branches,
|
||||
filteredBranches,
|
||||
branchFilter,
|
||||
@@ -319,7 +317,6 @@ export function WorktreeTab({
|
||||
<WorktreeActionsDropdown
|
||||
worktree={worktree}
|
||||
isSelected={isSelected}
|
||||
defaultEditorName={defaultEditorName}
|
||||
aheadCount={aheadCount}
|
||||
behindCount={behindCount}
|
||||
isPulling={isPulling}
|
||||
|
||||
@@ -2,5 +2,5 @@ export { useWorktrees } from './use-worktrees';
|
||||
export { useDevServers } from './use-dev-servers';
|
||||
export { useBranches } from './use-branches';
|
||||
export { useWorktreeActions } from './use-worktree-actions';
|
||||
export { useDefaultEditor } from './use-default-editor';
|
||||
export { useRunningFeatures } from './use-running-features';
|
||||
export { useAvailableEditors, useEffectiveDefaultEditor } from './use-available-editors';
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -125,14 +125,14 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre
|
||||
[isPushing, fetchBranches, fetchWorktrees]
|
||||
);
|
||||
|
||||
const handleOpenInEditor = useCallback(async (worktree: WorktreeInfo) => {
|
||||
const handleOpenInEditor = useCallback(async (worktree: WorktreeInfo, editorCommand?: string) => {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.openInEditor) {
|
||||
logger.warn('Open in editor API not available');
|
||||
return;
|
||||
}
|
||||
const result = await api.worktree.openInEditor(worktree.path);
|
||||
const result = await api.worktree.openInEditor(worktree.path, editorCommand);
|
||||
if (result.success && result.result) {
|
||||
toast.success(result.result.message);
|
||||
} else if (result.error) {
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
useDevServers,
|
||||
useBranches,
|
||||
useWorktreeActions,
|
||||
useDefaultEditor,
|
||||
useRunningFeatures,
|
||||
} from './hooks';
|
||||
import { WorktreeTab } from './components';
|
||||
@@ -77,8 +76,6 @@ export function WorktreePanel({
|
||||
fetchBranches,
|
||||
});
|
||||
|
||||
const { defaultEditorName } = useDefaultEditor();
|
||||
|
||||
const { hasRunningFeatures } = useRunningFeatures({
|
||||
runningFeatureIds,
|
||||
features,
|
||||
@@ -188,7 +185,6 @@ export function WorktreePanel({
|
||||
isActivating={isActivating}
|
||||
isDevServerRunning={isDevServerRunning(mainWorktree)}
|
||||
devServerInfo={getDevServerInfo(mainWorktree)}
|
||||
defaultEditorName={defaultEditorName}
|
||||
branches={branches}
|
||||
filteredBranches={filteredBranches}
|
||||
branchFilter={branchFilter}
|
||||
@@ -245,7 +241,6 @@ export function WorktreePanel({
|
||||
isActivating={isActivating}
|
||||
isDevServerRunning={isDevServerRunning(worktree)}
|
||||
devServerInfo={getDevServerInfo(worktree)}
|
||||
defaultEditorName={defaultEditorName}
|
||||
branches={branches}
|
||||
filteredBranches={filteredBranches}
|
||||
branchFilter={branchFilter}
|
||||
|
||||
318
apps/ui/src/components/views/graph-view-page.tsx
Normal file
318
apps/ui/src/components/views/graph-view-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -31,7 +31,10 @@ export function GraphControls({
|
||||
return (
|
||||
<Panel position="bottom-left" className="flex flex-col gap-2">
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<div className="flex flex-col gap-1 p-1.5 rounded-lg bg-popover/90 backdrop-blur-sm border border-border shadow-lg text-popover-foreground">
|
||||
<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 */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -110,7 +110,10 @@ export function GraphFilterControls({
|
||||
return (
|
||||
<Panel position="top-left" className="flex items-center gap-2">
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<div className="flex items-center gap-2 p-2 rounded-lg bg-popover/90 backdrop-blur-sm border border-border shadow-lg text-popover-foreground">
|
||||
<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 */}
|
||||
<Popover>
|
||||
<Tooltip>
|
||||
|
||||
@@ -44,7 +44,10 @@ const legendItems = [
|
||||
export function GraphLegend() {
|
||||
return (
|
||||
<Panel position="bottom-right" className="pointer-events-none">
|
||||
<div className="flex flex-wrap gap-3 p-2 rounded-lg bg-popover/90 backdrop-blur-sm border border-border shadow-lg pointer-events-auto text-popover-foreground">
|
||||
<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) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
|
||||
@@ -75,6 +75,24 @@ const priorityConfig = {
|
||||
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) {
|
||||
// Handle pipeline statuses by treating them like in_progress
|
||||
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
|
||||
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 (
|
||||
<>
|
||||
{/* Target handle (left side - receives dependencies) */}
|
||||
@@ -109,22 +149,26 @@ export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps
|
||||
|
||||
<div
|
||||
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',
|
||||
config.borderClass,
|
||||
selected && 'ring-2 ring-brand-500 ring-offset-2 ring-offset-background',
|
||||
data.isRunning && 'animate-pulse-subtle',
|
||||
data.error && 'border-[var(--status-error)]',
|
||||
// Filter highlight states
|
||||
isMatched && 'graph-node-matched',
|
||||
isHighlighted && !isMatched && 'graph-node-highlighted',
|
||||
isDimmed && 'graph-node-dimmed'
|
||||
)}
|
||||
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 */}
|
||||
<div
|
||||
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
|
||||
)}
|
||||
>
|
||||
@@ -301,7 +345,7 @@ export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-3 py-2">
|
||||
<div className="relative px-3 py-2">
|
||||
{/* Category */}
|
||||
<span className="text-[10px] text-muted-foreground font-medium uppercase tracking-wide">
|
||||
{data.category}
|
||||
|
||||
@@ -15,7 +15,8 @@ import {
|
||||
} from '@xyflow/react';
|
||||
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 {
|
||||
TaskNode,
|
||||
DependencyEdge,
|
||||
@@ -47,6 +48,13 @@ const edgeTypes: any = {
|
||||
dependency: DependencyEdge,
|
||||
};
|
||||
|
||||
interface BackgroundSettings {
|
||||
cardOpacity: number;
|
||||
cardGlassmorphism: boolean;
|
||||
cardBorderEnabled: boolean;
|
||||
cardBorderOpacity: number;
|
||||
}
|
||||
|
||||
interface GraphCanvasProps {
|
||||
features: Feature[];
|
||||
runningAutoTasks: string[];
|
||||
@@ -56,6 +64,7 @@ interface GraphCanvasProps {
|
||||
nodeActionCallbacks?: NodeActionCallbacks;
|
||||
onCreateDependency?: (sourceId: string, targetId: string) => Promise<boolean>;
|
||||
backgroundStyle?: React.CSSProperties;
|
||||
backgroundSettings?: BackgroundSettings;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -68,11 +77,42 @@ function GraphCanvasInner({
|
||||
nodeActionCallbacks,
|
||||
onCreateDependency,
|
||||
backgroundStyle,
|
||||
backgroundSettings,
|
||||
className,
|
||||
}: GraphCanvasProps) {
|
||||
const [isLocked, setIsLocked] = useState(false);
|
||||
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)
|
||||
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
|
||||
const [selectedStatuses, setSelectedStatuses] = useState<string[]>([]);
|
||||
@@ -98,6 +138,7 @@ function GraphCanvasInner({
|
||||
runningAutoTasks,
|
||||
filterResult,
|
||||
actionCallbacks: nodeActionCallbacks,
|
||||
backgroundSettings,
|
||||
});
|
||||
|
||||
// Apply layout
|
||||
@@ -234,6 +275,7 @@ function GraphCanvasInner({
|
||||
isValidConnection={isValidConnection}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
colorMode={colorMode}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.2 }}
|
||||
minZoom={0.1}
|
||||
@@ -256,7 +298,8 @@ function GraphCanvasInner({
|
||||
nodeStrokeWidth={3}
|
||||
zoomable
|
||||
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
|
||||
@@ -281,7 +324,10 @@ function GraphCanvasInner({
|
||||
{/* Empty state when all nodes are filtered out */}
|
||||
{filterResult.hasActiveFilter && filterResult.matchedNodeIds.size === 0 && (
|
||||
<Panel position="top-center" className="mt-20">
|
||||
<div className="flex flex-col items-center gap-3 p-6 rounded-lg bg-popover/95 backdrop-blur-sm border border-border shadow-lg text-popover-foreground">
|
||||
<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" />
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium">No matching tasks</p>
|
||||
|
||||
@@ -44,7 +44,7 @@ export function GraphView({
|
||||
const { currentProject } = useAppStore();
|
||||
|
||||
// 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)
|
||||
const filteredFeatures = useMemo(() => {
|
||||
@@ -213,6 +213,7 @@ export function GraphView({
|
||||
nodeActionCallbacks={nodeActionCallbacks}
|
||||
onCreateDependency={handleCreateDependency}
|
||||
backgroundStyle={backgroundImageStyle}
|
||||
backgroundSettings={backgroundSettings}
|
||||
className="h-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -18,6 +18,11 @@ export interface TaskNodeData extends Feature {
|
||||
isMatched?: boolean;
|
||||
isHighlighted?: boolean;
|
||||
isDimmed?: boolean;
|
||||
// Background/theme settings
|
||||
cardOpacity?: number;
|
||||
cardGlassmorphism?: boolean;
|
||||
cardBorderEnabled?: boolean;
|
||||
cardBorderOpacity?: number;
|
||||
// Action callbacks
|
||||
onViewLogs?: () => void;
|
||||
onViewDetails?: () => void;
|
||||
@@ -48,11 +53,19 @@ export interface NodeActionCallbacks {
|
||||
onDeleteDependency?: (sourceId: string, targetId: string) => void;
|
||||
}
|
||||
|
||||
interface BackgroundSettings {
|
||||
cardOpacity: number;
|
||||
cardGlassmorphism: boolean;
|
||||
cardBorderEnabled: boolean;
|
||||
cardBorderOpacity: number;
|
||||
}
|
||||
|
||||
interface UseGraphNodesProps {
|
||||
features: Feature[];
|
||||
runningAutoTasks: string[];
|
||||
filterResult?: GraphFilterResult;
|
||||
actionCallbacks?: NodeActionCallbacks;
|
||||
backgroundSettings?: BackgroundSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,6 +77,7 @@ export function useGraphNodes({
|
||||
runningAutoTasks,
|
||||
filterResult,
|
||||
actionCallbacks,
|
||||
backgroundSettings,
|
||||
}: UseGraphNodesProps) {
|
||||
const { nodes, edges } = useMemo(() => {
|
||||
const nodeList: TaskNode[] = [];
|
||||
@@ -102,6 +116,11 @@ export function useGraphNodes({
|
||||
isMatched,
|
||||
isHighlighted,
|
||||
isDimmed,
|
||||
// Background/theme settings
|
||||
cardOpacity: backgroundSettings?.cardOpacity,
|
||||
cardGlassmorphism: backgroundSettings?.cardGlassmorphism,
|
||||
cardBorderEnabled: backgroundSettings?.cardBorderEnabled,
|
||||
cardBorderOpacity: backgroundSettings?.cardBorderOpacity,
|
||||
// Action callbacks (bound to this feature's ID)
|
||||
onViewLogs: actionCallbacks?.onViewLogs
|
||||
? () => actionCallbacks.onViewLogs!(feature.id)
|
||||
@@ -163,7 +182,7 @@ export function useGraphNodes({
|
||||
});
|
||||
|
||||
return { nodes: nodeList, edges: edgeList };
|
||||
}, [features, runningAutoTasks, filterResult, actionCallbacks]);
|
||||
}, [features, runningAutoTasks, filterResult, actionCallbacks, backgroundSettings]);
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* 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 { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -17,6 +17,7 @@ import type { AnalysisSuggestion } from '@automaker/types';
|
||||
|
||||
interface IdeationDashboardProps {
|
||||
onGenerateIdeas: () => void;
|
||||
onAcceptAllReady?: (isReady: boolean, count: number, handler: () => Promise<void>) => void;
|
||||
}
|
||||
|
||||
function SuggestionCard({
|
||||
@@ -37,14 +38,16 @@ function SuggestionCard({
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h4 className="font-medium">{suggestion.title}</h4>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{suggestion.priority}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{job.prompt.title}
|
||||
</Badge>
|
||||
<div className="flex items-start gap-2 mb-1">
|
||||
<h4 className="font-medium shrink-0">{suggestion.title}</h4>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge variant="outline" className="text-xs whitespace-nowrap">
|
||||
{suggestion.priority}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="text-xs whitespace-nowrap">
|
||||
{job.prompt.title}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{suggestion.description}</p>
|
||||
{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 generationJobs = useIdeationStore((s) => s.generationJobs);
|
||||
const removeSuggestionFromJob = useIdeationStore((s) => s.removeSuggestionFromJob);
|
||||
const [addingId, setAddingId] = useState<string | null>(null);
|
||||
const [isAcceptingAll, setIsAcceptingAll] = useState(false);
|
||||
const [selectedTags, setSelectedTags] = useState<Set<string>>(new Set());
|
||||
|
||||
// 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');
|
||||
};
|
||||
|
||||
// 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;
|
||||
|
||||
return (
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Dashboard-first design with Generate Ideas flow
|
||||
*/
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useIdeationStore } from '@/store/ideation-store';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { PromptCategoryGrid } from './components/prompt-category-grid';
|
||||
@@ -11,7 +11,7 @@ import { PromptList } from './components/prompt-list';
|
||||
import { IdeationDashboard } from './components/ideation-dashboard';
|
||||
import { useGuidedPrompts } from '@/hooks/use-guided-prompts';
|
||||
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 { IdeationMode } from '@/store/ideation-store';
|
||||
|
||||
@@ -67,12 +67,20 @@ function IdeationHeader({
|
||||
onNavigate,
|
||||
onGenerateIdeas,
|
||||
onBack,
|
||||
acceptAllReady,
|
||||
acceptAllCount,
|
||||
onAcceptAll,
|
||||
isAcceptingAll,
|
||||
}: {
|
||||
currentMode: IdeationMode;
|
||||
selectedCategory: IdeaCategory | null;
|
||||
onNavigate: (mode: IdeationMode, category?: IdeaCategory | null) => void;
|
||||
onGenerateIdeas: () => void;
|
||||
onBack: () => void;
|
||||
acceptAllReady: boolean;
|
||||
acceptAllCount: number;
|
||||
onAcceptAll: () => void;
|
||||
isAcceptingAll: boolean;
|
||||
}) {
|
||||
const { getCategoryById } = useGuidedPrompts();
|
||||
const showBackButton = currentMode === 'prompts';
|
||||
@@ -120,6 +128,21 @@ function IdeationHeader({
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<Lightbulb className="w-4 h-4" />
|
||||
Generate Ideas
|
||||
@@ -133,6 +156,32 @@ export function IdeationView() {
|
||||
const currentProject = useAppStore((s) => s.currentProject);
|
||||
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(
|
||||
(mode: IdeationMode, category?: IdeaCategory | null) => {
|
||||
setMode(mode);
|
||||
@@ -192,10 +241,19 @@ export function IdeationView() {
|
||||
onNavigate={handleNavigate}
|
||||
onGenerateIdeas={handleGenerateIdeas}
|
||||
onBack={handleBackFromPrompts}
|
||||
acceptAllReady={acceptAllReady}
|
||||
acceptAllCount={acceptAllCount}
|
||||
onAcceptAll={handleAcceptAll}
|
||||
isAcceptingAll={isAcceptingAll}
|
||||
/>
|
||||
|
||||
{/* Dashboard - main view */}
|
||||
{currentMode === 'dashboard' && <IdeationDashboard onGenerateIdeas={handleGenerateIdeas} />}
|
||||
{currentMode === 'dashboard' && (
|
||||
<IdeationDashboard
|
||||
onGenerateIdeas={handleGenerateIdeas}
|
||||
onAcceptAllReady={handleAcceptAllReady}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Prompts - category selection */}
|
||||
{currentMode === 'prompts' && !selectedCategory && (
|
||||
|
||||
@@ -13,7 +13,15 @@
|
||||
|
||||
import { useReducer, useEffect, useRef } from 'react';
|
||||
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 { Input } from '@/components/ui/input';
|
||||
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 BACKOFF_BASE_MS = 400;
|
||||
const NO_STORE_CACHE_MODE: RequestCache = 'no-store';
|
||||
|
||||
// =============================================================================
|
||||
// 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()
|
||||
* 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 }
|
||||
* Throws: on network errors (for retry logic)
|
||||
@@ -110,9 +121,31 @@ const BACKOFF_BASE_MS = 400;
|
||||
async function checkAuthStatusSafe(): Promise<{ authenticated: boolean }> {
|
||||
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`, {
|
||||
credentials: 'include', // Send HTTP-only session cookie
|
||||
headers,
|
||||
credentials: 'include',
|
||||
signal: AbortSignal.timeout(5000),
|
||||
cache: NO_STORE_CACHE_MODE,
|
||||
});
|
||||
|
||||
// Any response means server is reachable
|
||||
@@ -246,6 +279,14 @@ export function LoginView() {
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
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.
|
||||
// IMPORTANT: Do not "run once" via a ref guard here.
|
||||
// In React StrictMode (dev), effects mount -> cleanup -> mount.
|
||||
|
||||
624
apps/ui/src/components/views/memory-view.tsx
Normal file
624
apps/ui/src/components/views/memory-view.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,51 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
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 { logout } from '@/lib/http-api-client';
|
||||
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() {
|
||||
const navigate = useNavigate();
|
||||
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 () => {
|
||||
setIsLoggingOut(true);
|
||||
try {
|
||||
@@ -43,6 +79,81 @@ export function AccountSection() {
|
||||
<p className="text-sm text-muted-foreground/80 ml-12">Manage your session and account.</p>
|
||||
</div>
|
||||
<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 */}
|
||||
<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">
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
QwenIcon,
|
||||
MistralIcon,
|
||||
MetaIcon,
|
||||
getProviderIconForModel,
|
||||
} from '@/components/ui/provider-icon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -513,27 +514,8 @@ export function PhaseModelSelector({
|
||||
const isSelected = selectedModel === model.id;
|
||||
const isFavorite = favoriteModels.includes(model.id);
|
||||
|
||||
// Get the appropriate icon based on provider
|
||||
const ProviderIcon = (() => {
|
||||
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;
|
||||
}
|
||||
})();
|
||||
// Get the appropriate icon based on the specific model ID
|
||||
const ProviderIcon = getProviderIconForModel(model.id);
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
|
||||
@@ -431,6 +431,16 @@ export function PromptCustomizationSection({
|
||||
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>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
AnthropicIcon,
|
||||
MistralIcon,
|
||||
MetaIcon,
|
||||
getProviderIconForModel,
|
||||
} from '@/components/ui/provider-icon';
|
||||
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 }> {
|
||||
switch (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;
|
||||
}
|
||||
function getModelIcon(modelId: OpencodeModelId): ComponentType<{ className?: string }> {
|
||||
return getProviderIconForModel(modelId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -146,11 +130,11 @@ export function OpencodeModelConfiguration({
|
||||
{enabledOpencodeModels.map((modelId) => {
|
||||
const model = OPENCODE_MODEL_CONFIG_MAP[modelId];
|
||||
if (!model) return null;
|
||||
const ProviderIconComponent = getProviderIcon(model.provider);
|
||||
const ModelIconComponent = getModelIcon(modelId);
|
||||
return (
|
||||
<SelectItem key={modelId} value={modelId}>
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIconComponent className="w-4 h-4" />
|
||||
<ModelIconComponent className="w-4 h-4" />
|
||||
<span>{model.label}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
@@ -167,7 +151,9 @@ export function OpencodeModelConfiguration({
|
||||
const models = modelsByProvider[provider];
|
||||
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 (
|
||||
<div key={provider} className="space-y-2">
|
||||
|
||||
@@ -81,7 +81,7 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
|
||||
return;
|
||||
}
|
||||
|
||||
const status = await api.specRegeneration.status();
|
||||
const status = await api.specRegeneration.status(currentProject.path);
|
||||
logger.debug(
|
||||
'[useSpecGeneration] Status check on mount:',
|
||||
status,
|
||||
@@ -90,9 +90,7 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
|
||||
);
|
||||
|
||||
if (status.success && status.isRunning) {
|
||||
logger.debug(
|
||||
'[useSpecGeneration] Spec generation is running globally. Tentatively showing loader.'
|
||||
);
|
||||
logger.debug('[useSpecGeneration] Spec generation is running for this project.');
|
||||
|
||||
setIsCreating(true);
|
||||
setIsRegenerating(true);
|
||||
@@ -143,7 +141,7 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
|
||||
const api = getElectronAPI();
|
||||
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);
|
||||
|
||||
if (!status.isRunning) {
|
||||
@@ -180,7 +178,7 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
|
||||
const api = getElectronAPI();
|
||||
if (!api.specRegeneration) return;
|
||||
|
||||
const status = await api.specRegeneration.status();
|
||||
const status = await api.specRegeneration.status(currentProject.path);
|
||||
|
||||
if (!status.isRunning) {
|
||||
logger.debug(
|
||||
|
||||
@@ -21,9 +21,9 @@ export function useSpecLoading() {
|
||||
// Check if spec generation is running before trying to load
|
||||
// This prevents showing "No App Specification Found" during generation
|
||||
if (api.specRegeneration) {
|
||||
const status = await api.specRegeneration.status();
|
||||
const status = await api.specRegeneration.status(currentProject.path);
|
||||
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);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
|
||||
@@ -44,6 +44,7 @@ import { getElectronAPI } from '@/lib/electron';
|
||||
import { getApiKey, getSessionToken, getServerUrlSync } from '@/lib/http-api-client';
|
||||
|
||||
const logger = createLogger('Terminal');
|
||||
const NO_STORE_CACHE_MODE: RequestCache = 'no-store';
|
||||
|
||||
// Font size constraints
|
||||
const MIN_FONT_SIZE = 8;
|
||||
@@ -504,6 +505,7 @@ export function TerminalPanel({
|
||||
const response = await fetch(`${serverUrl}/api/auth/token`, {
|
||||
headers,
|
||||
credentials: 'include',
|
||||
cache: NO_STORE_CACHE_MODE,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -517,8 +517,9 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
|
||||
}
|
||||
|
||||
// Save theme to localStorage for fallback when server settings aren't available
|
||||
if (settings.theme) {
|
||||
setItem(THEME_STORAGE_KEY, settings.theme);
|
||||
const storedTheme = (currentProject?.theme as string | undefined) || settings.theme;
|
||||
if (storedTheme) {
|
||||
setItem(THEME_STORAGE_KEY, storedTheme);
|
||||
}
|
||||
|
||||
useAppStore.setState({
|
||||
|
||||
@@ -47,6 +47,7 @@ const SETTINGS_FIELDS_TO_SYNC = [
|
||||
'autoLoadClaudeMd',
|
||||
'keyboardShortcuts',
|
||||
'mcpServers',
|
||||
'defaultEditorCommand',
|
||||
'promptCustomization',
|
||||
'projects',
|
||||
'trashedProjects',
|
||||
@@ -89,6 +90,7 @@ export function useSettingsSync(): SettingsSyncState {
|
||||
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
const authChecked = useAuthStore((s) => s.authChecked);
|
||||
const settingsLoaded = useAuthStore((s) => s.settingsLoaded);
|
||||
|
||||
const syncTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastSyncedRef = useRef<string>('');
|
||||
@@ -117,9 +119,17 @@ export function useSettingsSync(): SettingsSyncState {
|
||||
// Debounced sync function
|
||||
const syncToServer = useCallback(async () => {
|
||||
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();
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -127,6 +137,8 @@ export function useSettingsSync(): SettingsSyncState {
|
||||
const api = getHttpApiClient();
|
||||
const appState = useAppStore.getState();
|
||||
|
||||
logger.debug('Syncing to server:', { projectsCount: appState.projects?.length ?? 0 });
|
||||
|
||||
// Build updates object from current state
|
||||
const updates: Record<string, unknown> = {};
|
||||
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
|
||||
const updateHash = JSON.stringify(updates);
|
||||
if (updateHash === lastSyncedRef.current) {
|
||||
logger.debug('Sync skipped: no changes');
|
||||
setState((s) => ({ ...s, syncing: false }));
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('Sending settings update:', { projects: updates.projects });
|
||||
|
||||
const result = await api.settings.updateGlobal(updates);
|
||||
if (result.success) {
|
||||
lastSyncedRef.current = updateHash;
|
||||
@@ -184,11 +199,20 @@ export function useSettingsSync(): SettingsSyncState {
|
||||
void syncToServer();
|
||||
}, [syncToServer]);
|
||||
|
||||
// Initialize sync - WAIT for migration to complete first
|
||||
// Initialize sync - WAIT for settings to be loaded and migration to complete
|
||||
useEffect(() => {
|
||||
// Don't initialize syncing until we know auth status and are authenticated.
|
||||
// Prevents accidental overwrites when the app boots before settings are hydrated.
|
||||
if (!authChecked || !isAuthenticated) return;
|
||||
// Don't initialize syncing until:
|
||||
// 1. Auth has been checked
|
||||
// 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;
|
||||
isInitializedRef.current = true;
|
||||
|
||||
@@ -198,14 +222,26 @@ export function useSettingsSync(): SettingsSyncState {
|
||||
await waitForApiKeyInit();
|
||||
|
||||
// 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...');
|
||||
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');
|
||||
|
||||
// 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
|
||||
// (migration has already hydrated the store from server/localStorage)
|
||||
const appState = useAppStore.getState();
|
||||
const updates: Record<string, unknown> = {};
|
||||
for (const field of SETTINGS_FIELDS_TO_SYNC) {
|
||||
if (field === 'currentProjectId') {
|
||||
@@ -214,7 +250,6 @@ export function useSettingsSync(): SettingsSyncState {
|
||||
updates[field] = appState[field as keyof typeof appState];
|
||||
}
|
||||
}
|
||||
const setupState = useSetupStore.getState();
|
||||
for (const field of SETUP_FIELDS_TO_SYNC) {
|
||||
updates[field] = setupState[field as keyof typeof setupState];
|
||||
}
|
||||
@@ -233,16 +268,33 @@ export function useSettingsSync(): SettingsSyncState {
|
||||
}
|
||||
|
||||
initializeSync();
|
||||
}, [authChecked, isAuthenticated]);
|
||||
}, [authChecked, isAuthenticated, settingsLoaded]);
|
||||
|
||||
// Subscribe to store changes and sync to server
|
||||
useEffect(() => {
|
||||
if (!state.loaded || !authChecked || !isAuthenticated) return;
|
||||
if (!state.loaded || !authChecked || !isAuthenticated || !settingsLoaded) return;
|
||||
|
||||
// Subscribe to app store changes
|
||||
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 (newState.currentProject?.id !== prevState.currentProject?.id) {
|
||||
logger.debug('Current project changed, syncing immediately');
|
||||
syncNow();
|
||||
return;
|
||||
}
|
||||
@@ -266,6 +318,7 @@ export function useSettingsSync(): SettingsSyncState {
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
logger.debug('Store changed, scheduling sync');
|
||||
scheduleSyncToServer();
|
||||
}
|
||||
});
|
||||
@@ -294,11 +347,11 @@ export function useSettingsSync(): SettingsSyncState {
|
||||
clearTimeout(syncTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [state.loaded, authChecked, isAuthenticated, scheduleSyncToServer, syncNow]);
|
||||
}, [state.loaded, authChecked, isAuthenticated, settingsLoaded, scheduleSyncToServer, syncNow]);
|
||||
|
||||
// Best-effort flush on tab close / backgrounding
|
||||
useEffect(() => {
|
||||
if (!state.loaded || !authChecked || !isAuthenticated) return;
|
||||
if (!state.loaded || !authChecked || !isAuthenticated || !settingsLoaded) return;
|
||||
|
||||
const handleBeforeUnload = () => {
|
||||
// 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);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, [state.loaded, authChecked, isAuthenticated, syncNow]);
|
||||
}, [state.loaded, authChecked, isAuthenticated, settingsLoaded, syncNow]);
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -399,6 +452,7 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
||||
>),
|
||||
},
|
||||
mcpServers: serverSettings.mcpServers,
|
||||
defaultEditorCommand: serverSettings.defaultEditorCommand ?? null,
|
||||
promptCustomization: serverSettings.promptCustomization ?? {},
|
||||
projects: serverSettings.projects,
|
||||
trashedProjects: serverSettings.trashedProjects,
|
||||
|
||||
@@ -13,6 +13,7 @@ import { getApiKey, getSessionToken, getServerUrlSync } from './http-api-client'
|
||||
|
||||
// Server URL - uses shared cached URL from http-api-client
|
||||
const getServerUrl = (): string => getServerUrlSync();
|
||||
const DEFAULT_CACHE_MODE: RequestCache = 'no-store';
|
||||
|
||||
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
||||
|
||||
@@ -80,7 +81,7 @@ export async function apiFetch(
|
||||
method: HttpMethod = 'GET',
|
||||
options: ApiFetchOptions = {}
|
||||
): Promise<Response> {
|
||||
const { headers: additionalHeaders, body, skipAuth, ...restOptions } = options;
|
||||
const { headers: additionalHeaders, body, skipAuth, cache, ...restOptions } = options;
|
||||
|
||||
const headers = skipAuth
|
||||
? { 'Content-Type': 'application/json', ...additionalHeaders }
|
||||
@@ -90,6 +91,7 @@ export async function apiFetch(
|
||||
method,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
cache: cache ?? DEFAULT_CACHE_MODE,
|
||||
...restOptions,
|
||||
};
|
||||
|
||||
|
||||
@@ -433,11 +433,12 @@ export interface SpecRegenerationAPI {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
stop: () => Promise<{ success: boolean; error?: string }>;
|
||||
status: () => Promise<{
|
||||
stop: (projectPath?: string) => Promise<{ success: boolean; error?: string }>;
|
||||
status: (projectPath?: string) => Promise<{
|
||||
success: boolean;
|
||||
isRunning?: boolean;
|
||||
currentPhase?: string;
|
||||
projectPath?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
onEvent: (callback: (event: SpecRegenerationEvent) => void) => () => void;
|
||||
@@ -461,7 +462,8 @@ export interface FeaturesAPI {
|
||||
featureId: string,
|
||||
updates: Partial<Feature>,
|
||||
descriptionHistorySource?: 'enhance' | 'edit',
|
||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance'
|
||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer',
|
||||
preEnhancementDescription?: string
|
||||
) => Promise<{ success: boolean; feature?: Feature; error?: string }>;
|
||||
delete: (projectPath: string, featureId: string) => Promise<{ success: boolean; error?: string }>;
|
||||
getAgentOutput: (
|
||||
@@ -532,6 +534,9 @@ export interface AutoModeAPI {
|
||||
editedPlan?: string,
|
||||
feedback?: string
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
resumeInterrupted: (
|
||||
projectPath: string
|
||||
) => Promise<{ success: boolean; message?: string; error?: string }>;
|
||||
onEvent: (callback: (event: AutoModeEvent) => void) => () => void;
|
||||
}
|
||||
|
||||
@@ -608,7 +613,8 @@ export interface ElectronAPI {
|
||||
enhance: (
|
||||
originalText: string,
|
||||
enhancementMode: string,
|
||||
model?: string
|
||||
model?: string,
|
||||
thinkingLevel?: string
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
enhancedText?: string;
|
||||
@@ -1639,13 +1645,34 @@ function createMockWorktreeAPI(): WorktreeAPI {
|
||||
};
|
||||
},
|
||||
|
||||
openInEditor: async (worktreePath: string) => {
|
||||
console.log('[Mock] Opening in editor:', worktreePath);
|
||||
openInEditor: async (worktreePath: string, editorCommand?: string) => {
|
||||
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 {
|
||||
success: true,
|
||||
result: {
|
||||
message: `Opened ${worktreePath} in VS Code`,
|
||||
editorName: 'VS Code',
|
||||
message: `Opened ${worktreePath} in ${editorName}`,
|
||||
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) => {
|
||||
console.log('[Mock] Initializing git:', projectPath);
|
||||
return {
|
||||
@@ -2110,6 +2163,11 @@ function createMockAutoModeAPI(): AutoModeAPI {
|
||||
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) => {
|
||||
mockAutoModeCallbacks.push(callback);
|
||||
return () => {
|
||||
@@ -2539,7 +2597,7 @@ function createMockSpecRegenerationAPI(): SpecRegenerationAPI {
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
stop: async () => {
|
||||
stop: async (_projectPath?: string) => {
|
||||
mockSpecRegenerationRunning = false;
|
||||
mockSpecRegenerationPhase = '';
|
||||
if (mockSpecRegenerationTimeout) {
|
||||
@@ -2549,7 +2607,7 @@ function createMockSpecRegenerationAPI(): SpecRegenerationAPI {
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
status: async () => {
|
||||
status: async (_projectPath?: string) => {
|
||||
return {
|
||||
success: true,
|
||||
isRunning: mockSpecRegenerationRunning,
|
||||
|
||||
@@ -39,6 +39,7 @@ import type { WorktreeAPI, GitAPI, ModelDefinition, ProviderStatus } from '@/typ
|
||||
import { getGlobalFileBrowser } from '@/contexts/file-browser-context';
|
||||
|
||||
const logger = createLogger('HttpClient');
|
||||
const NO_STORE_CACHE_MODE: RequestCache = 'no-store';
|
||||
|
||||
// Cached server URL (set during initialization in Electron mode)
|
||||
let cachedServerUrl: string | null = null;
|
||||
@@ -69,6 +70,7 @@ const handleUnauthorized = (): void => {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: '{}',
|
||||
cache: NO_STORE_CACHE_MODE,
|
||||
}).catch(() => {});
|
||||
notifyLoggedOut();
|
||||
};
|
||||
@@ -296,6 +298,7 @@ export const checkAuthStatus = async (): Promise<{
|
||||
const response = await fetch(`${getServerUrl()}/api/auth/status`, {
|
||||
credentials: 'include',
|
||||
headers: getApiKey() ? { 'X-API-Key': getApiKey()! } : undefined,
|
||||
cache: NO_STORE_CACHE_MODE,
|
||||
});
|
||||
const data = await response.json();
|
||||
return {
|
||||
@@ -322,6 +325,7 @@ export const login = async (
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ apiKey }),
|
||||
cache: NO_STORE_CACHE_MODE,
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
@@ -361,6 +365,7 @@ export const fetchSessionToken = async (): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(`${getServerUrl()}/api/auth/status`, {
|
||||
credentials: 'include', // Send the session cookie
|
||||
cache: NO_STORE_CACHE_MODE,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -391,6 +396,7 @@ export const logout = async (): Promise<{ success: boolean }> => {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
cache: NO_STORE_CACHE_MODE,
|
||||
});
|
||||
|
||||
// Clear the cached session token
|
||||
@@ -439,6 +445,7 @@ export const verifySession = async (): Promise<boolean> => {
|
||||
const response = await fetch(`${getServerUrl()}/api/settings/status`, {
|
||||
headers,
|
||||
credentials: 'include',
|
||||
cache: NO_STORE_CACHE_MODE,
|
||||
// Avoid hanging indefinitely during backend reloads or network issues
|
||||
signal: AbortSignal.timeout(2500),
|
||||
});
|
||||
@@ -475,6 +482,7 @@ export const checkSandboxEnvironment = async (): Promise<{
|
||||
try {
|
||||
const response = await fetch(`${getServerUrl()}/api/health/environment`, {
|
||||
method: 'GET',
|
||||
cache: NO_STORE_CACHE_MODE,
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
@@ -559,6 +567,7 @@ export class HttpApiClient implements ElectronAPI {
|
||||
const response = await fetch(`${this.serverUrl}/api/auth/token`, {
|
||||
headers,
|
||||
credentials: 'include',
|
||||
cache: NO_STORE_CACHE_MODE,
|
||||
});
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
@@ -590,6 +599,17 @@ export class HttpApiClient implements ElectronAPI {
|
||||
|
||||
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.
|
||||
// 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
|
||||
@@ -774,6 +794,7 @@ export class HttpApiClient implements ElectronAPI {
|
||||
const response = await fetch(`${this.serverUrl}${endpoint}`, {
|
||||
headers: this.getHeaders(),
|
||||
credentials: 'include', // Include cookies for session auth
|
||||
cache: NO_STORE_CACHE_MODE,
|
||||
});
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
@@ -1442,6 +1463,16 @@ export class HttpApiClient implements ElectronAPI {
|
||||
features?: Feature[];
|
||||
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 }),
|
||||
get: (projectPath: string, featureId: string) =>
|
||||
@@ -1453,7 +1484,8 @@ export class HttpApiClient implements ElectronAPI {
|
||||
featureId: string,
|
||||
updates: Partial<Feature>,
|
||||
descriptionHistorySource?: 'enhance' | 'edit',
|
||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance'
|
||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer',
|
||||
preEnhancementDescription?: string
|
||||
) =>
|
||||
this.post('/api/features/update', {
|
||||
projectPath,
|
||||
@@ -1461,6 +1493,7 @@ export class HttpApiClient implements ElectronAPI {
|
||||
updates,
|
||||
descriptionHistorySource,
|
||||
enhancementMode,
|
||||
preEnhancementDescription,
|
||||
}),
|
||||
delete: (projectPath: string, featureId: string) =>
|
||||
this.post('/api/features/delete', { projectPath, featureId }),
|
||||
@@ -1470,6 +1503,8 @@ export class HttpApiClient implements ElectronAPI {
|
||||
this.post('/api/features/generate-title', { description }),
|
||||
bulkUpdate: (projectPath: string, featureIds: string[], updates: Partial<Feature>) =>
|
||||
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
|
||||
@@ -1537,6 +1572,8 @@ export class HttpApiClient implements ElectronAPI {
|
||||
editedPlan,
|
||||
feedback,
|
||||
}),
|
||||
resumeInterrupted: (projectPath: string) =>
|
||||
this.post('/api/auto-mode/resume-interrupted', { projectPath }),
|
||||
onEvent: (callback: (event: AutoModeEvent) => void) => {
|
||||
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 }),
|
||||
switchBranch: (worktreePath: string, branchName: string) =>
|
||||
this.post('/api/worktree/switch-branch', { worktreePath, branchName }),
|
||||
openInEditor: (worktreePath: string) =>
|
||||
this.post('/api/worktree/open-in-editor', { worktreePath }),
|
||||
openInEditor: (worktreePath: string, editorCommand?: string) =>
|
||||
this.post('/api/worktree/open-in-editor', { worktreePath, editorCommand }),
|
||||
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 }),
|
||||
startDevServer: (projectPath: string, worktreePath: string) =>
|
||||
this.post('/api/worktree/start-dev', { projectPath, worktreePath }),
|
||||
@@ -1703,8 +1742,13 @@ export class HttpApiClient implements ElectronAPI {
|
||||
projectPath,
|
||||
maxFeatures,
|
||||
}),
|
||||
stop: () => this.post('/api/spec-regeneration/stop'),
|
||||
status: () => this.get('/api/spec-regeneration/status'),
|
||||
stop: (projectPath?: string) => this.post('/api/spec-regeneration/stop', { projectPath }),
|
||||
status: (projectPath?: string) =>
|
||||
this.get(
|
||||
projectPath
|
||||
? `/api/spec-regeneration/status?projectPath=${encodeURIComponent(projectPath)}`
|
||||
: '/api/spec-regeneration/status'
|
||||
),
|
||||
onEvent: (callback: (event: SpecRegenerationEvent) => void) => {
|
||||
return this.subscribeToEvent('spec-regeneration:event', callback as EventCallback);
|
||||
},
|
||||
|
||||
@@ -12,12 +12,14 @@ import { useSetupStore } from '@/store/setup-store';
|
||||
import { useAuthStore } from '@/store/auth-store';
|
||||
import { getElectronAPI, isElectron } from '@/lib/electron';
|
||||
import { isMac } from '@/lib/utils';
|
||||
import { initializeProject } from '@/lib/project-init';
|
||||
import {
|
||||
initApiKey,
|
||||
verifySession,
|
||||
checkSandboxEnvironment,
|
||||
getServerUrlSync,
|
||||
getHttpApiClient,
|
||||
handleServerOffline,
|
||||
} from '@/lib/http-api-client';
|
||||
import {
|
||||
hydrateStoreFromSettings,
|
||||
@@ -30,8 +32,23 @@ import { SandboxRiskDialog } from '@/components/dialogs/sandbox-risk-dialog';
|
||||
import { SandboxRejectionScreen } from '@/components/dialogs/sandbox-rejection-screen';
|
||||
import { LoadingState } from '@/components/ui/loading-state';
|
||||
import { useProjectSettingsLoader } from '@/hooks/use-project-settings-loader';
|
||||
import type { Project } from '@/lib/electron';
|
||||
|
||||
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)
|
||||
// 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)
|
||||
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() {
|
||||
const location = useLocation();
|
||||
const {
|
||||
setIpcConnected,
|
||||
projects,
|
||||
currentProject,
|
||||
projectHistory,
|
||||
upsertAndSetCurrentProject,
|
||||
getEffectiveTheme,
|
||||
skipSandboxWarning,
|
||||
setSkipSandboxWarning,
|
||||
@@ -76,6 +166,7 @@ function RootLayoutContent() {
|
||||
const [streamerPanelOpen, setStreamerPanelOpen] = useState(false);
|
||||
const authChecked = useAuthStore((s) => s.authChecked);
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
const settingsLoaded = useAuthStore((s) => s.settingsLoaded);
|
||||
const { openFileBrowser } = useFileBrowser();
|
||||
|
||||
// Load project settings when switching projects
|
||||
@@ -85,6 +176,22 @@ function RootLayoutContent() {
|
||||
const isLoginRoute = location.pathname === '/login';
|
||||
const isLoggedOutRoute = location.pathname === '/logged-out';
|
||||
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
|
||||
type SandboxStatus = 'pending' | 'containerized' | 'needs-confirmation' | 'denied' | 'confirmed';
|
||||
@@ -218,9 +325,11 @@ function RootLayoutContent() {
|
||||
// Works for ALL modes (unified flow)
|
||||
useEffect(() => {
|
||||
const handleLoggedOut = () => {
|
||||
logger.warn('automaker:logged-out event received!');
|
||||
useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
|
||||
|
||||
if (location.pathname !== '/logged-out') {
|
||||
logger.warn('Navigating to /logged-out due to logged-out event');
|
||||
navigate({ to: '/logged-out' });
|
||||
}
|
||||
};
|
||||
@@ -236,6 +345,7 @@ function RootLayoutContent() {
|
||||
// Redirects to login page which will detect server is offline and show error UI.
|
||||
useEffect(() => {
|
||||
const handleServerOffline = () => {
|
||||
logger.warn('automaker:server-offline event received!');
|
||||
useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
|
||||
|
||||
// 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
|
||||
await initApiKey();
|
||||
|
||||
const serverReady = await waitForServerReady();
|
||||
if (!serverReady) {
|
||||
handleServerOffline();
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Verify session (Single Request, ALL modes)
|
||||
let isValid = false;
|
||||
try {
|
||||
@@ -302,13 +418,28 @@ function RootLayoutContent() {
|
||||
// Hydrate store with the final settings (merged if migration occurred)
|
||||
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();
|
||||
|
||||
// Mark auth as checked only after settings hydration succeeded.
|
||||
useAuthStore
|
||||
.getState()
|
||||
.setAuthState({ isAuthenticated: true, authChecked: true });
|
||||
// Now mark auth as checked AND settings as loaded.
|
||||
// The settingsLoaded flag ensures useSettingsSync won't start syncing
|
||||
// until settings have been properly hydrated, even if authChecked was
|
||||
// set earlier by login-view.
|
||||
useAuthStore.getState().setAuthState({
|
||||
isAuthenticated: true,
|
||||
authChecked: true,
|
||||
settingsLoaded: true,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -373,17 +504,38 @@ function RootLayoutContent() {
|
||||
// - If authenticated but setup incomplete: force /setup
|
||||
// - If authenticated and setup complete: allow access to app
|
||||
useEffect(() => {
|
||||
logger.debug('Routing effect triggered:', {
|
||||
authChecked,
|
||||
isAuthenticated,
|
||||
settingsLoaded,
|
||||
setupComplete,
|
||||
pathname: location.pathname,
|
||||
});
|
||||
|
||||
// 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)
|
||||
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') {
|
||||
navigate({ to: '/logged-out' });
|
||||
}
|
||||
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
|
||||
if (!setupComplete && location.pathname !== '/setup') {
|
||||
navigate({ to: '/setup' });
|
||||
@@ -394,7 +546,46 @@ function RootLayoutContent() {
|
||||
if (setupComplete && location.pathname === '/setup') {
|
||||
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(() => {
|
||||
setGlobalFileBrowser(openFileBrowser);
|
||||
@@ -428,7 +619,10 @@ function RootLayoutContent() {
|
||||
|
||||
// Redirect from welcome page based on project state
|
||||
useEffect(() => {
|
||||
if (isMounted && location.pathname === '/') {
|
||||
if (isMounted && isRootRoute) {
|
||||
if (!settingsLoaded || shouldAutoOpen) {
|
||||
return;
|
||||
}
|
||||
if (currentProject) {
|
||||
// Project is selected, go to board
|
||||
navigate({ to: '/board' });
|
||||
@@ -437,14 +631,66 @@ function RootLayoutContent() {
|
||||
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)
|
||||
useEffect(() => {
|
||||
// Only fetch if authenticated and Codex CLI is available
|
||||
if (!authChecked || !isAuthenticated) return;
|
||||
|
||||
const isCodexAvailable = codexCliStatus?.installed && codexCliStatus?.auth?.authenticated;
|
||||
const isCodexAvailable = codexCliStatus?.installed && codexCliStatus?.hasApiKey;
|
||||
if (!isCodexAvailable) return;
|
||||
|
||||
// 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
|
||||
if (isSetupRoute) {
|
||||
return (
|
||||
|
||||
6
apps/ui/src/routes/graph.tsx
Normal file
6
apps/ui/src/routes/graph.tsx
Normal 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,
|
||||
});
|
||||
6
apps/ui/src/routes/memory.tsx
Normal file
6
apps/ui/src/routes/memory.tsx
Normal 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,
|
||||
});
|
||||
@@ -114,6 +114,12 @@ function saveThemeToStorage(theme: ThemeMode): void {
|
||||
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 interface ApiKeys {
|
||||
@@ -210,9 +216,11 @@ export function formatShortcut(shortcut: string | undefined | null, forDisplay =
|
||||
export interface KeyboardShortcuts {
|
||||
// Navigation shortcuts
|
||||
board: string;
|
||||
graph: string;
|
||||
agent: string;
|
||||
spec: string;
|
||||
context: string;
|
||||
memory: string;
|
||||
settings: string;
|
||||
terminal: string;
|
||||
ideation: string;
|
||||
@@ -243,9 +251,11 @@ export interface KeyboardShortcuts {
|
||||
export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = {
|
||||
// Navigation
|
||||
board: 'K',
|
||||
graph: 'H',
|
||||
agent: 'A',
|
||||
spec: 'D',
|
||||
context: 'C',
|
||||
memory: 'Y',
|
||||
settings: 'S',
|
||||
terminal: 'T',
|
||||
ideation: 'I',
|
||||
@@ -578,6 +588,9 @@ export interface AppState {
|
||||
// MCP Servers
|
||||
mcpServers: MCPServerConfig[]; // List of configured MCP servers for agent use
|
||||
|
||||
// Editor Configuration
|
||||
defaultEditorCommand: string | null; // Default editor for "Open In" action
|
||||
|
||||
// Skills Configuration
|
||||
enableSkills: boolean; // Enable Skills functionality (loads from .claude/skills/ directories)
|
||||
skillsSources: Array<'user' | 'project'>; // Which directories to load Skills from
|
||||
@@ -973,6 +986,9 @@ export interface AppActions {
|
||||
setAutoLoadClaudeMd: (enabled: boolean) => Promise<void>;
|
||||
setSkipSandboxWarning: (skip: boolean) => Promise<void>;
|
||||
|
||||
// Editor Configuration actions
|
||||
setDefaultEditorCommand: (command: string | null) => void;
|
||||
|
||||
// Prompt Customization actions
|
||||
setPromptCustomization: (customization: PromptCustomization) => Promise<void>;
|
||||
|
||||
@@ -1198,6 +1214,7 @@ const initialState: AppState = {
|
||||
autoLoadClaudeMd: false, // Default to disabled (user must opt-in)
|
||||
skipSandboxWarning: false, // Default to disabled (show sandbox warning dialog)
|
||||
mcpServers: [], // No MCP servers configured by default
|
||||
defaultEditorCommand: null, // Auto-detect: Cursor > VS Code > first available
|
||||
enableSkills: true, // Skills enabled by default
|
||||
skillsSources: ['user', 'project'] as Array<'user' | 'project'>, // Load from both sources 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 nextCurrentProject = isCurrent ? null : get().currentProject;
|
||||
|
||||
set({
|
||||
projects: remainingProjects,
|
||||
trashedProjects: [trashedProject, ...existingTrash],
|
||||
currentProject: isCurrent ? null : get().currentProject,
|
||||
currentProject: nextCurrentProject,
|
||||
currentView: isCurrent ? 'welcome' : get().currentView,
|
||||
});
|
||||
|
||||
persistEffectiveThemeForProject(nextCurrentProject, get().theme);
|
||||
},
|
||||
|
||||
restoreTrashedProject: (projectId) => {
|
||||
@@ -1314,6 +1334,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
currentProject: samePathProject,
|
||||
currentView: 'board',
|
||||
});
|
||||
persistEffectiveThemeForProject(samePathProject, get().theme);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1331,6 +1352,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
currentProject: restoredProject,
|
||||
currentView: 'board',
|
||||
});
|
||||
persistEffectiveThemeForProject(restoredProject, get().theme);
|
||||
},
|
||||
|
||||
deleteTrashedProject: (projectId) => {
|
||||
@@ -1350,6 +1372,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
|
||||
setCurrentProject: (project) => {
|
||||
set({ currentProject: project });
|
||||
persistEffectiveThemeForProject(project, get().theme);
|
||||
if (project) {
|
||||
set({ currentView: 'board' });
|
||||
// Add to project history (MRU order)
|
||||
@@ -1433,6 +1456,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
projectHistoryIndex: newIndex,
|
||||
currentView: 'board',
|
||||
});
|
||||
persistEffectiveThemeForProject(targetProject, get().theme);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1466,6 +1490,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
projectHistoryIndex: newIndex,
|
||||
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
|
||||
const currentProject = get().currentProject;
|
||||
if (currentProject?.id === projectId) {
|
||||
const updatedTheme = theme === null ? undefined : theme;
|
||||
set({
|
||||
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 });
|
||||
}
|
||||
},
|
||||
|
||||
// Editor Configuration actions
|
||||
setDefaultEditorCommand: (command) => set({ defaultEditorCommand: command }),
|
||||
// Prompt Customization actions
|
||||
setPromptCustomization: async (customization) => {
|
||||
set({ promptCustomization: customization });
|
||||
|
||||
@@ -5,6 +5,8 @@ interface AuthState {
|
||||
authChecked: boolean;
|
||||
/** Whether the user is currently authenticated (web mode: valid session cookie) */
|
||||
isAuthenticated: boolean;
|
||||
/** Whether settings have been loaded and hydrated from server */
|
||||
settingsLoaded: boolean;
|
||||
}
|
||||
|
||||
interface AuthActions {
|
||||
@@ -15,15 +17,18 @@ interface AuthActions {
|
||||
const initialState: AuthState = {
|
||||
authChecked: false,
|
||||
isAuthenticated: false,
|
||||
settingsLoaded: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* 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) => ({
|
||||
...initialState,
|
||||
setAuthState: (state) => set(state),
|
||||
setAuthState: (state) => {
|
||||
set({ ...state });
|
||||
},
|
||||
resetAuth: () => set(initialState),
|
||||
}));
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface CliStatus {
|
||||
path: string | null;
|
||||
version: string | null;
|
||||
method: string;
|
||||
hasApiKey?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 */
|
||||
--background: oklch(1 0 0); /* White */
|
||||
--background-50: oklch(1 0 0 / 0.5);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* Dark Theme */
|
||||
|
||||
.dark {
|
||||
:root.dark {
|
||||
/* Deep dark backgrounds - zinc-950 family */
|
||||
--background: oklch(0.04 0 0); /* zinc-950 */
|
||||
--background-50: oklch(0.04 0 0 / 0.5); /* zinc-950/50 */
|
||||
|
||||
45
apps/ui/src/types/electron.d.ts
vendored
45
apps/ui/src/types/electron.d.ts
vendored
@@ -300,6 +300,17 @@ export type AutoModeEvent =
|
||||
featureId: string;
|
||||
projectPath?: string;
|
||||
phaseNumber: number;
|
||||
}
|
||||
| {
|
||||
type: 'auto_mode_resuming_features';
|
||||
message: string;
|
||||
projectPath?: string;
|
||||
featureIds: string[];
|
||||
features: Array<{
|
||||
id: string;
|
||||
title?: string;
|
||||
status?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type SpecRegenerationEvent =
|
||||
@@ -356,15 +367,16 @@ export interface SpecRegenerationAPI {
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
stop: () => Promise<{
|
||||
stop: (projectPath?: string) => Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
status: () => Promise<{
|
||||
status: (projectPath?: string) => Promise<{
|
||||
success: boolean;
|
||||
isRunning?: boolean;
|
||||
currentPhase?: string;
|
||||
projectPath?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
@@ -872,7 +884,10 @@ export interface WorktreeAPI {
|
||||
}>;
|
||||
|
||||
// Open a worktree directory in the editor
|
||||
openInEditor: (worktreePath: string) => Promise<{
|
||||
openInEditor: (
|
||||
worktreePath: string,
|
||||
editorCommand?: string
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
result?: {
|
||||
message: string;
|
||||
@@ -891,6 +906,30 @@ export interface WorktreeAPI {
|
||||
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
|
||||
initGit: (projectPath: string) => Promise<{
|
||||
success: boolean;
|
||||
|
||||
@@ -3,9 +3,10 @@ import { routeTree } from '../routeTree.gen';
|
||||
|
||||
// Use browser history in web mode (for e2e tests and dev), memory history in Electron
|
||||
const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined;
|
||||
const BOARD_ROUTE_PATH = '/board';
|
||||
|
||||
const history = isElectron
|
||||
? createMemoryHistory({ initialEntries: [window.location.pathname || '/'] })
|
||||
? createMemoryHistory({ initialEntries: [BOARD_ROUTE_PATH] })
|
||||
: createBrowserHistory();
|
||||
|
||||
export const router = createRouter({
|
||||
|
||||
@@ -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';
|
||||
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
Reference in New Issue
Block a user