mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-03 21:03:08 +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"
|
git config --global user.email "ci@example.com"
|
||||||
|
|
||||||
- name: Start backend server
|
- name: Start backend server
|
||||||
run: npm run start --workspace=apps/server &
|
run: |
|
||||||
|
echo "Starting backend server..."
|
||||||
|
# Start server in background and save PID
|
||||||
|
npm run start --workspace=apps/server > backend.log 2>&1 &
|
||||||
|
SERVER_PID=$!
|
||||||
|
echo "Server started with PID: $SERVER_PID"
|
||||||
|
echo "SERVER_PID=$SERVER_PID" >> $GITHUB_ENV
|
||||||
|
|
||||||
env:
|
env:
|
||||||
PORT: 3008
|
PORT: 3008
|
||||||
NODE_ENV: test
|
NODE_ENV: test
|
||||||
@@ -53,21 +60,70 @@ jobs:
|
|||||||
- name: Wait for backend server
|
- name: Wait for backend server
|
||||||
run: |
|
run: |
|
||||||
echo "Waiting for backend server to be ready..."
|
echo "Waiting for backend server to be ready..."
|
||||||
|
|
||||||
|
# Check if server process is running
|
||||||
|
if [ -z "$SERVER_PID" ]; then
|
||||||
|
echo "ERROR: Server PID not found in environment"
|
||||||
|
cat backend.log 2>/dev/null || echo "No backend log found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if process is actually running
|
||||||
|
if ! kill -0 $SERVER_PID 2>/dev/null; then
|
||||||
|
echo "ERROR: Server process $SERVER_PID is not running!"
|
||||||
|
echo "=== Backend logs ==="
|
||||||
|
cat backend.log
|
||||||
|
echo ""
|
||||||
|
echo "=== Recent system logs ==="
|
||||||
|
dmesg 2>/dev/null | tail -20 || echo "No dmesg available"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Wait for health endpoint
|
||||||
for i in {1..60}; do
|
for i in {1..60}; do
|
||||||
if curl -s -f http://localhost:3008/api/health > /dev/null 2>&1; then
|
if curl -s -f http://localhost:3008/api/health > /dev/null 2>&1; then
|
||||||
echo "Backend server is ready!"
|
echo "Backend server is ready!"
|
||||||
curl -s http://localhost:3008/api/health | jq . 2>/dev/null || echo "Health check response: $(curl -s http://localhost:3008/api/health 2>/dev/null || echo 'No response')"
|
echo "=== Backend logs ==="
|
||||||
|
cat backend.log
|
||||||
|
echo ""
|
||||||
|
echo "Health check response:"
|
||||||
|
curl -s http://localhost:3008/api/health | jq . 2>/dev/null || echo "Health check: $(curl -s http://localhost:3008/api/health 2>/dev/null || echo 'No response')"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Check if server process is still running
|
||||||
|
if ! kill -0 $SERVER_PID 2>/dev/null; then
|
||||||
|
echo "ERROR: Server process died during wait!"
|
||||||
|
echo "=== Backend logs ==="
|
||||||
|
cat backend.log
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Waiting... ($i/60)"
|
echo "Waiting... ($i/60)"
|
||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
echo "Backend server failed to start!"
|
|
||||||
echo "Checking server status..."
|
echo "ERROR: Backend server failed to start within 60 seconds!"
|
||||||
|
echo "=== Backend logs ==="
|
||||||
|
cat backend.log
|
||||||
|
echo ""
|
||||||
|
echo "=== Process status ==="
|
||||||
ps aux | grep -E "(node|tsx)" | grep -v grep || echo "No node processes found"
|
ps aux | grep -E "(node|tsx)" | grep -v grep || echo "No node processes found"
|
||||||
|
echo ""
|
||||||
|
echo "=== Port status ==="
|
||||||
netstat -tlnp 2>/dev/null | grep :3008 || echo "Port 3008 not listening"
|
netstat -tlnp 2>/dev/null | grep :3008 || echo "Port 3008 not listening"
|
||||||
echo "Testing health endpoint..."
|
lsof -i :3008 2>/dev/null || echo "lsof not available or port not in use"
|
||||||
|
echo ""
|
||||||
|
echo "=== Health endpoint test ==="
|
||||||
curl -v http://localhost:3008/api/health 2>&1 || echo "Health endpoint failed"
|
curl -v http://localhost:3008/api/health 2>&1 || echo "Health endpoint failed"
|
||||||
|
|
||||||
|
# Kill the server process if it's still hanging
|
||||||
|
if kill -0 $SERVER_PID 2>/dev/null; then
|
||||||
|
echo ""
|
||||||
|
echo "Killing stuck server process..."
|
||||||
|
kill -9 $SERVER_PID 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
exit 1
|
exit 1
|
||||||
|
|
||||||
- name: Run E2E tests
|
- name: Run E2E tests
|
||||||
@@ -81,6 +137,18 @@ jobs:
|
|||||||
# Keep UI-side login/defaults consistent
|
# Keep UI-side login/defaults consistent
|
||||||
AUTOMAKER_API_KEY: test-api-key-for-e2e-tests
|
AUTOMAKER_API_KEY: test-api-key-for-e2e-tests
|
||||||
|
|
||||||
|
- name: Print backend logs on failure
|
||||||
|
if: failure()
|
||||||
|
run: |
|
||||||
|
echo "=== E2E Tests Failed - Backend Logs ==="
|
||||||
|
cat backend.log 2>/dev/null || echo "No backend log found"
|
||||||
|
echo ""
|
||||||
|
echo "=== Process status at failure ==="
|
||||||
|
ps aux | grep -E "(node|tsx)" | grep -v grep || echo "No node processes found"
|
||||||
|
echo ""
|
||||||
|
echo "=== Port status ==="
|
||||||
|
netstat -tlnp 2>/dev/null | grep :3008 || echo "Port 3008 not listening"
|
||||||
|
|
||||||
- name: Upload Playwright report
|
- name: Upload Playwright report
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
if: always()
|
if: always()
|
||||||
@@ -98,3 +166,13 @@ jobs:
|
|||||||
apps/ui/test-results/
|
apps/ui/test-results/
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
if-no-files-found: ignore
|
if-no-files-found: ignore
|
||||||
|
|
||||||
|
- name: Cleanup - Kill backend server
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
if [ -n "$SERVER_PID" ]; then
|
||||||
|
echo "Cleaning up backend server (PID: $SERVER_PID)..."
|
||||||
|
kill $SERVER_PID 2>/dev/null || true
|
||||||
|
kill -9 $SERVER_PID 2>/dev/null || true
|
||||||
|
echo "Backend server cleanup complete"
|
||||||
|
fi
|
||||||
|
|||||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -87,4 +87,12 @@ docker-compose.override.yml
|
|||||||
.claude/hans/
|
.claude/hans/
|
||||||
|
|
||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
yarn.lock
|
yarn.lock
|
||||||
|
|
||||||
|
# Fork-specific workflow files (should never be committed)
|
||||||
|
DEVELOPMENT_WORKFLOW.md
|
||||||
|
check-sync.sh
|
||||||
|
# API key files
|
||||||
|
data/.api-key
|
||||||
|
data/credentials.json
|
||||||
|
data/
|
||||||
|
|||||||
@@ -597,6 +597,26 @@ const startServer = (port: number) => {
|
|||||||
|
|
||||||
startServer(PORT);
|
startServer(PORT);
|
||||||
|
|
||||||
|
// Global error handlers to prevent crashes from uncaught errors
|
||||||
|
process.on('unhandledRejection', (reason: unknown, _promise: Promise<unknown>) => {
|
||||||
|
logger.error('Unhandled Promise Rejection:', {
|
||||||
|
reason: reason instanceof Error ? reason.message : String(reason),
|
||||||
|
stack: reason instanceof Error ? reason.stack : undefined,
|
||||||
|
});
|
||||||
|
// Don't exit - log the error and continue running
|
||||||
|
// This prevents the server from crashing due to unhandled rejections
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('uncaughtException', (error: Error) => {
|
||||||
|
logger.error('Uncaught Exception:', {
|
||||||
|
message: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
});
|
||||||
|
// Exit on uncaught exceptions to prevent undefined behavior
|
||||||
|
// The process is in an unknown state after an uncaught exception
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
// Graceful shutdown
|
// Graceful shutdown
|
||||||
process.on('SIGTERM', () => {
|
process.on('SIGTERM', () => {
|
||||||
logger.info('SIGTERM received, shutting down...');
|
logger.info('SIGTERM received, shutting down...');
|
||||||
|
|||||||
@@ -6,26 +6,57 @@ import { createLogger } from '@automaker/utils';
|
|||||||
|
|
||||||
const logger = createLogger('SpecRegeneration');
|
const logger = createLogger('SpecRegeneration');
|
||||||
|
|
||||||
// Shared state for tracking generation status - private
|
// Shared state for tracking generation status - scoped by project path
|
||||||
let isRunning = false;
|
const runningProjects = new Map<string, boolean>();
|
||||||
let currentAbortController: AbortController | null = null;
|
const abortControllers = new Map<string, AbortController>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the current running state
|
* Get the running state for a specific project
|
||||||
*/
|
*/
|
||||||
export function getSpecRegenerationStatus(): {
|
export function getSpecRegenerationStatus(projectPath?: string): {
|
||||||
isRunning: boolean;
|
isRunning: boolean;
|
||||||
currentAbortController: AbortController | null;
|
currentAbortController: AbortController | null;
|
||||||
|
projectPath?: string;
|
||||||
} {
|
} {
|
||||||
return { isRunning, currentAbortController };
|
if (projectPath) {
|
||||||
|
return {
|
||||||
|
isRunning: runningProjects.get(projectPath) || false,
|
||||||
|
currentAbortController: abortControllers.get(projectPath) || null,
|
||||||
|
projectPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Fallback: check if any project is running (for backward compatibility)
|
||||||
|
const isAnyRunning = Array.from(runningProjects.values()).some((running) => running);
|
||||||
|
return { isRunning: isAnyRunning, currentAbortController: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set the running state and abort controller
|
* Get the project path that is currently running (if any)
|
||||||
*/
|
*/
|
||||||
export function setRunningState(running: boolean, controller: AbortController | null = null): void {
|
export function getRunningProjectPath(): string | null {
|
||||||
isRunning = running;
|
for (const [path, running] of runningProjects.entries()) {
|
||||||
currentAbortController = controller;
|
if (running) return path;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the running state and abort controller for a specific project
|
||||||
|
*/
|
||||||
|
export function setRunningState(
|
||||||
|
projectPath: string,
|
||||||
|
running: boolean,
|
||||||
|
controller: AbortController | null = null
|
||||||
|
): void {
|
||||||
|
if (running) {
|
||||||
|
runningProjects.set(projectPath, true);
|
||||||
|
if (controller) {
|
||||||
|
abortControllers.set(projectPath, controller);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
runningProjects.delete(projectPath);
|
||||||
|
abortControllers.delete(projectPath);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -47,17 +47,17 @@ export function createCreateHandler(events: EventEmitter) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { isRunning } = getSpecRegenerationStatus();
|
const { isRunning } = getSpecRegenerationStatus(projectPath);
|
||||||
if (isRunning) {
|
if (isRunning) {
|
||||||
logger.warn('Generation already running, rejecting request');
|
logger.warn('Generation already running for project:', projectPath);
|
||||||
res.json({ success: false, error: 'Spec generation already running' });
|
res.json({ success: false, error: 'Spec generation already running for this project' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logAuthStatus('Before starting generation');
|
logAuthStatus('Before starting generation');
|
||||||
|
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
setRunningState(true, abortController);
|
setRunningState(projectPath, true, abortController);
|
||||||
logger.info('Starting background generation task...');
|
logger.info('Starting background generation task...');
|
||||||
|
|
||||||
// Start generation in background
|
// Start generation in background
|
||||||
@@ -80,7 +80,7 @@ export function createCreateHandler(events: EventEmitter) {
|
|||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
logger.info('Generation task finished (success or error)');
|
logger.info('Generation task finished (success or error)');
|
||||||
setRunningState(false, null);
|
setRunningState(projectPath, false, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info('Returning success response (generation running in background)');
|
logger.info('Returning success response (generation running in background)');
|
||||||
|
|||||||
@@ -40,17 +40,17 @@ export function createGenerateFeaturesHandler(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { isRunning } = getSpecRegenerationStatus();
|
const { isRunning } = getSpecRegenerationStatus(projectPath);
|
||||||
if (isRunning) {
|
if (isRunning) {
|
||||||
logger.warn('Generation already running, rejecting request');
|
logger.warn('Generation already running for project:', projectPath);
|
||||||
res.json({ success: false, error: 'Generation already running' });
|
res.json({ success: false, error: 'Generation already running for this project' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logAuthStatus('Before starting feature generation');
|
logAuthStatus('Before starting feature generation');
|
||||||
|
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
setRunningState(true, abortController);
|
setRunningState(projectPath, true, abortController);
|
||||||
logger.info('Starting background feature generation task...');
|
logger.info('Starting background feature generation task...');
|
||||||
|
|
||||||
generateFeaturesFromSpec(projectPath, events, abortController, maxFeatures, settingsService)
|
generateFeaturesFromSpec(projectPath, events, abortController, maxFeatures, settingsService)
|
||||||
@@ -63,7 +63,7 @@ export function createGenerateFeaturesHandler(
|
|||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
logger.info('Feature generation task finished (success or error)');
|
logger.info('Feature generation task finished (success or error)');
|
||||||
setRunningState(false, null);
|
setRunningState(projectPath, false, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info('Returning success response (generation running in background)');
|
logger.info('Returning success response (generation running in background)');
|
||||||
|
|||||||
@@ -48,17 +48,17 @@ export function createGenerateHandler(events: EventEmitter, settingsService?: Se
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { isRunning } = getSpecRegenerationStatus();
|
const { isRunning } = getSpecRegenerationStatus(projectPath);
|
||||||
if (isRunning) {
|
if (isRunning) {
|
||||||
logger.warn('Generation already running, rejecting request');
|
logger.warn('Generation already running for project:', projectPath);
|
||||||
res.json({ success: false, error: 'Spec generation already running' });
|
res.json({ success: false, error: 'Spec generation already running for this project' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logAuthStatus('Before starting generation');
|
logAuthStatus('Before starting generation');
|
||||||
|
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
setRunningState(true, abortController);
|
setRunningState(projectPath, true, abortController);
|
||||||
logger.info('Starting background generation task...');
|
logger.info('Starting background generation task...');
|
||||||
|
|
||||||
generateSpec(
|
generateSpec(
|
||||||
@@ -81,7 +81,7 @@ export function createGenerateHandler(events: EventEmitter, settingsService?: Se
|
|||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
logger.info('Generation task finished (success or error)');
|
logger.info('Generation task finished (success or error)');
|
||||||
setRunningState(false, null);
|
setRunningState(projectPath, false, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info('Returning success response (generation running in background)');
|
logger.info('Returning success response (generation running in background)');
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ import type { Request, Response } from 'express';
|
|||||||
import { getSpecRegenerationStatus, getErrorMessage } from '../common.js';
|
import { getSpecRegenerationStatus, getErrorMessage } from '../common.js';
|
||||||
|
|
||||||
export function createStatusHandler() {
|
export function createStatusHandler() {
|
||||||
return async (_req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { isRunning } = getSpecRegenerationStatus();
|
const projectPath = req.query.projectPath as string | undefined;
|
||||||
res.json({ success: true, isRunning });
|
const { isRunning } = getSpecRegenerationStatus(projectPath);
|
||||||
|
res.json({ success: true, isRunning, projectPath });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,13 +6,16 @@ import type { Request, Response } from 'express';
|
|||||||
import { getSpecRegenerationStatus, setRunningState, getErrorMessage } from '../common.js';
|
import { getSpecRegenerationStatus, setRunningState, getErrorMessage } from '../common.js';
|
||||||
|
|
||||||
export function createStopHandler() {
|
export function createStopHandler() {
|
||||||
return async (_req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { currentAbortController } = getSpecRegenerationStatus();
|
const { projectPath } = req.body as { projectPath?: string };
|
||||||
|
const { currentAbortController } = getSpecRegenerationStatus(projectPath);
|
||||||
if (currentAbortController) {
|
if (currentAbortController) {
|
||||||
currentAbortController.abort();
|
currentAbortController.abort();
|
||||||
}
|
}
|
||||||
setRunningState(false, null);
|
if (projectPath) {
|
||||||
|
setRunningState(projectPath, false, null);
|
||||||
|
}
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { createAnalyzeProjectHandler } from './routes/analyze-project.js';
|
|||||||
import { createFollowUpFeatureHandler } from './routes/follow-up-feature.js';
|
import { createFollowUpFeatureHandler } from './routes/follow-up-feature.js';
|
||||||
import { createCommitFeatureHandler } from './routes/commit-feature.js';
|
import { createCommitFeatureHandler } from './routes/commit-feature.js';
|
||||||
import { createApprovePlanHandler } from './routes/approve-plan.js';
|
import { createApprovePlanHandler } from './routes/approve-plan.js';
|
||||||
|
import { createResumeInterruptedHandler } from './routes/resume-interrupted.js';
|
||||||
|
|
||||||
export function createAutoModeRoutes(autoModeService: AutoModeService): Router {
|
export function createAutoModeRoutes(autoModeService: AutoModeService): Router {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -63,6 +64,11 @@ export function createAutoModeRoutes(autoModeService: AutoModeService): Router {
|
|||||||
validatePathParams('projectPath'),
|
validatePathParams('projectPath'),
|
||||||
createApprovePlanHandler(autoModeService)
|
createApprovePlanHandler(autoModeService)
|
||||||
);
|
);
|
||||||
|
router.post(
|
||||||
|
'/resume-interrupted',
|
||||||
|
validatePathParams('projectPath'),
|
||||||
|
createResumeInterruptedHandler(autoModeService)
|
||||||
|
);
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
technical: prompts.enhancement.technicalSystemPrompt,
|
||||||
simplify: prompts.enhancement.simplifySystemPrompt,
|
simplify: prompts.enhancement.simplifySystemPrompt,
|
||||||
acceptance: prompts.enhancement.acceptanceSystemPrompt,
|
acceptance: prompts.enhancement.acceptanceSystemPrompt,
|
||||||
|
'ux-reviewer': prompts.enhancement.uxReviewerSystemPrompt,
|
||||||
};
|
};
|
||||||
const systemPrompt = systemPromptMap[validMode];
|
const systemPrompt = systemPromptMap[validMode];
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { createGetHandler } from './routes/get.js';
|
|||||||
import { createCreateHandler } from './routes/create.js';
|
import { createCreateHandler } from './routes/create.js';
|
||||||
import { createUpdateHandler } from './routes/update.js';
|
import { createUpdateHandler } from './routes/update.js';
|
||||||
import { createBulkUpdateHandler } from './routes/bulk-update.js';
|
import { createBulkUpdateHandler } from './routes/bulk-update.js';
|
||||||
|
import { createBulkDeleteHandler } from './routes/bulk-delete.js';
|
||||||
import { createDeleteHandler } from './routes/delete.js';
|
import { createDeleteHandler } from './routes/delete.js';
|
||||||
import { createAgentOutputHandler, createRawOutputHandler } from './routes/agent-output.js';
|
import { createAgentOutputHandler, createRawOutputHandler } from './routes/agent-output.js';
|
||||||
import { createGenerateTitleHandler } from './routes/generate-title.js';
|
import { createGenerateTitleHandler } from './routes/generate-title.js';
|
||||||
@@ -26,6 +27,11 @@ export function createFeaturesRoutes(featureLoader: FeatureLoader): Router {
|
|||||||
validatePathParams('projectPath'),
|
validatePathParams('projectPath'),
|
||||||
createBulkUpdateHandler(featureLoader)
|
createBulkUpdateHandler(featureLoader)
|
||||||
);
|
);
|
||||||
|
router.post(
|
||||||
|
'/bulk-delete',
|
||||||
|
validatePathParams('projectPath'),
|
||||||
|
createBulkDeleteHandler(featureLoader)
|
||||||
|
);
|
||||||
router.post('/delete', validatePathParams('projectPath'), createDeleteHandler(featureLoader));
|
router.post('/delete', validatePathParams('projectPath'), createDeleteHandler(featureLoader));
|
||||||
router.post('/agent-output', createAgentOutputHandler(featureLoader));
|
router.post('/agent-output', createAgentOutputHandler(featureLoader));
|
||||||
router.post('/raw-output', createRawOutputHandler(featureLoader));
|
router.post('/raw-output', createRawOutputHandler(featureLoader));
|
||||||
|
|||||||
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) {
|
export function createUpdateHandler(featureLoader: FeatureLoader) {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { projectPath, featureId, updates, descriptionHistorySource, enhancementMode } =
|
const {
|
||||||
req.body as {
|
projectPath,
|
||||||
projectPath: string;
|
featureId,
|
||||||
featureId: string;
|
updates,
|
||||||
updates: Partial<Feature>;
|
descriptionHistorySource,
|
||||||
descriptionHistorySource?: 'enhance' | 'edit';
|
enhancementMode,
|
||||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance';
|
preEnhancementDescription,
|
||||||
};
|
} = req.body as {
|
||||||
|
projectPath: string;
|
||||||
|
featureId: string;
|
||||||
|
updates: Partial<Feature>;
|
||||||
|
descriptionHistorySource?: 'enhance' | 'edit';
|
||||||
|
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer';
|
||||||
|
preEnhancementDescription?: string;
|
||||||
|
};
|
||||||
|
|
||||||
if (!projectPath || !featureId || !updates) {
|
if (!projectPath || !featureId || !updates) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
@@ -32,7 +39,8 @@ export function createUpdateHandler(featureLoader: FeatureLoader) {
|
|||||||
featureId,
|
featureId,
|
||||||
updates,
|
updates,
|
||||||
descriptionHistorySource,
|
descriptionHistorySource,
|
||||||
enhancementMode
|
enhancementMode,
|
||||||
|
preEnhancementDescription
|
||||||
);
|
);
|
||||||
res.json({ success: true, feature: updated });
|
res.json({ success: true, feature: updated });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import { createSwitchBranchHandler } from './routes/switch-branch.js';
|
|||||||
import {
|
import {
|
||||||
createOpenInEditorHandler,
|
createOpenInEditorHandler,
|
||||||
createGetDefaultEditorHandler,
|
createGetDefaultEditorHandler,
|
||||||
|
createGetAvailableEditorsHandler,
|
||||||
|
createRefreshEditorsHandler,
|
||||||
} from './routes/open-in-editor.js';
|
} from './routes/open-in-editor.js';
|
||||||
import { createInitGitHandler } from './routes/init-git.js';
|
import { createInitGitHandler } from './routes/init-git.js';
|
||||||
import { createMigrateHandler } from './routes/migrate.js';
|
import { createMigrateHandler } from './routes/migrate.js';
|
||||||
@@ -84,6 +86,8 @@ export function createWorktreeRoutes(events: EventEmitter): Router {
|
|||||||
router.post('/switch-branch', requireValidWorktree, createSwitchBranchHandler());
|
router.post('/switch-branch', requireValidWorktree, createSwitchBranchHandler());
|
||||||
router.post('/open-in-editor', validatePathParams('worktreePath'), createOpenInEditorHandler());
|
router.post('/open-in-editor', validatePathParams('worktreePath'), createOpenInEditorHandler());
|
||||||
router.get('/default-editor', createGetDefaultEditorHandler());
|
router.get('/default-editor', createGetDefaultEditorHandler());
|
||||||
|
router.get('/available-editors', createGetAvailableEditorsHandler());
|
||||||
|
router.post('/refresh-editors', createRefreshEditorsHandler());
|
||||||
router.post('/init-git', validatePathParams('projectPath'), createInitGitHandler());
|
router.post('/init-git', validatePathParams('projectPath'), createInitGitHandler());
|
||||||
router.post('/migrate', createMigrateHandler());
|
router.post('/migrate', createMigrateHandler());
|
||||||
router.post(
|
router.post(
|
||||||
|
|||||||
@@ -2,18 +2,23 @@
|
|||||||
* POST /list endpoint - List all git worktrees
|
* POST /list endpoint - List all git worktrees
|
||||||
*
|
*
|
||||||
* Returns actual git worktrees from `git worktree list`.
|
* Returns actual git worktrees from `git worktree list`.
|
||||||
|
* Also scans .worktrees/ directory to discover worktrees that may have been
|
||||||
|
* created externally or whose git state was corrupted.
|
||||||
* Does NOT include tracked branches - only real worktrees with separate directories.
|
* Does NOT include tracked branches - only real worktrees with separate directories.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { exec } from 'child_process';
|
import { exec } from 'child_process';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
|
import path from 'path';
|
||||||
import * as secureFs from '../../../lib/secure-fs.js';
|
import * as secureFs from '../../../lib/secure-fs.js';
|
||||||
import { isGitRepo } from '@automaker/git-utils';
|
import { isGitRepo } from '@automaker/git-utils';
|
||||||
import { getErrorMessage, logError, normalizePath } from '../common.js';
|
import { getErrorMessage, logError, normalizePath } from '../common.js';
|
||||||
import { readAllWorktreeMetadata, type WorktreePRInfo } from '../../../lib/worktree-metadata.js';
|
import { readAllWorktreeMetadata, type WorktreePRInfo } from '../../../lib/worktree-metadata.js';
|
||||||
|
import { createLogger } from '@automaker/utils';
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
const logger = createLogger('Worktree');
|
||||||
|
|
||||||
interface WorktreeInfo {
|
interface WorktreeInfo {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -35,6 +40,87 @@ async function getCurrentBranch(cwd: string): Promise<string> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan the .worktrees directory to discover worktrees that may exist on disk
|
||||||
|
* but are not registered with git (e.g., created externally or corrupted state).
|
||||||
|
*/
|
||||||
|
async function scanWorktreesDirectory(
|
||||||
|
projectPath: string,
|
||||||
|
knownWorktreePaths: Set<string>
|
||||||
|
): Promise<Array<{ path: string; branch: string }>> {
|
||||||
|
const discovered: Array<{ path: string; branch: string }> = [];
|
||||||
|
const worktreesDir = path.join(projectPath, '.worktrees');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if .worktrees directory exists
|
||||||
|
await secureFs.access(worktreesDir);
|
||||||
|
} catch {
|
||||||
|
// .worktrees directory doesn't exist
|
||||||
|
return discovered;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entries = await secureFs.readdir(worktreesDir, { withFileTypes: true });
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory()) continue;
|
||||||
|
|
||||||
|
const worktreePath = path.join(worktreesDir, entry.name);
|
||||||
|
const normalizedPath = normalizePath(worktreePath);
|
||||||
|
|
||||||
|
// Skip if already known from git worktree list
|
||||||
|
if (knownWorktreePaths.has(normalizedPath)) continue;
|
||||||
|
|
||||||
|
// Check if this is a valid git repository
|
||||||
|
const gitPath = path.join(worktreePath, '.git');
|
||||||
|
try {
|
||||||
|
const gitStat = await secureFs.stat(gitPath);
|
||||||
|
|
||||||
|
// Git worktrees have a .git FILE (not directory) that points to the parent repo
|
||||||
|
// Regular repos have a .git DIRECTORY
|
||||||
|
if (gitStat.isFile() || gitStat.isDirectory()) {
|
||||||
|
// Try to get the branch name
|
||||||
|
const branch = await getCurrentBranch(worktreePath);
|
||||||
|
if (branch) {
|
||||||
|
logger.info(
|
||||||
|
`Discovered worktree in .worktrees/ not in git worktree list: ${entry.name} (branch: ${branch})`
|
||||||
|
);
|
||||||
|
discovered.push({
|
||||||
|
path: normalizedPath,
|
||||||
|
branch,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Try to get branch from HEAD if branch --show-current fails (detached HEAD)
|
||||||
|
try {
|
||||||
|
const { stdout: headRef } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
||||||
|
cwd: worktreePath,
|
||||||
|
});
|
||||||
|
const headBranch = headRef.trim();
|
||||||
|
if (headBranch && headBranch !== 'HEAD') {
|
||||||
|
logger.info(
|
||||||
|
`Discovered worktree in .worktrees/ not in git worktree list: ${entry.name} (branch: ${headBranch})`
|
||||||
|
);
|
||||||
|
discovered.push({
|
||||||
|
path: normalizedPath,
|
||||||
|
branch: headBranch,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Can't determine branch, skip this directory
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not a git repo, skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`Failed to scan .worktrees directory: ${getErrorMessage(error)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return discovered;
|
||||||
|
}
|
||||||
|
|
||||||
export function createListHandler() {
|
export function createListHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
@@ -116,6 +202,22 @@ export function createListHandler() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Scan .worktrees directory to discover worktrees that exist on disk
|
||||||
|
// but are not registered with git (e.g., created externally)
|
||||||
|
const knownPaths = new Set(worktrees.map((w) => w.path));
|
||||||
|
const discoveredWorktrees = await scanWorktreesDirectory(projectPath, knownPaths);
|
||||||
|
|
||||||
|
// Add discovered worktrees to the list
|
||||||
|
for (const discovered of discoveredWorktrees) {
|
||||||
|
worktrees.push({
|
||||||
|
path: discovered.path,
|
||||||
|
branch: discovered.branch,
|
||||||
|
isMain: false,
|
||||||
|
isCurrent: discovered.branch === currentBranch,
|
||||||
|
hasWorktree: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Read all worktree metadata to get PR info
|
// Read all worktree metadata to get PR info
|
||||||
const allMetadata = await readAllWorktreeMetadata(projectPath);
|
const allMetadata = await readAllWorktreeMetadata(projectPath);
|
||||||
|
|
||||||
|
|||||||
@@ -1,78 +1,40 @@
|
|||||||
/**
|
/**
|
||||||
* POST /open-in-editor endpoint - Open a worktree directory in the default code editor
|
* POST /open-in-editor endpoint - Open a worktree directory in the default code editor
|
||||||
* GET /default-editor endpoint - Get the name of the default code editor
|
* GET /default-editor endpoint - Get the name of the default code editor
|
||||||
|
* POST /refresh-editors endpoint - Clear editor cache and re-detect available editors
|
||||||
|
*
|
||||||
|
* This module uses @automaker/platform for cross-platform editor detection and launching.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { exec } from 'child_process';
|
import { isAbsolute } from 'path';
|
||||||
import { promisify } from 'util';
|
import {
|
||||||
|
clearEditorCache,
|
||||||
|
detectAllEditors,
|
||||||
|
detectDefaultEditor,
|
||||||
|
openInEditor,
|
||||||
|
openInFileManager,
|
||||||
|
} from '@automaker/platform';
|
||||||
|
import { createLogger } from '@automaker/utils';
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const logger = createLogger('open-in-editor');
|
||||||
|
|
||||||
// Editor detection with caching
|
export function createGetAvailableEditorsHandler() {
|
||||||
interface EditorInfo {
|
return async (_req: Request, res: Response): Promise<void> => {
|
||||||
name: string;
|
try {
|
||||||
command: string;
|
const editors = await detectAllEditors();
|
||||||
}
|
res.json({
|
||||||
|
success: true,
|
||||||
let cachedEditor: EditorInfo | null = null;
|
result: {
|
||||||
|
editors,
|
||||||
/**
|
},
|
||||||
* Detect which code editor is available on the system
|
});
|
||||||
*/
|
} catch (error) {
|
||||||
async function detectDefaultEditor(): Promise<EditorInfo> {
|
logError(error, 'Get available editors failed');
|
||||||
// Return cached result if available
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
if (cachedEditor) {
|
}
|
||||||
return cachedEditor;
|
};
|
||||||
}
|
|
||||||
|
|
||||||
// Try Cursor first (if user has Cursor, they probably prefer it)
|
|
||||||
try {
|
|
||||||
await execAsync('which cursor || where cursor');
|
|
||||||
cachedEditor = { name: 'Cursor', command: 'cursor' };
|
|
||||||
return cachedEditor;
|
|
||||||
} catch {
|
|
||||||
// Cursor not found
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try VS Code
|
|
||||||
try {
|
|
||||||
await execAsync('which code || where code');
|
|
||||||
cachedEditor = { name: 'VS Code', command: 'code' };
|
|
||||||
return cachedEditor;
|
|
||||||
} catch {
|
|
||||||
// VS Code not found
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try Zed
|
|
||||||
try {
|
|
||||||
await execAsync('which zed || where zed');
|
|
||||||
cachedEditor = { name: 'Zed', command: 'zed' };
|
|
||||||
return cachedEditor;
|
|
||||||
} catch {
|
|
||||||
// Zed not found
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try Sublime Text
|
|
||||||
try {
|
|
||||||
await execAsync('which subl || where subl');
|
|
||||||
cachedEditor = { name: 'Sublime Text', command: 'subl' };
|
|
||||||
return cachedEditor;
|
|
||||||
} catch {
|
|
||||||
// Sublime not found
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to file manager
|
|
||||||
const platform = process.platform;
|
|
||||||
if (platform === 'darwin') {
|
|
||||||
cachedEditor = { name: 'Finder', command: 'open' };
|
|
||||||
} else if (platform === 'win32') {
|
|
||||||
cachedEditor = { name: 'Explorer', command: 'explorer' };
|
|
||||||
} else {
|
|
||||||
cachedEditor = { name: 'File Manager', command: 'xdg-open' };
|
|
||||||
}
|
|
||||||
return cachedEditor;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createGetDefaultEditorHandler() {
|
export function createGetDefaultEditorHandler() {
|
||||||
@@ -93,11 +55,41 @@ export function createGetDefaultEditorHandler() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler to refresh the editor cache and re-detect available editors
|
||||||
|
* Useful when the user has installed/uninstalled editors
|
||||||
|
*/
|
||||||
|
export function createRefreshEditorsHandler() {
|
||||||
|
return async (_req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
// Clear the cache
|
||||||
|
clearEditorCache();
|
||||||
|
|
||||||
|
// Re-detect editors (this will repopulate the cache)
|
||||||
|
const editors = await detectAllEditors();
|
||||||
|
|
||||||
|
logger.info(`Editor cache refreshed, found ${editors.length} editors`);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
editors,
|
||||||
|
message: `Found ${editors.length} available editors`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logError(error, 'Refresh editors failed');
|
||||||
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function createOpenInEditorHandler() {
|
export function createOpenInEditorHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { worktreePath } = req.body as {
|
const { worktreePath, editorCommand } = req.body as {
|
||||||
worktreePath: string;
|
worktreePath: string;
|
||||||
|
editorCommand?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!worktreePath) {
|
if (!worktreePath) {
|
||||||
@@ -108,42 +100,44 @@ export function createOpenInEditorHandler() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const editor = await detectDefaultEditor();
|
// Security: Validate that worktreePath is an absolute path
|
||||||
|
if (!isAbsolute(worktreePath)) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'worktreePath must be an absolute path',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await execAsync(`${editor.command} "${worktreePath}"`);
|
// Use the platform utility to open in editor
|
||||||
|
const result = await openInEditor(worktreePath, editorCommand);
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
result: {
|
result: {
|
||||||
message: `Opened ${worktreePath} in ${editor.name}`,
|
message: `Opened ${worktreePath} in ${result.editorName}`,
|
||||||
editorName: editor.name,
|
editorName: result.editorName,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (editorError) {
|
} catch (editorError) {
|
||||||
// If the detected editor fails, try opening in default file manager as fallback
|
// If the specified editor fails, try opening in default file manager as fallback
|
||||||
const platform = process.platform;
|
logger.warn(
|
||||||
let openCommand: string;
|
`Failed to open in editor, falling back to file manager: ${getErrorMessage(editorError)}`
|
||||||
let fallbackName: string;
|
);
|
||||||
|
|
||||||
if (platform === 'darwin') {
|
try {
|
||||||
openCommand = `open "${worktreePath}"`;
|
const result = await openInFileManager(worktreePath);
|
||||||
fallbackName = 'Finder';
|
res.json({
|
||||||
} else if (platform === 'win32') {
|
success: true,
|
||||||
openCommand = `explorer "${worktreePath}"`;
|
result: {
|
||||||
fallbackName = 'Explorer';
|
message: `Opened ${worktreePath} in ${result.editorName}`,
|
||||||
} else {
|
editorName: result.editorName,
|
||||||
openCommand = `xdg-open "${worktreePath}"`;
|
},
|
||||||
fallbackName = 'File Manager';
|
});
|
||||||
|
} catch (fallbackError) {
|
||||||
|
// Both editor and file manager failed
|
||||||
|
throw fallbackError;
|
||||||
}
|
}
|
||||||
|
|
||||||
await execAsync(openCommand);
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
result: {
|
|
||||||
message: `Opened ${worktreePath} in ${fallbackName}`,
|
|
||||||
editorName: fallbackName,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(error, 'Open in editor failed');
|
logError(error, 'Open in editor failed');
|
||||||
|
|||||||
@@ -31,7 +31,13 @@ import {
|
|||||||
const logger = createLogger('AutoMode');
|
const logger = createLogger('AutoMode');
|
||||||
import { resolveModelString, resolvePhaseModel, DEFAULT_MODELS } from '@automaker/model-resolver';
|
import { resolveModelString, resolvePhaseModel, DEFAULT_MODELS } from '@automaker/model-resolver';
|
||||||
import { resolveDependencies, areDependenciesSatisfied } from '@automaker/dependency-resolver';
|
import { resolveDependencies, areDependenciesSatisfied } from '@automaker/dependency-resolver';
|
||||||
import { getFeatureDir, getAutomakerDir, getFeaturesDir } from '@automaker/platform';
|
import {
|
||||||
|
getFeatureDir,
|
||||||
|
getAutomakerDir,
|
||||||
|
getFeaturesDir,
|
||||||
|
getExecutionStatePath,
|
||||||
|
ensureAutomakerDir,
|
||||||
|
} from '@automaker/platform';
|
||||||
import { exec } from 'child_process';
|
import { exec } from 'child_process';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
@@ -201,6 +207,29 @@ interface AutoModeConfig {
|
|||||||
projectPath: string;
|
projectPath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execution state for recovery after server restart
|
||||||
|
* Tracks which features were running and auto-loop configuration
|
||||||
|
*/
|
||||||
|
interface ExecutionState {
|
||||||
|
version: 1;
|
||||||
|
autoLoopWasRunning: boolean;
|
||||||
|
maxConcurrency: number;
|
||||||
|
projectPath: string;
|
||||||
|
runningFeatureIds: string[];
|
||||||
|
savedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default empty execution state
|
||||||
|
const DEFAULT_EXECUTION_STATE: ExecutionState = {
|
||||||
|
version: 1,
|
||||||
|
autoLoopWasRunning: false,
|
||||||
|
maxConcurrency: 3,
|
||||||
|
projectPath: '',
|
||||||
|
runningFeatureIds: [],
|
||||||
|
savedAt: '',
|
||||||
|
};
|
||||||
|
|
||||||
// Constants for consecutive failure tracking
|
// Constants for consecutive failure tracking
|
||||||
const CONSECUTIVE_FAILURE_THRESHOLD = 3; // Pause after 3 consecutive failures
|
const CONSECUTIVE_FAILURE_THRESHOLD = 3; // Pause after 3 consecutive failures
|
||||||
const FAILURE_WINDOW_MS = 60000; // Failures within 1 minute count as consecutive
|
const FAILURE_WINDOW_MS = 60000; // Failures within 1 minute count as consecutive
|
||||||
@@ -322,6 +351,9 @@ export class AutoModeService {
|
|||||||
projectPath,
|
projectPath,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Save execution state for recovery after restart
|
||||||
|
await this.saveExecutionState(projectPath);
|
||||||
|
|
||||||
// Note: Memory folder initialization is now handled by loadContextFiles
|
// Note: Memory folder initialization is now handled by loadContextFiles
|
||||||
|
|
||||||
// Run the loop in the background
|
// Run the loop in the background
|
||||||
@@ -390,17 +422,23 @@ export class AutoModeService {
|
|||||||
*/
|
*/
|
||||||
async stopAutoLoop(): Promise<number> {
|
async stopAutoLoop(): Promise<number> {
|
||||||
const wasRunning = this.autoLoopRunning;
|
const wasRunning = this.autoLoopRunning;
|
||||||
|
const projectPath = this.config?.projectPath;
|
||||||
this.autoLoopRunning = false;
|
this.autoLoopRunning = false;
|
||||||
if (this.autoLoopAbortController) {
|
if (this.autoLoopAbortController) {
|
||||||
this.autoLoopAbortController.abort();
|
this.autoLoopAbortController.abort();
|
||||||
this.autoLoopAbortController = null;
|
this.autoLoopAbortController = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear execution state when auto-loop is explicitly stopped
|
||||||
|
if (projectPath) {
|
||||||
|
await this.clearExecutionState(projectPath);
|
||||||
|
}
|
||||||
|
|
||||||
// Emit stop event immediately when user explicitly stops
|
// Emit stop event immediately when user explicitly stops
|
||||||
if (wasRunning) {
|
if (wasRunning) {
|
||||||
this.emitAutoModeEvent('auto_mode_stopped', {
|
this.emitAutoModeEvent('auto_mode_stopped', {
|
||||||
message: 'Auto mode stopped',
|
message: 'Auto mode stopped',
|
||||||
projectPath: this.config?.projectPath,
|
projectPath,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -441,6 +479,11 @@ export class AutoModeService {
|
|||||||
};
|
};
|
||||||
this.runningFeatures.set(featureId, tempRunningFeature);
|
this.runningFeatures.set(featureId, tempRunningFeature);
|
||||||
|
|
||||||
|
// Save execution state when feature starts
|
||||||
|
if (isAutoMode) {
|
||||||
|
await this.saveExecutionState(projectPath);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Validate that project path is allowed using centralized validation
|
// Validate that project path is allowed using centralized validation
|
||||||
validateWorkingDirectory(projectPath);
|
validateWorkingDirectory(projectPath);
|
||||||
@@ -695,6 +738,11 @@ export class AutoModeService {
|
|||||||
`Pending approvals at cleanup: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}`
|
`Pending approvals at cleanup: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}`
|
||||||
);
|
);
|
||||||
this.runningFeatures.delete(featureId);
|
this.runningFeatures.delete(featureId);
|
||||||
|
|
||||||
|
// Update execution state after feature completes
|
||||||
|
if (this.autoLoopRunning && projectPath) {
|
||||||
|
await this.saveExecutionState(projectPath);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2950,6 +2998,149 @@ Begin implementing task ${task.id} now.`;
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Execution State Persistence - For recovery after server restart
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save execution state to disk for recovery after server restart
|
||||||
|
*/
|
||||||
|
private async saveExecutionState(projectPath: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await ensureAutomakerDir(projectPath);
|
||||||
|
const statePath = getExecutionStatePath(projectPath);
|
||||||
|
const state: ExecutionState = {
|
||||||
|
version: 1,
|
||||||
|
autoLoopWasRunning: this.autoLoopRunning,
|
||||||
|
maxConcurrency: this.config?.maxConcurrency ?? 3,
|
||||||
|
projectPath,
|
||||||
|
runningFeatureIds: Array.from(this.runningFeatures.keys()),
|
||||||
|
savedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
await secureFs.writeFile(statePath, JSON.stringify(state, null, 2), 'utf-8');
|
||||||
|
logger.info(`Saved execution state: ${state.runningFeatureIds.length} running features`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to save execution state:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load execution state from disk
|
||||||
|
*/
|
||||||
|
private async loadExecutionState(projectPath: string): Promise<ExecutionState> {
|
||||||
|
try {
|
||||||
|
const statePath = getExecutionStatePath(projectPath);
|
||||||
|
const content = (await secureFs.readFile(statePath, 'utf-8')) as string;
|
||||||
|
const state = JSON.parse(content) as ExecutionState;
|
||||||
|
return state;
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||||
|
logger.error('Failed to load execution state:', error);
|
||||||
|
}
|
||||||
|
return DEFAULT_EXECUTION_STATE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear execution state (called on successful shutdown or when auto-loop stops)
|
||||||
|
*/
|
||||||
|
private async clearExecutionState(projectPath: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const statePath = getExecutionStatePath(projectPath);
|
||||||
|
await secureFs.unlink(statePath);
|
||||||
|
logger.info('Cleared execution state');
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||||
|
logger.error('Failed to clear execution state:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check for and resume interrupted features after server restart
|
||||||
|
* This should be called during server initialization
|
||||||
|
*/
|
||||||
|
async resumeInterruptedFeatures(projectPath: string): Promise<void> {
|
||||||
|
logger.info('Checking for interrupted features to resume...');
|
||||||
|
|
||||||
|
// Load all features and find those that were interrupted
|
||||||
|
const featuresDir = getFeaturesDir(projectPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entries = await secureFs.readdir(featuresDir, { withFileTypes: true });
|
||||||
|
const interruptedFeatures: Feature[] = [];
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
const featurePath = path.join(featuresDir, entry.name, 'feature.json');
|
||||||
|
try {
|
||||||
|
const data = (await secureFs.readFile(featurePath, 'utf-8')) as string;
|
||||||
|
const feature = JSON.parse(data) as Feature;
|
||||||
|
|
||||||
|
// Check if feature was interrupted (in_progress or pipeline_*)
|
||||||
|
if (
|
||||||
|
feature.status === 'in_progress' ||
|
||||||
|
(feature.status && feature.status.startsWith('pipeline_'))
|
||||||
|
) {
|
||||||
|
// Verify it has existing context (agent-output.md)
|
||||||
|
const featureDir = getFeatureDir(projectPath, feature.id);
|
||||||
|
const contextPath = path.join(featureDir, 'agent-output.md');
|
||||||
|
try {
|
||||||
|
await secureFs.access(contextPath);
|
||||||
|
interruptedFeatures.push(feature);
|
||||||
|
logger.info(
|
||||||
|
`Found interrupted feature: ${feature.id} (${feature.title}) - status: ${feature.status}`
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// No context file, skip this feature - it will be restarted fresh
|
||||||
|
logger.info(`Interrupted feature ${feature.id} has no context, will restart fresh`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip invalid features
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interruptedFeatures.length === 0) {
|
||||||
|
logger.info('No interrupted features found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Found ${interruptedFeatures.length} interrupted feature(s) to resume`);
|
||||||
|
|
||||||
|
// Emit event to notify UI
|
||||||
|
this.emitAutoModeEvent('auto_mode_resuming_features', {
|
||||||
|
message: `Resuming ${interruptedFeatures.length} interrupted feature(s) after server restart`,
|
||||||
|
projectPath,
|
||||||
|
featureIds: interruptedFeatures.map((f) => f.id),
|
||||||
|
features: interruptedFeatures.map((f) => ({
|
||||||
|
id: f.id,
|
||||||
|
title: f.title,
|
||||||
|
status: f.status,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resume each interrupted feature
|
||||||
|
for (const feature of interruptedFeatures) {
|
||||||
|
try {
|
||||||
|
logger.info(`Resuming feature: ${feature.id} (${feature.title})`);
|
||||||
|
// Use resumeFeature which will detect the existing context and continue
|
||||||
|
await this.resumeFeature(projectPath, feature.id, true);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to resume feature ${feature.id}:`, error);
|
||||||
|
// Continue with other features
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
|
logger.info('No features directory found, nothing to resume');
|
||||||
|
} else {
|
||||||
|
logger.error('Error checking for interrupted features:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract and record learnings from a completed feature
|
* Extract and record learnings from a completed feature
|
||||||
* Uses a quick Claude call to identify important decisions and patterns
|
* Uses a quick Claude call to identify important decisions and patterns
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { spawn } from 'child_process';
|
|||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as pty from 'node-pty';
|
import * as pty from 'node-pty';
|
||||||
import { ClaudeUsage } from '../routes/claude/types.js';
|
import { ClaudeUsage } from '../routes/claude/types.js';
|
||||||
|
import { createLogger } from '@automaker/utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Claude Usage Service
|
* Claude Usage Service
|
||||||
@@ -14,6 +15,8 @@ import { ClaudeUsage } from '../routes/claude/types.js';
|
|||||||
* - macOS: Uses 'expect' command for PTY
|
* - macOS: Uses 'expect' command for PTY
|
||||||
* - Windows/Linux: Uses node-pty for PTY
|
* - Windows/Linux: Uses node-pty for PTY
|
||||||
*/
|
*/
|
||||||
|
const logger = createLogger('ClaudeUsage');
|
||||||
|
|
||||||
export class ClaudeUsageService {
|
export class ClaudeUsageService {
|
||||||
private claudeBinary = 'claude';
|
private claudeBinary = 'claude';
|
||||||
private timeout = 30000; // 30 second timeout
|
private timeout = 30000; // 30 second timeout
|
||||||
@@ -164,21 +167,40 @@ export class ClaudeUsageService {
|
|||||||
const shell = this.isWindows ? 'cmd.exe' : '/bin/sh';
|
const shell = this.isWindows ? 'cmd.exe' : '/bin/sh';
|
||||||
const args = this.isWindows ? ['/c', 'claude', '/usage'] : ['-c', 'claude /usage'];
|
const args = this.isWindows ? ['/c', 'claude', '/usage'] : ['-c', 'claude /usage'];
|
||||||
|
|
||||||
const ptyProcess = pty.spawn(shell, args, {
|
let ptyProcess: any = null;
|
||||||
name: 'xterm-256color',
|
|
||||||
cols: 120,
|
try {
|
||||||
rows: 30,
|
ptyProcess = pty.spawn(shell, args, {
|
||||||
cwd: workingDirectory,
|
name: 'xterm-256color',
|
||||||
env: {
|
cols: 120,
|
||||||
...process.env,
|
rows: 30,
|
||||||
TERM: 'xterm-256color',
|
cwd: workingDirectory,
|
||||||
} as Record<string, string>,
|
env: {
|
||||||
});
|
...process.env,
|
||||||
|
TERM: 'xterm-256color',
|
||||||
|
} as Record<string, string>,
|
||||||
|
});
|
||||||
|
} catch (spawnError) {
|
||||||
|
// pty.spawn() can throw synchronously if the native module fails to load
|
||||||
|
// or if PTY is not available in the current environment (e.g., containers without /dev/pts)
|
||||||
|
const errorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError);
|
||||||
|
logger.error('[executeClaudeUsageCommandPty] Failed to spawn PTY:', errorMessage);
|
||||||
|
|
||||||
|
// Return a user-friendly error instead of crashing
|
||||||
|
reject(
|
||||||
|
new Error(
|
||||||
|
`Unable to access terminal: ${errorMessage}. Claude CLI may not be available or PTY support is limited in this environment.`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
if (!settled) {
|
if (!settled) {
|
||||||
settled = true;
|
settled = true;
|
||||||
ptyProcess.kill();
|
if (ptyProcess && !ptyProcess.killed) {
|
||||||
|
ptyProcess.kill();
|
||||||
|
}
|
||||||
// Don't fail if we have data - return it instead
|
// Don't fail if we have data - return it instead
|
||||||
if (output.includes('Current session')) {
|
if (output.includes('Current session')) {
|
||||||
resolve(output);
|
resolve(output);
|
||||||
@@ -188,7 +210,7 @@ export class ClaudeUsageService {
|
|||||||
}
|
}
|
||||||
}, this.timeout);
|
}, this.timeout);
|
||||||
|
|
||||||
ptyProcess.onData((data) => {
|
ptyProcess.onData((data: string) => {
|
||||||
output += data;
|
output += data;
|
||||||
|
|
||||||
// Check if we've seen the usage data (look for "Current session")
|
// Check if we've seen the usage data (look for "Current session")
|
||||||
@@ -196,12 +218,12 @@ export class ClaudeUsageService {
|
|||||||
hasSeenUsageData = true;
|
hasSeenUsageData = true;
|
||||||
// Wait for full output, then send escape to exit
|
// Wait for full output, then send escape to exit
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!settled) {
|
if (!settled && ptyProcess && !ptyProcess.killed) {
|
||||||
ptyProcess.write('\x1b'); // Send escape key
|
ptyProcess.write('\x1b'); // Send escape key
|
||||||
|
|
||||||
// Fallback: if ESC doesn't exit (Linux), use SIGTERM after 2s
|
// Fallback: if ESC doesn't exit (Linux), use SIGTERM after 2s
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!settled) {
|
if (!settled && ptyProcess && !ptyProcess.killed) {
|
||||||
ptyProcess.kill('SIGTERM');
|
ptyProcess.kill('SIGTERM');
|
||||||
}
|
}
|
||||||
}, 2000);
|
}, 2000);
|
||||||
@@ -212,14 +234,14 @@ export class ClaudeUsageService {
|
|||||||
// Fallback: if we see "Esc to cancel" but haven't seen usage data yet
|
// Fallback: if we see "Esc to cancel" but haven't seen usage data yet
|
||||||
if (!hasSeenUsageData && output.includes('Esc to cancel')) {
|
if (!hasSeenUsageData && output.includes('Esc to cancel')) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!settled) {
|
if (!settled && ptyProcess && !ptyProcess.killed) {
|
||||||
ptyProcess.write('\x1b'); // Send escape key
|
ptyProcess.write('\x1b'); // Send escape key
|
||||||
}
|
}
|
||||||
}, 3000);
|
}, 3000);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ptyProcess.onExit(({ exitCode }) => {
|
ptyProcess.onExit(({ exitCode }: { exitCode: number }) => {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
|
|||||||
@@ -308,13 +308,15 @@ export class FeatureLoader {
|
|||||||
* @param updates - Partial feature updates
|
* @param updates - Partial feature updates
|
||||||
* @param descriptionHistorySource - Source of description change ('enhance' or 'edit')
|
* @param descriptionHistorySource - Source of description change ('enhance' or 'edit')
|
||||||
* @param enhancementMode - Enhancement mode if source is 'enhance'
|
* @param enhancementMode - Enhancement mode if source is 'enhance'
|
||||||
|
* @param preEnhancementDescription - Description before enhancement (for restoring original)
|
||||||
*/
|
*/
|
||||||
async update(
|
async update(
|
||||||
projectPath: string,
|
projectPath: string,
|
||||||
featureId: string,
|
featureId: string,
|
||||||
updates: Partial<Feature>,
|
updates: Partial<Feature>,
|
||||||
descriptionHistorySource?: 'enhance' | 'edit',
|
descriptionHistorySource?: 'enhance' | 'edit',
|
||||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance'
|
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer',
|
||||||
|
preEnhancementDescription?: string
|
||||||
): Promise<Feature> {
|
): Promise<Feature> {
|
||||||
const feature = await this.get(projectPath, featureId);
|
const feature = await this.get(projectPath, featureId);
|
||||||
if (!feature) {
|
if (!feature) {
|
||||||
@@ -338,9 +340,31 @@ export class FeatureLoader {
|
|||||||
updates.description !== feature.description &&
|
updates.description !== feature.description &&
|
||||||
updates.description.trim()
|
updates.description.trim()
|
||||||
) {
|
) {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
|
||||||
|
// If this is an enhancement and we have the pre-enhancement description,
|
||||||
|
// add the original text to history first (so user can restore to it)
|
||||||
|
if (
|
||||||
|
descriptionHistorySource === 'enhance' &&
|
||||||
|
preEnhancementDescription &&
|
||||||
|
preEnhancementDescription.trim()
|
||||||
|
) {
|
||||||
|
// Check if this pre-enhancement text is different from the last history entry
|
||||||
|
const lastEntry = updatedHistory[updatedHistory.length - 1];
|
||||||
|
if (!lastEntry || lastEntry.description !== preEnhancementDescription) {
|
||||||
|
const preEnhanceEntry: DescriptionHistoryEntry = {
|
||||||
|
description: preEnhancementDescription,
|
||||||
|
timestamp,
|
||||||
|
source: updatedHistory.length === 0 ? 'initial' : 'edit',
|
||||||
|
};
|
||||||
|
updatedHistory = [...updatedHistory, preEnhanceEntry];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the new/enhanced description to history
|
||||||
const historyEntry: DescriptionHistoryEntry = {
|
const historyEntry: DescriptionHistoryEntry = {
|
||||||
description: updates.description,
|
description: updates.description,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp,
|
||||||
source: descriptionHistorySource || 'edit',
|
source: descriptionHistorySource || 'edit',
|
||||||
...(descriptionHistorySource === 'enhance' && enhancementMode ? { enhancementMode } : {}),
|
...(descriptionHistorySource === 'enhance' && enhancementMode ? { enhancementMode } : {}),
|
||||||
};
|
};
|
||||||
|
|||||||
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
|
// Derive isCreatingSpec from store state
|
||||||
const isCreatingSpec = specCreatingForProject !== null;
|
const isCreatingSpec = specCreatingForProject !== null;
|
||||||
const creatingSpecProjectPath = specCreatingForProject;
|
const creatingSpecProjectPath = specCreatingForProject;
|
||||||
|
// Check if the current project is specifically the one generating spec
|
||||||
|
const isCurrentProjectGeneratingSpec =
|
||||||
|
specCreatingForProject !== null && specCreatingForProject === currentProject?.path;
|
||||||
|
|
||||||
// Auto-collapse sidebar on small screens and update Electron window minWidth
|
// Auto-collapse sidebar on small screens and update Electron window minWidth
|
||||||
useSidebarAutoCollapse({ sidebarOpen, toggleSidebar });
|
useSidebarAutoCollapse({ sidebarOpen, toggleSidebar });
|
||||||
@@ -241,6 +244,7 @@ export function Sidebar() {
|
|||||||
cyclePrevProject,
|
cyclePrevProject,
|
||||||
cycleNextProject,
|
cycleNextProject,
|
||||||
unviewedValidationsCount,
|
unviewedValidationsCount,
|
||||||
|
isSpecGenerating: isCurrentProjectGeneratingSpec,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Register keyboard shortcuts
|
// Register keyboard shortcuts
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { NavigateOptions } from '@tanstack/react-router';
|
import type { NavigateOptions } from '@tanstack/react-router';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { formatShortcut } from '@/store/app-store';
|
import { formatShortcut } from '@/store/app-store';
|
||||||
import type { NavSection } from '../types';
|
import type { NavSection } from '../types';
|
||||||
@@ -80,14 +81,23 @@ export function SidebarNavigation({
|
|||||||
data-testid={`nav-${item.id}`}
|
data-testid={`nav-${item.id}`}
|
||||||
>
|
>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Icon
|
{item.isLoading ? (
|
||||||
className={cn(
|
<Loader2
|
||||||
'w-[18px] h-[18px] shrink-0 transition-all duration-200',
|
className={cn(
|
||||||
isActive
|
'w-[18px] h-[18px] shrink-0 animate-spin',
|
||||||
? 'text-brand-500 drop-shadow-sm'
|
isActive ? 'text-brand-500' : 'text-muted-foreground'
|
||||||
: 'group-hover:text-brand-400 group-hover:scale-110'
|
)}
|
||||||
)}
|
/>
|
||||||
/>
|
) : (
|
||||||
|
<Icon
|
||||||
|
className={cn(
|
||||||
|
'w-[18px] h-[18px] shrink-0 transition-all duration-200',
|
||||||
|
isActive
|
||||||
|
? 'text-brand-500 drop-shadow-sm'
|
||||||
|
: 'group-hover:text-brand-400 group-hover:scale-110'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{/* Count badge for collapsed state */}
|
{/* Count badge for collapsed state */}
|
||||||
{!sidebarOpen && item.count !== undefined && item.count > 0 && (
|
{!sidebarOpen && item.count !== undefined && item.count > 0 && (
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useRef } from 'react';
|
||||||
import { Rocket, CheckCircle2, Zap, FileText, Sparkles, ArrowRight } from 'lucide-react';
|
import { Rocket, CheckCircle2, Zap, FileText, Sparkles, ArrowRight } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -24,13 +25,25 @@ export function OnboardingDialog({
|
|||||||
onSkip,
|
onSkip,
|
||||||
onGenerateSpec,
|
onGenerateSpec,
|
||||||
}: OnboardingDialogProps) {
|
}: OnboardingDialogProps) {
|
||||||
|
// Track if we're closing because user clicked "Generate App Spec"
|
||||||
|
// to avoid incorrectly calling onSkip
|
||||||
|
const isGeneratingRef = useRef(false);
|
||||||
|
|
||||||
|
const handleGenerateSpec = () => {
|
||||||
|
isGeneratingRef.current = true;
|
||||||
|
onGenerateSpec();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={(isOpen) => {
|
onOpenChange={(isOpen) => {
|
||||||
if (!isOpen) {
|
if (!isOpen && !isGeneratingRef.current) {
|
||||||
|
// Only call onSkip when user dismisses dialog (escape, click outside, or skip button)
|
||||||
|
// NOT when they click "Generate App Spec"
|
||||||
onSkip();
|
onSkip();
|
||||||
}
|
}
|
||||||
|
isGeneratingRef.current = false;
|
||||||
onOpenChange(isOpen);
|
onOpenChange(isOpen);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -108,7 +121,7 @@ export function OnboardingDialog({
|
|||||||
Skip for now
|
Skip for now
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={onGenerateSpec}
|
onClick={handleGenerateSpec}
|
||||||
className="bg-gradient-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-600 text-white border-0"
|
className="bg-gradient-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-600 text-white border-0"
|
||||||
>
|
>
|
||||||
<Sparkles className="w-4 h-4 mr-2" />
|
<Sparkles className="w-4 h-4 mr-2" />
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
CircleDot,
|
CircleDot,
|
||||||
GitPullRequest,
|
GitPullRequest,
|
||||||
Lightbulb,
|
Lightbulb,
|
||||||
|
Brain,
|
||||||
|
Network,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { NavSection, NavItem } from '../types';
|
import type { NavSection, NavItem } from '../types';
|
||||||
import type { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts';
|
import type { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts';
|
||||||
@@ -24,7 +26,9 @@ interface UseNavigationProps {
|
|||||||
cycleNextProject: string;
|
cycleNextProject: string;
|
||||||
spec: string;
|
spec: string;
|
||||||
context: string;
|
context: string;
|
||||||
|
memory: string;
|
||||||
board: string;
|
board: string;
|
||||||
|
graph: string;
|
||||||
agent: string;
|
agent: string;
|
||||||
terminal: string;
|
terminal: string;
|
||||||
settings: string;
|
settings: string;
|
||||||
@@ -46,6 +50,8 @@ interface UseNavigationProps {
|
|||||||
cycleNextProject: () => void;
|
cycleNextProject: () => void;
|
||||||
/** Count of unviewed validations to show on GitHub Issues nav item */
|
/** Count of unviewed validations to show on GitHub Issues nav item */
|
||||||
unviewedValidationsCount?: number;
|
unviewedValidationsCount?: number;
|
||||||
|
/** Whether spec generation is currently running for the current project */
|
||||||
|
isSpecGenerating?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useNavigation({
|
export function useNavigation({
|
||||||
@@ -63,6 +69,7 @@ export function useNavigation({
|
|||||||
cyclePrevProject,
|
cyclePrevProject,
|
||||||
cycleNextProject,
|
cycleNextProject,
|
||||||
unviewedValidationsCount,
|
unviewedValidationsCount,
|
||||||
|
isSpecGenerating,
|
||||||
}: UseNavigationProps) {
|
}: UseNavigationProps) {
|
||||||
// Track if current project has a GitHub remote
|
// Track if current project has a GitHub remote
|
||||||
const [hasGitHubRemote, setHasGitHubRemote] = useState(false);
|
const [hasGitHubRemote, setHasGitHubRemote] = useState(false);
|
||||||
@@ -102,6 +109,7 @@ export function useNavigation({
|
|||||||
label: 'Spec Editor',
|
label: 'Spec Editor',
|
||||||
icon: FileText,
|
icon: FileText,
|
||||||
shortcut: shortcuts.spec,
|
shortcut: shortcuts.spec,
|
||||||
|
isLoading: isSpecGenerating,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'context',
|
id: 'context',
|
||||||
@@ -109,6 +117,12 @@ export function useNavigation({
|
|||||||
icon: BookOpen,
|
icon: BookOpen,
|
||||||
shortcut: shortcuts.context,
|
shortcut: shortcuts.context,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'memory',
|
||||||
|
label: 'Memory',
|
||||||
|
icon: Brain,
|
||||||
|
shortcut: shortcuts.memory,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Filter out hidden items
|
// Filter out hidden items
|
||||||
@@ -130,6 +144,12 @@ export function useNavigation({
|
|||||||
icon: LayoutGrid,
|
icon: LayoutGrid,
|
||||||
shortcut: shortcuts.board,
|
shortcut: shortcuts.board,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'graph',
|
||||||
|
label: 'Graph View',
|
||||||
|
icon: Network,
|
||||||
|
shortcut: shortcuts.graph,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'agent',
|
id: 'agent',
|
||||||
label: 'Agent Runner',
|
label: 'Agent Runner',
|
||||||
@@ -189,6 +209,7 @@ export function useNavigation({
|
|||||||
hideTerminal,
|
hideTerminal,
|
||||||
hasGitHubRemote,
|
hasGitHubRemote,
|
||||||
unviewedValidationsCount,
|
unviewedValidationsCount,
|
||||||
|
isSpecGenerating,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Build keyboard shortcuts for navigation
|
// Build keyboard shortcuts for navigation
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export interface NavItem {
|
|||||||
shortcut?: string;
|
shortcut?: string;
|
||||||
/** Optional count badge to display next to the nav item */
|
/** Optional count badge to display next to the nav item */
|
||||||
count?: number;
|
count?: number;
|
||||||
|
/** Whether this nav item is in a loading state (shows spinner) */
|
||||||
|
isLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SortableProjectItemProps {
|
export interface SortableProjectItemProps {
|
||||||
|
|||||||
@@ -84,9 +84,11 @@ const KEYBOARD_ROWS = [
|
|||||||
// Map shortcut names to human-readable labels
|
// Map shortcut names to human-readable labels
|
||||||
const SHORTCUT_LABELS: Record<keyof KeyboardShortcuts, string> = {
|
const SHORTCUT_LABELS: Record<keyof KeyboardShortcuts, string> = {
|
||||||
board: 'Kanban Board',
|
board: 'Kanban Board',
|
||||||
|
graph: 'Graph View',
|
||||||
agent: 'Agent Runner',
|
agent: 'Agent Runner',
|
||||||
spec: 'Spec Editor',
|
spec: 'Spec Editor',
|
||||||
context: 'Context',
|
context: 'Context',
|
||||||
|
memory: 'Memory',
|
||||||
settings: 'Settings',
|
settings: 'Settings',
|
||||||
terminal: 'Terminal',
|
terminal: 'Terminal',
|
||||||
ideation: 'Ideation',
|
ideation: 'Ideation',
|
||||||
@@ -110,9 +112,11 @@ const SHORTCUT_LABELS: Record<keyof KeyboardShortcuts, string> = {
|
|||||||
// Categorize shortcuts for color coding
|
// Categorize shortcuts for color coding
|
||||||
const SHORTCUT_CATEGORIES: Record<keyof KeyboardShortcuts, 'navigation' | 'ui' | 'action'> = {
|
const SHORTCUT_CATEGORIES: Record<keyof KeyboardShortcuts, 'navigation' | 'ui' | 'action'> = {
|
||||||
board: 'navigation',
|
board: 'navigation',
|
||||||
|
graph: 'navigation',
|
||||||
agent: 'navigation',
|
agent: 'navigation',
|
||||||
spec: 'navigation',
|
spec: 'navigation',
|
||||||
context: 'navigation',
|
context: 'navigation',
|
||||||
|
memory: 'navigation',
|
||||||
settings: 'navigation',
|
settings: 'navigation',
|
||||||
terminal: 'navigation',
|
terminal: 'navigation',
|
||||||
ideation: 'navigation',
|
ideation: 'navigation',
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ const PROVIDER_ICON_KEYS = {
|
|||||||
nova: 'nova',
|
nova: 'nova',
|
||||||
meta: 'meta',
|
meta: 'meta',
|
||||||
mistral: 'mistral',
|
mistral: 'mistral',
|
||||||
|
minimax: 'minimax',
|
||||||
|
glm: 'glm',
|
||||||
|
bigpickle: 'bigpickle',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
type ProviderIconKey = keyof typeof PROVIDER_ICON_KEYS;
|
type ProviderIconKey = keyof typeof PROVIDER_ICON_KEYS;
|
||||||
@@ -87,6 +90,22 @@ const PROVIDER_ICON_DEFINITIONS: Record<ProviderIconKey, ProviderIconDefinition>
|
|||||||
viewBox: '0 0 24 24',
|
viewBox: '0 0 24 24',
|
||||||
path: '',
|
path: '',
|
||||||
},
|
},
|
||||||
|
minimax: {
|
||||||
|
viewBox: '0 0 24 24',
|
||||||
|
// Official MiniMax logo from lobehub/lobe-icons
|
||||||
|
path: 'M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z',
|
||||||
|
},
|
||||||
|
glm: {
|
||||||
|
viewBox: '0 0 24 24',
|
||||||
|
// Official Z.ai logo from lobehub/lobe-icons (GLM provider)
|
||||||
|
path: 'M12.105 2L9.927 4.953H.653L2.83 2h9.276zM23.254 19.048L21.078 22h-9.242l2.174-2.952h9.244zM24 2L9.264 22H0L14.736 2H24z',
|
||||||
|
},
|
||||||
|
bigpickle: {
|
||||||
|
viewBox: '0 0 24 24',
|
||||||
|
// Big Pickle logo - stylized shape with dots
|
||||||
|
path: 'M8 4c-2.21 0-4 1.79-4 4v8c0 2.21 1.79 4 4 4h8c2.21 0 4-1.79 4-4V8c0-2.21-1.79-4-4-4H8zm0 2h8c1.103 0 2 .897 2 2v8c0 1.103-.897 2-2 2H8c-1.103 0-2-.897-2-2V8c0-1.103.897-2 2-2zm2 3a1 1 0 100 2 1 1 0 000-2zm4 0a1 1 0 100 2 1 1 0 000-2zm-4 4a1 1 0 100 2 1 1 0 000-2zm4 0a1 1 0 100 2 1 1 0 000-2z',
|
||||||
|
fill: '#4ADE80',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface ProviderIconProps extends Omit<SVGProps<SVGSVGElement>, 'viewBox'> {
|
export interface ProviderIconProps extends Omit<SVGProps<SVGSVGElement>, 'viewBox'> {
|
||||||
@@ -280,6 +299,83 @@ export function MetaIcon({ className, title, ...props }: { className?: string; t
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function MiniMaxIcon({
|
||||||
|
className,
|
||||||
|
title,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
title?: string;
|
||||||
|
}) {
|
||||||
|
const hasAccessibleLabel = Boolean(title);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
className={cn('inline-block', className)}
|
||||||
|
role={hasAccessibleLabel ? 'img' : 'presentation'}
|
||||||
|
aria-hidden={!hasAccessibleLabel}
|
||||||
|
focusable="false"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{title && <title>{title}</title>}
|
||||||
|
<path
|
||||||
|
d="M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GlmIcon({ className, title, ...props }: { className?: string; title?: string }) {
|
||||||
|
const hasAccessibleLabel = Boolean(title);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
className={cn('inline-block', className)}
|
||||||
|
role={hasAccessibleLabel ? 'img' : 'presentation'}
|
||||||
|
aria-hidden={!hasAccessibleLabel}
|
||||||
|
focusable="false"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{title && <title>{title}</title>}
|
||||||
|
<path
|
||||||
|
d="M12.105 2L9.927 4.953H.653L2.83 2h9.276zM23.254 19.048L21.078 22h-9.242l2.174-2.952h9.244zM24 2L9.264 22H0L14.736 2H24z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BigPickleIcon({
|
||||||
|
className,
|
||||||
|
title,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
title?: string;
|
||||||
|
}) {
|
||||||
|
const hasAccessibleLabel = Boolean(title);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
className={cn('inline-block', className)}
|
||||||
|
role={hasAccessibleLabel ? 'img' : 'presentation'}
|
||||||
|
aria-hidden={!hasAccessibleLabel}
|
||||||
|
focusable="false"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{title && <title>{title}</title>}
|
||||||
|
<path
|
||||||
|
d="M8 4c-2.21 0-4 1.79-4 4v8c0 2.21 1.79 4 4 4h8c2.21 0 4-1.79 4-4V8c0-2.21-1.79-4-4-4H8zm0 2h8c1.103 0 2 .897 2 2v8c0 1.103-.897 2-2 2H8c-1.103 0-2-.897-2-2V8c0-1.103.897-2 2-2zm2 3a1 1 0 100 2 1 1 0 000-2zm4 0a1 1 0 100 2 1 1 0 000-2zm-4 4a1 1 0 100 2 1 1 0 000-2zm4 0a1 1 0 100 2 1 1 0 000-2z"
|
||||||
|
fill="#4ADE80"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export const PROVIDER_ICON_COMPONENTS: Record<
|
export const PROVIDER_ICON_COMPONENTS: Record<
|
||||||
ModelProvider,
|
ModelProvider,
|
||||||
ComponentType<{ className?: string }>
|
ComponentType<{ className?: string }>
|
||||||
@@ -299,33 +395,50 @@ function getUnderlyingModelIcon(model?: AgentModel | string): ProviderIconKey {
|
|||||||
|
|
||||||
const modelStr = typeof model === 'string' ? model.toLowerCase() : model;
|
const modelStr = typeof model === 'string' ? model.toLowerCase() : model;
|
||||||
|
|
||||||
// Check for OpenCode models (opencode/, amazon-bedrock/, opencode-*)
|
// Check for Amazon Bedrock models first (amazon-bedrock/...)
|
||||||
if (modelStr.includes('opencode')) {
|
if (modelStr.startsWith('amazon-bedrock/')) {
|
||||||
// For OpenCode models, check which specific provider
|
// Bedrock-hosted models - detect the specific provider
|
||||||
if (modelStr.includes('amazon-bedrock')) {
|
if (modelStr.includes('anthropic') || modelStr.includes('claude')) {
|
||||||
// Bedrock-hosted models - detect the specific provider
|
return 'anthropic';
|
||||||
if (modelStr.includes('anthropic') || modelStr.includes('claude')) {
|
|
||||||
return 'anthropic';
|
|
||||||
}
|
|
||||||
if (modelStr.includes('deepseek')) {
|
|
||||||
return 'deepseek';
|
|
||||||
}
|
|
||||||
if (modelStr.includes('nova')) {
|
|
||||||
return 'nova';
|
|
||||||
}
|
|
||||||
if (modelStr.includes('meta') || modelStr.includes('llama')) {
|
|
||||||
return 'meta';
|
|
||||||
}
|
|
||||||
if (modelStr.includes('mistral')) {
|
|
||||||
return 'mistral';
|
|
||||||
}
|
|
||||||
if (modelStr.includes('qwen')) {
|
|
||||||
return 'qwen';
|
|
||||||
}
|
|
||||||
// Default for Bedrock
|
|
||||||
return 'opencode';
|
|
||||||
}
|
}
|
||||||
// Native OpenCode models (opencode/big-pickle, etc.)
|
if (modelStr.includes('deepseek')) {
|
||||||
|
return 'deepseek';
|
||||||
|
}
|
||||||
|
if (modelStr.includes('nova')) {
|
||||||
|
return 'nova';
|
||||||
|
}
|
||||||
|
if (modelStr.includes('meta') || modelStr.includes('llama')) {
|
||||||
|
return 'meta';
|
||||||
|
}
|
||||||
|
if (modelStr.includes('mistral')) {
|
||||||
|
return 'mistral';
|
||||||
|
}
|
||||||
|
if (modelStr.includes('qwen')) {
|
||||||
|
return 'qwen';
|
||||||
|
}
|
||||||
|
// Default for unknown Bedrock models
|
||||||
|
return 'opencode';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for native OpenCode models (opencode/...)
|
||||||
|
if (modelStr.startsWith('opencode/')) {
|
||||||
|
// Native OpenCode models - check specific model types
|
||||||
|
if (modelStr.includes('big-pickle')) {
|
||||||
|
return 'bigpickle';
|
||||||
|
}
|
||||||
|
if (modelStr.includes('grok')) {
|
||||||
|
return 'grok';
|
||||||
|
}
|
||||||
|
if (modelStr.includes('glm')) {
|
||||||
|
return 'glm';
|
||||||
|
}
|
||||||
|
if (modelStr.includes('gpt-5-nano') || modelStr.includes('nano')) {
|
||||||
|
return 'openai'; // GPT-5 Nano uses OpenAI icon
|
||||||
|
}
|
||||||
|
if (modelStr.includes('minimax')) {
|
||||||
|
return 'minimax';
|
||||||
|
}
|
||||||
|
// Default for other OpenCode models
|
||||||
return 'opencode';
|
return 'opencode';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,6 +484,9 @@ export function getProviderIconForModel(
|
|||||||
nova: NovaIcon,
|
nova: NovaIcon,
|
||||||
meta: MetaIcon,
|
meta: MetaIcon,
|
||||||
mistral: MistralIcon,
|
mistral: MistralIcon,
|
||||||
|
minimax: MiniMaxIcon,
|
||||||
|
glm: GlmIcon,
|
||||||
|
bigpickle: BigPickleIcon,
|
||||||
};
|
};
|
||||||
|
|
||||||
return iconMap[iconKey] || AnthropicIcon;
|
return iconMap[iconKey] || AnthropicIcon;
|
||||||
|
|||||||
@@ -40,10 +40,7 @@ import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
|
|||||||
import { useWindowState } from '@/hooks/use-window-state';
|
import { useWindowState } from '@/hooks/use-window-state';
|
||||||
// Board-view specific imports
|
// Board-view specific imports
|
||||||
import { BoardHeader } from './board-view/board-header';
|
import { BoardHeader } from './board-view/board-header';
|
||||||
import { BoardSearchBar } from './board-view/board-search-bar';
|
|
||||||
import { BoardControls } from './board-view/board-controls';
|
|
||||||
import { KanbanBoard } from './board-view/kanban-board';
|
import { KanbanBoard } from './board-view/kanban-board';
|
||||||
import { GraphView } from './graph-view';
|
|
||||||
import {
|
import {
|
||||||
AddFeatureDialog,
|
AddFeatureDialog,
|
||||||
AgentOutputModal,
|
AgentOutputModal,
|
||||||
@@ -92,8 +89,6 @@ export function BoardView() {
|
|||||||
maxConcurrency,
|
maxConcurrency,
|
||||||
setMaxConcurrency,
|
setMaxConcurrency,
|
||||||
defaultSkipTests,
|
defaultSkipTests,
|
||||||
boardViewMode,
|
|
||||||
setBoardViewMode,
|
|
||||||
specCreatingForProject,
|
specCreatingForProject,
|
||||||
setSpecCreatingForProject,
|
setSpecCreatingForProject,
|
||||||
pendingPlanApproval,
|
pendingPlanApproval,
|
||||||
@@ -174,12 +169,14 @@ export function BoardView() {
|
|||||||
followUpPrompt,
|
followUpPrompt,
|
||||||
followUpImagePaths,
|
followUpImagePaths,
|
||||||
followUpPreviewMap,
|
followUpPreviewMap,
|
||||||
|
followUpPromptHistory,
|
||||||
setShowFollowUpDialog,
|
setShowFollowUpDialog,
|
||||||
setFollowUpFeature,
|
setFollowUpFeature,
|
||||||
setFollowUpPrompt,
|
setFollowUpPrompt,
|
||||||
setFollowUpImagePaths,
|
setFollowUpImagePaths,
|
||||||
setFollowUpPreviewMap,
|
setFollowUpPreviewMap,
|
||||||
handleFollowUpDialogChange,
|
handleFollowUpDialogChange,
|
||||||
|
addToPromptHistory,
|
||||||
} = useFollowUpState();
|
} = useFollowUpState();
|
||||||
|
|
||||||
// Selection mode hook for mass editing
|
// Selection mode hook for mass editing
|
||||||
@@ -521,6 +518,45 @@ export function BoardView() {
|
|||||||
[currentProject, selectedFeatureIds, updateFeature, exitSelectionMode]
|
[currentProject, selectedFeatureIds, updateFeature, exitSelectionMode]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Handler for bulk deleting multiple features
|
||||||
|
const handleBulkDelete = useCallback(async () => {
|
||||||
|
if (!currentProject || selectedFeatureIds.size === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = getHttpApiClient();
|
||||||
|
const featureIds = Array.from(selectedFeatureIds);
|
||||||
|
const result = await api.features.bulkDelete(currentProject.path, featureIds);
|
||||||
|
|
||||||
|
const successfullyDeletedIds =
|
||||||
|
result.results?.filter((r) => r.success).map((r) => r.featureId) ?? [];
|
||||||
|
|
||||||
|
if (successfullyDeletedIds.length > 0) {
|
||||||
|
// Delete from local state without calling the API again
|
||||||
|
successfullyDeletedIds.forEach((featureId) => {
|
||||||
|
useAppStore.getState().removeFeature(featureId);
|
||||||
|
});
|
||||||
|
toast.success(`Deleted ${successfullyDeletedIds.length} features`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.failedCount && result.failedCount > 0) {
|
||||||
|
toast.error('Failed to delete some features', {
|
||||||
|
description: `${result.failedCount} features failed to delete`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exit selection mode and reload if the operation was at least partially processed.
|
||||||
|
if (result.results) {
|
||||||
|
exitSelectionMode();
|
||||||
|
loadFeatures();
|
||||||
|
} else if (!result.success) {
|
||||||
|
toast.error('Failed to delete features', { description: result.error });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Bulk delete failed:', error);
|
||||||
|
toast.error('Failed to delete features');
|
||||||
|
}
|
||||||
|
}, [currentProject, selectedFeatureIds, exitSelectionMode, loadFeatures]);
|
||||||
|
|
||||||
// Get selected features for mass edit dialog
|
// Get selected features for mass edit dialog
|
||||||
const selectedFeatures = useMemo(() => {
|
const selectedFeatures = useMemo(() => {
|
||||||
return hookFeatures.filter((f) => selectedFeatureIds.has(f.id));
|
return hookFeatures.filter((f) => selectedFeatureIds.has(f.id));
|
||||||
@@ -1166,7 +1202,6 @@ export function BoardView() {
|
|||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<BoardHeader
|
<BoardHeader
|
||||||
projectName={currentProject.name}
|
|
||||||
projectPath={currentProject.path}
|
projectPath={currentProject.path}
|
||||||
maxConcurrency={maxConcurrency}
|
maxConcurrency={maxConcurrency}
|
||||||
runningAgentsCount={runningAutoTasks.length}
|
runningAgentsCount={runningAutoTasks.length}
|
||||||
@@ -1181,6 +1216,13 @@ export function BoardView() {
|
|||||||
}}
|
}}
|
||||||
onOpenPlanDialog={() => setShowPlanDialog(true)}
|
onOpenPlanDialog={() => setShowPlanDialog(true)}
|
||||||
isMounted={isMounted}
|
isMounted={isMounted}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
onSearchChange={setSearchQuery}
|
||||||
|
isCreatingSpec={isCreatingSpec}
|
||||||
|
creatingSpecProjectPath={creatingSpecProjectPath}
|
||||||
|
onShowBoardBackground={() => setShowBoardBackgroundModal(true)}
|
||||||
|
onShowCompletedModal={() => setShowCompletedModal(true)}
|
||||||
|
completedCount={completedFeatures.length}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Worktree Panel - conditionally rendered based on visibility setting */}
|
{/* Worktree Panel - conditionally rendered based on visibility setting */}
|
||||||
@@ -1219,89 +1261,46 @@ export function BoardView() {
|
|||||||
|
|
||||||
{/* Main Content Area */}
|
{/* Main Content Area */}
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
{/* Search Bar Row */}
|
{/* View Content - Kanban Board */}
|
||||||
<div className="px-4 pt-4 pb-2 flex items-center justify-between">
|
<KanbanBoard
|
||||||
<BoardSearchBar
|
sensors={sensors}
|
||||||
searchQuery={searchQuery}
|
collisionDetectionStrategy={collisionDetectionStrategy}
|
||||||
onSearchChange={setSearchQuery}
|
onDragStart={handleDragStart}
|
||||||
isCreatingSpec={isCreatingSpec}
|
onDragEnd={handleDragEnd}
|
||||||
creatingSpecProjectPath={creatingSpecProjectPath ?? undefined}
|
activeFeature={activeFeature}
|
||||||
currentProjectPath={currentProject?.path}
|
getColumnFeatures={getColumnFeatures}
|
||||||
/>
|
backgroundImageStyle={backgroundImageStyle}
|
||||||
|
backgroundSettings={backgroundSettings}
|
||||||
{/* Board Background & Detail Level Controls */}
|
onEdit={(feature) => setEditingFeature(feature)}
|
||||||
<BoardControls
|
onDelete={(featureId) => handleDeleteFeature(featureId)}
|
||||||
isMounted={isMounted}
|
onViewOutput={handleViewOutput}
|
||||||
onShowBoardBackground={() => setShowBoardBackgroundModal(true)}
|
onVerify={handleVerifyFeature}
|
||||||
onShowCompletedModal={() => setShowCompletedModal(true)}
|
onResume={handleResumeFeature}
|
||||||
completedCount={completedFeatures.length}
|
onForceStop={handleForceStopFeature}
|
||||||
boardViewMode={boardViewMode}
|
onManualVerify={handleManualVerify}
|
||||||
onBoardViewModeChange={setBoardViewMode}
|
onMoveBackToInProgress={handleMoveBackToInProgress}
|
||||||
/>
|
onFollowUp={handleOpenFollowUp}
|
||||||
</div>
|
onComplete={handleCompleteFeature}
|
||||||
{/* View Content - Kanban or Graph */}
|
onImplement={handleStartImplementation}
|
||||||
{boardViewMode === 'kanban' ? (
|
onViewPlan={(feature) => setViewPlanFeature(feature)}
|
||||||
<KanbanBoard
|
onApprovePlan={handleOpenApprovalDialog}
|
||||||
sensors={sensors}
|
onSpawnTask={(feature) => {
|
||||||
collisionDetectionStrategy={collisionDetectionStrategy}
|
setSpawnParentFeature(feature);
|
||||||
onDragStart={handleDragStart}
|
setShowAddDialog(true);
|
||||||
onDragEnd={handleDragEnd}
|
}}
|
||||||
activeFeature={activeFeature}
|
featuresWithContext={featuresWithContext}
|
||||||
getColumnFeatures={getColumnFeatures}
|
runningAutoTasks={runningAutoTasks}
|
||||||
backgroundImageStyle={backgroundImageStyle}
|
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
|
||||||
backgroundSettings={backgroundSettings}
|
onAddFeature={() => setShowAddDialog(true)}
|
||||||
onEdit={(feature) => setEditingFeature(feature)}
|
pipelineConfig={
|
||||||
onDelete={(featureId) => handleDeleteFeature(featureId)}
|
currentProject?.path ? pipelineConfigByProject[currentProject.path] || null : null
|
||||||
onViewOutput={handleViewOutput}
|
}
|
||||||
onVerify={handleVerifyFeature}
|
onOpenPipelineSettings={() => setShowPipelineSettings(true)}
|
||||||
onResume={handleResumeFeature}
|
isSelectionMode={isSelectionMode}
|
||||||
onForceStop={handleForceStopFeature}
|
selectedFeatureIds={selectedFeatureIds}
|
||||||
onManualVerify={handleManualVerify}
|
onToggleFeatureSelection={toggleFeatureSelection}
|
||||||
onMoveBackToInProgress={handleMoveBackToInProgress}
|
onToggleSelectionMode={toggleSelectionMode}
|
||||||
onFollowUp={handleOpenFollowUp}
|
/>
|
||||||
onComplete={handleCompleteFeature}
|
|
||||||
onImplement={handleStartImplementation}
|
|
||||||
onViewPlan={(feature) => setViewPlanFeature(feature)}
|
|
||||||
onApprovePlan={handleOpenApprovalDialog}
|
|
||||||
onSpawnTask={(feature) => {
|
|
||||||
setSpawnParentFeature(feature);
|
|
||||||
setShowAddDialog(true);
|
|
||||||
}}
|
|
||||||
featuresWithContext={featuresWithContext}
|
|
||||||
runningAutoTasks={runningAutoTasks}
|
|
||||||
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
|
|
||||||
onAddFeature={() => setShowAddDialog(true)}
|
|
||||||
pipelineConfig={
|
|
||||||
currentProject?.path ? pipelineConfigByProject[currentProject.path] || null : null
|
|
||||||
}
|
|
||||||
onOpenPipelineSettings={() => setShowPipelineSettings(true)}
|
|
||||||
isSelectionMode={isSelectionMode}
|
|
||||||
selectedFeatureIds={selectedFeatureIds}
|
|
||||||
onToggleFeatureSelection={toggleFeatureSelection}
|
|
||||||
onToggleSelectionMode={toggleSelectionMode}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<GraphView
|
|
||||||
features={hookFeatures}
|
|
||||||
runningAutoTasks={runningAutoTasks}
|
|
||||||
currentWorktreePath={currentWorktreePath}
|
|
||||||
currentWorktreeBranch={currentWorktreeBranch}
|
|
||||||
projectPath={currentProject?.path || null}
|
|
||||||
searchQuery={searchQuery}
|
|
||||||
onSearchQueryChange={setSearchQuery}
|
|
||||||
onEditFeature={(feature) => setEditingFeature(feature)}
|
|
||||||
onViewOutput={handleViewOutput}
|
|
||||||
onStartTask={handleStartImplementation}
|
|
||||||
onStopTask={handleForceStopFeature}
|
|
||||||
onResumeTask={handleResumeFeature}
|
|
||||||
onUpdateFeature={updateFeature}
|
|
||||||
onSpawnTask={(feature) => {
|
|
||||||
setSpawnParentFeature(feature);
|
|
||||||
setShowAddDialog(true);
|
|
||||||
}}
|
|
||||||
onDeleteTask={(feature) => handleDeleteFeature(feature.id)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Selection Action Bar */}
|
{/* Selection Action Bar */}
|
||||||
@@ -1310,6 +1309,7 @@ export function BoardView() {
|
|||||||
selectedCount={selectedCount}
|
selectedCount={selectedCount}
|
||||||
totalCount={allSelectableFeatureIds.length}
|
totalCount={allSelectableFeatureIds.length}
|
||||||
onEdit={() => setShowMassEditDialog(true)}
|
onEdit={() => setShowMassEditDialog(true)}
|
||||||
|
onDelete={handleBulkDelete}
|
||||||
onClear={clearSelection}
|
onClear={clearSelection}
|
||||||
onSelectAll={() => selectAll(allSelectableFeatureIds)}
|
onSelectAll={() => selectAll(allSelectableFeatureIds)}
|
||||||
/>
|
/>
|
||||||
@@ -1435,6 +1435,8 @@ export function BoardView() {
|
|||||||
onPreviewMapChange={setFollowUpPreviewMap}
|
onPreviewMapChange={setFollowUpPreviewMap}
|
||||||
onSend={handleSendFollowUp}
|
onSend={handleSendFollowUp}
|
||||||
isMaximized={isMaximized}
|
isMaximized={isMaximized}
|
||||||
|
promptHistory={followUpPromptHistory}
|
||||||
|
onHistoryAdd={addToPromptHistory}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Backlog Plan Dialog */}
|
{/* Backlog Plan Dialog */}
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { ImageIcon, Archive, Columns3, Network } from 'lucide-react';
|
import { ImageIcon, Archive } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { BoardViewMode } from '@/store/app-store';
|
|
||||||
|
|
||||||
interface BoardControlsProps {
|
interface BoardControlsProps {
|
||||||
isMounted: boolean;
|
isMounted: boolean;
|
||||||
onShowBoardBackground: () => void;
|
onShowBoardBackground: () => void;
|
||||||
onShowCompletedModal: () => void;
|
onShowCompletedModal: () => void;
|
||||||
completedCount: number;
|
completedCount: number;
|
||||||
boardViewMode: BoardViewMode;
|
|
||||||
onBoardViewModeChange: (mode: BoardViewMode) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BoardControls({
|
export function BoardControls({
|
||||||
@@ -18,59 +14,12 @@ export function BoardControls({
|
|||||||
onShowBoardBackground,
|
onShowBoardBackground,
|
||||||
onShowCompletedModal,
|
onShowCompletedModal,
|
||||||
completedCount,
|
completedCount,
|
||||||
boardViewMode,
|
|
||||||
onBoardViewModeChange,
|
|
||||||
}: BoardControlsProps) {
|
}: BoardControlsProps) {
|
||||||
if (!isMounted) return null;
|
if (!isMounted) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<div className="flex items-center gap-2 ml-4">
|
<div className="flex items-center gap-2">
|
||||||
{/* View Mode Toggle - Kanban / Graph */}
|
|
||||||
<div
|
|
||||||
className="flex items-center rounded-lg bg-secondary border border-border"
|
|
||||||
data-testid="view-mode-toggle"
|
|
||||||
>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<button
|
|
||||||
onClick={() => onBoardViewModeChange('kanban')}
|
|
||||||
className={cn(
|
|
||||||
'p-2 rounded-l-lg transition-colors',
|
|
||||||
boardViewMode === 'kanban'
|
|
||||||
? 'bg-brand-500/20 text-brand-500'
|
|
||||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
|
||||||
)}
|
|
||||||
data-testid="view-mode-kanban"
|
|
||||||
>
|
|
||||||
<Columns3 className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>Kanban Board View</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<button
|
|
||||||
onClick={() => onBoardViewModeChange('graph')}
|
|
||||||
className={cn(
|
|
||||||
'p-2 rounded-r-lg transition-colors',
|
|
||||||
boardViewMode === 'graph'
|
|
||||||
? 'bg-brand-500/20 text-brand-500'
|
|
||||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
|
||||||
)}
|
|
||||||
data-testid="view-mode-graph"
|
|
||||||
>
|
|
||||||
<Network className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>Dependency Graph View</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Board Background Button */}
|
{/* Board Background Button */}
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ import { useAppStore } from '@/store/app-store';
|
|||||||
import { useSetupStore } from '@/store/setup-store';
|
import { useSetupStore } from '@/store/setup-store';
|
||||||
import { AutoModeSettingsDialog } from './dialogs/auto-mode-settings-dialog';
|
import { AutoModeSettingsDialog } from './dialogs/auto-mode-settings-dialog';
|
||||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||||
|
import { BoardSearchBar } from './board-search-bar';
|
||||||
|
import { BoardControls } from './board-controls';
|
||||||
|
|
||||||
interface BoardHeaderProps {
|
interface BoardHeaderProps {
|
||||||
projectName: string;
|
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
maxConcurrency: number;
|
maxConcurrency: number;
|
||||||
runningAgentsCount: number;
|
runningAgentsCount: number;
|
||||||
@@ -21,6 +22,15 @@ interface BoardHeaderProps {
|
|||||||
onAutoModeToggle: (enabled: boolean) => void;
|
onAutoModeToggle: (enabled: boolean) => void;
|
||||||
onOpenPlanDialog: () => void;
|
onOpenPlanDialog: () => void;
|
||||||
isMounted: boolean;
|
isMounted: boolean;
|
||||||
|
// Search bar props
|
||||||
|
searchQuery: string;
|
||||||
|
onSearchChange: (query: string) => void;
|
||||||
|
isCreatingSpec: boolean;
|
||||||
|
creatingSpecProjectPath?: string;
|
||||||
|
// Board controls props
|
||||||
|
onShowBoardBackground: () => void;
|
||||||
|
onShowCompletedModal: () => void;
|
||||||
|
completedCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shared styles for header control containers
|
// Shared styles for header control containers
|
||||||
@@ -28,7 +38,6 @@ const controlContainerClass =
|
|||||||
'flex items-center gap-1.5 px-3 h-8 rounded-md bg-secondary border border-border';
|
'flex items-center gap-1.5 px-3 h-8 rounded-md bg-secondary border border-border';
|
||||||
|
|
||||||
export function BoardHeader({
|
export function BoardHeader({
|
||||||
projectName,
|
|
||||||
projectPath,
|
projectPath,
|
||||||
maxConcurrency,
|
maxConcurrency,
|
||||||
runningAgentsCount,
|
runningAgentsCount,
|
||||||
@@ -37,6 +46,13 @@ export function BoardHeader({
|
|||||||
onAutoModeToggle,
|
onAutoModeToggle,
|
||||||
onOpenPlanDialog,
|
onOpenPlanDialog,
|
||||||
isMounted,
|
isMounted,
|
||||||
|
searchQuery,
|
||||||
|
onSearchChange,
|
||||||
|
isCreatingSpec,
|
||||||
|
creatingSpecProjectPath,
|
||||||
|
onShowBoardBackground,
|
||||||
|
onShowCompletedModal,
|
||||||
|
completedCount,
|
||||||
}: BoardHeaderProps) {
|
}: BoardHeaderProps) {
|
||||||
const [showAutoModeSettings, setShowAutoModeSettings] = useState(false);
|
const [showAutoModeSettings, setShowAutoModeSettings] = useState(false);
|
||||||
const apiKeys = useAppStore((state) => state.apiKeys);
|
const apiKeys = useAppStore((state) => state.apiKeys);
|
||||||
@@ -84,9 +100,20 @@ export function BoardHeader({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
|
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
|
||||||
<div>
|
<div className="flex items-center gap-4">
|
||||||
<h1 className="text-xl font-bold">Kanban Board</h1>
|
<BoardSearchBar
|
||||||
<p className="text-sm text-muted-foreground">{projectName}</p>
|
searchQuery={searchQuery}
|
||||||
|
onSearchChange={onSearchChange}
|
||||||
|
isCreatingSpec={isCreatingSpec}
|
||||||
|
creatingSpecProjectPath={creatingSpecProjectPath}
|
||||||
|
currentProjectPath={projectPath}
|
||||||
|
/>
|
||||||
|
<BoardControls
|
||||||
|
isMounted={isMounted}
|
||||||
|
onShowBoardBackground={onShowBoardBackground}
|
||||||
|
onShowCompletedModal={onShowCompletedModal}
|
||||||
|
completedCount={completedCount}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
{/* Usage Popover - show if either provider is authenticated */}
|
{/* Usage Popover - show if either provider is authenticated */}
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export function AgentInfoPanel({
|
|||||||
}: AgentInfoPanelProps) {
|
}: AgentInfoPanelProps) {
|
||||||
const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null);
|
const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null);
|
||||||
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
|
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
|
||||||
|
const [isTodosExpanded, setIsTodosExpanded] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadContext = async () => {
|
const loadContext = async () => {
|
||||||
@@ -197,32 +198,47 @@ export function AgentInfoPanel({
|
|||||||
{agentInfo.todos.length} tasks
|
{agentInfo.todos.length} tasks
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-0.5 max-h-16 overflow-y-auto">
|
<div
|
||||||
{agentInfo.todos.slice(0, 3).map((todo, idx) => (
|
className={cn(
|
||||||
<div key={idx} className="flex items-center gap-1.5 text-[10px]">
|
'space-y-0.5 overflow-y-auto',
|
||||||
{todo.status === 'completed' ? (
|
isTodosExpanded ? 'max-h-40' : 'max-h-16'
|
||||||
<CheckCircle2 className="w-2.5 h-2.5 text-[var(--status-success)] shrink-0" />
|
)}
|
||||||
) : todo.status === 'in_progress' ? (
|
>
|
||||||
<Loader2 className="w-2.5 h-2.5 text-[var(--status-warning)] animate-spin shrink-0" />
|
{(isTodosExpanded ? agentInfo.todos : agentInfo.todos.slice(0, 3)).map(
|
||||||
) : (
|
(todo, idx) => (
|
||||||
<Circle className="w-2.5 h-2.5 text-muted-foreground/50 shrink-0" />
|
<div key={idx} className="flex items-center gap-1.5 text-[10px]">
|
||||||
)}
|
{todo.status === 'completed' ? (
|
||||||
<span
|
<CheckCircle2 className="w-2.5 h-2.5 text-[var(--status-success)] shrink-0" />
|
||||||
className={cn(
|
) : todo.status === 'in_progress' ? (
|
||||||
'break-words hyphens-auto line-clamp-2 leading-relaxed',
|
<Loader2 className="w-2.5 h-2.5 text-[var(--status-warning)] animate-spin shrink-0" />
|
||||||
todo.status === 'completed' && 'text-muted-foreground/60 line-through',
|
) : (
|
||||||
todo.status === 'in_progress' && 'text-[var(--status-warning)]',
|
<Circle className="w-2.5 h-2.5 text-muted-foreground/50 shrink-0" />
|
||||||
todo.status === 'pending' && 'text-muted-foreground/80'
|
|
||||||
)}
|
)}
|
||||||
>
|
<span
|
||||||
{todo.content}
|
className={cn(
|
||||||
</span>
|
'break-words hyphens-auto line-clamp-2 leading-relaxed',
|
||||||
</div>
|
todo.status === 'completed' && 'text-muted-foreground/60 line-through',
|
||||||
))}
|
todo.status === 'in_progress' && 'text-[var(--status-warning)]',
|
||||||
|
todo.status === 'pending' && 'text-muted-foreground/80'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{todo.content}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
{agentInfo.todos.length > 3 && (
|
{agentInfo.todos.length > 3 && (
|
||||||
<p className="text-[10px] text-muted-foreground/60 pl-4">
|
<button
|
||||||
+{agentInfo.todos.length - 3} more
|
onClick={(e) => {
|
||||||
</p>
|
e.stopPropagation();
|
||||||
|
setIsTodosExpanded(!isTodosExpanded);
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
className="text-[10px] text-muted-foreground/60 pl-4 hover:text-muted-foreground transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
{isTodosExpanded ? 'Show less' : `+${agentInfo.todos.length - 3} more`}
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export function SummaryDialog({
|
|||||||
data-testid={`summary-dialog-${feature.id}`}
|
data-testid={`summary-dialog-${feature.id}`}
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
onDoubleClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Pencil, X, CheckSquare } from 'lucide-react';
|
import { Pencil, X, CheckSquare, Trash2 } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
|
||||||
interface SelectionActionBarProps {
|
interface SelectionActionBarProps {
|
||||||
selectedCount: number;
|
selectedCount: number;
|
||||||
totalCount: number;
|
totalCount: number;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
onClear: () => void;
|
onClear: () => void;
|
||||||
onSelectAll: () => void;
|
onSelectAll: () => void;
|
||||||
}
|
}
|
||||||
@@ -14,65 +24,126 @@ export function SelectionActionBar({
|
|||||||
selectedCount,
|
selectedCount,
|
||||||
totalCount,
|
totalCount,
|
||||||
onEdit,
|
onEdit,
|
||||||
|
onDelete,
|
||||||
onClear,
|
onClear,
|
||||||
onSelectAll,
|
onSelectAll,
|
||||||
}: SelectionActionBarProps) {
|
}: SelectionActionBarProps) {
|
||||||
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
|
|
||||||
if (selectedCount === 0) return null;
|
if (selectedCount === 0) return null;
|
||||||
|
|
||||||
const allSelected = selectedCount === totalCount;
|
const allSelected = selectedCount === totalCount;
|
||||||
|
|
||||||
|
const handleDeleteClick = () => {
|
||||||
|
setShowDeleteDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmDelete = () => {
|
||||||
|
setShowDeleteDialog(false);
|
||||||
|
onDelete();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
className={cn(
|
<div
|
||||||
'fixed bottom-6 left-1/2 -translate-x-1/2 z-50',
|
className={cn(
|
||||||
'flex items-center gap-3 px-4 py-3 rounded-xl',
|
'fixed bottom-6 left-1/2 -translate-x-1/2 z-50',
|
||||||
'bg-background/95 backdrop-blur-sm border border-border shadow-lg',
|
'flex items-center gap-3 px-4 py-3 rounded-xl',
|
||||||
'animate-in slide-in-from-bottom-4 fade-in duration-200'
|
'bg-background/95 backdrop-blur-sm border border-border shadow-lg',
|
||||||
)}
|
'animate-in slide-in-from-bottom-4 fade-in duration-200'
|
||||||
data-testid="selection-action-bar"
|
)}
|
||||||
>
|
data-testid="selection-action-bar"
|
||||||
<span className="text-sm font-medium text-foreground">
|
>
|
||||||
{selectedCount} feature{selectedCount !== 1 ? 's' : ''} selected
|
<span className="text-sm font-medium text-foreground">
|
||||||
</span>
|
{selectedCount} feature{selectedCount !== 1 ? 's' : ''} selected
|
||||||
|
</span>
|
||||||
|
|
||||||
<div className="h-4 w-px bg-border" />
|
<div className="h-4 w-px bg-border" />
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onEdit}
|
onClick={onEdit}
|
||||||
className="h-8 bg-brand-500 hover:bg-brand-600"
|
className="h-8 bg-brand-500 hover:bg-brand-600"
|
||||||
data-testid="selection-edit-button"
|
data-testid="selection-edit-button"
|
||||||
>
|
>
|
||||||
<Pencil className="w-4 h-4 mr-1.5" />
|
<Pencil className="w-4 h-4 mr-1.5" />
|
||||||
Edit Selected
|
Edit Selected
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{!allSelected && (
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onSelectAll}
|
onClick={handleDeleteClick}
|
||||||
className="h-8"
|
className="h-8 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
data-testid="selection-select-all-button"
|
data-testid="selection-delete-button"
|
||||||
>
|
>
|
||||||
<CheckSquare className="w-4 h-4 mr-1.5" />
|
<Trash2 className="w-4 h-4 mr-1.5" />
|
||||||
Select All ({totalCount})
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
{!allSelected && (
|
||||||
variant="ghost"
|
<Button
|
||||||
size="sm"
|
variant="outline"
|
||||||
onClick={onClear}
|
size="sm"
|
||||||
className="h-8 text-muted-foreground hover:text-foreground"
|
onClick={onSelectAll}
|
||||||
data-testid="selection-clear-button"
|
className="h-8"
|
||||||
>
|
data-testid="selection-select-all-button"
|
||||||
<X className="w-4 h-4 mr-1.5" />
|
>
|
||||||
Clear
|
<CheckSquare className="w-4 h-4 mr-1.5" />
|
||||||
</Button>
|
Select All ({totalCount})
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onClear}
|
||||||
|
className="h-8 text-muted-foreground hover:text-foreground"
|
||||||
|
data-testid="selection-clear-button"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4 mr-1.5" />
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
{/* Delete Confirmation Dialog */}
|
||||||
|
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||||
|
<DialogContent data-testid="bulk-delete-confirmation-dialog">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-destructive">
|
||||||
|
<Trash2 className="w-5 h-5" />
|
||||||
|
Delete Selected Features?
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Are you sure you want to permanently delete {selectedCount} feature
|
||||||
|
{selectedCount !== 1 ? 's' : ''}?
|
||||||
|
<span className="block mt-2 text-destructive font-medium">
|
||||||
|
This action cannot be undone.
|
||||||
|
</span>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setShowDeleteDialog(false)}
|
||||||
|
data-testid="cancel-bulk-delete-button"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleConfirmDelete}
|
||||||
|
data-testid="confirm-bulk-delete-button"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
FeatureTextFilePath as DescriptionTextFilePath,
|
||||||
ImagePreviewMap,
|
ImagePreviewMap,
|
||||||
} from '@/components/ui/description-image-dropzone';
|
} from '@/components/ui/description-image-dropzone';
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
import { Play, Cpu, FolderKanban } from 'lucide-react';
|
||||||
import { Sparkles, ChevronDown, ChevronRight, Play, Cpu, FolderKanban } from 'lucide-react';
|
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
|
||||||
import { modelSupportsThinking } from '@/lib/utils';
|
import { modelSupportsThinking } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
useAppStore,
|
useAppStore,
|
||||||
@@ -43,16 +41,12 @@ import {
|
|||||||
WorkModeSelector,
|
WorkModeSelector,
|
||||||
PlanningModeSelect,
|
PlanningModeSelect,
|
||||||
AncestorContextSection,
|
AncestorContextSection,
|
||||||
|
EnhanceWithAI,
|
||||||
|
EnhancementHistoryButton,
|
||||||
|
type BaseHistoryEntry,
|
||||||
} from '../shared';
|
} from '../shared';
|
||||||
import type { WorkMode } from '../shared';
|
import type { WorkMode } from '../shared';
|
||||||
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
|
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
|
||||||
import { ModelOverrideTrigger, useModelOverride } from '@/components/shared';
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@/components/ui/dropdown-menu';
|
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import {
|
import {
|
||||||
getAncestors,
|
getAncestors,
|
||||||
@@ -97,6 +91,13 @@ interface AddFeatureDialogProps {
|
|||||||
allFeatures?: Feature[];
|
allFeatures?: Feature[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single entry in the description history
|
||||||
|
*/
|
||||||
|
interface DescriptionHistoryEntry extends BaseHistoryEntry {
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function AddFeatureDialog({
|
export function AddFeatureDialog({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
@@ -139,11 +140,9 @@ export function AddFeatureDialog({
|
|||||||
// UI state
|
// UI state
|
||||||
const [previewMap, setPreviewMap] = useState<ImagePreviewMap>(() => new Map());
|
const [previewMap, setPreviewMap] = useState<ImagePreviewMap>(() => new Map());
|
||||||
const [descriptionError, setDescriptionError] = useState(false);
|
const [descriptionError, setDescriptionError] = useState(false);
|
||||||
const [isEnhancing, setIsEnhancing] = useState(false);
|
|
||||||
const [enhancementMode, setEnhancementMode] = useState<
|
// Description history state
|
||||||
'improve' | 'technical' | 'simplify' | 'acceptance'
|
const [descriptionHistory, setDescriptionHistory] = useState<DescriptionHistoryEntry[]>([]);
|
||||||
>('improve');
|
|
||||||
const [enhanceOpen, setEnhanceOpen] = useState(false);
|
|
||||||
|
|
||||||
// Spawn mode state
|
// Spawn mode state
|
||||||
const [ancestors, setAncestors] = useState<AncestorContext[]>([]);
|
const [ancestors, setAncestors] = useState<AncestorContext[]>([]);
|
||||||
@@ -152,9 +151,6 @@ export function AddFeatureDialog({
|
|||||||
// Get defaults from store
|
// Get defaults from store
|
||||||
const { defaultPlanningMode, defaultRequirePlanApproval } = useAppStore();
|
const { defaultPlanningMode, defaultRequirePlanApproval } = useAppStore();
|
||||||
|
|
||||||
// Enhancement model override
|
|
||||||
const enhancementOverride = useModelOverride({ phase: 'enhancementModel' });
|
|
||||||
|
|
||||||
// Track previous open state to detect when dialog opens
|
// Track previous open state to detect when dialog opens
|
||||||
const wasOpenRef = useRef(false);
|
const wasOpenRef = useRef(false);
|
||||||
|
|
||||||
@@ -171,6 +167,9 @@ export function AddFeatureDialog({
|
|||||||
setRequirePlanApproval(defaultRequirePlanApproval);
|
setRequirePlanApproval(defaultRequirePlanApproval);
|
||||||
setModelEntry({ model: 'opus' });
|
setModelEntry({ model: 'opus' });
|
||||||
|
|
||||||
|
// Initialize description history (empty for new feature)
|
||||||
|
setDescriptionHistory([]);
|
||||||
|
|
||||||
// Initialize ancestors for spawn mode
|
// Initialize ancestors for spawn mode
|
||||||
if (parentFeature) {
|
if (parentFeature) {
|
||||||
const ancestorList = getAncestors(parentFeature, allFeatures);
|
const ancestorList = getAncestors(parentFeature, allFeatures);
|
||||||
@@ -279,7 +278,7 @@ export function AddFeatureDialog({
|
|||||||
setRequirePlanApproval(defaultRequirePlanApproval);
|
setRequirePlanApproval(defaultRequirePlanApproval);
|
||||||
setPreviewMap(new Map());
|
setPreviewMap(new Map());
|
||||||
setDescriptionError(false);
|
setDescriptionError(false);
|
||||||
setEnhanceOpen(false);
|
setDescriptionHistory([]);
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -302,33 +301,6 @@ export function AddFeatureDialog({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEnhanceDescription = async () => {
|
|
||||||
if (!description.trim() || isEnhancing) return;
|
|
||||||
|
|
||||||
setIsEnhancing(true);
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
const result = await api.enhancePrompt?.enhance(
|
|
||||||
description,
|
|
||||||
enhancementMode,
|
|
||||||
enhancementOverride.effectiveModel,
|
|
||||||
enhancementOverride.effectiveModelEntry.thinkingLevel
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result?.success && result.enhancedText) {
|
|
||||||
setDescription(result.enhancedText);
|
|
||||||
toast.success('Description enhanced!');
|
|
||||||
} else {
|
|
||||||
toast.error(result?.error || 'Failed to enhance description');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Enhancement failed:', error);
|
|
||||||
toast.error('Failed to enhance description');
|
|
||||||
} finally {
|
|
||||||
setIsEnhancing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Shared card styling
|
// Shared card styling
|
||||||
const cardClass = 'rounded-lg border border-border/50 bg-muted/30 p-4 space-y-3';
|
const cardClass = 'rounded-lg border border-border/50 bg-muted/30 p-4 space-y-3';
|
||||||
const sectionHeaderClass = 'flex items-center gap-2 text-sm font-medium text-foreground';
|
const sectionHeaderClass = 'flex items-center gap-2 text-sm font-medium text-foreground';
|
||||||
@@ -380,7 +352,18 @@ export function AddFeatureDialog({
|
|||||||
{/* Task Details Section */}
|
{/* Task Details Section */}
|
||||||
<div className={cardClass}>
|
<div className={cardClass}>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="description">Description</Label>
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="description">Description</Label>
|
||||||
|
{/* Version History Button */}
|
||||||
|
<EnhancementHistoryButton
|
||||||
|
history={descriptionHistory}
|
||||||
|
currentValue={description}
|
||||||
|
onRestore={setDescription}
|
||||||
|
valueAccessor={(entry) => entry.description}
|
||||||
|
title="Version History"
|
||||||
|
restoreMessage="Description restored from history"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<DescriptionImageDropZone
|
<DescriptionImageDropZone
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
@@ -409,71 +392,35 @@ export function AddFeatureDialog({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Collapsible Enhancement Section */}
|
{/* Enhancement Section */}
|
||||||
<Collapsible open={enhanceOpen} onOpenChange={setEnhanceOpen}>
|
<EnhanceWithAI
|
||||||
<CollapsibleTrigger asChild>
|
value={description}
|
||||||
<button className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors w-full py-1">
|
onChange={setDescription}
|
||||||
{enhanceOpen ? (
|
onHistoryAdd={({ mode, originalText, enhancedText }) => {
|
||||||
<ChevronDown className="w-4 h-4" />
|
const timestamp = new Date().toISOString();
|
||||||
) : (
|
setDescriptionHistory((prev) => {
|
||||||
<ChevronRight className="w-4 h-4" />
|
const newHistory = [...prev];
|
||||||
)}
|
// Add original text first (so user can restore to pre-enhancement state)
|
||||||
<Sparkles className="w-4 h-4" />
|
// Only add if it's different from the last entry to avoid duplicates
|
||||||
<span>Enhance with AI</span>
|
const lastEntry = prev[prev.length - 1];
|
||||||
</button>
|
if (!lastEntry || lastEntry.description !== originalText) {
|
||||||
</CollapsibleTrigger>
|
newHistory.push({
|
||||||
<CollapsibleContent className="pt-3">
|
description: originalText,
|
||||||
<div className="flex flex-wrap items-center gap-2 pl-6">
|
timestamp,
|
||||||
<DropdownMenu>
|
source: prev.length === 0 ? 'initial' : 'edit',
|
||||||
<DropdownMenuTrigger asChild>
|
});
|
||||||
<Button variant="outline" size="sm" className="h-8 text-xs">
|
}
|
||||||
{enhancementMode === 'improve' && 'Improve Clarity'}
|
// Add enhanced text
|
||||||
{enhancementMode === 'technical' && 'Add Technical Details'}
|
newHistory.push({
|
||||||
{enhancementMode === 'simplify' && 'Simplify'}
|
description: enhancedText,
|
||||||
{enhancementMode === 'acceptance' && 'Add Acceptance Criteria'}
|
timestamp,
|
||||||
<ChevronDown className="w-3 h-3 ml-1" />
|
source: 'enhance',
|
||||||
</Button>
|
enhancementMode: mode,
|
||||||
</DropdownMenuTrigger>
|
});
|
||||||
<DropdownMenuContent align="start">
|
return newHistory;
|
||||||
<DropdownMenuItem onClick={() => setEnhancementMode('improve')}>
|
});
|
||||||
Improve Clarity
|
}}
|
||||||
</DropdownMenuItem>
|
/>
|
||||||
<DropdownMenuItem onClick={() => setEnhancementMode('technical')}>
|
|
||||||
Add Technical Details
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => setEnhancementMode('simplify')}>
|
|
||||||
Simplify
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => setEnhancementMode('acceptance')}>
|
|
||||||
Add Acceptance Criteria
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="default"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 text-xs"
|
|
||||||
onClick={handleEnhanceDescription}
|
|
||||||
disabled={!description.trim() || isEnhancing}
|
|
||||||
loading={isEnhancing}
|
|
||||||
>
|
|
||||||
<Sparkles className="w-3 h-3 mr-1" />
|
|
||||||
Enhance
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<ModelOverrideTrigger
|
|
||||||
currentModelEntry={enhancementOverride.effectiveModelEntry}
|
|
||||||
onModelChange={enhancementOverride.setOverride}
|
|
||||||
phase="enhancementModel"
|
|
||||||
isOverridden={enhancementOverride.isOverridden}
|
|
||||||
size="sm"
|
|
||||||
variant="icon"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CollapsibleContent>
|
|
||||||
</Collapsible>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* AI & Execution Section */}
|
{/* AI & Execution Section */}
|
||||||
|
|||||||
@@ -21,18 +21,8 @@ import {
|
|||||||
FeatureTextFilePath as DescriptionTextFilePath,
|
FeatureTextFilePath as DescriptionTextFilePath,
|
||||||
ImagePreviewMap,
|
ImagePreviewMap,
|
||||||
} from '@/components/ui/description-image-dropzone';
|
} from '@/components/ui/description-image-dropzone';
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
import { GitBranch, Cpu, FolderKanban } from 'lucide-react';
|
||||||
import {
|
|
||||||
Sparkles,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronRight,
|
|
||||||
GitBranch,
|
|
||||||
History,
|
|
||||||
Cpu,
|
|
||||||
FolderKanban,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
|
||||||
import { cn, modelSupportsThinking } from '@/lib/utils';
|
import { cn, modelSupportsThinking } from '@/lib/utils';
|
||||||
import { Feature, ModelAlias, ThinkingLevel, useAppStore, PlanningMode } from '@/store/app-store';
|
import { Feature, ModelAlias, ThinkingLevel, useAppStore, PlanningMode } from '@/store/app-store';
|
||||||
import type { ReasoningEffort, PhaseModelEntry, DescriptionHistoryEntry } from '@automaker/types';
|
import type { ReasoningEffort, PhaseModelEntry, DescriptionHistoryEntry } from '@automaker/types';
|
||||||
@@ -41,17 +31,12 @@ import {
|
|||||||
PrioritySelector,
|
PrioritySelector,
|
||||||
WorkModeSelector,
|
WorkModeSelector,
|
||||||
PlanningModeSelect,
|
PlanningModeSelect,
|
||||||
|
EnhanceWithAI,
|
||||||
|
EnhancementHistoryButton,
|
||||||
|
type EnhancementMode,
|
||||||
} from '../shared';
|
} from '../shared';
|
||||||
import type { WorkMode } from '../shared';
|
import type { WorkMode } from '../shared';
|
||||||
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
|
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
|
||||||
import { ModelOverrideTrigger, useModelOverride } from '@/components/shared';
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@/components/ui/dropdown-menu';
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { DependencyTreeDialog } from './dependency-tree-dialog';
|
import { DependencyTreeDialog } from './dependency-tree-dialog';
|
||||||
import { isClaudeModel, supportsReasoningEffort } from '@automaker/types';
|
import { isClaudeModel, supportsReasoningEffort } from '@automaker/types';
|
||||||
@@ -79,7 +64,8 @@ interface EditFeatureDialogProps {
|
|||||||
requirePlanApproval: boolean;
|
requirePlanApproval: boolean;
|
||||||
},
|
},
|
||||||
descriptionHistorySource?: 'enhance' | 'edit',
|
descriptionHistorySource?: 'enhance' | 'edit',
|
||||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance'
|
enhancementMode?: EnhancementMode,
|
||||||
|
preEnhancementDescription?: string
|
||||||
) => void;
|
) => void;
|
||||||
categorySuggestions: string[];
|
categorySuggestions: string[];
|
||||||
branchSuggestions: string[];
|
branchSuggestions: string[];
|
||||||
@@ -110,11 +96,6 @@ export function EditFeatureDialog({
|
|||||||
const [editFeaturePreviewMap, setEditFeaturePreviewMap] = useState<ImagePreviewMap>(
|
const [editFeaturePreviewMap, setEditFeaturePreviewMap] = useState<ImagePreviewMap>(
|
||||||
() => new Map()
|
() => new Map()
|
||||||
);
|
);
|
||||||
const [isEnhancing, setIsEnhancing] = useState(false);
|
|
||||||
const [enhancementMode, setEnhancementMode] = useState<
|
|
||||||
'improve' | 'technical' | 'simplify' | 'acceptance'
|
|
||||||
>('improve');
|
|
||||||
const [enhanceOpen, setEnhanceOpen] = useState(false);
|
|
||||||
const [showDependencyTree, setShowDependencyTree] = useState(false);
|
const [showDependencyTree, setShowDependencyTree] = useState(false);
|
||||||
const [planningMode, setPlanningMode] = useState<PlanningMode>(feature?.planningMode ?? 'skip');
|
const [planningMode, setPlanningMode] = useState<PlanningMode>(feature?.planningMode ?? 'skip');
|
||||||
const [requirePlanApproval, setRequirePlanApproval] = useState(
|
const [requirePlanApproval, setRequirePlanApproval] = useState(
|
||||||
@@ -133,15 +114,16 @@ export function EditFeatureDialog({
|
|||||||
|
|
||||||
// Track the source of description changes for history
|
// Track the source of description changes for history
|
||||||
const [descriptionChangeSource, setDescriptionChangeSource] = useState<
|
const [descriptionChangeSource, setDescriptionChangeSource] = useState<
|
||||||
{ source: 'enhance'; mode: 'improve' | 'technical' | 'simplify' | 'acceptance' } | 'edit' | null
|
{ source: 'enhance'; mode: EnhancementMode } | 'edit' | null
|
||||||
>(null);
|
>(null);
|
||||||
// Track the original description when the dialog opened for comparison
|
// Track the original description when the dialog opened for comparison
|
||||||
const [originalDescription, setOriginalDescription] = useState(feature?.description ?? '');
|
const [originalDescription, setOriginalDescription] = useState(feature?.description ?? '');
|
||||||
// Track if history dropdown is open
|
// Track the description before enhancement (so it can be restored)
|
||||||
const [showHistory, setShowHistory] = useState(false);
|
const [preEnhancementDescription, setPreEnhancementDescription] = useState<string | null>(null);
|
||||||
|
// Local history state for real-time display (combines persisted + session history)
|
||||||
// Enhancement model override
|
const [localHistory, setLocalHistory] = useState<DescriptionHistoryEntry[]>(
|
||||||
const enhancementOverride = useModelOverride({ phase: 'enhancementModel' });
|
feature?.descriptionHistory ?? []
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setEditingFeature(feature);
|
setEditingFeature(feature);
|
||||||
@@ -153,8 +135,8 @@ export function EditFeatureDialog({
|
|||||||
// Reset history tracking state
|
// Reset history tracking state
|
||||||
setOriginalDescription(feature.description ?? '');
|
setOriginalDescription(feature.description ?? '');
|
||||||
setDescriptionChangeSource(null);
|
setDescriptionChangeSource(null);
|
||||||
setShowHistory(false);
|
setPreEnhancementDescription(null);
|
||||||
setEnhanceOpen(false);
|
setLocalHistory(feature.descriptionHistory ?? []);
|
||||||
// Reset model entry
|
// Reset model entry
|
||||||
setModelEntry({
|
setModelEntry({
|
||||||
model: (feature.model as ModelAlias) || 'opus',
|
model: (feature.model as ModelAlias) || 'opus',
|
||||||
@@ -164,7 +146,8 @@ export function EditFeatureDialog({
|
|||||||
} else {
|
} else {
|
||||||
setEditFeaturePreviewMap(new Map());
|
setEditFeaturePreviewMap(new Map());
|
||||||
setDescriptionChangeSource(null);
|
setDescriptionChangeSource(null);
|
||||||
setShowHistory(false);
|
setPreEnhancementDescription(null);
|
||||||
|
setLocalHistory([]);
|
||||||
}
|
}
|
||||||
}, [feature]);
|
}, [feature]);
|
||||||
|
|
||||||
@@ -226,7 +209,13 @@ export function EditFeatureDialog({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onUpdate(editingFeature.id, updates, historySource, historyEnhancementMode);
|
onUpdate(
|
||||||
|
editingFeature.id,
|
||||||
|
updates,
|
||||||
|
historySource,
|
||||||
|
historyEnhancementMode,
|
||||||
|
preEnhancementDescription ?? undefined
|
||||||
|
);
|
||||||
setEditFeaturePreviewMap(new Map());
|
setEditFeaturePreviewMap(new Map());
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
@@ -237,36 +226,6 @@ export function EditFeatureDialog({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEnhanceDescription = async () => {
|
|
||||||
if (!editingFeature?.description.trim() || isEnhancing) return;
|
|
||||||
|
|
||||||
setIsEnhancing(true);
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
const result = await api.enhancePrompt?.enhance(
|
|
||||||
editingFeature.description,
|
|
||||||
enhancementMode,
|
|
||||||
enhancementOverride.effectiveModel, // API accepts string, extract from PhaseModelEntry
|
|
||||||
enhancementOverride.effectiveModelEntry.thinkingLevel // Pass thinking level
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result?.success && result.enhancedText) {
|
|
||||||
const enhancedText = result.enhancedText;
|
|
||||||
setEditingFeature((prev) => (prev ? { ...prev, description: enhancedText } : prev));
|
|
||||||
// Track that this change was from enhancement
|
|
||||||
setDescriptionChangeSource({ source: 'enhance', mode: enhancementMode });
|
|
||||||
toast.success('Description enhanced!');
|
|
||||||
} else {
|
|
||||||
toast.error(result?.error || 'Failed to enhance description');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Enhancement failed:', error);
|
|
||||||
toast.error('Failed to enhance description');
|
|
||||||
} finally {
|
|
||||||
setIsEnhancing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!editingFeature) {
|
if (!editingFeature) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -304,85 +263,18 @@ export function EditFeatureDialog({
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label htmlFor="edit-description">Description</Label>
|
<Label htmlFor="edit-description">Description</Label>
|
||||||
{/* Version History Button */}
|
{/* Version History Button - uses local history for real-time updates */}
|
||||||
{feature?.descriptionHistory && feature.descriptionHistory.length > 0 && (
|
<EnhancementHistoryButton
|
||||||
<Popover open={showHistory} onOpenChange={setShowHistory}>
|
history={localHistory}
|
||||||
<PopoverTrigger asChild>
|
currentValue={editingFeature.description}
|
||||||
<Button
|
onRestore={(description) => {
|
||||||
type="button"
|
setEditingFeature((prev) => (prev ? { ...prev, description } : prev));
|
||||||
variant="ghost"
|
setDescriptionChangeSource('edit');
|
||||||
size="sm"
|
}}
|
||||||
className="h-7 gap-1.5 text-xs text-muted-foreground"
|
valueAccessor={(entry) => entry.description}
|
||||||
>
|
title="Version History"
|
||||||
<History className="w-3.5 h-3.5" />
|
restoreMessage="Description restored from history"
|
||||||
History ({feature.descriptionHistory.length})
|
/>
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-80 p-0" align="end">
|
|
||||||
<div className="p-3 border-b">
|
|
||||||
<h4 className="font-medium text-sm">Version History</h4>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
Click a version to restore it
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="max-h-64 overflow-y-auto p-2 space-y-1">
|
|
||||||
{[...(feature.descriptionHistory || [])]
|
|
||||||
.reverse()
|
|
||||||
.map((entry: DescriptionHistoryEntry, index: number) => {
|
|
||||||
const isCurrentVersion =
|
|
||||||
entry.description === editingFeature.description;
|
|
||||||
const date = new Date(entry.timestamp);
|
|
||||||
const formattedDate = date.toLocaleDateString(undefined, {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
});
|
|
||||||
const sourceLabel =
|
|
||||||
entry.source === 'initial'
|
|
||||||
? 'Original'
|
|
||||||
: entry.source === 'enhance'
|
|
||||||
? `Enhanced (${entry.enhancementMode || 'improve'})`
|
|
||||||
: 'Edited';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={`${entry.timestamp}-${index}`}
|
|
||||||
onClick={() => {
|
|
||||||
setEditingFeature((prev) =>
|
|
||||||
prev ? { ...prev, description: entry.description } : prev
|
|
||||||
);
|
|
||||||
// Mark as edit since user is restoring from history
|
|
||||||
setDescriptionChangeSource('edit');
|
|
||||||
setShowHistory(false);
|
|
||||||
toast.success('Description restored from history');
|
|
||||||
}}
|
|
||||||
className={`w-full text-left p-2 rounded-md hover:bg-muted transition-colors ${
|
|
||||||
isCurrentVersion ? 'bg-muted/50 border border-primary/20' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<span className="text-xs font-medium">{sourceLabel}</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{formattedDate}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
|
||||||
{entry.description.slice(0, 100)}
|
|
||||||
{entry.description.length > 100 ? '...' : ''}
|
|
||||||
</p>
|
|
||||||
{isCurrentVersion && (
|
|
||||||
<span className="text-xs text-primary font-medium mt-1 block">
|
|
||||||
Current version
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<DescriptionImageDropZone
|
<DescriptionImageDropZone
|
||||||
value={editingFeature.description}
|
value={editingFeature.description}
|
||||||
@@ -433,71 +325,40 @@ export function EditFeatureDialog({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Collapsible Enhancement Section */}
|
{/* Enhancement Section */}
|
||||||
<Collapsible open={enhanceOpen} onOpenChange={setEnhanceOpen}>
|
<EnhanceWithAI
|
||||||
<CollapsibleTrigger asChild>
|
value={editingFeature.description}
|
||||||
<button className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors w-full py-1">
|
onChange={(enhanced) =>
|
||||||
{enhanceOpen ? (
|
setEditingFeature((prev) => (prev ? { ...prev, description: enhanced } : prev))
|
||||||
<ChevronDown className="w-4 h-4" />
|
}
|
||||||
) : (
|
onHistoryAdd={({ mode, originalText, enhancedText }) => {
|
||||||
<ChevronRight className="w-4 h-4" />
|
setDescriptionChangeSource({ source: 'enhance', mode });
|
||||||
)}
|
setPreEnhancementDescription(originalText);
|
||||||
<Sparkles className="w-4 h-4" />
|
|
||||||
<span>Enhance with AI</span>
|
|
||||||
</button>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
<CollapsibleContent className="pt-3">
|
|
||||||
<div className="flex flex-wrap items-center gap-2 pl-6">
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="outline" size="sm" className="h-8 text-xs">
|
|
||||||
{enhancementMode === 'improve' && 'Improve Clarity'}
|
|
||||||
{enhancementMode === 'technical' && 'Add Technical Details'}
|
|
||||||
{enhancementMode === 'simplify' && 'Simplify'}
|
|
||||||
{enhancementMode === 'acceptance' && 'Add Acceptance Criteria'}
|
|
||||||
<ChevronDown className="w-3 h-3 ml-1" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="start">
|
|
||||||
<DropdownMenuItem onClick={() => setEnhancementMode('improve')}>
|
|
||||||
Improve Clarity
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => setEnhancementMode('technical')}>
|
|
||||||
Add Technical Details
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => setEnhancementMode('simplify')}>
|
|
||||||
Simplify
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => setEnhancementMode('acceptance')}>
|
|
||||||
Add Acceptance Criteria
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
|
|
||||||
<Button
|
// Update local history for real-time display
|
||||||
type="button"
|
const timestamp = new Date().toISOString();
|
||||||
variant="default"
|
setLocalHistory((prev) => {
|
||||||
size="sm"
|
const newHistory = [...prev];
|
||||||
className="h-8 text-xs"
|
// Add original text first (so user can restore to pre-enhancement state)
|
||||||
onClick={handleEnhanceDescription}
|
const lastEntry = prev[prev.length - 1];
|
||||||
disabled={!editingFeature.description.trim() || isEnhancing}
|
if (!lastEntry || lastEntry.description !== originalText) {
|
||||||
loading={isEnhancing}
|
newHistory.push({
|
||||||
>
|
description: originalText,
|
||||||
<Sparkles className="w-3 h-3 mr-1" />
|
timestamp,
|
||||||
Enhance
|
source: prev.length === 0 ? 'initial' : 'edit',
|
||||||
</Button>
|
});
|
||||||
|
}
|
||||||
<ModelOverrideTrigger
|
// Add enhanced text
|
||||||
currentModelEntry={enhancementOverride.effectiveModelEntry}
|
newHistory.push({
|
||||||
onModelChange={enhancementOverride.setOverride}
|
description: enhancedText,
|
||||||
phase="enhancementModel"
|
timestamp,
|
||||||
isOverridden={enhancementOverride.isOverridden}
|
source: 'enhance',
|
||||||
size="sm"
|
enhancementMode: mode,
|
||||||
variant="icon"
|
});
|
||||||
/>
|
return newHistory;
|
||||||
</div>
|
});
|
||||||
</CollapsibleContent>
|
}}
|
||||||
</Collapsible>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* AI & Execution Section */}
|
{/* AI & Execution Section */}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -17,6 +18,21 @@ import {
|
|||||||
} from '@/components/ui/description-image-dropzone';
|
} from '@/components/ui/description-image-dropzone';
|
||||||
import { MessageSquare } from 'lucide-react';
|
import { MessageSquare } from 'lucide-react';
|
||||||
import { Feature } from '@/store/app-store';
|
import { Feature } from '@/store/app-store';
|
||||||
|
import {
|
||||||
|
EnhanceWithAI,
|
||||||
|
EnhancementHistoryButton,
|
||||||
|
type EnhancementMode,
|
||||||
|
type BaseHistoryEntry,
|
||||||
|
} from '../shared';
|
||||||
|
|
||||||
|
const logger = createLogger('FollowUpDialog');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single entry in the follow-up prompt history
|
||||||
|
*/
|
||||||
|
export interface FollowUpHistoryEntry extends BaseHistoryEntry {
|
||||||
|
prompt: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface FollowUpDialogProps {
|
interface FollowUpDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -30,6 +46,10 @@ interface FollowUpDialogProps {
|
|||||||
onPreviewMapChange: (map: ImagePreviewMap) => void;
|
onPreviewMapChange: (map: ImagePreviewMap) => void;
|
||||||
onSend: () => void;
|
onSend: () => void;
|
||||||
isMaximized: boolean;
|
isMaximized: boolean;
|
||||||
|
/** History of prompt versions for restoration */
|
||||||
|
promptHistory?: FollowUpHistoryEntry[];
|
||||||
|
/** Callback to add a new entry to prompt history */
|
||||||
|
onHistoryAdd?: (entry: FollowUpHistoryEntry) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FollowUpDialog({
|
export function FollowUpDialog({
|
||||||
@@ -44,9 +64,11 @@ export function FollowUpDialog({
|
|||||||
onPreviewMapChange,
|
onPreviewMapChange,
|
||||||
onSend,
|
onSend,
|
||||||
isMaximized,
|
isMaximized,
|
||||||
|
promptHistory = [],
|
||||||
|
onHistoryAdd,
|
||||||
}: FollowUpDialogProps) {
|
}: FollowUpDialogProps) {
|
||||||
const handleClose = (open: boolean) => {
|
const handleClose = (openState: boolean) => {
|
||||||
if (!open) {
|
if (!openState) {
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -77,7 +99,18 @@ export function FollowUpDialog({
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4 py-4 overflow-y-auto flex-1 min-h-0">
|
<div className="space-y-4 py-4 overflow-y-auto flex-1 min-h-0">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="follow-up-prompt">Instructions</Label>
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="follow-up-prompt">Instructions</Label>
|
||||||
|
{/* Version History Button */}
|
||||||
|
<EnhancementHistoryButton
|
||||||
|
history={promptHistory}
|
||||||
|
currentValue={prompt}
|
||||||
|
onRestore={onPromptChange}
|
||||||
|
valueAccessor={(entry) => entry.prompt}
|
||||||
|
title="Prompt History"
|
||||||
|
restoreMessage="Prompt restored from history"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<DescriptionImageDropZone
|
<DescriptionImageDropZone
|
||||||
value={prompt}
|
value={prompt}
|
||||||
onChange={onPromptChange}
|
onChange={onPromptChange}
|
||||||
@@ -88,6 +121,33 @@ export function FollowUpDialog({
|
|||||||
onPreviewMapChange={onPreviewMapChange}
|
onPreviewMapChange={onPreviewMapChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Enhancement Section */}
|
||||||
|
<EnhanceWithAI
|
||||||
|
value={prompt}
|
||||||
|
onChange={onPromptChange}
|
||||||
|
onHistoryAdd={({ mode, originalText, enhancedText }) => {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
// Add original text first (so user can restore to pre-enhancement state)
|
||||||
|
// Only add if it's different from the last history entry
|
||||||
|
const lastEntry = promptHistory[promptHistory.length - 1];
|
||||||
|
if (!lastEntry || lastEntry.prompt !== originalText) {
|
||||||
|
onHistoryAdd?.({
|
||||||
|
prompt: originalText,
|
||||||
|
timestamp,
|
||||||
|
source: promptHistory.length === 0 ? 'initial' : 'edit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Add enhanced text
|
||||||
|
onHistoryAdd?.({
|
||||||
|
prompt: enhancedText,
|
||||||
|
timestamp,
|
||||||
|
source: 'enhance',
|
||||||
|
enhancementMode: mode,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
The agent will continue from where it left off, using the existing context. You can
|
The agent will continue from where it left off, using the existing context. You can
|
||||||
attach screenshots to help explain the issue.
|
attach screenshots to help explain the issue.
|
||||||
|
|||||||
@@ -5,6 +5,6 @@ export { CompletedFeaturesModal } from './completed-features-modal';
|
|||||||
export { ArchiveAllVerifiedDialog } from './archive-all-verified-dialog';
|
export { ArchiveAllVerifiedDialog } from './archive-all-verified-dialog';
|
||||||
export { DeleteCompletedFeatureDialog } from './delete-completed-feature-dialog';
|
export { DeleteCompletedFeatureDialog } from './delete-completed-feature-dialog';
|
||||||
export { EditFeatureDialog } from './edit-feature-dialog';
|
export { EditFeatureDialog } from './edit-feature-dialog';
|
||||||
export { FollowUpDialog } from './follow-up-dialog';
|
export { FollowUpDialog, type FollowUpHistoryEntry } from './follow-up-dialog';
|
||||||
export { PlanApprovalDialog } from './plan-approval-dialog';
|
export { PlanApprovalDialog } from './plan-approval-dialog';
|
||||||
export { MassEditDialog } from './mass-edit-dialog';
|
export { MassEditDialog } from './mass-edit-dialog';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -8,223 +8,11 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Plus, Trash2, ChevronUp, ChevronDown, Pencil } from 'lucide-react';
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select';
|
|
||||||
import { Plus, Trash2, ChevronUp, ChevronDown, Upload, Pencil, X, FileText } from 'lucide-react';
|
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import type { PipelineConfig, PipelineStep } from '@automaker/types';
|
import type { PipelineConfig, PipelineStep } from '@automaker/types';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { AddEditPipelineStepDialog } from './add-edit-pipeline-step-dialog';
|
||||||
// Color options for pipeline columns
|
|
||||||
const COLOR_OPTIONS = [
|
|
||||||
{ value: 'bg-blue-500/20', label: 'Blue', preview: 'bg-blue-500' },
|
|
||||||
{ value: 'bg-purple-500/20', label: 'Purple', preview: 'bg-purple-500' },
|
|
||||||
{ value: 'bg-green-500/20', label: 'Green', preview: 'bg-green-500' },
|
|
||||||
{ value: 'bg-orange-500/20', label: 'Orange', preview: 'bg-orange-500' },
|
|
||||||
{ value: 'bg-red-500/20', label: 'Red', preview: 'bg-red-500' },
|
|
||||||
{ value: 'bg-pink-500/20', label: 'Pink', preview: 'bg-pink-500' },
|
|
||||||
{ value: 'bg-cyan-500/20', label: 'Cyan', preview: 'bg-cyan-500' },
|
|
||||||
{ value: 'bg-amber-500/20', label: 'Amber', preview: 'bg-amber-500' },
|
|
||||||
{ value: 'bg-indigo-500/20', label: 'Indigo', preview: 'bg-indigo-500' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Pre-built step templates with well-designed prompts
|
|
||||||
const STEP_TEMPLATES = [
|
|
||||||
{
|
|
||||||
id: 'code-review',
|
|
||||||
name: 'Code Review',
|
|
||||||
colorClass: 'bg-blue-500/20',
|
|
||||||
instructions: `## Code Review
|
|
||||||
|
|
||||||
Please perform a thorough code review of the changes made in this feature. Focus on:
|
|
||||||
|
|
||||||
### Code Quality
|
|
||||||
- **Readability**: Is the code easy to understand? Are variable/function names descriptive?
|
|
||||||
- **Maintainability**: Will this code be easy to modify in the future?
|
|
||||||
- **DRY Principle**: Is there any duplicated code that should be abstracted?
|
|
||||||
- **Single Responsibility**: Do functions and classes have a single, clear purpose?
|
|
||||||
|
|
||||||
### Best Practices
|
|
||||||
- Follow established patterns and conventions used in the codebase
|
|
||||||
- Ensure proper error handling is in place
|
|
||||||
- Check for appropriate logging where needed
|
|
||||||
- Verify that magic numbers/strings are replaced with named constants
|
|
||||||
|
|
||||||
### Performance
|
|
||||||
- Identify any potential performance bottlenecks
|
|
||||||
- Check for unnecessary re-renders (React) or redundant computations
|
|
||||||
- Ensure efficient data structures are used
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
- Verify that new code has appropriate test coverage
|
|
||||||
- Check that edge cases are handled
|
|
||||||
|
|
||||||
### Action Required
|
|
||||||
After reviewing, make any necessary improvements directly. If you find issues:
|
|
||||||
1. Fix them immediately if they are straightforward
|
|
||||||
2. For complex issues, document them clearly with suggested solutions
|
|
||||||
|
|
||||||
Provide a brief summary of changes made or issues found.`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'security-review',
|
|
||||||
name: 'Security Review',
|
|
||||||
colorClass: 'bg-red-500/20',
|
|
||||||
instructions: `## Security Review
|
|
||||||
|
|
||||||
Perform a comprehensive security audit of the changes made in this feature. Check for vulnerabilities in the following areas:
|
|
||||||
|
|
||||||
### Input Validation & Sanitization
|
|
||||||
- Verify all user inputs are properly validated and sanitized
|
|
||||||
- Check for SQL injection vulnerabilities
|
|
||||||
- Check for XSS (Cross-Site Scripting) vulnerabilities
|
|
||||||
- Ensure proper encoding of output data
|
|
||||||
|
|
||||||
### Authentication & Authorization
|
|
||||||
- Verify authentication checks are in place where needed
|
|
||||||
- Ensure authorization logic correctly restricts access
|
|
||||||
- Check for privilege escalation vulnerabilities
|
|
||||||
- Verify session management is secure
|
|
||||||
|
|
||||||
### Data Protection
|
|
||||||
- Ensure sensitive data is not logged or exposed
|
|
||||||
- Check that secrets/credentials are not hardcoded
|
|
||||||
- Verify proper encryption is used for sensitive data
|
|
||||||
- Check for secure transmission of data (HTTPS, etc.)
|
|
||||||
|
|
||||||
### Common Vulnerabilities (OWASP Top 10)
|
|
||||||
- Injection flaws
|
|
||||||
- Broken authentication
|
|
||||||
- Sensitive data exposure
|
|
||||||
- XML External Entities (XXE)
|
|
||||||
- Broken access control
|
|
||||||
- Security misconfiguration
|
|
||||||
- Cross-Site Scripting (XSS)
|
|
||||||
- Insecure deserialization
|
|
||||||
- Using components with known vulnerabilities
|
|
||||||
- Insufficient logging & monitoring
|
|
||||||
|
|
||||||
### Action Required
|
|
||||||
1. Fix any security vulnerabilities immediately
|
|
||||||
2. For complex security issues, document them with severity levels
|
|
||||||
3. Add security-related comments where appropriate
|
|
||||||
|
|
||||||
Provide a security assessment summary with any issues found and fixes applied.`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'testing',
|
|
||||||
name: 'Testing',
|
|
||||||
colorClass: 'bg-green-500/20',
|
|
||||||
instructions: `## Testing Step
|
|
||||||
|
|
||||||
Please ensure comprehensive test coverage for the changes made in this feature.
|
|
||||||
|
|
||||||
### Unit Tests
|
|
||||||
- Write unit tests for all new functions and methods
|
|
||||||
- Ensure edge cases are covered
|
|
||||||
- Test error handling paths
|
|
||||||
- Aim for high code coverage on new code
|
|
||||||
|
|
||||||
### Integration Tests
|
|
||||||
- Test interactions between components/modules
|
|
||||||
- Verify API endpoints work correctly
|
|
||||||
- Test database operations if applicable
|
|
||||||
|
|
||||||
### Test Quality
|
|
||||||
- Tests should be readable and well-documented
|
|
||||||
- Each test should have a clear purpose
|
|
||||||
- Use descriptive test names that explain the scenario
|
|
||||||
- Follow the Arrange-Act-Assert pattern
|
|
||||||
|
|
||||||
### Run Tests
|
|
||||||
After writing tests, run the full test suite and ensure:
|
|
||||||
1. All new tests pass
|
|
||||||
2. No existing tests are broken
|
|
||||||
3. Test coverage meets project standards
|
|
||||||
|
|
||||||
Provide a summary of tests added and any issues found during testing.`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'documentation',
|
|
||||||
name: 'Documentation',
|
|
||||||
colorClass: 'bg-amber-500/20',
|
|
||||||
instructions: `## Documentation Step
|
|
||||||
|
|
||||||
Please ensure all changes are properly documented.
|
|
||||||
|
|
||||||
### Code Documentation
|
|
||||||
- Add/update JSDoc or docstrings for new functions and classes
|
|
||||||
- Document complex algorithms or business logic
|
|
||||||
- Add inline comments for non-obvious code
|
|
||||||
|
|
||||||
### API Documentation
|
|
||||||
- Document any new or modified API endpoints
|
|
||||||
- Include request/response examples
|
|
||||||
- Document error responses
|
|
||||||
|
|
||||||
### README Updates
|
|
||||||
- Update README if new setup steps are required
|
|
||||||
- Document any new environment variables
|
|
||||||
- Update architecture diagrams if applicable
|
|
||||||
|
|
||||||
### Changelog
|
|
||||||
- Document notable changes for the changelog
|
|
||||||
- Include breaking changes if any
|
|
||||||
|
|
||||||
Provide a summary of documentation added or updated.`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'optimization',
|
|
||||||
name: 'Performance Optimization',
|
|
||||||
colorClass: 'bg-cyan-500/20',
|
|
||||||
instructions: `## Performance Optimization Step
|
|
||||||
|
|
||||||
Review and optimize the performance of the changes made in this feature.
|
|
||||||
|
|
||||||
### Code Performance
|
|
||||||
- Identify and optimize slow algorithms (O(n²) → O(n log n), etc.)
|
|
||||||
- Remove unnecessary computations or redundant operations
|
|
||||||
- Optimize loops and iterations
|
|
||||||
- Use appropriate data structures
|
|
||||||
|
|
||||||
### Memory Usage
|
|
||||||
- Check for memory leaks
|
|
||||||
- Optimize memory-intensive operations
|
|
||||||
- Ensure proper cleanup of resources
|
|
||||||
|
|
||||||
### Database/API
|
|
||||||
- Optimize database queries (add indexes, reduce N+1 queries)
|
|
||||||
- Implement caching where appropriate
|
|
||||||
- Batch API calls when possible
|
|
||||||
|
|
||||||
### Frontend (if applicable)
|
|
||||||
- Minimize bundle size
|
|
||||||
- Optimize render performance
|
|
||||||
- Implement lazy loading where appropriate
|
|
||||||
- Use memoization for expensive computations
|
|
||||||
|
|
||||||
### Action Required
|
|
||||||
1. Profile the code to identify bottlenecks
|
|
||||||
2. Apply optimizations
|
|
||||||
3. Measure improvements
|
|
||||||
|
|
||||||
Provide a summary of optimizations applied and performance improvements achieved.`,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Helper to get template color class
|
|
||||||
const getTemplateColorClass = (templateId: string): string => {
|
|
||||||
const template = STEP_TEMPLATES.find((t) => t.id === templateId);
|
|
||||||
return template?.colorClass || COLOR_OPTIONS[0].value;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface PipelineSettingsDialogProps {
|
interface PipelineSettingsDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -234,18 +22,10 @@ interface PipelineSettingsDialogProps {
|
|||||||
onSave: (config: PipelineConfig) => Promise<void>;
|
onSave: (config: PipelineConfig) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EditingStep {
|
|
||||||
id?: string;
|
|
||||||
name: string;
|
|
||||||
instructions: string;
|
|
||||||
colorClass: string;
|
|
||||||
order: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PipelineSettingsDialog({
|
export function PipelineSettingsDialog({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
projectPath,
|
projectPath: _projectPath,
|
||||||
pipelineConfig,
|
pipelineConfig,
|
||||||
onSave,
|
onSave,
|
||||||
}: PipelineSettingsDialogProps) {
|
}: PipelineSettingsDialogProps) {
|
||||||
@@ -262,9 +42,11 @@ export function PipelineSettingsDialog({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const [steps, setSteps] = useState<PipelineStep[]>(() => validateSteps(pipelineConfig?.steps));
|
const [steps, setSteps] = useState<PipelineStep[]>(() => validateSteps(pipelineConfig?.steps));
|
||||||
const [editingStep, setEditingStep] = useState<EditingStep | null>(null);
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
// Sub-dialog state
|
||||||
|
const [addEditDialogOpen, setAddEditDialogOpen] = useState(false);
|
||||||
|
const [editingStep, setEditingStep] = useState<PipelineStep | null>(null);
|
||||||
|
|
||||||
// Sync steps when dialog opens or pipelineConfig changes
|
// Sync steps when dialog opens or pipelineConfig changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -276,22 +58,13 @@ export function PipelineSettingsDialog({
|
|||||||
const sortedSteps = [...steps].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
const sortedSteps = [...steps].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||||
|
|
||||||
const handleAddStep = () => {
|
const handleAddStep = () => {
|
||||||
setEditingStep({
|
setEditingStep(null);
|
||||||
name: '',
|
setAddEditDialogOpen(true);
|
||||||
instructions: '',
|
|
||||||
colorClass: COLOR_OPTIONS[steps.length % COLOR_OPTIONS.length].value,
|
|
||||||
order: steps.length,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditStep = (step: PipelineStep) => {
|
const handleEditStep = (step: PipelineStep) => {
|
||||||
setEditingStep({
|
setEditingStep(step);
|
||||||
id: step.id,
|
setAddEditDialogOpen(true);
|
||||||
name: step.name,
|
|
||||||
instructions: step.instructions,
|
|
||||||
colorClass: step.colorClass,
|
|
||||||
order: step.order,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteStep = (stepId: string) => {
|
const handleDeleteStep = (stepId: string) => {
|
||||||
@@ -323,53 +96,21 @@ export function PipelineSettingsDialog({
|
|||||||
setSteps(newSteps);
|
setSteps(newSteps);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFileUpload = () => {
|
const handleSaveStep = (
|
||||||
fileInputRef.current?.click();
|
stepData: Omit<PipelineStep, 'id' | 'createdAt' | 'updatedAt'> & { id?: string }
|
||||||
};
|
) => {
|
||||||
|
|
||||||
const handleFileInputChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const content = await file.text();
|
|
||||||
setEditingStep((prev) => (prev ? { ...prev, instructions: content } : null));
|
|
||||||
toast.success('Instructions loaded from file');
|
|
||||||
} catch (error) {
|
|
||||||
toast.error('Failed to load file');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset the input so the same file can be selected again
|
|
||||||
if (fileInputRef.current) {
|
|
||||||
fileInputRef.current.value = '';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveStep = () => {
|
|
||||||
if (!editingStep) return;
|
|
||||||
|
|
||||||
if (!editingStep.name.trim()) {
|
|
||||||
toast.error('Step name is required');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!editingStep.instructions.trim()) {
|
|
||||||
toast.error('Step instructions are required');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
if (editingStep.id) {
|
if (stepData.id) {
|
||||||
// Update existing step
|
// Update existing step
|
||||||
setSteps((prev) =>
|
setSteps((prev) =>
|
||||||
prev.map((s) =>
|
prev.map((s) =>
|
||||||
s.id === editingStep.id
|
s.id === stepData.id
|
||||||
? {
|
? {
|
||||||
...s,
|
...s,
|
||||||
name: editingStep.name,
|
name: stepData.name,
|
||||||
instructions: editingStep.instructions,
|
instructions: stepData.instructions,
|
||||||
colorClass: editingStep.colorClass,
|
colorClass: stepData.colorClass,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
}
|
}
|
||||||
: s
|
: s
|
||||||
@@ -379,90 +120,21 @@ export function PipelineSettingsDialog({
|
|||||||
// Add new step
|
// Add new step
|
||||||
const newStep: PipelineStep = {
|
const newStep: PipelineStep = {
|
||||||
id: `step_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 8)}`,
|
id: `step_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 8)}`,
|
||||||
name: editingStep.name,
|
name: stepData.name,
|
||||||
instructions: editingStep.instructions,
|
instructions: stepData.instructions,
|
||||||
colorClass: editingStep.colorClass,
|
colorClass: stepData.colorClass,
|
||||||
order: steps.length,
|
order: steps.length,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
setSteps((prev) => [...prev, newStep]);
|
setSteps((prev) => [...prev, newStep]);
|
||||||
}
|
}
|
||||||
|
|
||||||
setEditingStep(null);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveConfig = async () => {
|
const handleSaveConfig = async () => {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
// If the user is currently editing a step and clicks "Save Configuration",
|
const sortedEffectiveSteps = [...steps].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||||
// include that step in the config (common expectation) instead of silently dropping it.
|
|
||||||
let effectiveSteps = steps;
|
|
||||||
if (editingStep) {
|
|
||||||
if (!editingStep.name.trim()) {
|
|
||||||
toast.error('Step name is required');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!editingStep.instructions.trim()) {
|
|
||||||
toast.error('Step instructions are required');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
if (editingStep.id) {
|
|
||||||
// Update existing (or add if missing for some reason)
|
|
||||||
const existingIdx = effectiveSteps.findIndex((s) => s.id === editingStep.id);
|
|
||||||
if (existingIdx >= 0) {
|
|
||||||
effectiveSteps = effectiveSteps.map((s) =>
|
|
||||||
s.id === editingStep.id
|
|
||||||
? {
|
|
||||||
...s,
|
|
||||||
name: editingStep.name,
|
|
||||||
instructions: editingStep.instructions,
|
|
||||||
colorClass: editingStep.colorClass,
|
|
||||||
updatedAt: now,
|
|
||||||
}
|
|
||||||
: s
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
effectiveSteps = [
|
|
||||||
...effectiveSteps,
|
|
||||||
{
|
|
||||||
id: editingStep.id,
|
|
||||||
name: editingStep.name,
|
|
||||||
instructions: editingStep.instructions,
|
|
||||||
colorClass: editingStep.colorClass,
|
|
||||||
order: effectiveSteps.length,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Add new step
|
|
||||||
effectiveSteps = [
|
|
||||||
...effectiveSteps,
|
|
||||||
{
|
|
||||||
id: `step_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 8)}`,
|
|
||||||
name: editingStep.name,
|
|
||||||
instructions: editingStep.instructions,
|
|
||||||
colorClass: editingStep.colorClass,
|
|
||||||
order: effectiveSteps.length,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep local UI state consistent with what we are saving.
|
|
||||||
setSteps(effectiveSteps);
|
|
||||||
setEditingStep(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortedEffectiveSteps = [...effectiveSteps].sort(
|
|
||||||
(a, b) => (a.order ?? 0) - (b.order ?? 0)
|
|
||||||
);
|
|
||||||
const config: PipelineConfig = {
|
const config: PipelineConfig = {
|
||||||
version: 1,
|
version: 1,
|
||||||
steps: sortedEffectiveSteps.map((s, index) => ({ ...s, order: index })),
|
steps: sortedEffectiveSteps.map((s, index) => ({ ...s, order: index })),
|
||||||
@@ -470,7 +142,7 @@ export function PipelineSettingsDialog({
|
|||||||
await onSave(config);
|
await onSave(config);
|
||||||
toast.success('Pipeline configuration saved');
|
toast.success('Pipeline configuration saved');
|
||||||
onClose();
|
onClose();
|
||||||
} catch (error) {
|
} catch {
|
||||||
toast.error('Failed to save pipeline configuration');
|
toast.error('Failed to save pipeline configuration');
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
@@ -478,259 +150,121 @@ export function PipelineSettingsDialog({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(open) => !open && onClose()}>
|
<>
|
||||||
<DialogContent className="max-w-2xl max-h-[85vh] overflow-hidden flex flex-col">
|
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||||
{/* Hidden file input for loading instructions from .md files */}
|
<DialogContent className="max-w-2xl max-h-[85vh] overflow-hidden flex flex-col">
|
||||||
<input
|
<DialogHeader>
|
||||||
ref={fileInputRef}
|
<DialogTitle>Pipeline Settings</DialogTitle>
|
||||||
type="file"
|
<DialogDescription>
|
||||||
accept=".md,.txt"
|
Configure custom pipeline steps that run after a feature completes "In Progress". Each
|
||||||
className="hidden"
|
step will automatically prompt the agent with its instructions.
|
||||||
onChange={handleFileInputChange}
|
</DialogDescription>
|
||||||
/>
|
</DialogHeader>
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Pipeline Settings</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Configure custom pipeline steps that run after a feature completes "In Progress". Each
|
|
||||||
step will automatically prompt the agent with its instructions.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto py-4 space-y-4">
|
|
||||||
{/* Steps List */}
|
|
||||||
{sortedSteps.length > 0 ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{sortedSteps.map((step, index) => (
|
|
||||||
<div
|
|
||||||
key={step.id}
|
|
||||||
className="flex items-center gap-2 p-3 border rounded-lg bg-muted/30"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-5 w-5"
|
|
||||||
onClick={() => handleMoveStep(step.id, 'up')}
|
|
||||||
disabled={index === 0}
|
|
||||||
>
|
|
||||||
<ChevronUp className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-5 w-5"
|
|
||||||
onClick={() => handleMoveStep(step.id, 'down')}
|
|
||||||
disabled={index === sortedSteps.length - 1}
|
|
||||||
>
|
|
||||||
<ChevronDown className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto py-4 space-y-4">
|
||||||
|
{/* Steps List */}
|
||||||
|
{sortedSteps.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{sortedSteps.map((step, index) => (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
key={step.id}
|
||||||
'w-3 h-8 rounded',
|
className="flex items-center gap-2 p-3 border rounded-lg bg-muted/30"
|
||||||
(step.colorClass || 'bg-blue-500/20').replace('/20', '')
|
>
|
||||||
)}
|
<div className="flex flex-col gap-1">
|
||||||
/>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-5 w-5"
|
||||||
|
onClick={() => handleMoveStep(step.id, 'up')}
|
||||||
|
disabled={index === 0}
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-5 w-5"
|
||||||
|
onClick={() => handleMoveStep(step.id, 'down')}
|
||||||
|
disabled={index === sortedSteps.length - 1}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div
|
||||||
<div className="font-medium truncate">{step.name || 'Unnamed Step'}</div>
|
className={cn(
|
||||||
<div className="text-xs text-muted-foreground truncate">
|
'w-3 h-8 rounded',
|
||||||
{(step.instructions || '').substring(0, 100)}
|
(step.colorClass || 'bg-blue-500/20').replace('/20', '')
|
||||||
{(step.instructions || '').length > 100 ? '...' : ''}
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-medium truncate">{step.name || 'Unnamed Step'}</div>
|
||||||
|
<div className="text-xs text-muted-foreground truncate">
|
||||||
|
{(step.instructions || '').substring(0, 100)}
|
||||||
|
{(step.instructions || '').length > 100 ? '...' : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={() => handleEditStep(step)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-destructive hover:text-destructive"
|
||||||
|
onClick={() => handleDeleteStep(step.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
<p>No pipeline steps configured.</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
Add steps to create a custom workflow after features complete.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
{/* Add Step Button */}
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7"
|
|
||||||
onClick={() => handleEditStep(step)}
|
|
||||||
>
|
|
||||||
<Pencil className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7 text-destructive hover:text-destructive"
|
|
||||||
onClick={() => handleDeleteStep(step.id)}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
|
||||||
<p>No pipeline steps configured.</p>
|
|
||||||
<p className="text-sm">
|
|
||||||
Add steps to create a custom workflow after features complete.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Add Step Button */}
|
|
||||||
{!editingStep && (
|
|
||||||
<Button variant="outline" className="w-full" onClick={handleAddStep}>
|
<Button variant="outline" className="w-full" onClick={handleAddStep}>
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
Add Pipeline Step
|
Add Pipeline Step
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{/* Edit/Add Step Form */}
|
<DialogFooter>
|
||||||
{editingStep && (
|
<Button variant="outline" onClick={onClose}>
|
||||||
<div className="border rounded-lg p-4 space-y-4 bg-muted/20">
|
Cancel
|
||||||
<div className="flex items-center justify-between">
|
</Button>
|
||||||
<h4 className="font-medium">{editingStep.id ? 'Edit Step' : 'New Step'}</h4>
|
<Button onClick={handleSaveConfig} disabled={isSubmitting}>
|
||||||
<Button
|
{isSubmitting ? 'Saving...' : 'Save Pipeline'}
|
||||||
variant="ghost"
|
</Button>
|
||||||
size="icon"
|
</DialogFooter>
|
||||||
className="h-6 w-6"
|
</DialogContent>
|
||||||
onClick={() => setEditingStep(null)}
|
</Dialog>
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Template Selector - only show for new steps */}
|
{/* Sub-dialog for adding/editing steps */}
|
||||||
{!editingStep.id && (
|
<AddEditPipelineStepDialog
|
||||||
<div className="space-y-2">
|
open={addEditDialogOpen}
|
||||||
<Label>Start from Template</Label>
|
onClose={() => {
|
||||||
<Select
|
setAddEditDialogOpen(false);
|
||||||
onValueChange={(templateId) => {
|
setEditingStep(null);
|
||||||
const template = STEP_TEMPLATES.find((t) => t.id === templateId);
|
}}
|
||||||
if (template) {
|
onSave={handleSaveStep}
|
||||||
setEditingStep((prev) =>
|
existingStep={editingStep}
|
||||||
prev
|
defaultOrder={steps.length}
|
||||||
? {
|
/>
|
||||||
...prev,
|
</>
|
||||||
name: template.name,
|
|
||||||
instructions: template.instructions,
|
|
||||||
colorClass: template.colorClass,
|
|
||||||
}
|
|
||||||
: null
|
|
||||||
);
|
|
||||||
toast.success(`Loaded "${template.name}" template`);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-full">
|
|
||||||
<SelectValue placeholder="Choose a template (optional)" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{STEP_TEMPLATES.map((template) => (
|
|
||||||
<SelectItem key={template.id} value={template.id}>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'w-2 h-2 rounded-full',
|
|
||||||
template.colorClass.replace('/20', '')
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{template.name}
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Select a pre-built template to populate the form, or create your own from
|
|
||||||
scratch.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="step-name">Step Name</Label>
|
|
||||||
<Input
|
|
||||||
id="step-name"
|
|
||||||
placeholder="e.g., Code Review, Testing, Documentation"
|
|
||||||
value={editingStep.name}
|
|
||||||
onChange={(e) =>
|
|
||||||
setEditingStep((prev) => (prev ? { ...prev, name: e.target.value } : null))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Color</Label>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{COLOR_OPTIONS.map((color) => (
|
|
||||||
<button
|
|
||||||
key={color.value}
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
|
||||||
'w-8 h-8 rounded-full transition-all',
|
|
||||||
color.preview,
|
|
||||||
editingStep.colorClass === color.value
|
|
||||||
? 'ring-2 ring-offset-2 ring-primary'
|
|
||||||
: 'opacity-60 hover:opacity-100'
|
|
||||||
)}
|
|
||||||
onClick={() =>
|
|
||||||
setEditingStep((prev) =>
|
|
||||||
prev ? { ...prev, colorClass: color.value } : null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
title={color.label}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Label htmlFor="step-instructions">Agent Instructions</Label>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 text-xs"
|
|
||||||
onClick={handleFileUpload}
|
|
||||||
>
|
|
||||||
<Upload className="h-3 w-3 mr-1" />
|
|
||||||
Load from .md file
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<Textarea
|
|
||||||
id="step-instructions"
|
|
||||||
placeholder="Instructions for the agent to follow during this pipeline step..."
|
|
||||||
value={editingStep.instructions}
|
|
||||||
onChange={(e) =>
|
|
||||||
setEditingStep((prev) =>
|
|
||||||
prev ? { ...prev, instructions: e.target.value } : null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
rows={6}
|
|
||||||
className="font-mono text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button variant="outline" onClick={() => setEditingStep(null)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleSaveStep}>
|
|
||||||
{editingStep.id ? 'Update Step' : 'Add Step'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={onClose}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleSaveConfig} disabled={isSubmitting}>
|
|
||||||
{isSubmitting
|
|
||||||
? 'Saving...'
|
|
||||||
: editingStep
|
|
||||||
? 'Save Step & Configuration'
|
|
||||||
: 'Save Configuration'}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
featureId: string,
|
||||||
updates: Partial<Feature>,
|
updates: Partial<Feature>,
|
||||||
descriptionHistorySource?: 'enhance' | 'edit',
|
descriptionHistorySource?: 'enhance' | 'edit',
|
||||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance'
|
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer',
|
||||||
|
preEnhancementDescription?: string
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
persistFeatureDelete: (featureId: string) => Promise<void>;
|
persistFeatureDelete: (featureId: string) => Promise<void>;
|
||||||
saveCategory: (category: string) => Promise<void>;
|
saveCategory: (category: string) => Promise<void>;
|
||||||
@@ -251,7 +252,8 @@ export function useBoardActions({
|
|||||||
workMode?: 'current' | 'auto' | 'custom';
|
workMode?: 'current' | 'auto' | 'custom';
|
||||||
},
|
},
|
||||||
descriptionHistorySource?: 'enhance' | 'edit',
|
descriptionHistorySource?: 'enhance' | 'edit',
|
||||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance'
|
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer',
|
||||||
|
preEnhancementDescription?: string
|
||||||
) => {
|
) => {
|
||||||
const workMode = updates.workMode || 'current';
|
const workMode = updates.workMode || 'current';
|
||||||
|
|
||||||
@@ -308,7 +310,13 @@ export function useBoardActions({
|
|||||||
};
|
};
|
||||||
|
|
||||||
updateFeature(featureId, finalUpdates);
|
updateFeature(featureId, finalUpdates);
|
||||||
persistFeatureUpdate(featureId, finalUpdates, descriptionHistorySource, enhancementMode);
|
persistFeatureUpdate(
|
||||||
|
featureId,
|
||||||
|
finalUpdates,
|
||||||
|
descriptionHistorySource,
|
||||||
|
enhancementMode,
|
||||||
|
preEnhancementDescription
|
||||||
|
);
|
||||||
if (updates.category) {
|
if (updates.category) {
|
||||||
saveCategory(updates.category);
|
saveCategory(updates.category);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,9 +70,21 @@ export function useBoardColumnFeatures({
|
|||||||
// We're viewing main but branch hasn't been initialized yet
|
// We're viewing main but branch hasn't been initialized yet
|
||||||
// (worktrees disabled or haven't loaded yet).
|
// (worktrees disabled or haven't loaded yet).
|
||||||
// Show features assigned to primary worktree's branch.
|
// Show features assigned to primary worktree's branch.
|
||||||
matchesWorktree = projectPath
|
if (projectPath) {
|
||||||
? useAppStore.getState().isPrimaryWorktreeBranch(projectPath, featureBranch)
|
const worktrees = useAppStore.getState().worktreesByProject[projectPath] ?? [];
|
||||||
: false;
|
if (worktrees.length === 0) {
|
||||||
|
// Worktrees not loaded yet - fallback to showing features on common default branches
|
||||||
|
// This prevents features from disappearing during initial load
|
||||||
|
matchesWorktree =
|
||||||
|
featureBranch === 'main' || featureBranch === 'master' || featureBranch === 'develop';
|
||||||
|
} else {
|
||||||
|
matchesWorktree = useAppStore
|
||||||
|
.getState()
|
||||||
|
.isPrimaryWorktreeBranch(projectPath, featureBranch);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
matchesWorktree = false;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Match by branch name
|
// Match by branch name
|
||||||
matchesWorktree = featureBranch === effectiveBranch;
|
matchesWorktree = featureBranch === effectiveBranch;
|
||||||
|
|||||||
@@ -75,6 +75,17 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
|
|||||||
if (isProjectSwitch) {
|
if (isProjectSwitch) {
|
||||||
setPersistedCategories([]);
|
setPersistedCategories([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for interrupted features and resume them
|
||||||
|
// This handles server restarts where features were in pipeline steps
|
||||||
|
if (api.autoMode?.resumeInterrupted) {
|
||||||
|
try {
|
||||||
|
await api.autoMode.resumeInterrupted(currentProject.path);
|
||||||
|
logger.info('Checked for interrupted features');
|
||||||
|
} catch (resumeError) {
|
||||||
|
logger.warn('Failed to check for interrupted features:', resumeError);
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if (!result.success && result.error) {
|
} else if (!result.success && result.error) {
|
||||||
logger.error('API returned error:', result.error);
|
logger.error('API returned error:', result.error);
|
||||||
// If it's a new project or the error indicates no features found,
|
// If it's a new project or the error indicates no features found,
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
|
|||||||
featureId: string,
|
featureId: string,
|
||||||
updates: Partial<Feature>,
|
updates: Partial<Feature>,
|
||||||
descriptionHistorySource?: 'enhance' | 'edit',
|
descriptionHistorySource?: 'enhance' | 'edit',
|
||||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance'
|
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer',
|
||||||
|
preEnhancementDescription?: string
|
||||||
) => {
|
) => {
|
||||||
if (!currentProject) return;
|
if (!currentProject) return;
|
||||||
|
|
||||||
@@ -35,7 +36,8 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
|
|||||||
featureId,
|
featureId,
|
||||||
updates,
|
updates,
|
||||||
descriptionHistorySource,
|
descriptionHistorySource,
|
||||||
enhancementMode
|
enhancementMode,
|
||||||
|
preEnhancementDescription
|
||||||
);
|
);
|
||||||
if (result.success && result.feature) {
|
if (result.success && result.feature) {
|
||||||
updateFeature(result.feature.id, result.feature);
|
updateFeature(result.feature.id, result.feature);
|
||||||
|
|||||||
@@ -4,13 +4,18 @@ import {
|
|||||||
FeatureImagePath as DescriptionImagePath,
|
FeatureImagePath as DescriptionImagePath,
|
||||||
ImagePreviewMap,
|
ImagePreviewMap,
|
||||||
} from '@/components/ui/description-image-dropzone';
|
} from '@/components/ui/description-image-dropzone';
|
||||||
|
import type { FollowUpHistoryEntry } from '../dialogs/follow-up-dialog';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook for managing follow-up dialog state including prompt history
|
||||||
|
*/
|
||||||
export function useFollowUpState() {
|
export function useFollowUpState() {
|
||||||
const [showFollowUpDialog, setShowFollowUpDialog] = useState(false);
|
const [showFollowUpDialog, setShowFollowUpDialog] = useState(false);
|
||||||
const [followUpFeature, setFollowUpFeature] = useState<Feature | null>(null);
|
const [followUpFeature, setFollowUpFeature] = useState<Feature | null>(null);
|
||||||
const [followUpPrompt, setFollowUpPrompt] = useState('');
|
const [followUpPrompt, setFollowUpPrompt] = useState('');
|
||||||
const [followUpImagePaths, setFollowUpImagePaths] = useState<DescriptionImagePath[]>([]);
|
const [followUpImagePaths, setFollowUpImagePaths] = useState<DescriptionImagePath[]>([]);
|
||||||
const [followUpPreviewMap, setFollowUpPreviewMap] = useState<ImagePreviewMap>(() => new Map());
|
const [followUpPreviewMap, setFollowUpPreviewMap] = useState<ImagePreviewMap>(() => new Map());
|
||||||
|
const [followUpPromptHistory, setFollowUpPromptHistory] = useState<FollowUpHistoryEntry[]>([]);
|
||||||
|
|
||||||
const resetFollowUpState = useCallback(() => {
|
const resetFollowUpState = useCallback(() => {
|
||||||
setShowFollowUpDialog(false);
|
setShowFollowUpDialog(false);
|
||||||
@@ -18,6 +23,7 @@ export function useFollowUpState() {
|
|||||||
setFollowUpPrompt('');
|
setFollowUpPrompt('');
|
||||||
setFollowUpImagePaths([]);
|
setFollowUpImagePaths([]);
|
||||||
setFollowUpPreviewMap(new Map());
|
setFollowUpPreviewMap(new Map());
|
||||||
|
setFollowUpPromptHistory([]);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleFollowUpDialogChange = useCallback(
|
const handleFollowUpDialogChange = useCallback(
|
||||||
@@ -31,6 +37,13 @@ export function useFollowUpState() {
|
|||||||
[resetFollowUpState]
|
[resetFollowUpState]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a new entry to the prompt history
|
||||||
|
*/
|
||||||
|
const addToPromptHistory = useCallback((entry: FollowUpHistoryEntry) => {
|
||||||
|
setFollowUpPromptHistory((prev) => [...prev, entry]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// State
|
// State
|
||||||
showFollowUpDialog,
|
showFollowUpDialog,
|
||||||
@@ -38,14 +51,17 @@ export function useFollowUpState() {
|
|||||||
followUpPrompt,
|
followUpPrompt,
|
||||||
followUpImagePaths,
|
followUpImagePaths,
|
||||||
followUpPreviewMap,
|
followUpPreviewMap,
|
||||||
|
followUpPromptHistory,
|
||||||
// Setters
|
// Setters
|
||||||
setShowFollowUpDialog,
|
setShowFollowUpDialog,
|
||||||
setFollowUpFeature,
|
setFollowUpFeature,
|
||||||
setFollowUpPrompt,
|
setFollowUpPrompt,
|
||||||
setFollowUpImagePaths,
|
setFollowUpImagePaths,
|
||||||
setFollowUpPreviewMap,
|
setFollowUpPreviewMap,
|
||||||
|
setFollowUpPromptHistory,
|
||||||
// Helpers
|
// Helpers
|
||||||
resetFollowUpState,
|
resetFollowUpState,
|
||||||
handleFollowUpDialogChange,
|
handleFollowUpDialogChange,
|
||||||
|
addToPromptHistory,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export function KanbanBoard({
|
|||||||
const { columnWidth, containerStyle } = useResponsiveKanban(columns.length);
|
const { columnWidth, containerStyle } = useResponsiveKanban(columns.length);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 overflow-x-auto px-5 pb-4 relative" style={backgroundImageStyle}>
|
<div className="flex-1 overflow-x-auto px-5 pt-4 pb-4 relative" style={backgroundImageStyle}>
|
||||||
<DndContext
|
<DndContext
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
collisionDetection={collisionDetectionStrategy}
|
collisionDetection={collisionDetectionStrategy}
|
||||||
|
|||||||
@@ -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 './planning-mode-select';
|
||||||
export * from './ancestor-context-section';
|
export * from './ancestor-context-section';
|
||||||
export * from './work-mode-selector';
|
export * from './work-mode-selector';
|
||||||
|
export * from './enhancement';
|
||||||
|
|||||||
@@ -6,13 +6,15 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuSubContent,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import {
|
import {
|
||||||
Trash2,
|
Trash2,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
GitCommit,
|
GitCommit,
|
||||||
GitPullRequest,
|
GitPullRequest,
|
||||||
ExternalLink,
|
|
||||||
Download,
|
Download,
|
||||||
Upload,
|
Upload,
|
||||||
Play,
|
Play,
|
||||||
@@ -22,15 +24,18 @@ import {
|
|||||||
GitMerge,
|
GitMerge,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
Copy,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { WorktreeInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types';
|
import type { WorktreeInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types';
|
||||||
import { TooltipWrapper } from './tooltip-wrapper';
|
import { TooltipWrapper } from './tooltip-wrapper';
|
||||||
|
import { useAvailableEditors, useEffectiveDefaultEditor } from '../hooks/use-available-editors';
|
||||||
|
import { getEditorIcon } from '@/components/icons/editor-icons';
|
||||||
|
|
||||||
interface WorktreeActionsDropdownProps {
|
interface WorktreeActionsDropdownProps {
|
||||||
worktree: WorktreeInfo;
|
worktree: WorktreeInfo;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
defaultEditorName: string;
|
|
||||||
aheadCount: number;
|
aheadCount: number;
|
||||||
behindCount: number;
|
behindCount: number;
|
||||||
isPulling: boolean;
|
isPulling: boolean;
|
||||||
@@ -42,7 +47,7 @@ interface WorktreeActionsDropdownProps {
|
|||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
onPull: (worktree: WorktreeInfo) => void;
|
onPull: (worktree: WorktreeInfo) => void;
|
||||||
onPush: (worktree: WorktreeInfo) => void;
|
onPush: (worktree: WorktreeInfo) => void;
|
||||||
onOpenInEditor: (worktree: WorktreeInfo) => void;
|
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
|
||||||
onCommit: (worktree: WorktreeInfo) => void;
|
onCommit: (worktree: WorktreeInfo) => void;
|
||||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||||
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||||
@@ -58,7 +63,6 @@ interface WorktreeActionsDropdownProps {
|
|||||||
export function WorktreeActionsDropdown({
|
export function WorktreeActionsDropdown({
|
||||||
worktree,
|
worktree,
|
||||||
isSelected,
|
isSelected,
|
||||||
defaultEditorName,
|
|
||||||
aheadCount,
|
aheadCount,
|
||||||
behindCount,
|
behindCount,
|
||||||
isPulling,
|
isPulling,
|
||||||
@@ -82,6 +86,20 @@ export function WorktreeActionsDropdown({
|
|||||||
onRunInitScript,
|
onRunInitScript,
|
||||||
hasInitScript,
|
hasInitScript,
|
||||||
}: WorktreeActionsDropdownProps) {
|
}: WorktreeActionsDropdownProps) {
|
||||||
|
// Get available editors for the "Open In" submenu
|
||||||
|
const { editors } = useAvailableEditors();
|
||||||
|
|
||||||
|
// Use shared hook for effective default editor
|
||||||
|
const effectiveDefaultEditor = useEffectiveDefaultEditor(editors);
|
||||||
|
|
||||||
|
// Get other editors (excluding the default) for the submenu
|
||||||
|
const otherEditors = editors.filter((e) => e.command !== effectiveDefaultEditor?.command);
|
||||||
|
|
||||||
|
// Get icon component for the effective editor (avoid IIFE in JSX)
|
||||||
|
const DefaultEditorIcon = effectiveDefaultEditor
|
||||||
|
? getEditorIcon(effectiveDefaultEditor.command)
|
||||||
|
: null;
|
||||||
|
|
||||||
// Check if there's a PR associated with this worktree from stored metadata
|
// Check if there's a PR associated with this worktree from stored metadata
|
||||||
const hasPR = !!worktree.pr;
|
const hasPR = !!worktree.pr;
|
||||||
|
|
||||||
@@ -205,10 +223,54 @@ export function WorktreeActionsDropdown({
|
|||||||
</TooltipWrapper>
|
</TooltipWrapper>
|
||||||
)}
|
)}
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={() => onOpenInEditor(worktree)} className="text-xs">
|
{/* Open in editor - split button: click main area for default, chevron for other options */}
|
||||||
<ExternalLink className="w-3.5 h-3.5 mr-2" />
|
{effectiveDefaultEditor && (
|
||||||
Open in {defaultEditorName}
|
<DropdownMenuSub>
|
||||||
</DropdownMenuItem>
|
<div className="flex items-center">
|
||||||
|
{/* Main clickable area - opens in default editor */}
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onOpenInEditor(worktree, effectiveDefaultEditor.command)}
|
||||||
|
className="text-xs flex-1 pr-0 rounded-r-none"
|
||||||
|
>
|
||||||
|
{DefaultEditorIcon && <DefaultEditorIcon className="w-3.5 h-3.5 mr-2" />}
|
||||||
|
Open in {effectiveDefaultEditor.name}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{/* Chevron trigger for submenu with other editors and Copy Path */}
|
||||||
|
<DropdownMenuSubTrigger className="text-xs px-1 rounded-l-none border-l border-border/30 h-8" />
|
||||||
|
</div>
|
||||||
|
<DropdownMenuSubContent>
|
||||||
|
{/* Other editors */}
|
||||||
|
{otherEditors.map((editor) => {
|
||||||
|
const EditorIcon = getEditorIcon(editor.command);
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={editor.command}
|
||||||
|
onClick={() => onOpenInEditor(worktree, editor.command)}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<EditorIcon className="w-3.5 h-3.5 mr-2" />
|
||||||
|
{editor.name}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{otherEditors.length > 0 && <DropdownMenuSeparator />}
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(worktree.path);
|
||||||
|
toast.success('Path copied to clipboard');
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to copy path to clipboard');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Copy className="w-3.5 h-3.5 mr-2" />
|
||||||
|
Copy Path
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuSub>
|
||||||
|
)}
|
||||||
{!worktree.isMain && hasInitScript && (
|
{!worktree.isMain && hasInitScript && (
|
||||||
<DropdownMenuItem onClick={() => onRunInitScript(worktree)} className="text-xs">
|
<DropdownMenuItem onClick={() => onRunInitScript(worktree)} className="text-xs">
|
||||||
<RefreshCw className="w-3.5 h-3.5 mr-2" />
|
<RefreshCw className="w-3.5 h-3.5 mr-2" />
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ interface WorktreeTabProps {
|
|||||||
isActivating: boolean;
|
isActivating: boolean;
|
||||||
isDevServerRunning: boolean;
|
isDevServerRunning: boolean;
|
||||||
devServerInfo?: DevServerInfo;
|
devServerInfo?: DevServerInfo;
|
||||||
defaultEditorName: string;
|
|
||||||
branches: BranchInfo[];
|
branches: BranchInfo[];
|
||||||
filteredBranches: BranchInfo[];
|
filteredBranches: BranchInfo[];
|
||||||
branchFilter: string;
|
branchFilter: string;
|
||||||
@@ -37,7 +36,7 @@ interface WorktreeTabProps {
|
|||||||
onCreateBranch: (worktree: WorktreeInfo) => void;
|
onCreateBranch: (worktree: WorktreeInfo) => void;
|
||||||
onPull: (worktree: WorktreeInfo) => void;
|
onPull: (worktree: WorktreeInfo) => void;
|
||||||
onPush: (worktree: WorktreeInfo) => void;
|
onPush: (worktree: WorktreeInfo) => void;
|
||||||
onOpenInEditor: (worktree: WorktreeInfo) => void;
|
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
|
||||||
onCommit: (worktree: WorktreeInfo) => void;
|
onCommit: (worktree: WorktreeInfo) => void;
|
||||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||||
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||||
@@ -60,7 +59,6 @@ export function WorktreeTab({
|
|||||||
isActivating,
|
isActivating,
|
||||||
isDevServerRunning,
|
isDevServerRunning,
|
||||||
devServerInfo,
|
devServerInfo,
|
||||||
defaultEditorName,
|
|
||||||
branches,
|
branches,
|
||||||
filteredBranches,
|
filteredBranches,
|
||||||
branchFilter,
|
branchFilter,
|
||||||
@@ -319,7 +317,6 @@ export function WorktreeTab({
|
|||||||
<WorktreeActionsDropdown
|
<WorktreeActionsDropdown
|
||||||
worktree={worktree}
|
worktree={worktree}
|
||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
defaultEditorName={defaultEditorName}
|
|
||||||
aheadCount={aheadCount}
|
aheadCount={aheadCount}
|
||||||
behindCount={behindCount}
|
behindCount={behindCount}
|
||||||
isPulling={isPulling}
|
isPulling={isPulling}
|
||||||
|
|||||||
@@ -2,5 +2,5 @@ export { useWorktrees } from './use-worktrees';
|
|||||||
export { useDevServers } from './use-dev-servers';
|
export { useDevServers } from './use-dev-servers';
|
||||||
export { useBranches } from './use-branches';
|
export { useBranches } from './use-branches';
|
||||||
export { useWorktreeActions } from './use-worktree-actions';
|
export { useWorktreeActions } from './use-worktree-actions';
|
||||||
export { useDefaultEditor } from './use-default-editor';
|
|
||||||
export { useRunningFeatures } from './use-running-features';
|
export { useRunningFeatures } from './use-running-features';
|
||||||
|
export { useAvailableEditors, useEffectiveDefaultEditor } from './use-available-editors';
|
||||||
|
|||||||
@@ -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]
|
[isPushing, fetchBranches, fetchWorktrees]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleOpenInEditor = useCallback(async (worktree: WorktreeInfo) => {
|
const handleOpenInEditor = useCallback(async (worktree: WorktreeInfo, editorCommand?: string) => {
|
||||||
try {
|
try {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
if (!api?.worktree?.openInEditor) {
|
if (!api?.worktree?.openInEditor) {
|
||||||
logger.warn('Open in editor API not available');
|
logger.warn('Open in editor API not available');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const result = await api.worktree.openInEditor(worktree.path);
|
const result = await api.worktree.openInEditor(worktree.path, editorCommand);
|
||||||
if (result.success && result.result) {
|
if (result.success && result.result) {
|
||||||
toast.success(result.result.message);
|
toast.success(result.result.message);
|
||||||
} else if (result.error) {
|
} else if (result.error) {
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
useDevServers,
|
useDevServers,
|
||||||
useBranches,
|
useBranches,
|
||||||
useWorktreeActions,
|
useWorktreeActions,
|
||||||
useDefaultEditor,
|
|
||||||
useRunningFeatures,
|
useRunningFeatures,
|
||||||
} from './hooks';
|
} from './hooks';
|
||||||
import { WorktreeTab } from './components';
|
import { WorktreeTab } from './components';
|
||||||
@@ -77,8 +76,6 @@ export function WorktreePanel({
|
|||||||
fetchBranches,
|
fetchBranches,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { defaultEditorName } = useDefaultEditor();
|
|
||||||
|
|
||||||
const { hasRunningFeatures } = useRunningFeatures({
|
const { hasRunningFeatures } = useRunningFeatures({
|
||||||
runningFeatureIds,
|
runningFeatureIds,
|
||||||
features,
|
features,
|
||||||
@@ -188,7 +185,6 @@ export function WorktreePanel({
|
|||||||
isActivating={isActivating}
|
isActivating={isActivating}
|
||||||
isDevServerRunning={isDevServerRunning(mainWorktree)}
|
isDevServerRunning={isDevServerRunning(mainWorktree)}
|
||||||
devServerInfo={getDevServerInfo(mainWorktree)}
|
devServerInfo={getDevServerInfo(mainWorktree)}
|
||||||
defaultEditorName={defaultEditorName}
|
|
||||||
branches={branches}
|
branches={branches}
|
||||||
filteredBranches={filteredBranches}
|
filteredBranches={filteredBranches}
|
||||||
branchFilter={branchFilter}
|
branchFilter={branchFilter}
|
||||||
@@ -245,7 +241,6 @@ export function WorktreePanel({
|
|||||||
isActivating={isActivating}
|
isActivating={isActivating}
|
||||||
isDevServerRunning={isDevServerRunning(worktree)}
|
isDevServerRunning={isDevServerRunning(worktree)}
|
||||||
devServerInfo={getDevServerInfo(worktree)}
|
devServerInfo={getDevServerInfo(worktree)}
|
||||||
defaultEditorName={defaultEditorName}
|
|
||||||
branches={branches}
|
branches={branches}
|
||||||
filteredBranches={filteredBranches}
|
filteredBranches={filteredBranches}
|
||||||
branchFilter={branchFilter}
|
branchFilter={branchFilter}
|
||||||
|
|||||||
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 (
|
return (
|
||||||
<Panel position="bottom-left" className="flex flex-col gap-2">
|
<Panel position="bottom-left" className="flex flex-col gap-2">
|
||||||
<TooltipProvider delayDuration={200}>
|
<TooltipProvider delayDuration={200}>
|
||||||
<div className="flex flex-col gap-1 p-1.5 rounded-lg bg-popover/90 backdrop-blur-sm border border-border shadow-lg text-popover-foreground">
|
<div
|
||||||
|
className="flex flex-col gap-1 p-1.5 rounded-lg backdrop-blur-sm border border-border shadow-lg text-popover-foreground"
|
||||||
|
style={{ backgroundColor: 'color-mix(in oklch, var(--popover) 90%, transparent)' }}
|
||||||
|
>
|
||||||
{/* Zoom controls */}
|
{/* Zoom controls */}
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
|||||||
@@ -110,7 +110,10 @@ export function GraphFilterControls({
|
|||||||
return (
|
return (
|
||||||
<Panel position="top-left" className="flex items-center gap-2">
|
<Panel position="top-left" className="flex items-center gap-2">
|
||||||
<TooltipProvider delayDuration={200}>
|
<TooltipProvider delayDuration={200}>
|
||||||
<div className="flex items-center gap-2 p-2 rounded-lg bg-popover/90 backdrop-blur-sm border border-border shadow-lg text-popover-foreground">
|
<div
|
||||||
|
className="flex items-center gap-2 p-2 rounded-lg backdrop-blur-sm border border-border shadow-lg text-popover-foreground"
|
||||||
|
style={{ backgroundColor: 'color-mix(in oklch, var(--popover) 90%, transparent)' }}
|
||||||
|
>
|
||||||
{/* Category Filter Dropdown */}
|
{/* Category Filter Dropdown */}
|
||||||
<Popover>
|
<Popover>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|||||||
@@ -44,7 +44,10 @@ const legendItems = [
|
|||||||
export function GraphLegend() {
|
export function GraphLegend() {
|
||||||
return (
|
return (
|
||||||
<Panel position="bottom-right" className="pointer-events-none">
|
<Panel position="bottom-right" className="pointer-events-none">
|
||||||
<div className="flex flex-wrap gap-3 p-2 rounded-lg bg-popover/90 backdrop-blur-sm border border-border shadow-lg pointer-events-auto text-popover-foreground">
|
<div
|
||||||
|
className="flex flex-wrap gap-3 p-2 rounded-lg backdrop-blur-sm border border-border shadow-lg pointer-events-auto text-popover-foreground"
|
||||||
|
style={{ backgroundColor: 'color-mix(in oklch, var(--popover) 90%, transparent)' }}
|
||||||
|
>
|
||||||
{legendItems.map((item) => {
|
{legendItems.map((item) => {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -75,6 +75,24 @@ const priorityConfig = {
|
|||||||
3: { label: 'Low', colorClass: 'bg-[var(--status-info)] text-white' },
|
3: { label: 'Low', colorClass: 'bg-[var(--status-info)] text-white' },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Helper function to get border style with opacity (like KanbanCard does)
|
||||||
|
function getCardBorderStyle(
|
||||||
|
enabled: boolean,
|
||||||
|
opacity: number,
|
||||||
|
borderColor: string
|
||||||
|
): React.CSSProperties {
|
||||||
|
if (!enabled) {
|
||||||
|
return { borderWidth: '0px', borderColor: 'transparent' };
|
||||||
|
}
|
||||||
|
if (opacity !== 100) {
|
||||||
|
return {
|
||||||
|
borderWidth: '2px',
|
||||||
|
borderColor: `color-mix(in oklch, ${borderColor} ${opacity}%, transparent)`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { borderWidth: '2px' };
|
||||||
|
}
|
||||||
|
|
||||||
export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps) {
|
export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps) {
|
||||||
// Handle pipeline statuses by treating them like in_progress
|
// Handle pipeline statuses by treating them like in_progress
|
||||||
const status = data.status || 'backlog';
|
const status = data.status || 'backlog';
|
||||||
@@ -91,6 +109,28 @@ export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps
|
|||||||
// Task is stopped if it's in_progress but not actively running
|
// Task is stopped if it's in_progress but not actively running
|
||||||
const isStopped = data.status === 'in_progress' && !data.isRunning;
|
const isStopped = data.status === 'in_progress' && !data.isRunning;
|
||||||
|
|
||||||
|
// Background/theme settings with defaults
|
||||||
|
const cardOpacity = data.cardOpacity ?? 100;
|
||||||
|
const glassmorphism = data.cardGlassmorphism ?? true;
|
||||||
|
const cardBorderEnabled = data.cardBorderEnabled ?? true;
|
||||||
|
const cardBorderOpacity = data.cardBorderOpacity ?? 100;
|
||||||
|
|
||||||
|
// Get the border color based on status and error state
|
||||||
|
const borderColor = data.error
|
||||||
|
? 'var(--status-error)'
|
||||||
|
: config.borderClass.includes('border-border')
|
||||||
|
? 'var(--border)'
|
||||||
|
: config.borderClass.includes('status-in-progress')
|
||||||
|
? 'var(--status-in-progress)'
|
||||||
|
: config.borderClass.includes('status-waiting')
|
||||||
|
? 'var(--status-waiting)'
|
||||||
|
: config.borderClass.includes('status-success')
|
||||||
|
? 'var(--status-success)'
|
||||||
|
: 'var(--border)';
|
||||||
|
|
||||||
|
// Get computed border style
|
||||||
|
const borderStyle = getCardBorderStyle(cardBorderEnabled, cardBorderOpacity, borderColor);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Target handle (left side - receives dependencies) */}
|
{/* Target handle (left side - receives dependencies) */}
|
||||||
@@ -109,22 +149,26 @@ export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'min-w-[240px] max-w-[280px] rounded-xl border-2 bg-card shadow-md',
|
'min-w-[240px] max-w-[280px] rounded-xl shadow-md relative',
|
||||||
'transition-all duration-300',
|
'transition-all duration-300',
|
||||||
config.borderClass,
|
|
||||||
selected && 'ring-2 ring-brand-500 ring-offset-2 ring-offset-background',
|
selected && 'ring-2 ring-brand-500 ring-offset-2 ring-offset-background',
|
||||||
data.isRunning && 'animate-pulse-subtle',
|
data.isRunning && 'animate-pulse-subtle',
|
||||||
data.error && 'border-[var(--status-error)]',
|
|
||||||
// Filter highlight states
|
// Filter highlight states
|
||||||
isMatched && 'graph-node-matched',
|
isMatched && 'graph-node-matched',
|
||||||
isHighlighted && !isMatched && 'graph-node-highlighted',
|
isHighlighted && !isMatched && 'graph-node-highlighted',
|
||||||
isDimmed && 'graph-node-dimmed'
|
isDimmed && 'graph-node-dimmed'
|
||||||
)}
|
)}
|
||||||
|
style={borderStyle}
|
||||||
>
|
>
|
||||||
|
{/* Background layer with opacity control - like KanbanCard */}
|
||||||
|
<div
|
||||||
|
className={cn('absolute inset-0 rounded-xl bg-card', glassmorphism && 'backdrop-blur-sm')}
|
||||||
|
style={{ opacity: cardOpacity / 100 }}
|
||||||
|
/>
|
||||||
{/* Header with status and actions */}
|
{/* Header with status and actions */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center justify-between px-3 py-2 rounded-t-[10px]',
|
'relative flex items-center justify-between px-3 py-2 rounded-t-[10px]',
|
||||||
config.bgClass
|
config.bgClass
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -301,7 +345,7 @@ export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="px-3 py-2">
|
<div className="relative px-3 py-2">
|
||||||
{/* Category */}
|
{/* Category */}
|
||||||
<span className="text-[10px] text-muted-foreground font-medium uppercase tracking-wide">
|
<span className="text-[10px] text-muted-foreground font-medium uppercase tracking-wide">
|
||||||
{data.category}
|
{data.category}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import {
|
|||||||
} from '@xyflow/react';
|
} from '@xyflow/react';
|
||||||
import '@xyflow/react/dist/style.css';
|
import '@xyflow/react/dist/style.css';
|
||||||
|
|
||||||
import { Feature } from '@/store/app-store';
|
import { Feature, useAppStore } from '@/store/app-store';
|
||||||
|
import { themeOptions } from '@/config/theme-options';
|
||||||
import {
|
import {
|
||||||
TaskNode,
|
TaskNode,
|
||||||
DependencyEdge,
|
DependencyEdge,
|
||||||
@@ -47,6 +48,13 @@ const edgeTypes: any = {
|
|||||||
dependency: DependencyEdge,
|
dependency: DependencyEdge,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface BackgroundSettings {
|
||||||
|
cardOpacity: number;
|
||||||
|
cardGlassmorphism: boolean;
|
||||||
|
cardBorderEnabled: boolean;
|
||||||
|
cardBorderOpacity: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface GraphCanvasProps {
|
interface GraphCanvasProps {
|
||||||
features: Feature[];
|
features: Feature[];
|
||||||
runningAutoTasks: string[];
|
runningAutoTasks: string[];
|
||||||
@@ -56,6 +64,7 @@ interface GraphCanvasProps {
|
|||||||
nodeActionCallbacks?: NodeActionCallbacks;
|
nodeActionCallbacks?: NodeActionCallbacks;
|
||||||
onCreateDependency?: (sourceId: string, targetId: string) => Promise<boolean>;
|
onCreateDependency?: (sourceId: string, targetId: string) => Promise<boolean>;
|
||||||
backgroundStyle?: React.CSSProperties;
|
backgroundStyle?: React.CSSProperties;
|
||||||
|
backgroundSettings?: BackgroundSettings;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,11 +77,42 @@ function GraphCanvasInner({
|
|||||||
nodeActionCallbacks,
|
nodeActionCallbacks,
|
||||||
onCreateDependency,
|
onCreateDependency,
|
||||||
backgroundStyle,
|
backgroundStyle,
|
||||||
|
backgroundSettings,
|
||||||
className,
|
className,
|
||||||
}: GraphCanvasProps) {
|
}: GraphCanvasProps) {
|
||||||
const [isLocked, setIsLocked] = useState(false);
|
const [isLocked, setIsLocked] = useState(false);
|
||||||
const [layoutDirection, setLayoutDirection] = useState<'LR' | 'TB'>('LR');
|
const [layoutDirection, setLayoutDirection] = useState<'LR' | 'TB'>('LR');
|
||||||
|
|
||||||
|
// Determine React Flow color mode based on current theme
|
||||||
|
const effectiveTheme = useAppStore((state) => state.getEffectiveTheme());
|
||||||
|
const [systemColorMode, setSystemColorMode] = useState<'dark' | 'light'>(() => {
|
||||||
|
if (typeof window === 'undefined') return 'dark';
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (effectiveTheme !== 'system') return;
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
const mql = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
const update = () => setSystemColorMode(mql.matches ? 'dark' : 'light');
|
||||||
|
update();
|
||||||
|
|
||||||
|
// Safari < 14 fallback
|
||||||
|
if (mql.addEventListener) {
|
||||||
|
mql.addEventListener('change', update);
|
||||||
|
return () => mql.removeEventListener('change', update);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line deprecation/deprecation
|
||||||
|
mql.addListener(update);
|
||||||
|
// eslint-disable-next-line deprecation/deprecation
|
||||||
|
return () => mql.removeListener(update);
|
||||||
|
}, [effectiveTheme]);
|
||||||
|
|
||||||
|
const themeOption = themeOptions.find((t) => t.value === effectiveTheme);
|
||||||
|
const colorMode =
|
||||||
|
effectiveTheme === 'system' ? systemColorMode : themeOption?.isDark ? 'dark' : 'light';
|
||||||
|
|
||||||
// Filter state (category, status, and negative toggle are local to graph view)
|
// Filter state (category, status, and negative toggle are local to graph view)
|
||||||
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
|
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
|
||||||
const [selectedStatuses, setSelectedStatuses] = useState<string[]>([]);
|
const [selectedStatuses, setSelectedStatuses] = useState<string[]>([]);
|
||||||
@@ -98,6 +138,7 @@ function GraphCanvasInner({
|
|||||||
runningAutoTasks,
|
runningAutoTasks,
|
||||||
filterResult,
|
filterResult,
|
||||||
actionCallbacks: nodeActionCallbacks,
|
actionCallbacks: nodeActionCallbacks,
|
||||||
|
backgroundSettings,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Apply layout
|
// Apply layout
|
||||||
@@ -234,6 +275,7 @@ function GraphCanvasInner({
|
|||||||
isValidConnection={isValidConnection}
|
isValidConnection={isValidConnection}
|
||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
edgeTypes={edgeTypes}
|
edgeTypes={edgeTypes}
|
||||||
|
colorMode={colorMode}
|
||||||
fitView
|
fitView
|
||||||
fitViewOptions={{ padding: 0.2 }}
|
fitViewOptions={{ padding: 0.2 }}
|
||||||
minZoom={0.1}
|
minZoom={0.1}
|
||||||
@@ -256,7 +298,8 @@ function GraphCanvasInner({
|
|||||||
nodeStrokeWidth={3}
|
nodeStrokeWidth={3}
|
||||||
zoomable
|
zoomable
|
||||||
pannable
|
pannable
|
||||||
className="!bg-popover/90 !border-border rounded-lg shadow-lg"
|
className="border-border! rounded-lg shadow-lg"
|
||||||
|
style={{ backgroundColor: 'color-mix(in oklch, var(--popover) 90%, transparent)' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<GraphControls
|
<GraphControls
|
||||||
@@ -281,7 +324,10 @@ function GraphCanvasInner({
|
|||||||
{/* Empty state when all nodes are filtered out */}
|
{/* Empty state when all nodes are filtered out */}
|
||||||
{filterResult.hasActiveFilter && filterResult.matchedNodeIds.size === 0 && (
|
{filterResult.hasActiveFilter && filterResult.matchedNodeIds.size === 0 && (
|
||||||
<Panel position="top-center" className="mt-20">
|
<Panel position="top-center" className="mt-20">
|
||||||
<div className="flex flex-col items-center gap-3 p-6 rounded-lg bg-popover/95 backdrop-blur-sm border border-border shadow-lg text-popover-foreground">
|
<div
|
||||||
|
className="flex flex-col items-center gap-3 p-6 rounded-lg backdrop-blur-sm border border-border shadow-lg text-popover-foreground"
|
||||||
|
style={{ backgroundColor: 'color-mix(in oklch, var(--popover) 95%, transparent)' }}
|
||||||
|
>
|
||||||
<SearchX className="w-10 h-10 text-muted-foreground" />
|
<SearchX className="w-10 h-10 text-muted-foreground" />
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-sm font-medium">No matching tasks</p>
|
<p className="text-sm font-medium">No matching tasks</p>
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export function GraphView({
|
|||||||
const { currentProject } = useAppStore();
|
const { currentProject } = useAppStore();
|
||||||
|
|
||||||
// Use the same background hook as the board view
|
// Use the same background hook as the board view
|
||||||
const { backgroundImageStyle } = useBoardBackground({ currentProject });
|
const { backgroundImageStyle, backgroundSettings } = useBoardBackground({ currentProject });
|
||||||
|
|
||||||
// Filter features by current worktree (same logic as board view)
|
// Filter features by current worktree (same logic as board view)
|
||||||
const filteredFeatures = useMemo(() => {
|
const filteredFeatures = useMemo(() => {
|
||||||
@@ -213,6 +213,7 @@ export function GraphView({
|
|||||||
nodeActionCallbacks={nodeActionCallbacks}
|
nodeActionCallbacks={nodeActionCallbacks}
|
||||||
onCreateDependency={handleCreateDependency}
|
onCreateDependency={handleCreateDependency}
|
||||||
backgroundStyle={backgroundImageStyle}
|
backgroundStyle={backgroundImageStyle}
|
||||||
|
backgroundSettings={backgroundSettings}
|
||||||
className="h-full"
|
className="h-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ export interface TaskNodeData extends Feature {
|
|||||||
isMatched?: boolean;
|
isMatched?: boolean;
|
||||||
isHighlighted?: boolean;
|
isHighlighted?: boolean;
|
||||||
isDimmed?: boolean;
|
isDimmed?: boolean;
|
||||||
|
// Background/theme settings
|
||||||
|
cardOpacity?: number;
|
||||||
|
cardGlassmorphism?: boolean;
|
||||||
|
cardBorderEnabled?: boolean;
|
||||||
|
cardBorderOpacity?: number;
|
||||||
// Action callbacks
|
// Action callbacks
|
||||||
onViewLogs?: () => void;
|
onViewLogs?: () => void;
|
||||||
onViewDetails?: () => void;
|
onViewDetails?: () => void;
|
||||||
@@ -48,11 +53,19 @@ export interface NodeActionCallbacks {
|
|||||||
onDeleteDependency?: (sourceId: string, targetId: string) => void;
|
onDeleteDependency?: (sourceId: string, targetId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BackgroundSettings {
|
||||||
|
cardOpacity: number;
|
||||||
|
cardGlassmorphism: boolean;
|
||||||
|
cardBorderEnabled: boolean;
|
||||||
|
cardBorderOpacity: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface UseGraphNodesProps {
|
interface UseGraphNodesProps {
|
||||||
features: Feature[];
|
features: Feature[];
|
||||||
runningAutoTasks: string[];
|
runningAutoTasks: string[];
|
||||||
filterResult?: GraphFilterResult;
|
filterResult?: GraphFilterResult;
|
||||||
actionCallbacks?: NodeActionCallbacks;
|
actionCallbacks?: NodeActionCallbacks;
|
||||||
|
backgroundSettings?: BackgroundSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -64,6 +77,7 @@ export function useGraphNodes({
|
|||||||
runningAutoTasks,
|
runningAutoTasks,
|
||||||
filterResult,
|
filterResult,
|
||||||
actionCallbacks,
|
actionCallbacks,
|
||||||
|
backgroundSettings,
|
||||||
}: UseGraphNodesProps) {
|
}: UseGraphNodesProps) {
|
||||||
const { nodes, edges } = useMemo(() => {
|
const { nodes, edges } = useMemo(() => {
|
||||||
const nodeList: TaskNode[] = [];
|
const nodeList: TaskNode[] = [];
|
||||||
@@ -102,6 +116,11 @@ export function useGraphNodes({
|
|||||||
isMatched,
|
isMatched,
|
||||||
isHighlighted,
|
isHighlighted,
|
||||||
isDimmed,
|
isDimmed,
|
||||||
|
// Background/theme settings
|
||||||
|
cardOpacity: backgroundSettings?.cardOpacity,
|
||||||
|
cardGlassmorphism: backgroundSettings?.cardGlassmorphism,
|
||||||
|
cardBorderEnabled: backgroundSettings?.cardBorderEnabled,
|
||||||
|
cardBorderOpacity: backgroundSettings?.cardBorderOpacity,
|
||||||
// Action callbacks (bound to this feature's ID)
|
// Action callbacks (bound to this feature's ID)
|
||||||
onViewLogs: actionCallbacks?.onViewLogs
|
onViewLogs: actionCallbacks?.onViewLogs
|
||||||
? () => actionCallbacks.onViewLogs!(feature.id)
|
? () => actionCallbacks.onViewLogs!(feature.id)
|
||||||
@@ -163,7 +182,7 @@ export function useGraphNodes({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return { nodes: nodeList, edges: edgeList };
|
return { nodes: nodeList, edges: edgeList };
|
||||||
}, [features, runningAutoTasks, filterResult, actionCallbacks]);
|
}, [features, runningAutoTasks, filterResult, actionCallbacks, backgroundSettings]);
|
||||||
|
|
||||||
return { nodes, edges };
|
return { nodes, edges };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* First page users see - shows all ideas ready for accept/reject
|
* First page users see - shows all ideas ready for accept/reject
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo, useEffect, useCallback } from 'react';
|
||||||
import { Loader2, AlertCircle, Plus, X, Sparkles, Lightbulb } from 'lucide-react';
|
import { Loader2, AlertCircle, Plus, X, Sparkles, Lightbulb } from 'lucide-react';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -17,6 +17,7 @@ import type { AnalysisSuggestion } from '@automaker/types';
|
|||||||
|
|
||||||
interface IdeationDashboardProps {
|
interface IdeationDashboardProps {
|
||||||
onGenerateIdeas: () => void;
|
onGenerateIdeas: () => void;
|
||||||
|
onAcceptAllReady?: (isReady: boolean, count: number, handler: () => Promise<void>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SuggestionCard({
|
function SuggestionCard({
|
||||||
@@ -37,14 +38,16 @@ function SuggestionCard({
|
|||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-start gap-2 mb-1">
|
||||||
<h4 className="font-medium">{suggestion.title}</h4>
|
<h4 className="font-medium shrink-0">{suggestion.title}</h4>
|
||||||
<Badge variant="outline" className="text-xs">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
{suggestion.priority}
|
<Badge variant="outline" className="text-xs whitespace-nowrap">
|
||||||
</Badge>
|
{suggestion.priority}
|
||||||
<Badge variant="secondary" className="text-xs">
|
</Badge>
|
||||||
{job.prompt.title}
|
<Badge variant="secondary" className="text-xs whitespace-nowrap">
|
||||||
</Badge>
|
{job.prompt.title}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">{suggestion.description}</p>
|
<p className="text-sm text-muted-foreground">{suggestion.description}</p>
|
||||||
{suggestion.rationale && (
|
{suggestion.rationale && (
|
||||||
@@ -166,11 +169,12 @@ function TagFilter({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function IdeationDashboard({ onGenerateIdeas }: IdeationDashboardProps) {
|
export function IdeationDashboard({ onGenerateIdeas, onAcceptAllReady }: IdeationDashboardProps) {
|
||||||
const currentProject = useAppStore((s) => s.currentProject);
|
const currentProject = useAppStore((s) => s.currentProject);
|
||||||
const generationJobs = useIdeationStore((s) => s.generationJobs);
|
const generationJobs = useIdeationStore((s) => s.generationJobs);
|
||||||
const removeSuggestionFromJob = useIdeationStore((s) => s.removeSuggestionFromJob);
|
const removeSuggestionFromJob = useIdeationStore((s) => s.removeSuggestionFromJob);
|
||||||
const [addingId, setAddingId] = useState<string | null>(null);
|
const [addingId, setAddingId] = useState<string | null>(null);
|
||||||
|
const [isAcceptingAll, setIsAcceptingAll] = useState(false);
|
||||||
const [selectedTags, setSelectedTags] = useState<Set<string>>(new Set());
|
const [selectedTags, setSelectedTags] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
// Get jobs for current project only (memoized to prevent unnecessary re-renders)
|
// Get jobs for current project only (memoized to prevent unnecessary re-renders)
|
||||||
@@ -270,6 +274,54 @@ export function IdeationDashboard({ onGenerateIdeas }: IdeationDashboardProps) {
|
|||||||
toast.info('Idea removed');
|
toast.info('Idea removed');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Accept all filtered suggestions
|
||||||
|
const handleAcceptAll = useCallback(async () => {
|
||||||
|
if (!currentProject?.path || filteredSuggestions.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsAcceptingAll(true);
|
||||||
|
const api = getElectronAPI();
|
||||||
|
let successCount = 0;
|
||||||
|
let failCount = 0;
|
||||||
|
|
||||||
|
// Process all filtered suggestions
|
||||||
|
for (const { suggestion, job } of filteredSuggestions) {
|
||||||
|
try {
|
||||||
|
const result = await api.ideation?.addSuggestionToBoard(currentProject.path, suggestion);
|
||||||
|
if (result?.success) {
|
||||||
|
removeSuggestionFromJob(job.id, suggestion.id);
|
||||||
|
successCount++;
|
||||||
|
} else {
|
||||||
|
failCount++;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to add suggestion to board:', error);
|
||||||
|
failCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsAcceptingAll(false);
|
||||||
|
|
||||||
|
if (successCount > 0 && failCount === 0) {
|
||||||
|
toast.success(`Added ${successCount} idea${successCount > 1 ? 's' : ''} to board`);
|
||||||
|
} else if (successCount > 0 && failCount > 0) {
|
||||||
|
toast.warning(
|
||||||
|
`Added ${successCount} idea${successCount > 1 ? 's' : ''}, ${failCount} failed`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.error('Failed to add ideas to board');
|
||||||
|
}
|
||||||
|
}, [currentProject?.path, filteredSuggestions, removeSuggestionFromJob]);
|
||||||
|
|
||||||
|
// Notify parent about accept all readiness
|
||||||
|
useEffect(() => {
|
||||||
|
if (onAcceptAllReady) {
|
||||||
|
const isReady = filteredSuggestions.length > 0 && !isAcceptingAll && !addingId;
|
||||||
|
onAcceptAllReady(isReady, filteredSuggestions.length, handleAcceptAll);
|
||||||
|
}
|
||||||
|
}, [filteredSuggestions.length, isAcceptingAll, addingId, handleAcceptAll, onAcceptAllReady]);
|
||||||
|
|
||||||
const isEmpty = allSuggestions.length === 0 && activeJobs.length === 0;
|
const isEmpty = allSuggestions.length === 0 && activeJobs.length === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Dashboard-first design with Generate Ideas flow
|
* Dashboard-first design with Generate Ideas flow
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { useIdeationStore } from '@/store/ideation-store';
|
import { useIdeationStore } from '@/store/ideation-store';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { PromptCategoryGrid } from './components/prompt-category-grid';
|
import { PromptCategoryGrid } from './components/prompt-category-grid';
|
||||||
@@ -11,7 +11,7 @@ import { PromptList } from './components/prompt-list';
|
|||||||
import { IdeationDashboard } from './components/ideation-dashboard';
|
import { IdeationDashboard } from './components/ideation-dashboard';
|
||||||
import { useGuidedPrompts } from '@/hooks/use-guided-prompts';
|
import { useGuidedPrompts } from '@/hooks/use-guided-prompts';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { ArrowLeft, ChevronRight, Lightbulb } from 'lucide-react';
|
import { ArrowLeft, ChevronRight, Lightbulb, CheckCheck, Loader2 } from 'lucide-react';
|
||||||
import type { IdeaCategory } from '@automaker/types';
|
import type { IdeaCategory } from '@automaker/types';
|
||||||
import type { IdeationMode } from '@/store/ideation-store';
|
import type { IdeationMode } from '@/store/ideation-store';
|
||||||
|
|
||||||
@@ -67,12 +67,20 @@ function IdeationHeader({
|
|||||||
onNavigate,
|
onNavigate,
|
||||||
onGenerateIdeas,
|
onGenerateIdeas,
|
||||||
onBack,
|
onBack,
|
||||||
|
acceptAllReady,
|
||||||
|
acceptAllCount,
|
||||||
|
onAcceptAll,
|
||||||
|
isAcceptingAll,
|
||||||
}: {
|
}: {
|
||||||
currentMode: IdeationMode;
|
currentMode: IdeationMode;
|
||||||
selectedCategory: IdeaCategory | null;
|
selectedCategory: IdeaCategory | null;
|
||||||
onNavigate: (mode: IdeationMode, category?: IdeaCategory | null) => void;
|
onNavigate: (mode: IdeationMode, category?: IdeaCategory | null) => void;
|
||||||
onGenerateIdeas: () => void;
|
onGenerateIdeas: () => void;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
|
acceptAllReady: boolean;
|
||||||
|
acceptAllCount: number;
|
||||||
|
onAcceptAll: () => void;
|
||||||
|
isAcceptingAll: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { getCategoryById } = useGuidedPrompts();
|
const { getCategoryById } = useGuidedPrompts();
|
||||||
const showBackButton = currentMode === 'prompts';
|
const showBackButton = currentMode === 'prompts';
|
||||||
@@ -120,6 +128,21 @@ function IdeationHeader({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
|
{currentMode === 'dashboard' && acceptAllReady && (
|
||||||
|
<Button
|
||||||
|
onClick={onAcceptAll}
|
||||||
|
variant="outline"
|
||||||
|
className="gap-2"
|
||||||
|
disabled={isAcceptingAll}
|
||||||
|
>
|
||||||
|
{isAcceptingAll ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<CheckCheck className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
Accept All ({acceptAllCount})
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button onClick={onGenerateIdeas} className="gap-2">
|
<Button onClick={onGenerateIdeas} className="gap-2">
|
||||||
<Lightbulb className="w-4 h-4" />
|
<Lightbulb className="w-4 h-4" />
|
||||||
Generate Ideas
|
Generate Ideas
|
||||||
@@ -133,6 +156,32 @@ export function IdeationView() {
|
|||||||
const currentProject = useAppStore((s) => s.currentProject);
|
const currentProject = useAppStore((s) => s.currentProject);
|
||||||
const { currentMode, selectedCategory, setMode, setCategory } = useIdeationStore();
|
const { currentMode, selectedCategory, setMode, setCategory } = useIdeationStore();
|
||||||
|
|
||||||
|
// Accept all state
|
||||||
|
const [acceptAllReady, setAcceptAllReady] = useState(false);
|
||||||
|
const [acceptAllCount, setAcceptAllCount] = useState(0);
|
||||||
|
const [acceptAllHandler, setAcceptAllHandler] = useState<(() => Promise<void>) | null>(null);
|
||||||
|
const [isAcceptingAll, setIsAcceptingAll] = useState(false);
|
||||||
|
|
||||||
|
const handleAcceptAllReady = useCallback(
|
||||||
|
(isReady: boolean, count: number, handler: () => Promise<void>) => {
|
||||||
|
setAcceptAllReady(isReady);
|
||||||
|
setAcceptAllCount(count);
|
||||||
|
setAcceptAllHandler(() => handler);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAcceptAll = useCallback(async () => {
|
||||||
|
if (acceptAllHandler) {
|
||||||
|
setIsAcceptingAll(true);
|
||||||
|
try {
|
||||||
|
await acceptAllHandler();
|
||||||
|
} finally {
|
||||||
|
setIsAcceptingAll(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [acceptAllHandler]);
|
||||||
|
|
||||||
const handleNavigate = useCallback(
|
const handleNavigate = useCallback(
|
||||||
(mode: IdeationMode, category?: IdeaCategory | null) => {
|
(mode: IdeationMode, category?: IdeaCategory | null) => {
|
||||||
setMode(mode);
|
setMode(mode);
|
||||||
@@ -192,10 +241,19 @@ export function IdeationView() {
|
|||||||
onNavigate={handleNavigate}
|
onNavigate={handleNavigate}
|
||||||
onGenerateIdeas={handleGenerateIdeas}
|
onGenerateIdeas={handleGenerateIdeas}
|
||||||
onBack={handleBackFromPrompts}
|
onBack={handleBackFromPrompts}
|
||||||
|
acceptAllReady={acceptAllReady}
|
||||||
|
acceptAllCount={acceptAllCount}
|
||||||
|
onAcceptAll={handleAcceptAll}
|
||||||
|
isAcceptingAll={isAcceptingAll}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Dashboard - main view */}
|
{/* Dashboard - main view */}
|
||||||
{currentMode === 'dashboard' && <IdeationDashboard onGenerateIdeas={handleGenerateIdeas} />}
|
{currentMode === 'dashboard' && (
|
||||||
|
<IdeationDashboard
|
||||||
|
onGenerateIdeas={handleGenerateIdeas}
|
||||||
|
onAcceptAllReady={handleAcceptAllReady}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Prompts - category selection */}
|
{/* Prompts - category selection */}
|
||||||
{currentMode === 'prompts' && !selectedCategory && (
|
{currentMode === 'prompts' && !selectedCategory && (
|
||||||
|
|||||||
@@ -13,7 +13,15 @@
|
|||||||
|
|
||||||
import { useReducer, useEffect, useRef } from 'react';
|
import { useReducer, useEffect, useRef } from 'react';
|
||||||
import { useNavigate } from '@tanstack/react-router';
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
import { login, getHttpApiClient, getServerUrlSync } from '@/lib/http-api-client';
|
import {
|
||||||
|
login,
|
||||||
|
getHttpApiClient,
|
||||||
|
getServerUrlSync,
|
||||||
|
getApiKey,
|
||||||
|
getSessionToken,
|
||||||
|
initApiKey,
|
||||||
|
waitForApiKeyInit,
|
||||||
|
} from '@/lib/http-api-client';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { KeyRound, AlertCircle, Loader2, RefreshCw, ServerCrash } from 'lucide-react';
|
import { KeyRound, AlertCircle, Loader2, RefreshCw, ServerCrash } from 'lucide-react';
|
||||||
@@ -92,6 +100,7 @@ function reducer(state: State, action: Action): State {
|
|||||||
|
|
||||||
const MAX_RETRIES = 5;
|
const MAX_RETRIES = 5;
|
||||||
const BACKOFF_BASE_MS = 400;
|
const BACKOFF_BASE_MS = 400;
|
||||||
|
const NO_STORE_CACHE_MODE: RequestCache = 'no-store';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Imperative Flow Logic (runs once on mount)
|
// Imperative Flow Logic (runs once on mount)
|
||||||
@@ -102,7 +111,9 @@ const BACKOFF_BASE_MS = 400;
|
|||||||
* Unlike the httpClient methods, this does NOT call handleUnauthorized()
|
* Unlike the httpClient methods, this does NOT call handleUnauthorized()
|
||||||
* which would navigate us away to /logged-out.
|
* which would navigate us away to /logged-out.
|
||||||
*
|
*
|
||||||
* Relies on HTTP-only session cookie being sent via credentials: 'include'.
|
* Supports both:
|
||||||
|
* - Electron mode: Uses X-API-Key header (API key from IPC)
|
||||||
|
* - Web mode: Uses HTTP-only session cookie
|
||||||
*
|
*
|
||||||
* Returns: { authenticated: true } or { authenticated: false }
|
* Returns: { authenticated: true } or { authenticated: false }
|
||||||
* Throws: on network errors (for retry logic)
|
* Throws: on network errors (for retry logic)
|
||||||
@@ -110,9 +121,31 @@ const BACKOFF_BASE_MS = 400;
|
|||||||
async function checkAuthStatusSafe(): Promise<{ authenticated: boolean }> {
|
async function checkAuthStatusSafe(): Promise<{ authenticated: boolean }> {
|
||||||
const serverUrl = getServerUrlSync();
|
const serverUrl = getServerUrlSync();
|
||||||
|
|
||||||
|
// Wait for API key to be initialized before checking auth
|
||||||
|
// This ensures we have a valid API key to send in the header
|
||||||
|
await waitForApiKeyInit();
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Electron mode: use API key header
|
||||||
|
const apiKey = getApiKey();
|
||||||
|
if (apiKey) {
|
||||||
|
headers['X-API-Key'] = apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add session token header if available (web mode)
|
||||||
|
const sessionToken = getSessionToken();
|
||||||
|
if (sessionToken) {
|
||||||
|
headers['X-Session-Token'] = sessionToken;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(`${serverUrl}/api/auth/status`, {
|
const response = await fetch(`${serverUrl}/api/auth/status`, {
|
||||||
credentials: 'include', // Send HTTP-only session cookie
|
headers,
|
||||||
|
credentials: 'include',
|
||||||
signal: AbortSignal.timeout(5000),
|
signal: AbortSignal.timeout(5000),
|
||||||
|
cache: NO_STORE_CACHE_MODE,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Any response means server is reachable
|
// Any response means server is reachable
|
||||||
@@ -246,6 +279,14 @@ export function LoginView() {
|
|||||||
const [state, dispatch] = useReducer(reducer, initialState);
|
const [state, dispatch] = useReducer(reducer, initialState);
|
||||||
const retryControllerRef = useRef<AbortController | null>(null);
|
const retryControllerRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
// Initialize API key before checking session
|
||||||
|
// This ensures getApiKey() returns a valid value in checkAuthStatusSafe()
|
||||||
|
useEffect(() => {
|
||||||
|
initApiKey().catch((error) => {
|
||||||
|
console.warn('Failed to initialize API key:', error);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Run initial server/session check on mount.
|
// Run initial server/session check on mount.
|
||||||
// IMPORTANT: Do not "run once" via a ref guard here.
|
// IMPORTANT: Do not "run once" via a ref guard here.
|
||||||
// In React StrictMode (dev), effects mount -> cleanup -> mount.
|
// In React StrictMode (dev), effects mount -> cleanup -> mount.
|
||||||
|
|||||||
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 { useState } from 'react';
|
||||||
import { useNavigate } from '@tanstack/react-router';
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { LogOut, User } from 'lucide-react';
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { LogOut, User, Code2, RefreshCw } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { logout } from '@/lib/http-api-client';
|
import { logout } from '@/lib/http-api-client';
|
||||||
import { useAuthStore } from '@/store/auth-store';
|
import { useAuthStore } from '@/store/auth-store';
|
||||||
|
import { useAppStore } from '@/store/app-store';
|
||||||
|
import {
|
||||||
|
useAvailableEditors,
|
||||||
|
useEffectiveDefaultEditor,
|
||||||
|
} from '@/components/views/board-view/worktree-panel/hooks/use-available-editors';
|
||||||
|
import { getEditorIcon } from '@/components/icons/editor-icons';
|
||||||
|
|
||||||
export function AccountSection() {
|
export function AccountSection() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
||||||
|
|
||||||
|
// Editor settings
|
||||||
|
const { editors, isLoading: isLoadingEditors, isRefreshing, refresh } = useAvailableEditors();
|
||||||
|
const defaultEditorCommand = useAppStore((s) => s.defaultEditorCommand);
|
||||||
|
const setDefaultEditorCommand = useAppStore((s) => s.setDefaultEditorCommand);
|
||||||
|
|
||||||
|
// Use shared hook for effective default editor
|
||||||
|
const effectiveEditor = useEffectiveDefaultEditor(editors);
|
||||||
|
|
||||||
|
// Normalize Select value: if saved editor isn't found, show 'auto'
|
||||||
|
const hasSavedEditor =
|
||||||
|
!!defaultEditorCommand && editors.some((e) => e.command === defaultEditorCommand);
|
||||||
|
const selectValue = hasSavedEditor ? defaultEditorCommand : 'auto';
|
||||||
|
|
||||||
|
// Get icon component for the effective editor
|
||||||
|
const EffectiveEditorIcon = effectiveEditor ? getEditorIcon(effectiveEditor.command) : null;
|
||||||
|
|
||||||
|
const handleRefreshEditors = async () => {
|
||||||
|
await refresh();
|
||||||
|
toast.success('Editor list refreshed');
|
||||||
|
};
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
setIsLoggingOut(true);
|
setIsLoggingOut(true);
|
||||||
try {
|
try {
|
||||||
@@ -43,6 +79,81 @@ export function AccountSection() {
|
|||||||
<p className="text-sm text-muted-foreground/80 ml-12">Manage your session and account.</p>
|
<p className="text-sm text-muted-foreground/80 ml-12">Manage your session and account.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6 space-y-4">
|
<div className="p-6 space-y-4">
|
||||||
|
{/* Default IDE */}
|
||||||
|
<div className="flex items-center justify-between gap-4 p-4 rounded-xl bg-muted/30 border border-border/30">
|
||||||
|
<div className="flex items-center gap-3.5 min-w-0">
|
||||||
|
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-muted/50 to-muted/30 border border-border/30 flex items-center justify-center shrink-0">
|
||||||
|
<Code2 className="w-5 h-5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-medium text-foreground">Default IDE</p>
|
||||||
|
<p className="text-xs text-muted-foreground/70 mt-0.5">
|
||||||
|
Default IDE to use when opening branches or worktrees
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select
|
||||||
|
value={selectValue}
|
||||||
|
onValueChange={(value) => setDefaultEditorCommand(value === 'auto' ? null : value)}
|
||||||
|
disabled={isLoadingEditors || isRefreshing || editors.length === 0}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[180px] shrink-0">
|
||||||
|
<SelectValue placeholder="Select editor">
|
||||||
|
{effectiveEditor ? (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{EffectiveEditorIcon && <EffectiveEditorIcon className="w-4 h-4" />}
|
||||||
|
{effectiveEditor.name}
|
||||||
|
{selectValue === 'auto' && (
|
||||||
|
<span className="text-muted-foreground text-xs">(Auto)</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'Select editor'
|
||||||
|
)}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="auto">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Code2 className="w-4 h-4" />
|
||||||
|
Auto-detect
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
{editors.map((editor) => {
|
||||||
|
const Icon = getEditorIcon(editor.command);
|
||||||
|
return (
|
||||||
|
<SelectItem key={editor.command} value={editor.command}>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Icon className="w-4 h-4" />
|
||||||
|
{editor.name}
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleRefreshEditors}
|
||||||
|
disabled={isRefreshing || isLoadingEditors}
|
||||||
|
className="shrink-0 h-9 w-9"
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn('w-4 h-4', isRefreshing && 'animate-spin')} />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Refresh available editors</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Logout */}
|
{/* Logout */}
|
||||||
<div className="flex items-center justify-between gap-4 p-4 rounded-xl bg-muted/30 border border-border/30">
|
<div className="flex items-center justify-between gap-4 p-4 rounded-xl bg-muted/30 border border-border/30">
|
||||||
<div className="flex items-center gap-3.5 min-w-0">
|
<div className="flex items-center gap-3.5 min-w-0">
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
QwenIcon,
|
QwenIcon,
|
||||||
MistralIcon,
|
MistralIcon,
|
||||||
MetaIcon,
|
MetaIcon,
|
||||||
|
getProviderIconForModel,
|
||||||
} from '@/components/ui/provider-icon';
|
} from '@/components/ui/provider-icon';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -513,27 +514,8 @@ export function PhaseModelSelector({
|
|||||||
const isSelected = selectedModel === model.id;
|
const isSelected = selectedModel === model.id;
|
||||||
const isFavorite = favoriteModels.includes(model.id);
|
const isFavorite = favoriteModels.includes(model.id);
|
||||||
|
|
||||||
// Get the appropriate icon based on provider
|
// Get the appropriate icon based on the specific model ID
|
||||||
const ProviderIcon = (() => {
|
const ProviderIcon = getProviderIconForModel(model.id);
|
||||||
switch (model.provider) {
|
|
||||||
case 'opencode':
|
|
||||||
return OpenCodeIcon;
|
|
||||||
case 'amazon-bedrock-anthropic':
|
|
||||||
return AnthropicIcon;
|
|
||||||
case 'amazon-bedrock-deepseek':
|
|
||||||
return DeepSeekIcon;
|
|
||||||
case 'amazon-bedrock-amazon':
|
|
||||||
return NovaIcon;
|
|
||||||
case 'amazon-bedrock-meta':
|
|
||||||
return MetaIcon;
|
|
||||||
case 'amazon-bedrock-mistral':
|
|
||||||
return MistralIcon;
|
|
||||||
case 'amazon-bedrock-qwen':
|
|
||||||
return QwenIcon;
|
|
||||||
default:
|
|
||||||
return OpenCodeIcon;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
|
|||||||
@@ -431,6 +431,16 @@ export function PromptCustomizationSection({
|
|||||||
updatePrompt('enhancement', 'acceptanceSystemPrompt', value)
|
updatePrompt('enhancement', 'acceptanceSystemPrompt', value)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<PromptField
|
||||||
|
label="User Experience Mode"
|
||||||
|
description="Review and enhance from a user experience and design perspective"
|
||||||
|
defaultValue={DEFAULT_ENHANCEMENT_PROMPTS.uxReviewerSystemPrompt}
|
||||||
|
customValue={promptCustomization?.enhancement?.uxReviewerSystemPrompt}
|
||||||
|
onCustomValueChange={(value) =>
|
||||||
|
updatePrompt('enhancement', 'uxReviewerSystemPrompt', value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
AnthropicIcon,
|
AnthropicIcon,
|
||||||
MistralIcon,
|
MistralIcon,
|
||||||
MetaIcon,
|
MetaIcon,
|
||||||
|
getProviderIconForModel,
|
||||||
} from '@/components/ui/provider-icon';
|
} from '@/components/ui/provider-icon';
|
||||||
import type { ComponentType } from 'react';
|
import type { ComponentType } from 'react';
|
||||||
|
|
||||||
@@ -31,27 +32,10 @@ interface OpencodeModelConfigurationProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the appropriate icon component for a given OpenCode provider
|
* Returns the appropriate icon component for a given OpenCode model ID
|
||||||
*/
|
*/
|
||||||
function getProviderIcon(provider: OpencodeProvider): ComponentType<{ className?: string }> {
|
function getModelIcon(modelId: OpencodeModelId): ComponentType<{ className?: string }> {
|
||||||
switch (provider) {
|
return getProviderIconForModel(modelId);
|
||||||
case 'opencode':
|
|
||||||
return OpenCodeIcon;
|
|
||||||
case 'amazon-bedrock-anthropic':
|
|
||||||
return AnthropicIcon;
|
|
||||||
case 'amazon-bedrock-deepseek':
|
|
||||||
return DeepSeekIcon;
|
|
||||||
case 'amazon-bedrock-amazon':
|
|
||||||
return NovaIcon;
|
|
||||||
case 'amazon-bedrock-meta':
|
|
||||||
return MetaIcon;
|
|
||||||
case 'amazon-bedrock-mistral':
|
|
||||||
return MistralIcon;
|
|
||||||
case 'amazon-bedrock-qwen':
|
|
||||||
return QwenIcon;
|
|
||||||
default:
|
|
||||||
return OpenCodeIcon;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -146,11 +130,11 @@ export function OpencodeModelConfiguration({
|
|||||||
{enabledOpencodeModels.map((modelId) => {
|
{enabledOpencodeModels.map((modelId) => {
|
||||||
const model = OPENCODE_MODEL_CONFIG_MAP[modelId];
|
const model = OPENCODE_MODEL_CONFIG_MAP[modelId];
|
||||||
if (!model) return null;
|
if (!model) return null;
|
||||||
const ProviderIconComponent = getProviderIcon(model.provider);
|
const ModelIconComponent = getModelIcon(modelId);
|
||||||
return (
|
return (
|
||||||
<SelectItem key={modelId} value={modelId}>
|
<SelectItem key={modelId} value={modelId}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ProviderIconComponent className="w-4 h-4" />
|
<ModelIconComponent className="w-4 h-4" />
|
||||||
<span>{model.label}</span>
|
<span>{model.label}</span>
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
@@ -167,7 +151,9 @@ export function OpencodeModelConfiguration({
|
|||||||
const models = modelsByProvider[provider];
|
const models = modelsByProvider[provider];
|
||||||
if (!models || models.length === 0) return null;
|
if (!models || models.length === 0) return null;
|
||||||
|
|
||||||
const ProviderIconComponent = getProviderIcon(provider);
|
// Use the first model's icon as the provider icon
|
||||||
|
const ProviderIconComponent =
|
||||||
|
models.length > 0 ? getModelIcon(models[0].id) : OpenCodeIcon;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={provider} className="space-y-2">
|
<div key={provider} className="space-y-2">
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const status = await api.specRegeneration.status();
|
const status = await api.specRegeneration.status(currentProject.path);
|
||||||
logger.debug(
|
logger.debug(
|
||||||
'[useSpecGeneration] Status check on mount:',
|
'[useSpecGeneration] Status check on mount:',
|
||||||
status,
|
status,
|
||||||
@@ -90,9 +90,7 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (status.success && status.isRunning) {
|
if (status.success && status.isRunning) {
|
||||||
logger.debug(
|
logger.debug('[useSpecGeneration] Spec generation is running for this project.');
|
||||||
'[useSpecGeneration] Spec generation is running globally. Tentatively showing loader.'
|
|
||||||
);
|
|
||||||
|
|
||||||
setIsCreating(true);
|
setIsCreating(true);
|
||||||
setIsRegenerating(true);
|
setIsRegenerating(true);
|
||||||
@@ -143,7 +141,7 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
|
|||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
if (!api.specRegeneration) return;
|
if (!api.specRegeneration) return;
|
||||||
|
|
||||||
const status = await api.specRegeneration.status();
|
const status = await api.specRegeneration.status(currentProject.path);
|
||||||
logger.debug('[useSpecGeneration] Visibility change - status check:', status);
|
logger.debug('[useSpecGeneration] Visibility change - status check:', status);
|
||||||
|
|
||||||
if (!status.isRunning) {
|
if (!status.isRunning) {
|
||||||
@@ -180,7 +178,7 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
|
|||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
if (!api.specRegeneration) return;
|
if (!api.specRegeneration) return;
|
||||||
|
|
||||||
const status = await api.specRegeneration.status();
|
const status = await api.specRegeneration.status(currentProject.path);
|
||||||
|
|
||||||
if (!status.isRunning) {
|
if (!status.isRunning) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ export function useSpecLoading() {
|
|||||||
// Check if spec generation is running before trying to load
|
// Check if spec generation is running before trying to load
|
||||||
// This prevents showing "No App Specification Found" during generation
|
// This prevents showing "No App Specification Found" during generation
|
||||||
if (api.specRegeneration) {
|
if (api.specRegeneration) {
|
||||||
const status = await api.specRegeneration.status();
|
const status = await api.specRegeneration.status(currentProject.path);
|
||||||
if (status.success && status.isRunning) {
|
if (status.success && status.isRunning) {
|
||||||
logger.debug('Spec generation is running, skipping load');
|
logger.debug('Spec generation is running for this project, skipping load');
|
||||||
setIsGenerationRunning(true);
|
setIsGenerationRunning(true);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import { getElectronAPI } from '@/lib/electron';
|
|||||||
import { getApiKey, getSessionToken, getServerUrlSync } from '@/lib/http-api-client';
|
import { getApiKey, getSessionToken, getServerUrlSync } from '@/lib/http-api-client';
|
||||||
|
|
||||||
const logger = createLogger('Terminal');
|
const logger = createLogger('Terminal');
|
||||||
|
const NO_STORE_CACHE_MODE: RequestCache = 'no-store';
|
||||||
|
|
||||||
// Font size constraints
|
// Font size constraints
|
||||||
const MIN_FONT_SIZE = 8;
|
const MIN_FONT_SIZE = 8;
|
||||||
@@ -504,6 +505,7 @@ export function TerminalPanel({
|
|||||||
const response = await fetch(`${serverUrl}/api/auth/token`, {
|
const response = await fetch(`${serverUrl}/api/auth/token`, {
|
||||||
headers,
|
headers,
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
|
cache: NO_STORE_CACHE_MODE,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -517,8 +517,9 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Save theme to localStorage for fallback when server settings aren't available
|
// Save theme to localStorage for fallback when server settings aren't available
|
||||||
if (settings.theme) {
|
const storedTheme = (currentProject?.theme as string | undefined) || settings.theme;
|
||||||
setItem(THEME_STORAGE_KEY, settings.theme);
|
if (storedTheme) {
|
||||||
|
setItem(THEME_STORAGE_KEY, storedTheme);
|
||||||
}
|
}
|
||||||
|
|
||||||
useAppStore.setState({
|
useAppStore.setState({
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ const SETTINGS_FIELDS_TO_SYNC = [
|
|||||||
'autoLoadClaudeMd',
|
'autoLoadClaudeMd',
|
||||||
'keyboardShortcuts',
|
'keyboardShortcuts',
|
||||||
'mcpServers',
|
'mcpServers',
|
||||||
|
'defaultEditorCommand',
|
||||||
'promptCustomization',
|
'promptCustomization',
|
||||||
'projects',
|
'projects',
|
||||||
'trashedProjects',
|
'trashedProjects',
|
||||||
@@ -89,6 +90,7 @@ export function useSettingsSync(): SettingsSyncState {
|
|||||||
|
|
||||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||||
const authChecked = useAuthStore((s) => s.authChecked);
|
const authChecked = useAuthStore((s) => s.authChecked);
|
||||||
|
const settingsLoaded = useAuthStore((s) => s.settingsLoaded);
|
||||||
|
|
||||||
const syncTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const syncTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const lastSyncedRef = useRef<string>('');
|
const lastSyncedRef = useRef<string>('');
|
||||||
@@ -117,9 +119,17 @@ export function useSettingsSync(): SettingsSyncState {
|
|||||||
// Debounced sync function
|
// Debounced sync function
|
||||||
const syncToServer = useCallback(async () => {
|
const syncToServer = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
// Never sync when not authenticated (prevents overwriting server settings during logout/login transitions)
|
// Never sync when not authenticated or settings not loaded
|
||||||
|
// The settingsLoaded flag ensures we don't sync default empty state before hydration
|
||||||
const auth = useAuthStore.getState();
|
const auth = useAuthStore.getState();
|
||||||
if (!auth.authChecked || !auth.isAuthenticated) {
|
logger.debug('syncToServer check:', {
|
||||||
|
authChecked: auth.authChecked,
|
||||||
|
isAuthenticated: auth.isAuthenticated,
|
||||||
|
settingsLoaded: auth.settingsLoaded,
|
||||||
|
projectsCount: useAppStore.getState().projects?.length ?? 0,
|
||||||
|
});
|
||||||
|
if (!auth.authChecked || !auth.isAuthenticated || !auth.settingsLoaded) {
|
||||||
|
logger.debug('Sync skipped: not authenticated or settings not loaded');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,6 +137,8 @@ export function useSettingsSync(): SettingsSyncState {
|
|||||||
const api = getHttpApiClient();
|
const api = getHttpApiClient();
|
||||||
const appState = useAppStore.getState();
|
const appState = useAppStore.getState();
|
||||||
|
|
||||||
|
logger.debug('Syncing to server:', { projectsCount: appState.projects?.length ?? 0 });
|
||||||
|
|
||||||
// Build updates object from current state
|
// Build updates object from current state
|
||||||
const updates: Record<string, unknown> = {};
|
const updates: Record<string, unknown> = {};
|
||||||
for (const field of SETTINGS_FIELDS_TO_SYNC) {
|
for (const field of SETTINGS_FIELDS_TO_SYNC) {
|
||||||
@@ -147,10 +159,13 @@ export function useSettingsSync(): SettingsSyncState {
|
|||||||
// Create a hash of the updates to avoid redundant syncs
|
// Create a hash of the updates to avoid redundant syncs
|
||||||
const updateHash = JSON.stringify(updates);
|
const updateHash = JSON.stringify(updates);
|
||||||
if (updateHash === lastSyncedRef.current) {
|
if (updateHash === lastSyncedRef.current) {
|
||||||
|
logger.debug('Sync skipped: no changes');
|
||||||
setState((s) => ({ ...s, syncing: false }));
|
setState((s) => ({ ...s, syncing: false }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info('Sending settings update:', { projects: updates.projects });
|
||||||
|
|
||||||
const result = await api.settings.updateGlobal(updates);
|
const result = await api.settings.updateGlobal(updates);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
lastSyncedRef.current = updateHash;
|
lastSyncedRef.current = updateHash;
|
||||||
@@ -184,11 +199,20 @@ export function useSettingsSync(): SettingsSyncState {
|
|||||||
void syncToServer();
|
void syncToServer();
|
||||||
}, [syncToServer]);
|
}, [syncToServer]);
|
||||||
|
|
||||||
// Initialize sync - WAIT for migration to complete first
|
// Initialize sync - WAIT for settings to be loaded and migration to complete
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Don't initialize syncing until we know auth status and are authenticated.
|
// Don't initialize syncing until:
|
||||||
// Prevents accidental overwrites when the app boots before settings are hydrated.
|
// 1. Auth has been checked
|
||||||
if (!authChecked || !isAuthenticated) return;
|
// 2. User is authenticated
|
||||||
|
// 3. Settings have been loaded from server (settingsLoaded flag)
|
||||||
|
// This prevents syncing empty/default state before hydration completes.
|
||||||
|
logger.debug('useSettingsSync initialization check:', {
|
||||||
|
authChecked,
|
||||||
|
isAuthenticated,
|
||||||
|
settingsLoaded,
|
||||||
|
stateLoaded: state.loaded,
|
||||||
|
});
|
||||||
|
if (!authChecked || !isAuthenticated || !settingsLoaded) return;
|
||||||
if (isInitializedRef.current) return;
|
if (isInitializedRef.current) return;
|
||||||
isInitializedRef.current = true;
|
isInitializedRef.current = true;
|
||||||
|
|
||||||
@@ -198,14 +222,26 @@ export function useSettingsSync(): SettingsSyncState {
|
|||||||
await waitForApiKeyInit();
|
await waitForApiKeyInit();
|
||||||
|
|
||||||
// CRITICAL: Wait for migration/hydration to complete before we start syncing
|
// CRITICAL: Wait for migration/hydration to complete before we start syncing
|
||||||
// This prevents overwriting server data with empty/default state
|
// This is a backup to the settingsLoaded flag for extra safety
|
||||||
logger.info('Waiting for migration to complete before starting sync...');
|
logger.info('Waiting for migration to complete before starting sync...');
|
||||||
await waitForMigrationComplete();
|
await waitForMigrationComplete();
|
||||||
|
|
||||||
|
// Wait for React to finish rendering after store hydration.
|
||||||
|
// Zustand's subscribe() fires during setState(), which happens BEFORE React's
|
||||||
|
// render completes. Use a small delay to ensure all pending state updates
|
||||||
|
// have propagated through the React tree before we read state.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
logger.info('Migration complete, initializing sync');
|
logger.info('Migration complete, initializing sync');
|
||||||
|
|
||||||
|
// Read state - at this point React has processed the store update
|
||||||
|
const appState = useAppStore.getState();
|
||||||
|
const setupState = useSetupStore.getState();
|
||||||
|
|
||||||
|
logger.info('Initial state read:', { projectsCount: appState.projects?.length ?? 0 });
|
||||||
|
|
||||||
// Store the initial state hash to avoid immediate re-sync
|
// Store the initial state hash to avoid immediate re-sync
|
||||||
// (migration has already hydrated the store from server/localStorage)
|
// (migration has already hydrated the store from server/localStorage)
|
||||||
const appState = useAppStore.getState();
|
|
||||||
const updates: Record<string, unknown> = {};
|
const updates: Record<string, unknown> = {};
|
||||||
for (const field of SETTINGS_FIELDS_TO_SYNC) {
|
for (const field of SETTINGS_FIELDS_TO_SYNC) {
|
||||||
if (field === 'currentProjectId') {
|
if (field === 'currentProjectId') {
|
||||||
@@ -214,7 +250,6 @@ export function useSettingsSync(): SettingsSyncState {
|
|||||||
updates[field] = appState[field as keyof typeof appState];
|
updates[field] = appState[field as keyof typeof appState];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const setupState = useSetupStore.getState();
|
|
||||||
for (const field of SETUP_FIELDS_TO_SYNC) {
|
for (const field of SETUP_FIELDS_TO_SYNC) {
|
||||||
updates[field] = setupState[field as keyof typeof setupState];
|
updates[field] = setupState[field as keyof typeof setupState];
|
||||||
}
|
}
|
||||||
@@ -233,16 +268,33 @@ export function useSettingsSync(): SettingsSyncState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
initializeSync();
|
initializeSync();
|
||||||
}, [authChecked, isAuthenticated]);
|
}, [authChecked, isAuthenticated, settingsLoaded]);
|
||||||
|
|
||||||
// Subscribe to store changes and sync to server
|
// Subscribe to store changes and sync to server
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!state.loaded || !authChecked || !isAuthenticated) return;
|
if (!state.loaded || !authChecked || !isAuthenticated || !settingsLoaded) return;
|
||||||
|
|
||||||
// Subscribe to app store changes
|
// Subscribe to app store changes
|
||||||
const unsubscribeApp = useAppStore.subscribe((newState, prevState) => {
|
const unsubscribeApp = useAppStore.subscribe((newState, prevState) => {
|
||||||
|
const auth = useAuthStore.getState();
|
||||||
|
logger.debug('Store subscription fired:', {
|
||||||
|
prevProjects: prevState.projects?.length ?? 0,
|
||||||
|
newProjects: newState.projects?.length ?? 0,
|
||||||
|
authChecked: auth.authChecked,
|
||||||
|
isAuthenticated: auth.isAuthenticated,
|
||||||
|
settingsLoaded: auth.settingsLoaded,
|
||||||
|
loaded: state.loaded,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Don't sync if settings not loaded yet
|
||||||
|
if (!auth.settingsLoaded) {
|
||||||
|
logger.debug('Store changed but settings not loaded, skipping sync');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// If the current project changed, sync immediately so we can restore on next launch
|
// If the current project changed, sync immediately so we can restore on next launch
|
||||||
if (newState.currentProject?.id !== prevState.currentProject?.id) {
|
if (newState.currentProject?.id !== prevState.currentProject?.id) {
|
||||||
|
logger.debug('Current project changed, syncing immediately');
|
||||||
syncNow();
|
syncNow();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -266,6 +318,7 @@ export function useSettingsSync(): SettingsSyncState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (changed) {
|
if (changed) {
|
||||||
|
logger.debug('Store changed, scheduling sync');
|
||||||
scheduleSyncToServer();
|
scheduleSyncToServer();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -294,11 +347,11 @@ export function useSettingsSync(): SettingsSyncState {
|
|||||||
clearTimeout(syncTimeoutRef.current);
|
clearTimeout(syncTimeoutRef.current);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [state.loaded, authChecked, isAuthenticated, scheduleSyncToServer, syncNow]);
|
}, [state.loaded, authChecked, isAuthenticated, settingsLoaded, scheduleSyncToServer, syncNow]);
|
||||||
|
|
||||||
// Best-effort flush on tab close / backgrounding
|
// Best-effort flush on tab close / backgrounding
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!state.loaded || !authChecked || !isAuthenticated) return;
|
if (!state.loaded || !authChecked || !isAuthenticated || !settingsLoaded) return;
|
||||||
|
|
||||||
const handleBeforeUnload = () => {
|
const handleBeforeUnload = () => {
|
||||||
// Fire-and-forget; may not complete in all browsers, but helps in Electron/webview
|
// Fire-and-forget; may not complete in all browsers, but helps in Electron/webview
|
||||||
@@ -318,7 +371,7 @@ export function useSettingsSync(): SettingsSyncState {
|
|||||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
};
|
};
|
||||||
}, [state.loaded, authChecked, isAuthenticated, syncNow]);
|
}, [state.loaded, authChecked, isAuthenticated, settingsLoaded, syncNow]);
|
||||||
|
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
@@ -399,6 +452,7 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
|||||||
>),
|
>),
|
||||||
},
|
},
|
||||||
mcpServers: serverSettings.mcpServers,
|
mcpServers: serverSettings.mcpServers,
|
||||||
|
defaultEditorCommand: serverSettings.defaultEditorCommand ?? null,
|
||||||
promptCustomization: serverSettings.promptCustomization ?? {},
|
promptCustomization: serverSettings.promptCustomization ?? {},
|
||||||
projects: serverSettings.projects,
|
projects: serverSettings.projects,
|
||||||
trashedProjects: serverSettings.trashedProjects,
|
trashedProjects: serverSettings.trashedProjects,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { getApiKey, getSessionToken, getServerUrlSync } from './http-api-client'
|
|||||||
|
|
||||||
// Server URL - uses shared cached URL from http-api-client
|
// Server URL - uses shared cached URL from http-api-client
|
||||||
const getServerUrl = (): string => getServerUrlSync();
|
const getServerUrl = (): string => getServerUrlSync();
|
||||||
|
const DEFAULT_CACHE_MODE: RequestCache = 'no-store';
|
||||||
|
|
||||||
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
||||||
|
|
||||||
@@ -80,7 +81,7 @@ export async function apiFetch(
|
|||||||
method: HttpMethod = 'GET',
|
method: HttpMethod = 'GET',
|
||||||
options: ApiFetchOptions = {}
|
options: ApiFetchOptions = {}
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const { headers: additionalHeaders, body, skipAuth, ...restOptions } = options;
|
const { headers: additionalHeaders, body, skipAuth, cache, ...restOptions } = options;
|
||||||
|
|
||||||
const headers = skipAuth
|
const headers = skipAuth
|
||||||
? { 'Content-Type': 'application/json', ...additionalHeaders }
|
? { 'Content-Type': 'application/json', ...additionalHeaders }
|
||||||
@@ -90,6 +91,7 @@ export async function apiFetch(
|
|||||||
method,
|
method,
|
||||||
headers,
|
headers,
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
|
cache: cache ?? DEFAULT_CACHE_MODE,
|
||||||
...restOptions,
|
...restOptions,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -433,11 +433,12 @@ export interface SpecRegenerationAPI {
|
|||||||
success: boolean;
|
success: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
}>;
|
}>;
|
||||||
stop: () => Promise<{ success: boolean; error?: string }>;
|
stop: (projectPath?: string) => Promise<{ success: boolean; error?: string }>;
|
||||||
status: () => Promise<{
|
status: (projectPath?: string) => Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
isRunning?: boolean;
|
isRunning?: boolean;
|
||||||
currentPhase?: string;
|
currentPhase?: string;
|
||||||
|
projectPath?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
}>;
|
}>;
|
||||||
onEvent: (callback: (event: SpecRegenerationEvent) => void) => () => void;
|
onEvent: (callback: (event: SpecRegenerationEvent) => void) => () => void;
|
||||||
@@ -461,7 +462,8 @@ export interface FeaturesAPI {
|
|||||||
featureId: string,
|
featureId: string,
|
||||||
updates: Partial<Feature>,
|
updates: Partial<Feature>,
|
||||||
descriptionHistorySource?: 'enhance' | 'edit',
|
descriptionHistorySource?: 'enhance' | 'edit',
|
||||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance'
|
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer',
|
||||||
|
preEnhancementDescription?: string
|
||||||
) => Promise<{ success: boolean; feature?: Feature; error?: string }>;
|
) => Promise<{ success: boolean; feature?: Feature; error?: string }>;
|
||||||
delete: (projectPath: string, featureId: string) => Promise<{ success: boolean; error?: string }>;
|
delete: (projectPath: string, featureId: string) => Promise<{ success: boolean; error?: string }>;
|
||||||
getAgentOutput: (
|
getAgentOutput: (
|
||||||
@@ -532,6 +534,9 @@ export interface AutoModeAPI {
|
|||||||
editedPlan?: string,
|
editedPlan?: string,
|
||||||
feedback?: string
|
feedback?: string
|
||||||
) => Promise<{ success: boolean; error?: string }>;
|
) => Promise<{ success: boolean; error?: string }>;
|
||||||
|
resumeInterrupted: (
|
||||||
|
projectPath: string
|
||||||
|
) => Promise<{ success: boolean; message?: string; error?: string }>;
|
||||||
onEvent: (callback: (event: AutoModeEvent) => void) => () => void;
|
onEvent: (callback: (event: AutoModeEvent) => void) => () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -608,7 +613,8 @@ export interface ElectronAPI {
|
|||||||
enhance: (
|
enhance: (
|
||||||
originalText: string,
|
originalText: string,
|
||||||
enhancementMode: string,
|
enhancementMode: string,
|
||||||
model?: string
|
model?: string,
|
||||||
|
thinkingLevel?: string
|
||||||
) => Promise<{
|
) => Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
enhancedText?: string;
|
enhancedText?: string;
|
||||||
@@ -1639,13 +1645,34 @@ function createMockWorktreeAPI(): WorktreeAPI {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
openInEditor: async (worktreePath: string) => {
|
openInEditor: async (worktreePath: string, editorCommand?: string) => {
|
||||||
console.log('[Mock] Opening in editor:', worktreePath);
|
const ANTIGRAVITY_EDITOR_COMMAND = 'antigravity';
|
||||||
|
const ANTIGRAVITY_LEGACY_COMMAND = 'agy';
|
||||||
|
// Map editor commands to display names
|
||||||
|
const editorNameMap: Record<string, string> = {
|
||||||
|
cursor: 'Cursor',
|
||||||
|
code: 'VS Code',
|
||||||
|
zed: 'Zed',
|
||||||
|
subl: 'Sublime Text',
|
||||||
|
windsurf: 'Windsurf',
|
||||||
|
trae: 'Trae',
|
||||||
|
rider: 'Rider',
|
||||||
|
webstorm: 'WebStorm',
|
||||||
|
xed: 'Xcode',
|
||||||
|
studio: 'Android Studio',
|
||||||
|
[ANTIGRAVITY_EDITOR_COMMAND]: 'Antigravity',
|
||||||
|
[ANTIGRAVITY_LEGACY_COMMAND]: 'Antigravity',
|
||||||
|
open: 'Finder',
|
||||||
|
explorer: 'Explorer',
|
||||||
|
'xdg-open': 'File Manager',
|
||||||
|
};
|
||||||
|
const editorName = editorCommand ? (editorNameMap[editorCommand] ?? 'Editor') : 'VS Code';
|
||||||
|
console.log('[Mock] Opening in editor:', worktreePath, 'using:', editorName);
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
result: {
|
result: {
|
||||||
message: `Opened ${worktreePath} in VS Code`,
|
message: `Opened ${worktreePath} in ${editorName}`,
|
||||||
editorName: 'VS Code',
|
editorName,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -1661,6 +1688,32 @@ function createMockWorktreeAPI(): WorktreeAPI {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getAvailableEditors: async () => {
|
||||||
|
console.log('[Mock] Getting available editors');
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
editors: [
|
||||||
|
{ name: 'VS Code', command: 'code' },
|
||||||
|
{ name: 'Finder', command: 'open' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
refreshEditors: async () => {
|
||||||
|
console.log('[Mock] Refreshing available editors');
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
editors: [
|
||||||
|
{ name: 'VS Code', command: 'code' },
|
||||||
|
{ name: 'Finder', command: 'open' },
|
||||||
|
],
|
||||||
|
message: 'Found 2 available editors',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
initGit: async (projectPath: string) => {
|
initGit: async (projectPath: string) => {
|
||||||
console.log('[Mock] Initializing git:', projectPath);
|
console.log('[Mock] Initializing git:', projectPath);
|
||||||
return {
|
return {
|
||||||
@@ -2110,6 +2163,11 @@ function createMockAutoModeAPI(): AutoModeAPI {
|
|||||||
return { success: true };
|
return { success: true };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
resumeInterrupted: async (projectPath: string) => {
|
||||||
|
console.log('[Mock] Resume interrupted features for:', projectPath);
|
||||||
|
return { success: true, message: 'Mock: no interrupted features' };
|
||||||
|
},
|
||||||
|
|
||||||
onEvent: (callback: (event: AutoModeEvent) => void) => {
|
onEvent: (callback: (event: AutoModeEvent) => void) => {
|
||||||
mockAutoModeCallbacks.push(callback);
|
mockAutoModeCallbacks.push(callback);
|
||||||
return () => {
|
return () => {
|
||||||
@@ -2539,7 +2597,7 @@ function createMockSpecRegenerationAPI(): SpecRegenerationAPI {
|
|||||||
return { success: true };
|
return { success: true };
|
||||||
},
|
},
|
||||||
|
|
||||||
stop: async () => {
|
stop: async (_projectPath?: string) => {
|
||||||
mockSpecRegenerationRunning = false;
|
mockSpecRegenerationRunning = false;
|
||||||
mockSpecRegenerationPhase = '';
|
mockSpecRegenerationPhase = '';
|
||||||
if (mockSpecRegenerationTimeout) {
|
if (mockSpecRegenerationTimeout) {
|
||||||
@@ -2549,7 +2607,7 @@ function createMockSpecRegenerationAPI(): SpecRegenerationAPI {
|
|||||||
return { success: true };
|
return { success: true };
|
||||||
},
|
},
|
||||||
|
|
||||||
status: async () => {
|
status: async (_projectPath?: string) => {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
isRunning: mockSpecRegenerationRunning,
|
isRunning: mockSpecRegenerationRunning,
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import type { WorktreeAPI, GitAPI, ModelDefinition, ProviderStatus } from '@/typ
|
|||||||
import { getGlobalFileBrowser } from '@/contexts/file-browser-context';
|
import { getGlobalFileBrowser } from '@/contexts/file-browser-context';
|
||||||
|
|
||||||
const logger = createLogger('HttpClient');
|
const logger = createLogger('HttpClient');
|
||||||
|
const NO_STORE_CACHE_MODE: RequestCache = 'no-store';
|
||||||
|
|
||||||
// Cached server URL (set during initialization in Electron mode)
|
// Cached server URL (set during initialization in Electron mode)
|
||||||
let cachedServerUrl: string | null = null;
|
let cachedServerUrl: string | null = null;
|
||||||
@@ -69,6 +70,7 @@ const handleUnauthorized = (): void => {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: '{}',
|
body: '{}',
|
||||||
|
cache: NO_STORE_CACHE_MODE,
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
notifyLoggedOut();
|
notifyLoggedOut();
|
||||||
};
|
};
|
||||||
@@ -296,6 +298,7 @@ export const checkAuthStatus = async (): Promise<{
|
|||||||
const response = await fetch(`${getServerUrl()}/api/auth/status`, {
|
const response = await fetch(`${getServerUrl()}/api/auth/status`, {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: getApiKey() ? { 'X-API-Key': getApiKey()! } : undefined,
|
headers: getApiKey() ? { 'X-API-Key': getApiKey()! } : undefined,
|
||||||
|
cache: NO_STORE_CACHE_MODE,
|
||||||
});
|
});
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return {
|
return {
|
||||||
@@ -322,6 +325,7 @@ export const login = async (
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: JSON.stringify({ apiKey }),
|
body: JSON.stringify({ apiKey }),
|
||||||
|
cache: NO_STORE_CACHE_MODE,
|
||||||
});
|
});
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
@@ -361,6 +365,7 @@ export const fetchSessionToken = async (): Promise<boolean> => {
|
|||||||
try {
|
try {
|
||||||
const response = await fetch(`${getServerUrl()}/api/auth/status`, {
|
const response = await fetch(`${getServerUrl()}/api/auth/status`, {
|
||||||
credentials: 'include', // Send the session cookie
|
credentials: 'include', // Send the session cookie
|
||||||
|
cache: NO_STORE_CACHE_MODE,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -391,6 +396,7 @@ export const logout = async (): Promise<{ success: boolean }> => {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
|
cache: NO_STORE_CACHE_MODE,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clear the cached session token
|
// Clear the cached session token
|
||||||
@@ -439,6 +445,7 @@ export const verifySession = async (): Promise<boolean> => {
|
|||||||
const response = await fetch(`${getServerUrl()}/api/settings/status`, {
|
const response = await fetch(`${getServerUrl()}/api/settings/status`, {
|
||||||
headers,
|
headers,
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
|
cache: NO_STORE_CACHE_MODE,
|
||||||
// Avoid hanging indefinitely during backend reloads or network issues
|
// Avoid hanging indefinitely during backend reloads or network issues
|
||||||
signal: AbortSignal.timeout(2500),
|
signal: AbortSignal.timeout(2500),
|
||||||
});
|
});
|
||||||
@@ -475,6 +482,7 @@ export const checkSandboxEnvironment = async (): Promise<{
|
|||||||
try {
|
try {
|
||||||
const response = await fetch(`${getServerUrl()}/api/health/environment`, {
|
const response = await fetch(`${getServerUrl()}/api/health/environment`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
|
cache: NO_STORE_CACHE_MODE,
|
||||||
signal: AbortSignal.timeout(5000),
|
signal: AbortSignal.timeout(5000),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -559,6 +567,7 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
const response = await fetch(`${this.serverUrl}/api/auth/token`, {
|
const response = await fetch(`${this.serverUrl}/api/auth/token`, {
|
||||||
headers,
|
headers,
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
|
cache: NO_STORE_CACHE_MODE,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 401 || response.status === 403) {
|
if (response.status === 401 || response.status === 403) {
|
||||||
@@ -590,6 +599,17 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
|
|
||||||
this.isConnecting = true;
|
this.isConnecting = true;
|
||||||
|
|
||||||
|
// Wait for API key initialization to complete before attempting connection
|
||||||
|
// This prevents race conditions during app startup
|
||||||
|
waitForApiKeyInit()
|
||||||
|
.then(() => this.doConnectWebSocketInternal())
|
||||||
|
.catch((error) => {
|
||||||
|
logger.error('Failed to initialize for WebSocket connection:', error);
|
||||||
|
this.isConnecting = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private doConnectWebSocketInternal(): void {
|
||||||
// Electron mode typically authenticates with the injected API key.
|
// Electron mode typically authenticates with the injected API key.
|
||||||
// However, in external-server/cookie-auth flows, the API key may be unavailable.
|
// However, in external-server/cookie-auth flows, the API key may be unavailable.
|
||||||
// In that case, fall back to the same wsToken/cookie authentication used in web mode
|
// In that case, fall back to the same wsToken/cookie authentication used in web mode
|
||||||
@@ -774,6 +794,7 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
const response = await fetch(`${this.serverUrl}${endpoint}`, {
|
const response = await fetch(`${this.serverUrl}${endpoint}`, {
|
||||||
headers: this.getHeaders(),
|
headers: this.getHeaders(),
|
||||||
credentials: 'include', // Include cookies for session auth
|
credentials: 'include', // Include cookies for session auth
|
||||||
|
cache: NO_STORE_CACHE_MODE,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 401 || response.status === 403) {
|
if (response.status === 401 || response.status === 403) {
|
||||||
@@ -1442,6 +1463,16 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
features?: Feature[];
|
features?: Feature[];
|
||||||
error?: string;
|
error?: string;
|
||||||
}>;
|
}>;
|
||||||
|
bulkDelete: (
|
||||||
|
projectPath: string,
|
||||||
|
featureIds: string[]
|
||||||
|
) => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
deletedCount?: number;
|
||||||
|
failedCount?: number;
|
||||||
|
results?: Array<{ featureId: string; success: boolean; error?: string }>;
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
} = {
|
} = {
|
||||||
getAll: (projectPath: string) => this.post('/api/features/list', { projectPath }),
|
getAll: (projectPath: string) => this.post('/api/features/list', { projectPath }),
|
||||||
get: (projectPath: string, featureId: string) =>
|
get: (projectPath: string, featureId: string) =>
|
||||||
@@ -1453,7 +1484,8 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
featureId: string,
|
featureId: string,
|
||||||
updates: Partial<Feature>,
|
updates: Partial<Feature>,
|
||||||
descriptionHistorySource?: 'enhance' | 'edit',
|
descriptionHistorySource?: 'enhance' | 'edit',
|
||||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance'
|
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer',
|
||||||
|
preEnhancementDescription?: string
|
||||||
) =>
|
) =>
|
||||||
this.post('/api/features/update', {
|
this.post('/api/features/update', {
|
||||||
projectPath,
|
projectPath,
|
||||||
@@ -1461,6 +1493,7 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
updates,
|
updates,
|
||||||
descriptionHistorySource,
|
descriptionHistorySource,
|
||||||
enhancementMode,
|
enhancementMode,
|
||||||
|
preEnhancementDescription,
|
||||||
}),
|
}),
|
||||||
delete: (projectPath: string, featureId: string) =>
|
delete: (projectPath: string, featureId: string) =>
|
||||||
this.post('/api/features/delete', { projectPath, featureId }),
|
this.post('/api/features/delete', { projectPath, featureId }),
|
||||||
@@ -1470,6 +1503,8 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
this.post('/api/features/generate-title', { description }),
|
this.post('/api/features/generate-title', { description }),
|
||||||
bulkUpdate: (projectPath: string, featureIds: string[], updates: Partial<Feature>) =>
|
bulkUpdate: (projectPath: string, featureIds: string[], updates: Partial<Feature>) =>
|
||||||
this.post('/api/features/bulk-update', { projectPath, featureIds, updates }),
|
this.post('/api/features/bulk-update', { projectPath, featureIds, updates }),
|
||||||
|
bulkDelete: (projectPath: string, featureIds: string[]) =>
|
||||||
|
this.post('/api/features/bulk-delete', { projectPath, featureIds }),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Auto Mode API
|
// Auto Mode API
|
||||||
@@ -1537,6 +1572,8 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
editedPlan,
|
editedPlan,
|
||||||
feedback,
|
feedback,
|
||||||
}),
|
}),
|
||||||
|
resumeInterrupted: (projectPath: string) =>
|
||||||
|
this.post('/api/auto-mode/resume-interrupted', { projectPath }),
|
||||||
onEvent: (callback: (event: AutoModeEvent) => void) => {
|
onEvent: (callback: (event: AutoModeEvent) => void) => {
|
||||||
return this.subscribeToEvent('auto-mode:event', callback as EventCallback);
|
return this.subscribeToEvent('auto-mode:event', callback as EventCallback);
|
||||||
},
|
},
|
||||||
@@ -1602,9 +1639,11 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
this.post('/api/worktree/list-branches', { worktreePath }),
|
this.post('/api/worktree/list-branches', { worktreePath }),
|
||||||
switchBranch: (worktreePath: string, branchName: string) =>
|
switchBranch: (worktreePath: string, branchName: string) =>
|
||||||
this.post('/api/worktree/switch-branch', { worktreePath, branchName }),
|
this.post('/api/worktree/switch-branch', { worktreePath, branchName }),
|
||||||
openInEditor: (worktreePath: string) =>
|
openInEditor: (worktreePath: string, editorCommand?: string) =>
|
||||||
this.post('/api/worktree/open-in-editor', { worktreePath }),
|
this.post('/api/worktree/open-in-editor', { worktreePath, editorCommand }),
|
||||||
getDefaultEditor: () => this.get('/api/worktree/default-editor'),
|
getDefaultEditor: () => this.get('/api/worktree/default-editor'),
|
||||||
|
getAvailableEditors: () => this.get('/api/worktree/available-editors'),
|
||||||
|
refreshEditors: () => this.post('/api/worktree/refresh-editors', {}),
|
||||||
initGit: (projectPath: string) => this.post('/api/worktree/init-git', { projectPath }),
|
initGit: (projectPath: string) => this.post('/api/worktree/init-git', { projectPath }),
|
||||||
startDevServer: (projectPath: string, worktreePath: string) =>
|
startDevServer: (projectPath: string, worktreePath: string) =>
|
||||||
this.post('/api/worktree/start-dev', { projectPath, worktreePath }),
|
this.post('/api/worktree/start-dev', { projectPath, worktreePath }),
|
||||||
@@ -1703,8 +1742,13 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
projectPath,
|
projectPath,
|
||||||
maxFeatures,
|
maxFeatures,
|
||||||
}),
|
}),
|
||||||
stop: () => this.post('/api/spec-regeneration/stop'),
|
stop: (projectPath?: string) => this.post('/api/spec-regeneration/stop', { projectPath }),
|
||||||
status: () => this.get('/api/spec-regeneration/status'),
|
status: (projectPath?: string) =>
|
||||||
|
this.get(
|
||||||
|
projectPath
|
||||||
|
? `/api/spec-regeneration/status?projectPath=${encodeURIComponent(projectPath)}`
|
||||||
|
: '/api/spec-regeneration/status'
|
||||||
|
),
|
||||||
onEvent: (callback: (event: SpecRegenerationEvent) => void) => {
|
onEvent: (callback: (event: SpecRegenerationEvent) => void) => {
|
||||||
return this.subscribeToEvent('spec-regeneration:event', callback as EventCallback);
|
return this.subscribeToEvent('spec-regeneration:event', callback as EventCallback);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,12 +12,14 @@ import { useSetupStore } from '@/store/setup-store';
|
|||||||
import { useAuthStore } from '@/store/auth-store';
|
import { useAuthStore } from '@/store/auth-store';
|
||||||
import { getElectronAPI, isElectron } from '@/lib/electron';
|
import { getElectronAPI, isElectron } from '@/lib/electron';
|
||||||
import { isMac } from '@/lib/utils';
|
import { isMac } from '@/lib/utils';
|
||||||
|
import { initializeProject } from '@/lib/project-init';
|
||||||
import {
|
import {
|
||||||
initApiKey,
|
initApiKey,
|
||||||
verifySession,
|
verifySession,
|
||||||
checkSandboxEnvironment,
|
checkSandboxEnvironment,
|
||||||
getServerUrlSync,
|
getServerUrlSync,
|
||||||
getHttpApiClient,
|
getHttpApiClient,
|
||||||
|
handleServerOffline,
|
||||||
} from '@/lib/http-api-client';
|
} from '@/lib/http-api-client';
|
||||||
import {
|
import {
|
||||||
hydrateStoreFromSettings,
|
hydrateStoreFromSettings,
|
||||||
@@ -30,8 +32,23 @@ import { SandboxRiskDialog } from '@/components/dialogs/sandbox-risk-dialog';
|
|||||||
import { SandboxRejectionScreen } from '@/components/dialogs/sandbox-rejection-screen';
|
import { SandboxRejectionScreen } from '@/components/dialogs/sandbox-rejection-screen';
|
||||||
import { LoadingState } from '@/components/ui/loading-state';
|
import { LoadingState } from '@/components/ui/loading-state';
|
||||||
import { useProjectSettingsLoader } from '@/hooks/use-project-settings-loader';
|
import { useProjectSettingsLoader } from '@/hooks/use-project-settings-loader';
|
||||||
|
import type { Project } from '@/lib/electron';
|
||||||
|
|
||||||
const logger = createLogger('RootLayout');
|
const logger = createLogger('RootLayout');
|
||||||
|
const SERVER_READY_MAX_ATTEMPTS = 8;
|
||||||
|
const SERVER_READY_BACKOFF_BASE_MS = 250;
|
||||||
|
const SERVER_READY_MAX_DELAY_MS = 1500;
|
||||||
|
const SERVER_READY_TIMEOUT_MS = 2000;
|
||||||
|
const NO_STORE_CACHE_MODE: RequestCache = 'no-store';
|
||||||
|
const AUTO_OPEN_HISTORY_INDEX = 0;
|
||||||
|
const SINGLE_PROJECT_COUNT = 1;
|
||||||
|
const DEFAULT_LAST_OPENED_TIME_MS = 0;
|
||||||
|
const AUTO_OPEN_STATUS = {
|
||||||
|
idle: 'idle',
|
||||||
|
opening: 'opening',
|
||||||
|
done: 'done',
|
||||||
|
} as const;
|
||||||
|
type AutoOpenStatus = (typeof AUTO_OPEN_STATUS)[keyof typeof AUTO_OPEN_STATUS];
|
||||||
|
|
||||||
// Apply stored theme immediately on page load (before React hydration)
|
// Apply stored theme immediately on page load (before React hydration)
|
||||||
// This prevents flash of default theme on login/setup pages
|
// This prevents flash of default theme on login/setup pages
|
||||||
@@ -60,11 +77,84 @@ function applyStoredTheme(): void {
|
|||||||
// Apply stored theme immediately (runs synchronously before render)
|
// Apply stored theme immediately (runs synchronously before render)
|
||||||
applyStoredTheme();
|
applyStoredTheme();
|
||||||
|
|
||||||
|
async function waitForServerReady(): Promise<boolean> {
|
||||||
|
const serverUrl = getServerUrlSync();
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= SERVER_READY_MAX_ATTEMPTS; attempt++) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${serverUrl}/api/health`, {
|
||||||
|
method: 'GET',
|
||||||
|
signal: AbortSignal.timeout(SERVER_READY_TIMEOUT_MS),
|
||||||
|
cache: NO_STORE_CACHE_MODE,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`Server readiness check failed (attempt ${attempt})`, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const delayMs = Math.min(SERVER_READY_MAX_DELAY_MS, SERVER_READY_BACKOFF_BASE_MS * attempt);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProjectLastOpenedMs(project: Project): number {
|
||||||
|
if (!project.lastOpened) return DEFAULT_LAST_OPENED_TIME_MS;
|
||||||
|
const parsed = Date.parse(project.lastOpened);
|
||||||
|
return Number.isNaN(parsed) ? DEFAULT_LAST_OPENED_TIME_MS : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAutoOpenProject(
|
||||||
|
currentProject: Project | null,
|
||||||
|
projects: Project[],
|
||||||
|
projectHistory: string[]
|
||||||
|
): Project | null {
|
||||||
|
if (currentProject) return currentProject;
|
||||||
|
|
||||||
|
if (projectHistory.length > 0) {
|
||||||
|
const historyProjectId = projectHistory[AUTO_OPEN_HISTORY_INDEX];
|
||||||
|
const historyProject = projects.find((project) => project.id === historyProjectId);
|
||||||
|
if (historyProject) {
|
||||||
|
return historyProject;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (projects.length === SINGLE_PROJECT_COUNT) {
|
||||||
|
return projects[AUTO_OPEN_HISTORY_INDEX] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (projects.length > SINGLE_PROJECT_COUNT) {
|
||||||
|
let latestProject: Project | null = projects[AUTO_OPEN_HISTORY_INDEX] ?? null;
|
||||||
|
let latestTimestamp = latestProject
|
||||||
|
? getProjectLastOpenedMs(latestProject)
|
||||||
|
: DEFAULT_LAST_OPENED_TIME_MS;
|
||||||
|
|
||||||
|
for (const project of projects) {
|
||||||
|
const openedAt = getProjectLastOpenedMs(project);
|
||||||
|
if (openedAt > latestTimestamp) {
|
||||||
|
latestTimestamp = openedAt;
|
||||||
|
latestProject = project;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return latestProject;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function RootLayoutContent() {
|
function RootLayoutContent() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const {
|
const {
|
||||||
setIpcConnected,
|
setIpcConnected,
|
||||||
|
projects,
|
||||||
currentProject,
|
currentProject,
|
||||||
|
projectHistory,
|
||||||
|
upsertAndSetCurrentProject,
|
||||||
getEffectiveTheme,
|
getEffectiveTheme,
|
||||||
skipSandboxWarning,
|
skipSandboxWarning,
|
||||||
setSkipSandboxWarning,
|
setSkipSandboxWarning,
|
||||||
@@ -76,6 +166,7 @@ function RootLayoutContent() {
|
|||||||
const [streamerPanelOpen, setStreamerPanelOpen] = useState(false);
|
const [streamerPanelOpen, setStreamerPanelOpen] = useState(false);
|
||||||
const authChecked = useAuthStore((s) => s.authChecked);
|
const authChecked = useAuthStore((s) => s.authChecked);
|
||||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||||
|
const settingsLoaded = useAuthStore((s) => s.settingsLoaded);
|
||||||
const { openFileBrowser } = useFileBrowser();
|
const { openFileBrowser } = useFileBrowser();
|
||||||
|
|
||||||
// Load project settings when switching projects
|
// Load project settings when switching projects
|
||||||
@@ -85,6 +176,22 @@ function RootLayoutContent() {
|
|||||||
const isLoginRoute = location.pathname === '/login';
|
const isLoginRoute = location.pathname === '/login';
|
||||||
const isLoggedOutRoute = location.pathname === '/logged-out';
|
const isLoggedOutRoute = location.pathname === '/logged-out';
|
||||||
const isDashboardRoute = location.pathname === '/dashboard';
|
const isDashboardRoute = location.pathname === '/dashboard';
|
||||||
|
const isBoardRoute = location.pathname === '/board';
|
||||||
|
const isRootRoute = location.pathname === '/';
|
||||||
|
const [autoOpenStatus, setAutoOpenStatus] = useState<AutoOpenStatus>(AUTO_OPEN_STATUS.idle);
|
||||||
|
const autoOpenCandidate = selectAutoOpenProject(currentProject, projects, projectHistory);
|
||||||
|
const canAutoOpen =
|
||||||
|
authChecked &&
|
||||||
|
isAuthenticated &&
|
||||||
|
settingsLoaded &&
|
||||||
|
setupComplete &&
|
||||||
|
!isLoginRoute &&
|
||||||
|
!isLoggedOutRoute &&
|
||||||
|
!isSetupRoute &&
|
||||||
|
!!autoOpenCandidate;
|
||||||
|
const shouldAutoOpen = canAutoOpen && autoOpenStatus !== AUTO_OPEN_STATUS.done;
|
||||||
|
const shouldBlockForSettings =
|
||||||
|
authChecked && isAuthenticated && !settingsLoaded && !isLoginRoute && !isLoggedOutRoute;
|
||||||
|
|
||||||
// Sandbox environment check state
|
// Sandbox environment check state
|
||||||
type SandboxStatus = 'pending' | 'containerized' | 'needs-confirmation' | 'denied' | 'confirmed';
|
type SandboxStatus = 'pending' | 'containerized' | 'needs-confirmation' | 'denied' | 'confirmed';
|
||||||
@@ -218,9 +325,11 @@ function RootLayoutContent() {
|
|||||||
// Works for ALL modes (unified flow)
|
// Works for ALL modes (unified flow)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleLoggedOut = () => {
|
const handleLoggedOut = () => {
|
||||||
|
logger.warn('automaker:logged-out event received!');
|
||||||
useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
|
useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
|
||||||
|
|
||||||
if (location.pathname !== '/logged-out') {
|
if (location.pathname !== '/logged-out') {
|
||||||
|
logger.warn('Navigating to /logged-out due to logged-out event');
|
||||||
navigate({ to: '/logged-out' });
|
navigate({ to: '/logged-out' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -236,6 +345,7 @@ function RootLayoutContent() {
|
|||||||
// Redirects to login page which will detect server is offline and show error UI.
|
// Redirects to login page which will detect server is offline and show error UI.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleServerOffline = () => {
|
const handleServerOffline = () => {
|
||||||
|
logger.warn('automaker:server-offline event received!');
|
||||||
useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
|
useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
|
||||||
|
|
||||||
// Navigate to login - the login page will detect server is offline and show appropriate UI
|
// Navigate to login - the login page will detect server is offline and show appropriate UI
|
||||||
@@ -266,6 +376,12 @@ function RootLayoutContent() {
|
|||||||
// Initialize API key for Electron mode
|
// Initialize API key for Electron mode
|
||||||
await initApiKey();
|
await initApiKey();
|
||||||
|
|
||||||
|
const serverReady = await waitForServerReady();
|
||||||
|
if (!serverReady) {
|
||||||
|
handleServerOffline();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Verify session (Single Request, ALL modes)
|
// 1. Verify session (Single Request, ALL modes)
|
||||||
let isValid = false;
|
let isValid = false;
|
||||||
try {
|
try {
|
||||||
@@ -302,13 +418,28 @@ function RootLayoutContent() {
|
|||||||
// Hydrate store with the final settings (merged if migration occurred)
|
// Hydrate store with the final settings (merged if migration occurred)
|
||||||
hydrateStoreFromSettings(finalSettings);
|
hydrateStoreFromSettings(finalSettings);
|
||||||
|
|
||||||
// Signal that settings hydration is complete so useSettingsSync can start
|
// CRITICAL: Wait for React to render the hydrated state before
|
||||||
|
// signaling completion. Zustand updates are synchronous, but React
|
||||||
|
// hasn't necessarily re-rendered yet. This prevents race conditions
|
||||||
|
// where useSettingsSync reads state before the UI has updated.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
// Signal that settings hydration is complete FIRST.
|
||||||
|
// This ensures useSettingsSync's waitForMigrationComplete() will resolve
|
||||||
|
// immediately when it starts after auth state change, preventing it from
|
||||||
|
// syncing default empty state to the server.
|
||||||
signalMigrationComplete();
|
signalMigrationComplete();
|
||||||
|
|
||||||
// Mark auth as checked only after settings hydration succeeded.
|
// Now mark auth as checked AND settings as loaded.
|
||||||
useAuthStore
|
// The settingsLoaded flag ensures useSettingsSync won't start syncing
|
||||||
.getState()
|
// until settings have been properly hydrated, even if authChecked was
|
||||||
.setAuthState({ isAuthenticated: true, authChecked: true });
|
// set earlier by login-view.
|
||||||
|
useAuthStore.getState().setAuthState({
|
||||||
|
isAuthenticated: true,
|
||||||
|
authChecked: true,
|
||||||
|
settingsLoaded: true,
|
||||||
|
});
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,17 +504,38 @@ function RootLayoutContent() {
|
|||||||
// - If authenticated but setup incomplete: force /setup
|
// - If authenticated but setup incomplete: force /setup
|
||||||
// - If authenticated and setup complete: allow access to app
|
// - If authenticated and setup complete: allow access to app
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
logger.debug('Routing effect triggered:', {
|
||||||
|
authChecked,
|
||||||
|
isAuthenticated,
|
||||||
|
settingsLoaded,
|
||||||
|
setupComplete,
|
||||||
|
pathname: location.pathname,
|
||||||
|
});
|
||||||
|
|
||||||
// Wait for auth check to complete before enforcing any redirects
|
// Wait for auth check to complete before enforcing any redirects
|
||||||
if (!authChecked) return;
|
if (!authChecked) {
|
||||||
|
logger.debug('Auth not checked yet, skipping routing');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Unauthenticated -> force /logged-out (but allow /login so user can authenticate)
|
// Unauthenticated -> force /logged-out (but allow /login so user can authenticate)
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
|
logger.warn('Not authenticated, redirecting to /logged-out. Auth state:', {
|
||||||
|
authChecked,
|
||||||
|
isAuthenticated,
|
||||||
|
settingsLoaded,
|
||||||
|
currentPath: location.pathname,
|
||||||
|
});
|
||||||
if (location.pathname !== '/logged-out' && location.pathname !== '/login') {
|
if (location.pathname !== '/logged-out' && location.pathname !== '/login') {
|
||||||
navigate({ to: '/logged-out' });
|
navigate({ to: '/logged-out' });
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wait for settings to be loaded before making setupComplete-based routing decisions
|
||||||
|
// This prevents redirecting to /setup before we know the actual setupComplete value
|
||||||
|
if (!settingsLoaded) return;
|
||||||
|
|
||||||
// Authenticated -> determine whether setup is required
|
// Authenticated -> determine whether setup is required
|
||||||
if (!setupComplete && location.pathname !== '/setup') {
|
if (!setupComplete && location.pathname !== '/setup') {
|
||||||
navigate({ to: '/setup' });
|
navigate({ to: '/setup' });
|
||||||
@@ -394,7 +546,46 @@ function RootLayoutContent() {
|
|||||||
if (setupComplete && location.pathname === '/setup') {
|
if (setupComplete && location.pathname === '/setup') {
|
||||||
navigate({ to: '/dashboard' });
|
navigate({ to: '/dashboard' });
|
||||||
}
|
}
|
||||||
}, [authChecked, isAuthenticated, setupComplete, location.pathname, navigate]);
|
}, [authChecked, isAuthenticated, settingsLoaded, setupComplete, location.pathname, navigate]);
|
||||||
|
|
||||||
|
// Fallback: If auth is checked and authenticated but settings not loaded,
|
||||||
|
// it means login-view or another component set auth state before __root.tsx's
|
||||||
|
// auth flow completed. Load settings now to prevent sync with empty state.
|
||||||
|
useEffect(() => {
|
||||||
|
// Only trigger if auth is valid but settings aren't loaded yet
|
||||||
|
// This handles the case where login-view sets authChecked=true before we finish our auth flow
|
||||||
|
if (!authChecked || !isAuthenticated || settingsLoaded) {
|
||||||
|
logger.debug('Fallback skipped:', { authChecked, isAuthenticated, settingsLoaded });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Auth valid but settings not loaded - triggering fallback load');
|
||||||
|
|
||||||
|
const loadSettings = async () => {
|
||||||
|
const api = getHttpApiClient();
|
||||||
|
try {
|
||||||
|
logger.debug('Fetching settings in fallback...');
|
||||||
|
const settingsResult = await api.settings.getGlobal();
|
||||||
|
logger.debug('Settings fetched:', settingsResult.success ? 'success' : 'failed');
|
||||||
|
if (settingsResult.success && settingsResult.settings) {
|
||||||
|
const { settings: finalSettings } = await performSettingsMigration(
|
||||||
|
settingsResult.settings as unknown as Parameters<typeof performSettingsMigration>[0]
|
||||||
|
);
|
||||||
|
logger.debug('Settings migrated, hydrating stores...');
|
||||||
|
hydrateStoreFromSettings(finalSettings);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
signalMigrationComplete();
|
||||||
|
logger.debug('Setting settingsLoaded=true');
|
||||||
|
useAuthStore.getState().setAuthState({ settingsLoaded: true });
|
||||||
|
logger.info('Fallback settings load completed successfully');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to load settings in fallback:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadSettings();
|
||||||
|
}, [authChecked, isAuthenticated, settingsLoaded]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setGlobalFileBrowser(openFileBrowser);
|
setGlobalFileBrowser(openFileBrowser);
|
||||||
@@ -428,7 +619,10 @@ function RootLayoutContent() {
|
|||||||
|
|
||||||
// Redirect from welcome page based on project state
|
// Redirect from welcome page based on project state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isMounted && location.pathname === '/') {
|
if (isMounted && isRootRoute) {
|
||||||
|
if (!settingsLoaded || shouldAutoOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (currentProject) {
|
if (currentProject) {
|
||||||
// Project is selected, go to board
|
// Project is selected, go to board
|
||||||
navigate({ to: '/board' });
|
navigate({ to: '/board' });
|
||||||
@@ -437,14 +631,66 @@ function RootLayoutContent() {
|
|||||||
navigate({ to: '/dashboard' });
|
navigate({ to: '/dashboard' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [isMounted, currentProject, location.pathname, navigate]);
|
}, [isMounted, currentProject, isRootRoute, navigate, shouldAutoOpen, settingsLoaded]);
|
||||||
|
|
||||||
|
// Auto-open the most recent project on startup
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canAutoOpen) return;
|
||||||
|
if (autoOpenStatus !== AUTO_OPEN_STATUS.idle) return;
|
||||||
|
|
||||||
|
if (!autoOpenCandidate) return;
|
||||||
|
|
||||||
|
setAutoOpenStatus(AUTO_OPEN_STATUS.opening);
|
||||||
|
|
||||||
|
const openProject = async () => {
|
||||||
|
try {
|
||||||
|
const initResult = await initializeProject(autoOpenCandidate.path);
|
||||||
|
if (!initResult.success) {
|
||||||
|
logger.warn('Auto-open project failed:', initResult.error);
|
||||||
|
if (isRootRoute) {
|
||||||
|
navigate({ to: '/dashboard' });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentProject || currentProject.id !== autoOpenCandidate.id) {
|
||||||
|
upsertAndSetCurrentProject(
|
||||||
|
autoOpenCandidate.path,
|
||||||
|
autoOpenCandidate.name,
|
||||||
|
autoOpenCandidate.theme
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRootRoute) {
|
||||||
|
navigate({ to: '/board' });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Auto-open project crashed:', error);
|
||||||
|
if (isRootRoute) {
|
||||||
|
navigate({ to: '/dashboard' });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setAutoOpenStatus(AUTO_OPEN_STATUS.done);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void openProject();
|
||||||
|
}, [
|
||||||
|
canAutoOpen,
|
||||||
|
autoOpenStatus,
|
||||||
|
autoOpenCandidate,
|
||||||
|
currentProject,
|
||||||
|
navigate,
|
||||||
|
upsertAndSetCurrentProject,
|
||||||
|
isRootRoute,
|
||||||
|
]);
|
||||||
|
|
||||||
// Bootstrap Codex models on app startup (after auth completes)
|
// Bootstrap Codex models on app startup (after auth completes)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only fetch if authenticated and Codex CLI is available
|
// Only fetch if authenticated and Codex CLI is available
|
||||||
if (!authChecked || !isAuthenticated) return;
|
if (!authChecked || !isAuthenticated) return;
|
||||||
|
|
||||||
const isCodexAvailable = codexCliStatus?.installed && codexCliStatus?.auth?.authenticated;
|
const isCodexAvailable = codexCliStatus?.installed && codexCliStatus?.hasApiKey;
|
||||||
if (!isCodexAvailable) return;
|
if (!isCodexAvailable) return;
|
||||||
|
|
||||||
// Fetch models in the background
|
// Fetch models in the background
|
||||||
@@ -512,6 +758,22 @@ function RootLayoutContent() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (shouldBlockForSettings) {
|
||||||
|
return (
|
||||||
|
<main className="flex h-screen items-center justify-center" data-testid="app-container">
|
||||||
|
<LoadingState message="Loading settings..." />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldAutoOpen) {
|
||||||
|
return (
|
||||||
|
<main className="flex h-screen items-center justify-center" data-testid="app-container">
|
||||||
|
<LoadingState message="Opening project..." />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Show setup page (full screen, no sidebar) - authenticated only
|
// Show setup page (full screen, no sidebar) - authenticated only
|
||||||
if (isSetupRoute) {
|
if (isSetupRoute) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
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);
|
setItem(THEME_STORAGE_KEY, theme);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function persistEffectiveThemeForProject(project: Project | null, fallbackTheme: ThemeMode): void {
|
||||||
|
const projectTheme = project?.theme as ThemeMode | undefined;
|
||||||
|
const themeToStore = projectTheme ?? fallbackTheme;
|
||||||
|
saveThemeToStorage(themeToStore);
|
||||||
|
}
|
||||||
|
|
||||||
export type BoardViewMode = 'kanban' | 'graph';
|
export type BoardViewMode = 'kanban' | 'graph';
|
||||||
|
|
||||||
export interface ApiKeys {
|
export interface ApiKeys {
|
||||||
@@ -210,9 +216,11 @@ export function formatShortcut(shortcut: string | undefined | null, forDisplay =
|
|||||||
export interface KeyboardShortcuts {
|
export interface KeyboardShortcuts {
|
||||||
// Navigation shortcuts
|
// Navigation shortcuts
|
||||||
board: string;
|
board: string;
|
||||||
|
graph: string;
|
||||||
agent: string;
|
agent: string;
|
||||||
spec: string;
|
spec: string;
|
||||||
context: string;
|
context: string;
|
||||||
|
memory: string;
|
||||||
settings: string;
|
settings: string;
|
||||||
terminal: string;
|
terminal: string;
|
||||||
ideation: string;
|
ideation: string;
|
||||||
@@ -243,9 +251,11 @@ export interface KeyboardShortcuts {
|
|||||||
export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = {
|
export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = {
|
||||||
// Navigation
|
// Navigation
|
||||||
board: 'K',
|
board: 'K',
|
||||||
|
graph: 'H',
|
||||||
agent: 'A',
|
agent: 'A',
|
||||||
spec: 'D',
|
spec: 'D',
|
||||||
context: 'C',
|
context: 'C',
|
||||||
|
memory: 'Y',
|
||||||
settings: 'S',
|
settings: 'S',
|
||||||
terminal: 'T',
|
terminal: 'T',
|
||||||
ideation: 'I',
|
ideation: 'I',
|
||||||
@@ -578,6 +588,9 @@ export interface AppState {
|
|||||||
// MCP Servers
|
// MCP Servers
|
||||||
mcpServers: MCPServerConfig[]; // List of configured MCP servers for agent use
|
mcpServers: MCPServerConfig[]; // List of configured MCP servers for agent use
|
||||||
|
|
||||||
|
// Editor Configuration
|
||||||
|
defaultEditorCommand: string | null; // Default editor for "Open In" action
|
||||||
|
|
||||||
// Skills Configuration
|
// Skills Configuration
|
||||||
enableSkills: boolean; // Enable Skills functionality (loads from .claude/skills/ directories)
|
enableSkills: boolean; // Enable Skills functionality (loads from .claude/skills/ directories)
|
||||||
skillsSources: Array<'user' | 'project'>; // Which directories to load Skills from
|
skillsSources: Array<'user' | 'project'>; // Which directories to load Skills from
|
||||||
@@ -973,6 +986,9 @@ export interface AppActions {
|
|||||||
setAutoLoadClaudeMd: (enabled: boolean) => Promise<void>;
|
setAutoLoadClaudeMd: (enabled: boolean) => Promise<void>;
|
||||||
setSkipSandboxWarning: (skip: boolean) => Promise<void>;
|
setSkipSandboxWarning: (skip: boolean) => Promise<void>;
|
||||||
|
|
||||||
|
// Editor Configuration actions
|
||||||
|
setDefaultEditorCommand: (command: string | null) => void;
|
||||||
|
|
||||||
// Prompt Customization actions
|
// Prompt Customization actions
|
||||||
setPromptCustomization: (customization: PromptCustomization) => Promise<void>;
|
setPromptCustomization: (customization: PromptCustomization) => Promise<void>;
|
||||||
|
|
||||||
@@ -1198,6 +1214,7 @@ const initialState: AppState = {
|
|||||||
autoLoadClaudeMd: false, // Default to disabled (user must opt-in)
|
autoLoadClaudeMd: false, // Default to disabled (user must opt-in)
|
||||||
skipSandboxWarning: false, // Default to disabled (show sandbox warning dialog)
|
skipSandboxWarning: false, // Default to disabled (show sandbox warning dialog)
|
||||||
mcpServers: [], // No MCP servers configured by default
|
mcpServers: [], // No MCP servers configured by default
|
||||||
|
defaultEditorCommand: null, // Auto-detect: Cursor > VS Code > first available
|
||||||
enableSkills: true, // Skills enabled by default
|
enableSkills: true, // Skills enabled by default
|
||||||
skillsSources: ['user', 'project'] as Array<'user' | 'project'>, // Load from both sources by default
|
skillsSources: ['user', 'project'] as Array<'user' | 'project'>, // Load from both sources by default
|
||||||
enableSubagents: true, // Subagents enabled by default
|
enableSubagents: true, // Subagents enabled by default
|
||||||
@@ -1289,13 +1306,16 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isCurrent = get().currentProject?.id === projectId;
|
const isCurrent = get().currentProject?.id === projectId;
|
||||||
|
const nextCurrentProject = isCurrent ? null : get().currentProject;
|
||||||
|
|
||||||
set({
|
set({
|
||||||
projects: remainingProjects,
|
projects: remainingProjects,
|
||||||
trashedProjects: [trashedProject, ...existingTrash],
|
trashedProjects: [trashedProject, ...existingTrash],
|
||||||
currentProject: isCurrent ? null : get().currentProject,
|
currentProject: nextCurrentProject,
|
||||||
currentView: isCurrent ? 'welcome' : get().currentView,
|
currentView: isCurrent ? 'welcome' : get().currentView,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
persistEffectiveThemeForProject(nextCurrentProject, get().theme);
|
||||||
},
|
},
|
||||||
|
|
||||||
restoreTrashedProject: (projectId) => {
|
restoreTrashedProject: (projectId) => {
|
||||||
@@ -1314,6 +1334,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
currentProject: samePathProject,
|
currentProject: samePathProject,
|
||||||
currentView: 'board',
|
currentView: 'board',
|
||||||
});
|
});
|
||||||
|
persistEffectiveThemeForProject(samePathProject, get().theme);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1331,6 +1352,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
currentProject: restoredProject,
|
currentProject: restoredProject,
|
||||||
currentView: 'board',
|
currentView: 'board',
|
||||||
});
|
});
|
||||||
|
persistEffectiveThemeForProject(restoredProject, get().theme);
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteTrashedProject: (projectId) => {
|
deleteTrashedProject: (projectId) => {
|
||||||
@@ -1350,6 +1372,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
|
|
||||||
setCurrentProject: (project) => {
|
setCurrentProject: (project) => {
|
||||||
set({ currentProject: project });
|
set({ currentProject: project });
|
||||||
|
persistEffectiveThemeForProject(project, get().theme);
|
||||||
if (project) {
|
if (project) {
|
||||||
set({ currentView: 'board' });
|
set({ currentView: 'board' });
|
||||||
// Add to project history (MRU order)
|
// Add to project history (MRU order)
|
||||||
@@ -1433,6 +1456,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
projectHistoryIndex: newIndex,
|
projectHistoryIndex: newIndex,
|
||||||
currentView: 'board',
|
currentView: 'board',
|
||||||
});
|
});
|
||||||
|
persistEffectiveThemeForProject(targetProject, get().theme);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1466,6 +1490,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
projectHistoryIndex: newIndex,
|
projectHistoryIndex: newIndex,
|
||||||
currentView: 'board',
|
currentView: 'board',
|
||||||
});
|
});
|
||||||
|
persistEffectiveThemeForProject(targetProject, get().theme);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1525,12 +1550,14 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
// Also update currentProject if it's the same project
|
// Also update currentProject if it's the same project
|
||||||
const currentProject = get().currentProject;
|
const currentProject = get().currentProject;
|
||||||
if (currentProject?.id === projectId) {
|
if (currentProject?.id === projectId) {
|
||||||
|
const updatedTheme = theme === null ? undefined : theme;
|
||||||
set({
|
set({
|
||||||
currentProject: {
|
currentProject: {
|
||||||
...currentProject,
|
...currentProject,
|
||||||
theme: theme === null ? undefined : theme,
|
theme: updatedTheme,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
persistEffectiveThemeForProject({ ...currentProject, theme: updatedTheme }, get().theme);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1981,6 +2008,9 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
set({ skipSandboxWarning: previous });
|
set({ skipSandboxWarning: previous });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Editor Configuration actions
|
||||||
|
setDefaultEditorCommand: (command) => set({ defaultEditorCommand: command }),
|
||||||
// Prompt Customization actions
|
// Prompt Customization actions
|
||||||
setPromptCustomization: async (customization) => {
|
setPromptCustomization: async (customization) => {
|
||||||
set({ promptCustomization: customization });
|
set({ promptCustomization: customization });
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ interface AuthState {
|
|||||||
authChecked: boolean;
|
authChecked: boolean;
|
||||||
/** Whether the user is currently authenticated (web mode: valid session cookie) */
|
/** Whether the user is currently authenticated (web mode: valid session cookie) */
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
|
/** Whether settings have been loaded and hydrated from server */
|
||||||
|
settingsLoaded: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthActions {
|
interface AuthActions {
|
||||||
@@ -15,15 +17,18 @@ interface AuthActions {
|
|||||||
const initialState: AuthState = {
|
const initialState: AuthState = {
|
||||||
authChecked: false,
|
authChecked: false,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
|
settingsLoaded: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Web authentication state.
|
* Web authentication state.
|
||||||
*
|
*
|
||||||
* Intentionally NOT persisted: source of truth is the server session cookie.
|
* Intentionally NOT persisted: source of truth is server session cookie.
|
||||||
*/
|
*/
|
||||||
export const useAuthStore = create<AuthState & AuthActions>((set) => ({
|
export const useAuthStore = create<AuthState & AuthActions>((set) => ({
|
||||||
...initialState,
|
...initialState,
|
||||||
setAuthState: (state) => set(state),
|
setAuthState: (state) => {
|
||||||
|
set({ ...state });
|
||||||
|
},
|
||||||
resetAuth: () => set(initialState),
|
resetAuth: () => set(initialState),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export interface CliStatus {
|
|||||||
path: string | null;
|
path: string | null;
|
||||||
version: string | null;
|
version: string | null;
|
||||||
method: string;
|
method: string;
|
||||||
|
hasApiKey?: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -298,7 +298,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.light {
|
/* IMPORTANT:
|
||||||
|
* Theme classes like `.light` are applied to `:root` (html).
|
||||||
|
* Some third-party libraries (e.g. React Flow) also add `.light`/`.dark` classes
|
||||||
|
* to nested containers. If we define CSS variables on `.light` broadly, those
|
||||||
|
* nested containers will override the app theme and cause "white cards" in dark themes.
|
||||||
|
* Scoping to `:root.light` ensures only the root theme toggle controls variables.
|
||||||
|
*/
|
||||||
|
:root.light {
|
||||||
/* Explicit light mode - same as root but ensures it overrides any dark defaults */
|
/* Explicit light mode - same as root but ensures it overrides any dark defaults */
|
||||||
--background: oklch(1 0 0); /* White */
|
--background: oklch(1 0 0); /* White */
|
||||||
--background-50: oklch(1 0 0 / 0.5);
|
--background-50: oklch(1 0 0 / 0.5);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/* Dark Theme */
|
/* Dark Theme */
|
||||||
|
|
||||||
.dark {
|
:root.dark {
|
||||||
/* Deep dark backgrounds - zinc-950 family */
|
/* Deep dark backgrounds - zinc-950 family */
|
||||||
--background: oklch(0.04 0 0); /* zinc-950 */
|
--background: oklch(0.04 0 0); /* zinc-950 */
|
||||||
--background-50: oklch(0.04 0 0 / 0.5); /* zinc-950/50 */
|
--background-50: oklch(0.04 0 0 / 0.5); /* zinc-950/50 */
|
||||||
|
|||||||
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;
|
featureId: string;
|
||||||
projectPath?: string;
|
projectPath?: string;
|
||||||
phaseNumber: number;
|
phaseNumber: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'auto_mode_resuming_features';
|
||||||
|
message: string;
|
||||||
|
projectPath?: string;
|
||||||
|
featureIds: string[];
|
||||||
|
features: Array<{
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
status?: string;
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SpecRegenerationEvent =
|
export type SpecRegenerationEvent =
|
||||||
@@ -356,15 +367,16 @@ export interface SpecRegenerationAPI {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
stop: () => Promise<{
|
stop: (projectPath?: string) => Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
status: () => Promise<{
|
status: (projectPath?: string) => Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
isRunning?: boolean;
|
isRunning?: boolean;
|
||||||
currentPhase?: string;
|
currentPhase?: string;
|
||||||
|
projectPath?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
@@ -872,7 +884,10 @@ export interface WorktreeAPI {
|
|||||||
}>;
|
}>;
|
||||||
|
|
||||||
// Open a worktree directory in the editor
|
// Open a worktree directory in the editor
|
||||||
openInEditor: (worktreePath: string) => Promise<{
|
openInEditor: (
|
||||||
|
worktreePath: string,
|
||||||
|
editorCommand?: string
|
||||||
|
) => Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
result?: {
|
result?: {
|
||||||
message: string;
|
message: string;
|
||||||
@@ -891,6 +906,30 @@ export interface WorktreeAPI {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
// Get all available code editors
|
||||||
|
getAvailableEditors: () => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
result?: {
|
||||||
|
editors: Array<{
|
||||||
|
name: string;
|
||||||
|
command: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
// Refresh editor cache and re-detect available editors
|
||||||
|
refreshEditors: () => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
result?: {
|
||||||
|
editors: Array<{
|
||||||
|
name: string;
|
||||||
|
command: string;
|
||||||
|
}>;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
// Initialize git repository in a project
|
// Initialize git repository in a project
|
||||||
initGit: (projectPath: string) => Promise<{
|
initGit: (projectPath: string) => Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import { routeTree } from '../routeTree.gen';
|
|||||||
|
|
||||||
// Use browser history in web mode (for e2e tests and dev), memory history in Electron
|
// Use browser history in web mode (for e2e tests and dev), memory history in Electron
|
||||||
const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined;
|
const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined;
|
||||||
|
const BOARD_ROUTE_PATH = '/board';
|
||||||
|
|
||||||
const history = isElectron
|
const history = isElectron
|
||||||
? createMemoryHistory({ initialEntries: [window.location.pathname || '/'] })
|
? createMemoryHistory({ initialEntries: [BOARD_ROUTE_PATH] })
|
||||||
: createBrowserHistory();
|
: createBrowserHistory();
|
||||||
|
|
||||||
export const router = createRouter({
|
export const router = createRouter({
|
||||||
|
|||||||
@@ -368,3 +368,42 @@ export async function authenticateForTests(page: Page): Promise<boolean> {
|
|||||||
const apiKey = process.env.AUTOMAKER_API_KEY || 'test-api-key-for-e2e-tests';
|
const apiKey = process.env.AUTOMAKER_API_KEY || 'test-api-key-for-e2e-tests';
|
||||||
return authenticateWithApiKey(page, apiKey);
|
return authenticateWithApiKey(page, apiKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the backend server is healthy
|
||||||
|
* Returns true if the server responds with status 200, false otherwise
|
||||||
|
*/
|
||||||
|
export async function checkBackendHealth(page: Page, timeout = 5000): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await page.request.get(`${API_BASE_URL}/api/health`, {
|
||||||
|
timeout,
|
||||||
|
});
|
||||||
|
return response.ok();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for the backend to be healthy, with retry logic
|
||||||
|
* Throws an error if the backend doesn't become healthy within the timeout
|
||||||
|
*/
|
||||||
|
export async function waitForBackendHealth(
|
||||||
|
page: Page,
|
||||||
|
maxWaitMs = 30000,
|
||||||
|
checkIntervalMs = 500
|
||||||
|
): Promise<void> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
while (Date.now() - startTime < maxWaitMs) {
|
||||||
|
if (await checkBackendHealth(page, checkIntervalMs)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(checkIntervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`Backend did not become healthy within ${maxWaitMs}ms. ` +
|
||||||
|
`Last health check failed or timed out.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user