mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 06:42:03 +00:00
Compare commits
4 Commits
feat/inter
...
feature/ui
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c19bb60d1 | ||
|
|
a2ccf200a9 | ||
|
|
5543b46f64 | ||
|
|
d9299b4680 |
88
.github/workflows/e2e-tests.yml
vendored
88
.github/workflows/e2e-tests.yml
vendored
@@ -37,14 +37,7 @@ jobs:
|
||||
git config --global user.email "ci@example.com"
|
||||
|
||||
- name: Start backend 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
|
||||
|
||||
run: npm run start --workspace=apps/server &
|
||||
env:
|
||||
PORT: 3008
|
||||
NODE_ENV: test
|
||||
@@ -60,70 +53,21 @@ jobs:
|
||||
- name: Wait for backend server
|
||||
run: |
|
||||
echo "Waiting for backend server to be ready..."
|
||||
|
||||
# Check if server process is running
|
||||
if [ -z "$SERVER_PID" ]; then
|
||||
echo "ERROR: Server PID not found in environment"
|
||||
cat backend.log 2>/dev/null || echo "No backend log found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if process is actually running
|
||||
if ! kill -0 $SERVER_PID 2>/dev/null; then
|
||||
echo "ERROR: Server process $SERVER_PID is not running!"
|
||||
echo "=== Backend logs ==="
|
||||
cat backend.log
|
||||
echo ""
|
||||
echo "=== Recent system logs ==="
|
||||
dmesg 2>/dev/null | tail -20 || echo "No dmesg available"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Wait for health endpoint
|
||||
for i in {1..60}; do
|
||||
if curl -s -f http://localhost:3008/api/health > /dev/null 2>&1; then
|
||||
echo "Backend server is ready!"
|
||||
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')"
|
||||
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')"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check if server process is still running
|
||||
if ! kill -0 $SERVER_PID 2>/dev/null; then
|
||||
echo "ERROR: Server process died during wait!"
|
||||
echo "=== Backend logs ==="
|
||||
cat backend.log
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Waiting... ($i/60)"
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "ERROR: Backend server failed to start within 60 seconds!"
|
||||
echo "=== Backend logs ==="
|
||||
cat backend.log
|
||||
echo ""
|
||||
echo "=== Process status ==="
|
||||
echo "Backend server failed to start!"
|
||||
echo "Checking server status..."
|
||||
ps aux | grep -E "(node|tsx)" | grep -v grep || echo "No node processes found"
|
||||
echo ""
|
||||
echo "=== Port status ==="
|
||||
netstat -tlnp 2>/dev/null | grep :3008 || echo "Port 3008 not listening"
|
||||
lsof -i :3008 2>/dev/null || echo "lsof not available or port not in use"
|
||||
echo ""
|
||||
echo "=== Health endpoint test ==="
|
||||
echo "Testing health endpoint..."
|
||||
curl -v http://localhost:3008/api/health 2>&1 || echo "Health endpoint failed"
|
||||
|
||||
# Kill the server process if it's still hanging
|
||||
if kill -0 $SERVER_PID 2>/dev/null; then
|
||||
echo ""
|
||||
echo "Killing stuck server process..."
|
||||
kill -9 $SERVER_PID 2>/dev/null || true
|
||||
fi
|
||||
|
||||
exit 1
|
||||
|
||||
- name: Run E2E tests
|
||||
@@ -137,18 +81,6 @@ jobs:
|
||||
# Keep UI-side login/defaults consistent
|
||||
AUTOMAKER_API_KEY: test-api-key-for-e2e-tests
|
||||
|
||||
- name: Print backend logs on failure
|
||||
if: failure()
|
||||
run: |
|
||||
echo "=== E2E Tests Failed - Backend Logs ==="
|
||||
cat backend.log 2>/dev/null || echo "No backend log found"
|
||||
echo ""
|
||||
echo "=== Process status at failure ==="
|
||||
ps aux | grep -E "(node|tsx)" | grep -v grep || echo "No node processes found"
|
||||
echo ""
|
||||
echo "=== Port status ==="
|
||||
netstat -tlnp 2>/dev/null | grep :3008 || echo "Port 3008 not listening"
|
||||
|
||||
- name: Upload Playwright report
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
@@ -166,13 +98,3 @@ jobs:
|
||||
apps/ui/test-results/
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: Cleanup - Kill backend server
|
||||
if: always()
|
||||
run: |
|
||||
if [ -n "$SERVER_PID" ]; then
|
||||
echo "Cleaning up backend server (PID: $SERVER_PID)..."
|
||||
kill $SERVER_PID 2>/dev/null || true
|
||||
kill -9 $SERVER_PID 2>/dev/null || true
|
||||
echo "Backend server cleanup complete"
|
||||
fi
|
||||
|
||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -73,9 +73,6 @@ blob-report/
|
||||
!.env.example
|
||||
!.env.local.example
|
||||
|
||||
# Codex config (contains API keys)
|
||||
.codex/config.toml
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
|
||||
@@ -87,11 +84,4 @@ docker-compose.override.yml
|
||||
.claude/hans/
|
||||
|
||||
pnpm-lock.yaml
|
||||
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
|
||||
yarn.lock
|
||||
@@ -32,7 +32,7 @@
|
||||
"@automaker/prompts": "1.0.0",
|
||||
"@automaker/types": "1.0.0",
|
||||
"@automaker/utils": "1.0.0",
|
||||
"@modelcontextprotocol/sdk": "1.25.2",
|
||||
"@modelcontextprotocol/sdk": "1.25.1",
|
||||
"@openai/codex-sdk": "^0.77.0",
|
||||
"cookie-parser": "1.4.7",
|
||||
"cors": "2.8.5",
|
||||
|
||||
@@ -55,8 +55,6 @@ import { createClaudeRoutes } from './routes/claude/index.js';
|
||||
import { ClaudeUsageService } from './services/claude-usage-service.js';
|
||||
import { createCodexRoutes } from './routes/codex/index.js';
|
||||
import { CodexUsageService } from './services/codex-usage-service.js';
|
||||
import { CodexAppServerService } from './services/codex-app-server-service.js';
|
||||
import { CodexModelCacheService } from './services/codex-model-cache-service.js';
|
||||
import { createGitHubRoutes } from './routes/github/index.js';
|
||||
import { createContextRoutes } from './routes/context/index.js';
|
||||
import { createBacklogPlanRoutes } from './routes/backlog-plan/index.js';
|
||||
@@ -170,9 +168,7 @@ const agentService = new AgentService(DATA_DIR, events, settingsService);
|
||||
const featureLoader = new FeatureLoader();
|
||||
const autoModeService = new AutoModeService(events, settingsService);
|
||||
const claudeUsageService = new ClaudeUsageService();
|
||||
const codexAppServerService = new CodexAppServerService();
|
||||
const codexModelCacheService = new CodexModelCacheService(DATA_DIR, codexAppServerService);
|
||||
const codexUsageService = new CodexUsageService(codexAppServerService);
|
||||
const codexUsageService = new CodexUsageService();
|
||||
const mcpTestService = new MCPTestService(settingsService);
|
||||
const ideationService = new IdeationService(events, settingsService, featureLoader);
|
||||
|
||||
@@ -180,11 +176,6 @@ const ideationService = new IdeationService(events, settingsService, featureLoad
|
||||
(async () => {
|
||||
await agentService.initialize();
|
||||
logger.info('Agent service initialized');
|
||||
|
||||
// Bootstrap Codex model cache in background (don't block server startup)
|
||||
void codexModelCacheService.getModels().catch((err) => {
|
||||
logger.error('Failed to bootstrap Codex model cache:', err);
|
||||
});
|
||||
})();
|
||||
|
||||
// Run stale validation cleanup every hour to prevent memory leaks from crashed validations
|
||||
@@ -228,7 +219,7 @@ app.use('/api/templates', createTemplatesRoutes());
|
||||
app.use('/api/terminal', createTerminalRoutes());
|
||||
app.use('/api/settings', createSettingsRoutes(settingsService));
|
||||
app.use('/api/claude', createClaudeRoutes(claudeUsageService));
|
||||
app.use('/api/codex', createCodexRoutes(codexUsageService, codexModelCacheService));
|
||||
app.use('/api/codex', createCodexRoutes(codexUsageService));
|
||||
app.use('/api/github', createGitHubRoutes(events, settingsService));
|
||||
app.use('/api/context', createContextRoutes(settingsService));
|
||||
app.use('/api/backlog-plan', createBacklogPlanRoutes(events, settingsService));
|
||||
@@ -597,26 +588,6 @@ const startServer = (port: number) => {
|
||||
|
||||
startServer(PORT);
|
||||
|
||||
// Global error handlers to prevent crashes from uncaught errors
|
||||
process.on('unhandledRejection', (reason: unknown, _promise: Promise<unknown>) => {
|
||||
logger.error('Unhandled Promise Rejection:', {
|
||||
reason: reason instanceof Error ? reason.message : String(reason),
|
||||
stack: reason instanceof Error ? reason.stack : undefined,
|
||||
});
|
||||
// Don't exit - log the error and continue running
|
||||
// This prevents the server from crashing due to unhandled rejections
|
||||
});
|
||||
|
||||
process.on('uncaughtException', (error: Error) => {
|
||||
logger.error('Uncaught Exception:', {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
// Exit on uncaught exceptions to prevent undefined behavior
|
||||
// The process is in an unknown state after an uncaught exception
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', () => {
|
||||
logger.info('SIGTERM received, shutting down...');
|
||||
|
||||
@@ -5,11 +5,9 @@
|
||||
* Never assumes authenticated - only returns true if CLI confirms.
|
||||
*/
|
||||
|
||||
import { spawnProcess } from '@automaker/platform';
|
||||
import { spawnProcess, getCodexAuthPath } from '@automaker/platform';
|
||||
import { findCodexCliPath } from '@automaker/platform';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('CodexAuth');
|
||||
import * as fs from 'fs';
|
||||
|
||||
const CODEX_COMMAND = 'codex';
|
||||
const OPENAI_API_KEY_ENV = 'OPENAI_API_KEY';
|
||||
@@ -28,16 +26,36 @@ export interface CodexAuthCheckResult {
|
||||
export async function checkCodexAuthentication(
|
||||
cliPath?: string | null
|
||||
): Promise<CodexAuthCheckResult> {
|
||||
console.log('[CodexAuth] checkCodexAuthentication called with cliPath:', cliPath);
|
||||
|
||||
const resolvedCliPath = cliPath || (await findCodexCliPath());
|
||||
const hasApiKey = !!process.env[OPENAI_API_KEY_ENV];
|
||||
|
||||
console.log('[CodexAuth] resolvedCliPath:', resolvedCliPath);
|
||||
console.log('[CodexAuth] hasApiKey:', hasApiKey);
|
||||
|
||||
// Debug: Check auth file
|
||||
const authFilePath = getCodexAuthPath();
|
||||
console.log('[CodexAuth] Auth file path:', authFilePath);
|
||||
try {
|
||||
const authFileExists = fs.existsSync(authFilePath);
|
||||
console.log('[CodexAuth] Auth file exists:', authFileExists);
|
||||
if (authFileExists) {
|
||||
const authContent = fs.readFileSync(authFilePath, 'utf-8');
|
||||
console.log('[CodexAuth] Auth file content:', authContent.substring(0, 500)); // First 500 chars
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('[CodexAuth] Error reading auth file:', error);
|
||||
}
|
||||
|
||||
// If CLI is not installed, cannot be authenticated
|
||||
if (!resolvedCliPath) {
|
||||
logger.info('CLI not found');
|
||||
console.log('[CodexAuth] No CLI path found, returning not authenticated');
|
||||
return { authenticated: false, method: 'none' };
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('[CodexAuth] Running: ' + resolvedCliPath + ' login status');
|
||||
const result = await spawnProcess({
|
||||
command: resolvedCliPath || CODEX_COMMAND,
|
||||
args: ['login', 'status'],
|
||||
@@ -48,21 +66,33 @@ export async function checkCodexAuthentication(
|
||||
},
|
||||
});
|
||||
|
||||
console.log('[CodexAuth] Command result:');
|
||||
console.log('[CodexAuth] exitCode:', result.exitCode);
|
||||
console.log('[CodexAuth] stdout:', JSON.stringify(result.stdout));
|
||||
console.log('[CodexAuth] stderr:', JSON.stringify(result.stderr));
|
||||
|
||||
// Check both stdout and stderr for "logged in" - Codex CLI outputs to stderr
|
||||
const combinedOutput = (result.stdout + result.stderr).toLowerCase();
|
||||
const isLoggedIn = combinedOutput.includes('logged in');
|
||||
console.log('[CodexAuth] isLoggedIn (contains "logged in" in stdout or stderr):', isLoggedIn);
|
||||
|
||||
if (result.exitCode === 0 && isLoggedIn) {
|
||||
// Determine auth method based on what we know
|
||||
const method = hasApiKey ? 'api_key_env' : 'cli_authenticated';
|
||||
logger.info(`✓ Authenticated (${method})`);
|
||||
console.log('[CodexAuth] Authenticated! method:', method);
|
||||
return { authenticated: true, method };
|
||||
}
|
||||
|
||||
logger.info('Not authenticated');
|
||||
return { authenticated: false, method: 'none' };
|
||||
console.log(
|
||||
'[CodexAuth] Not authenticated. exitCode:',
|
||||
result.exitCode,
|
||||
'isLoggedIn:',
|
||||
isLoggedIn
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Failed to check authentication:', error);
|
||||
return { authenticated: false, method: 'none' };
|
||||
console.log('[CodexAuth] Error running command:', error);
|
||||
}
|
||||
|
||||
console.log('[CodexAuth] Returning not authenticated');
|
||||
return { authenticated: false, method: 'none' };
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
extractTextFromContent,
|
||||
classifyError,
|
||||
getUserFriendlyErrorMessage,
|
||||
createLogger,
|
||||
} from '@automaker/utils';
|
||||
import type {
|
||||
ExecuteOptions,
|
||||
@@ -659,8 +658,6 @@ async function loadCodexInstructions(cwd: string, enabled: boolean): Promise<str
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
const logger = createLogger('CodexProvider');
|
||||
|
||||
export class CodexProvider extends BaseProvider {
|
||||
getName(): string {
|
||||
return 'codex';
|
||||
@@ -970,11 +967,21 @@ export class CodexProvider extends BaseProvider {
|
||||
}
|
||||
|
||||
async detectInstallation(): Promise<InstallationStatus> {
|
||||
console.log('[CodexProvider.detectInstallation] Starting...');
|
||||
|
||||
const cliPath = await findCodexCliPath();
|
||||
const hasApiKey = !!process.env[OPENAI_API_KEY_ENV];
|
||||
const authIndicators = await getCodexAuthIndicators();
|
||||
const installed = !!cliPath;
|
||||
|
||||
console.log('[CodexProvider.detectInstallation] cliPath:', cliPath);
|
||||
console.log('[CodexProvider.detectInstallation] hasApiKey:', hasApiKey);
|
||||
console.log(
|
||||
'[CodexProvider.detectInstallation] authIndicators:',
|
||||
JSON.stringify(authIndicators)
|
||||
);
|
||||
console.log('[CodexProvider.detectInstallation] installed:', installed);
|
||||
|
||||
let version = '';
|
||||
if (installed) {
|
||||
try {
|
||||
@@ -984,16 +991,20 @@ export class CodexProvider extends BaseProvider {
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
version = result.stdout.trim();
|
||||
console.log('[CodexProvider.detectInstallation] version:', version);
|
||||
} catch (error) {
|
||||
console.log('[CodexProvider.detectInstallation] Error getting version:', error);
|
||||
version = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Determine auth status - always verify with CLI, never assume authenticated
|
||||
console.log('[CodexProvider.detectInstallation] Calling checkCodexAuthentication...');
|
||||
const authCheck = await checkCodexAuthentication(cliPath);
|
||||
console.log('[CodexProvider.detectInstallation] authCheck result:', JSON.stringify(authCheck));
|
||||
const authenticated = authCheck.authenticated;
|
||||
|
||||
return {
|
||||
const result = {
|
||||
installed,
|
||||
path: cliPath || undefined,
|
||||
version: version || undefined,
|
||||
@@ -1001,6 +1012,8 @@ export class CodexProvider extends BaseProvider {
|
||||
hasApiKey,
|
||||
authenticated,
|
||||
};
|
||||
console.log('[CodexProvider.detectInstallation] Final result:', JSON.stringify(result));
|
||||
return result;
|
||||
}
|
||||
|
||||
getAvailableModels(): ModelDefinition[] {
|
||||
@@ -1012,24 +1025,36 @@ export class CodexProvider extends BaseProvider {
|
||||
* Check authentication status for Codex CLI
|
||||
*/
|
||||
async checkAuth(): Promise<CodexAuthStatus> {
|
||||
console.log('[CodexProvider.checkAuth] Starting auth check...');
|
||||
|
||||
const cliPath = await findCodexCliPath();
|
||||
const hasApiKey = !!process.env[OPENAI_API_KEY_ENV];
|
||||
const authIndicators = await getCodexAuthIndicators();
|
||||
|
||||
console.log('[CodexProvider.checkAuth] cliPath:', cliPath);
|
||||
console.log('[CodexProvider.checkAuth] hasApiKey:', hasApiKey);
|
||||
console.log('[CodexProvider.checkAuth] authIndicators:', JSON.stringify(authIndicators));
|
||||
|
||||
// Check for API key in environment
|
||||
if (hasApiKey) {
|
||||
console.log('[CodexProvider.checkAuth] Has API key, returning authenticated');
|
||||
return { authenticated: true, method: 'api_key' };
|
||||
}
|
||||
|
||||
// Check for OAuth/token from Codex CLI
|
||||
if (authIndicators.hasOAuthToken || authIndicators.hasApiKey) {
|
||||
console.log(
|
||||
'[CodexProvider.checkAuth] Has OAuth token or API key in auth file, returning authenticated'
|
||||
);
|
||||
return { authenticated: true, method: 'oauth' };
|
||||
}
|
||||
|
||||
// CLI is installed but not authenticated via indicators - try CLI command
|
||||
console.log('[CodexProvider.checkAuth] No indicators found, trying CLI command...');
|
||||
if (cliPath) {
|
||||
try {
|
||||
// Try 'codex login status' first (same as checkCodexAuthentication)
|
||||
console.log('[CodexProvider.checkAuth] Running: ' + cliPath + ' login status');
|
||||
const result = await spawnProcess({
|
||||
command: cliPath || CODEX_COMMAND,
|
||||
args: ['login', 'status'],
|
||||
@@ -1039,19 +1064,26 @@ export class CodexProvider extends BaseProvider {
|
||||
TERM: 'dumb',
|
||||
},
|
||||
});
|
||||
console.log('[CodexProvider.checkAuth] login status result:');
|
||||
console.log('[CodexProvider.checkAuth] exitCode:', result.exitCode);
|
||||
console.log('[CodexProvider.checkAuth] stdout:', JSON.stringify(result.stdout));
|
||||
console.log('[CodexProvider.checkAuth] stderr:', JSON.stringify(result.stderr));
|
||||
|
||||
// Check both stdout and stderr - Codex CLI outputs to stderr
|
||||
const combinedOutput = (result.stdout + result.stderr).toLowerCase();
|
||||
const isLoggedIn = combinedOutput.includes('logged in');
|
||||
console.log('[CodexProvider.checkAuth] isLoggedIn:', isLoggedIn);
|
||||
|
||||
if (result.exitCode === 0 && isLoggedIn) {
|
||||
console.log('[CodexProvider.checkAuth] CLI says logged in, returning authenticated');
|
||||
return { authenticated: true, method: 'oauth' };
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Error running login status command during auth check:', error);
|
||||
console.log('[CodexProvider.checkAuth] Error running login status:', error);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[CodexProvider.checkAuth] Not authenticated');
|
||||
return { authenticated: false, method: 'none' };
|
||||
}
|
||||
|
||||
|
||||
@@ -41,103 +41,95 @@ export interface OpenCodeAuthStatus {
|
||||
|
||||
/**
|
||||
* Base interface for all OpenCode stream events
|
||||
* OpenCode uses underscore format: step_start, step_finish, text
|
||||
*/
|
||||
interface OpenCodeBaseEvent {
|
||||
/** Event type identifier */
|
||||
type: string;
|
||||
/** Timestamp of the event */
|
||||
timestamp?: number;
|
||||
/** Session ID */
|
||||
sessionID?: string;
|
||||
/** Part object containing the actual event data */
|
||||
part?: Record<string, unknown>;
|
||||
/** Optional session identifier */
|
||||
session_id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Text event - Text output from the model
|
||||
* Format: {"type":"text","part":{"text":"content",...}}
|
||||
* Text delta event - Incremental text output from the model
|
||||
*/
|
||||
export interface OpenCodeTextEvent extends OpenCodeBaseEvent {
|
||||
type: 'text';
|
||||
part: {
|
||||
type: 'text';
|
||||
text: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
export interface OpenCodeTextDeltaEvent extends OpenCodeBaseEvent {
|
||||
type: 'text-delta';
|
||||
/** The incremental text content */
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Text end event - Signals completion of text generation
|
||||
*/
|
||||
export interface OpenCodeTextEndEvent extends OpenCodeBaseEvent {
|
||||
type: 'text-end';
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool call event - Request to execute a tool
|
||||
*/
|
||||
export interface OpenCodeToolCallEvent extends OpenCodeBaseEvent {
|
||||
type: 'tool_call';
|
||||
part: {
|
||||
type: 'tool-call';
|
||||
name: string;
|
||||
call_id?: string;
|
||||
args: unknown;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
type: 'tool-call';
|
||||
/** Unique identifier for this tool call */
|
||||
call_id?: string;
|
||||
/** Tool name to invoke */
|
||||
name: string;
|
||||
/** Arguments to pass to the tool */
|
||||
args: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool result event - Output from a tool execution
|
||||
*/
|
||||
export interface OpenCodeToolResultEvent extends OpenCodeBaseEvent {
|
||||
type: 'tool_result';
|
||||
part: {
|
||||
type: 'tool-result';
|
||||
call_id?: string;
|
||||
output: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
type: 'tool-result';
|
||||
/** The tool call ID this result corresponds to */
|
||||
call_id?: string;
|
||||
/** Output from the tool execution */
|
||||
output: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool error event - Tool execution failed
|
||||
*/
|
||||
export interface OpenCodeToolErrorEvent extends OpenCodeBaseEvent {
|
||||
type: 'tool_error';
|
||||
part: {
|
||||
type: 'tool-error';
|
||||
call_id?: string;
|
||||
error: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
type: 'tool-error';
|
||||
/** The tool call ID that failed */
|
||||
call_id?: string;
|
||||
/** Error message describing the failure */
|
||||
error: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start step event - Begins an agentic loop iteration
|
||||
* Format: {"type":"step_start","part":{...}}
|
||||
*/
|
||||
export interface OpenCodeStartStepEvent extends OpenCodeBaseEvent {
|
||||
type: 'step_start';
|
||||
part?: {
|
||||
type: 'step-start';
|
||||
[key: string]: unknown;
|
||||
};
|
||||
type: 'start-step';
|
||||
/** Step number in the agentic loop */
|
||||
step?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finish step event - Completes an agentic loop iteration
|
||||
* Format: {"type":"step_finish","part":{"reason":"stop",...}}
|
||||
*/
|
||||
export interface OpenCodeFinishStepEvent extends OpenCodeBaseEvent {
|
||||
type: 'step_finish';
|
||||
part?: {
|
||||
type: 'step-finish';
|
||||
reason?: string;
|
||||
error?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
type: 'finish-step';
|
||||
/** Step number that completed */
|
||||
step?: number;
|
||||
/** Whether the step completed successfully */
|
||||
success?: boolean;
|
||||
/** Optional result data */
|
||||
result?: string;
|
||||
/** Optional error if step failed */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Union type of all OpenCode stream events
|
||||
*/
|
||||
export type OpenCodeStreamEvent =
|
||||
| OpenCodeTextEvent
|
||||
| OpenCodeTextDeltaEvent
|
||||
| OpenCodeTextEndEvent
|
||||
| OpenCodeToolCallEvent
|
||||
| OpenCodeToolResultEvent
|
||||
| OpenCodeToolErrorEvent
|
||||
@@ -227,12 +219,14 @@ export class OpencodeProvider extends CliProvider {
|
||||
*
|
||||
* Arguments built:
|
||||
* - 'run' subcommand for executing queries
|
||||
* - '--format', 'json' for JSON streaming output
|
||||
* - '--format', 'stream-json' for JSONL streaming output
|
||||
* - '-q' / '--quiet' to suppress spinner and interactive elements
|
||||
* - '-c', '<cwd>' for working directory
|
||||
* - '--model', '<model>' for model selection (if specified)
|
||||
* - Message passed via stdin (no positional args needed)
|
||||
* - '-' as final arg to read prompt from stdin
|
||||
*
|
||||
* The prompt is passed via stdin to avoid shell escaping issues.
|
||||
* OpenCode will read from stdin when no positional message arguments are provided.
|
||||
* The prompt is NOT included in CLI args - it's passed via stdin to avoid
|
||||
* shell escaping issues with special characters in content.
|
||||
*
|
||||
* @param options - Execution options containing model, cwd, etc.
|
||||
* @returns Array of CLI arguments for opencode run
|
||||
@@ -240,18 +234,27 @@ export class OpencodeProvider extends CliProvider {
|
||||
buildCliArgs(options: ExecuteOptions): string[] {
|
||||
const args: string[] = ['run'];
|
||||
|
||||
// Add JSON output format for streaming
|
||||
args.push('--format', 'json');
|
||||
// Add streaming JSON output format for JSONL parsing
|
||||
args.push('--format', 'stream-json');
|
||||
|
||||
// Suppress spinner and interactive elements for non-TTY usage
|
||||
args.push('-q');
|
||||
|
||||
// Set working directory
|
||||
if (options.cwd) {
|
||||
args.push('-c', options.cwd);
|
||||
}
|
||||
|
||||
// Handle model selection
|
||||
// Strip 'opencode-' prefix if present, OpenCode uses native format
|
||||
// Strip 'opencode-' prefix if present, OpenCode uses format like 'anthropic/claude-sonnet-4-5'
|
||||
if (options.model) {
|
||||
const model = stripProviderPrefix(options.model);
|
||||
args.push('--model', model);
|
||||
}
|
||||
|
||||
// Note: Working directory is set via subprocess cwd option, not CLI args
|
||||
// Note: Message is passed via stdin, OpenCode reads from stdin automatically
|
||||
// Use '-' to indicate reading prompt from stdin
|
||||
// This avoids shell escaping issues with special characters
|
||||
args.push('-');
|
||||
|
||||
return args;
|
||||
}
|
||||
@@ -311,12 +314,14 @@ export class OpencodeProvider extends CliProvider {
|
||||
* Normalize a raw CLI event to ProviderMessage format
|
||||
*
|
||||
* Maps OpenCode event types to the standard ProviderMessage structure:
|
||||
* - text -> type: 'assistant', content with type: 'text'
|
||||
* - step_start -> null (informational, no message needed)
|
||||
* - step_finish -> type: 'result', subtype: 'success' (or error if failed)
|
||||
* - tool_call -> type: 'assistant', content with type: 'tool_use'
|
||||
* - tool_result -> type: 'assistant', content with type: 'tool_result'
|
||||
* - tool_error -> type: 'error'
|
||||
* - text-delta -> type: 'assistant', content with type: 'text'
|
||||
* - text-end -> null (informational, no message needed)
|
||||
* - tool-call -> type: 'assistant', content with type: 'tool_use'
|
||||
* - tool-result -> type: 'assistant', content with type: 'tool_result'
|
||||
* - tool-error -> type: 'error'
|
||||
* - start-step -> null (informational, no message needed)
|
||||
* - finish-step with success -> type: 'result', subtype: 'success'
|
||||
* - finish-step with error -> type: 'error'
|
||||
*
|
||||
* @param event - Raw event from OpenCode CLI JSONL output
|
||||
* @returns Normalized ProviderMessage or null to skip the event
|
||||
@@ -329,24 +334,24 @@ export class OpencodeProvider extends CliProvider {
|
||||
const openCodeEvent = event as OpenCodeStreamEvent;
|
||||
|
||||
switch (openCodeEvent.type) {
|
||||
case 'text': {
|
||||
const textEvent = openCodeEvent as OpenCodeTextEvent;
|
||||
case 'text-delta': {
|
||||
const textEvent = openCodeEvent as OpenCodeTextDeltaEvent;
|
||||
|
||||
// Skip if no text content
|
||||
if (!textEvent.part?.text) {
|
||||
// Skip empty text deltas
|
||||
if (!textEvent.text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content: ContentBlock[] = [
|
||||
{
|
||||
type: 'text',
|
||||
text: textEvent.part.text,
|
||||
text: textEvent.text,
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
type: 'assistant',
|
||||
session_id: textEvent.sessionID,
|
||||
session_id: textEvent.session_id,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content,
|
||||
@@ -354,66 +359,29 @@ export class OpencodeProvider extends CliProvider {
|
||||
};
|
||||
}
|
||||
|
||||
case 'step_start': {
|
||||
// Start step is informational - no message needed
|
||||
case 'text-end': {
|
||||
// Text end is informational - no message needed
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'step_finish': {
|
||||
const finishEvent = openCodeEvent as OpenCodeFinishStepEvent;
|
||||
|
||||
// Check if the step failed (either has error field or reason is 'error')
|
||||
if (finishEvent.part?.error || finishEvent.part?.reason === 'error') {
|
||||
return {
|
||||
type: 'error',
|
||||
session_id: finishEvent.sessionID,
|
||||
error: finishEvent.part?.error || 'Step execution failed',
|
||||
};
|
||||
}
|
||||
|
||||
// Successful completion
|
||||
const result: { type: 'result'; subtype: 'success'; session_id?: string; result?: string } =
|
||||
{
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
};
|
||||
|
||||
if (finishEvent.sessionID) {
|
||||
result.session_id = finishEvent.sessionID;
|
||||
}
|
||||
|
||||
// Safely handle arbitrary result payloads from CLI: ensure we assign a string.
|
||||
const rawResult =
|
||||
(finishEvent.part && (finishEvent.part as Record<string, unknown>).result) ?? undefined;
|
||||
if (rawResult !== undefined) {
|
||||
result.result = typeof rawResult === 'string' ? rawResult : JSON.stringify(rawResult);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
case 'tool_call': {
|
||||
case 'tool-call': {
|
||||
const toolEvent = openCodeEvent as OpenCodeToolCallEvent;
|
||||
|
||||
if (!toolEvent.part) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Generate a tool use ID if not provided
|
||||
const toolUseId = toolEvent.part.call_id || generateToolUseId();
|
||||
const toolUseId = toolEvent.call_id || generateToolUseId();
|
||||
|
||||
const content: ContentBlock[] = [
|
||||
{
|
||||
type: 'tool_use',
|
||||
name: toolEvent.part.name,
|
||||
name: toolEvent.name,
|
||||
tool_use_id: toolUseId,
|
||||
input: toolEvent.part.args,
|
||||
input: toolEvent.args,
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
type: 'assistant',
|
||||
session_id: toolEvent.sessionID,
|
||||
session_id: toolEvent.session_id,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content,
|
||||
@@ -421,24 +389,20 @@ export class OpencodeProvider extends CliProvider {
|
||||
};
|
||||
}
|
||||
|
||||
case 'tool_result': {
|
||||
case 'tool-result': {
|
||||
const resultEvent = openCodeEvent as OpenCodeToolResultEvent;
|
||||
|
||||
if (!resultEvent.part) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content: ContentBlock[] = [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: resultEvent.part.call_id,
|
||||
content: resultEvent.part.output,
|
||||
tool_use_id: resultEvent.call_id,
|
||||
content: resultEvent.output,
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
type: 'assistant',
|
||||
session_id: resultEvent.sessionID,
|
||||
session_id: resultEvent.session_id,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content,
|
||||
@@ -446,13 +410,39 @@ export class OpencodeProvider extends CliProvider {
|
||||
};
|
||||
}
|
||||
|
||||
case 'tool_error': {
|
||||
case 'tool-error': {
|
||||
const errorEvent = openCodeEvent as OpenCodeToolErrorEvent;
|
||||
|
||||
return {
|
||||
type: 'error',
|
||||
session_id: errorEvent.sessionID,
|
||||
error: errorEvent.part?.error || 'Tool execution failed',
|
||||
session_id: errorEvent.session_id,
|
||||
error: errorEvent.error || 'Tool execution failed',
|
||||
};
|
||||
}
|
||||
|
||||
case 'start-step': {
|
||||
// Start step is informational - no message needed
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'finish-step': {
|
||||
const finishEvent = openCodeEvent as OpenCodeFinishStepEvent;
|
||||
|
||||
// Check if the step failed
|
||||
if (finishEvent.success === false || finishEvent.error) {
|
||||
return {
|
||||
type: 'error',
|
||||
session_id: finishEvent.session_id,
|
||||
error: finishEvent.error || 'Step execution failed',
|
||||
};
|
||||
}
|
||||
|
||||
// Successful completion
|
||||
return {
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
session_id: finishEvent.session_id,
|
||||
result: finishEvent.result,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -6,57 +6,26 @@ import { createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('SpecRegeneration');
|
||||
|
||||
// Shared state for tracking generation status - scoped by project path
|
||||
const runningProjects = new Map<string, boolean>();
|
||||
const abortControllers = new Map<string, AbortController>();
|
||||
// Shared state for tracking generation status - private
|
||||
let isRunning = false;
|
||||
let currentAbortController: AbortController | null = null;
|
||||
|
||||
/**
|
||||
* Get the running state for a specific project
|
||||
* Get the current running state
|
||||
*/
|
||||
export function getSpecRegenerationStatus(projectPath?: string): {
|
||||
export function getSpecRegenerationStatus(): {
|
||||
isRunning: boolean;
|
||||
currentAbortController: AbortController | null;
|
||||
projectPath?: string;
|
||||
} {
|
||||
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 };
|
||||
return { isRunning, currentAbortController };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the project path that is currently running (if any)
|
||||
* Set the running state and abort controller
|
||||
*/
|
||||
export function getRunningProjectPath(): string | null {
|
||||
for (const [path, running] of runningProjects.entries()) {
|
||||
if (running) return path;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the running state and abort controller for a specific project
|
||||
*/
|
||||
export function setRunningState(
|
||||
projectPath: string,
|
||||
running: boolean,
|
||||
controller: AbortController | null = null
|
||||
): void {
|
||||
if (running) {
|
||||
runningProjects.set(projectPath, true);
|
||||
if (controller) {
|
||||
abortControllers.set(projectPath, controller);
|
||||
}
|
||||
} else {
|
||||
runningProjects.delete(projectPath);
|
||||
abortControllers.delete(projectPath);
|
||||
}
|
||||
export function setRunningState(running: boolean, controller: AbortController | null = null): void {
|
||||
isRunning = running;
|
||||
currentAbortController = controller;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -47,17 +47,17 @@ export function createCreateHandler(events: EventEmitter) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { isRunning } = getSpecRegenerationStatus(projectPath);
|
||||
const { isRunning } = getSpecRegenerationStatus();
|
||||
if (isRunning) {
|
||||
logger.warn('Generation already running for project:', projectPath);
|
||||
res.json({ success: false, error: 'Spec generation already running for this project' });
|
||||
logger.warn('Generation already running, rejecting request');
|
||||
res.json({ success: false, error: 'Spec generation already running' });
|
||||
return;
|
||||
}
|
||||
|
||||
logAuthStatus('Before starting generation');
|
||||
|
||||
const abortController = new AbortController();
|
||||
setRunningState(projectPath, true, abortController);
|
||||
setRunningState(true, abortController);
|
||||
logger.info('Starting background generation task...');
|
||||
|
||||
// Start generation in background
|
||||
@@ -80,7 +80,7 @@ export function createCreateHandler(events: EventEmitter) {
|
||||
})
|
||||
.finally(() => {
|
||||
logger.info('Generation task finished (success or error)');
|
||||
setRunningState(projectPath, false, null);
|
||||
setRunningState(false, null);
|
||||
});
|
||||
|
||||
logger.info('Returning success response (generation running in background)');
|
||||
|
||||
@@ -40,17 +40,17 @@ export function createGenerateFeaturesHandler(
|
||||
return;
|
||||
}
|
||||
|
||||
const { isRunning } = getSpecRegenerationStatus(projectPath);
|
||||
const { isRunning } = getSpecRegenerationStatus();
|
||||
if (isRunning) {
|
||||
logger.warn('Generation already running for project:', projectPath);
|
||||
res.json({ success: false, error: 'Generation already running for this project' });
|
||||
logger.warn('Generation already running, rejecting request');
|
||||
res.json({ success: false, error: 'Generation already running' });
|
||||
return;
|
||||
}
|
||||
|
||||
logAuthStatus('Before starting feature generation');
|
||||
|
||||
const abortController = new AbortController();
|
||||
setRunningState(projectPath, true, abortController);
|
||||
setRunningState(true, abortController);
|
||||
logger.info('Starting background feature generation task...');
|
||||
|
||||
generateFeaturesFromSpec(projectPath, events, abortController, maxFeatures, settingsService)
|
||||
@@ -63,7 +63,7 @@ export function createGenerateFeaturesHandler(
|
||||
})
|
||||
.finally(() => {
|
||||
logger.info('Feature generation task finished (success or error)');
|
||||
setRunningState(projectPath, false, null);
|
||||
setRunningState(false, null);
|
||||
});
|
||||
|
||||
logger.info('Returning success response (generation running in background)');
|
||||
|
||||
@@ -48,17 +48,17 @@ export function createGenerateHandler(events: EventEmitter, settingsService?: Se
|
||||
return;
|
||||
}
|
||||
|
||||
const { isRunning } = getSpecRegenerationStatus(projectPath);
|
||||
const { isRunning } = getSpecRegenerationStatus();
|
||||
if (isRunning) {
|
||||
logger.warn('Generation already running for project:', projectPath);
|
||||
res.json({ success: false, error: 'Spec generation already running for this project' });
|
||||
logger.warn('Generation already running, rejecting request');
|
||||
res.json({ success: false, error: 'Spec generation already running' });
|
||||
return;
|
||||
}
|
||||
|
||||
logAuthStatus('Before starting generation');
|
||||
|
||||
const abortController = new AbortController();
|
||||
setRunningState(projectPath, true, abortController);
|
||||
setRunningState(true, abortController);
|
||||
logger.info('Starting background generation task...');
|
||||
|
||||
generateSpec(
|
||||
@@ -81,7 +81,7 @@ export function createGenerateHandler(events: EventEmitter, settingsService?: Se
|
||||
})
|
||||
.finally(() => {
|
||||
logger.info('Generation task finished (success or error)');
|
||||
setRunningState(projectPath, false, null);
|
||||
setRunningState(false, null);
|
||||
});
|
||||
|
||||
logger.info('Returning success response (generation running in background)');
|
||||
|
||||
@@ -6,11 +6,10 @@ import type { Request, Response } from 'express';
|
||||
import { getSpecRegenerationStatus, getErrorMessage } from '../common.js';
|
||||
|
||||
export function createStatusHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const projectPath = req.query.projectPath as string | undefined;
|
||||
const { isRunning } = getSpecRegenerationStatus(projectPath);
|
||||
res.json({ success: true, isRunning, projectPath });
|
||||
const { isRunning } = getSpecRegenerationStatus();
|
||||
res.json({ success: true, isRunning });
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
|
||||
@@ -6,16 +6,13 @@ import type { Request, Response } from 'express';
|
||||
import { getSpecRegenerationStatus, setRunningState, getErrorMessage } from '../common.js';
|
||||
|
||||
export function createStopHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath } = req.body as { projectPath?: string };
|
||||
const { currentAbortController } = getSpecRegenerationStatus(projectPath);
|
||||
const { currentAbortController } = getSpecRegenerationStatus();
|
||||
if (currentAbortController) {
|
||||
currentAbortController.abort();
|
||||
}
|
||||
if (projectPath) {
|
||||
setRunningState(projectPath, false, null);
|
||||
}
|
||||
setRunningState(false, null);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
|
||||
@@ -17,7 +17,6 @@ import { createAnalyzeProjectHandler } from './routes/analyze-project.js';
|
||||
import { createFollowUpFeatureHandler } from './routes/follow-up-feature.js';
|
||||
import { createCommitFeatureHandler } from './routes/commit-feature.js';
|
||||
import { createApprovePlanHandler } from './routes/approve-plan.js';
|
||||
import { createResumeInterruptedHandler } from './routes/resume-interrupted.js';
|
||||
|
||||
export function createAutoModeRoutes(autoModeService: AutoModeService): Router {
|
||||
const router = Router();
|
||||
@@ -64,11 +63,6 @@ export function createAutoModeRoutes(autoModeService: AutoModeService): Router {
|
||||
validatePathParams('projectPath'),
|
||||
createApprovePlanHandler(autoModeService)
|
||||
);
|
||||
router.post(
|
||||
'/resume-interrupted',
|
||||
validatePathParams('projectPath'),
|
||||
createResumeInterruptedHandler(autoModeService)
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
/**
|
||||
* 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',
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,21 +1,17 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { CodexUsageService } from '../../services/codex-usage-service.js';
|
||||
import { CodexModelCacheService } from '../../services/codex-model-cache-service.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('Codex');
|
||||
|
||||
export function createCodexRoutes(
|
||||
usageService: CodexUsageService,
|
||||
modelCacheService: CodexModelCacheService
|
||||
): Router {
|
||||
export function createCodexRoutes(service: CodexUsageService): Router {
|
||||
const router = Router();
|
||||
|
||||
// Get current usage (attempts to fetch from Codex CLI)
|
||||
router.get('/usage', async (_req: Request, res: Response) => {
|
||||
router.get('/usage', async (req: Request, res: Response) => {
|
||||
try {
|
||||
// Check if Codex CLI is available first
|
||||
const isAvailable = await usageService.isAvailable();
|
||||
const isAvailable = await service.isAvailable();
|
||||
if (!isAvailable) {
|
||||
// IMPORTANT: This endpoint is behind Automaker session auth already.
|
||||
// Use a 200 + error payload for Codex CLI issues so the UI doesn't
|
||||
@@ -27,7 +23,7 @@ export function createCodexRoutes(
|
||||
return;
|
||||
}
|
||||
|
||||
const usage = await usageService.fetchUsageData();
|
||||
const usage = await service.fetchUsageData();
|
||||
res.json(usage);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
@@ -56,35 +52,5 @@ export function createCodexRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
// Get available Codex models (cached)
|
||||
router.get('/models', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const forceRefresh = req.query.refresh === 'true';
|
||||
const { models, cachedAt } = await modelCacheService.getModelsWithMetadata(forceRefresh);
|
||||
|
||||
if (models.length === 0) {
|
||||
res.status(503).json({
|
||||
success: false,
|
||||
error: 'Codex CLI not available or not authenticated',
|
||||
message: "Please install Codex CLI and run 'codex login' to authenticate",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
models,
|
||||
cachedAt,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error fetching models:', error);
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -188,7 +188,6 @@ export function createEnhanceHandler(
|
||||
technical: prompts.enhancement.technicalSystemPrompt,
|
||||
simplify: prompts.enhancement.simplifySystemPrompt,
|
||||
acceptance: prompts.enhancement.acceptanceSystemPrompt,
|
||||
'ux-reviewer': prompts.enhancement.uxReviewerSystemPrompt,
|
||||
};
|
||||
const systemPrompt = systemPromptMap[validMode];
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import { createGetHandler } from './routes/get.js';
|
||||
import { createCreateHandler } from './routes/create.js';
|
||||
import { createUpdateHandler } from './routes/update.js';
|
||||
import { createBulkUpdateHandler } from './routes/bulk-update.js';
|
||||
import { createBulkDeleteHandler } from './routes/bulk-delete.js';
|
||||
import { createDeleteHandler } from './routes/delete.js';
|
||||
import { createAgentOutputHandler, createRawOutputHandler } from './routes/agent-output.js';
|
||||
import { createGenerateTitleHandler } from './routes/generate-title.js';
|
||||
@@ -27,11 +26,6 @@ export function createFeaturesRoutes(featureLoader: FeatureLoader): Router {
|
||||
validatePathParams('projectPath'),
|
||||
createBulkUpdateHandler(featureLoader)
|
||||
);
|
||||
router.post(
|
||||
'/bulk-delete',
|
||||
validatePathParams('projectPath'),
|
||||
createBulkDeleteHandler(featureLoader)
|
||||
);
|
||||
router.post('/delete', validatePathParams('projectPath'), createDeleteHandler(featureLoader));
|
||||
router.post('/agent-output', createAgentOutputHandler(featureLoader));
|
||||
router.post('/raw-output', createRawOutputHandler(featureLoader));
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
/**
|
||||
* 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) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -16,7 +16,7 @@ export function createUpdateHandler(featureLoader: FeatureLoader) {
|
||||
featureId: string;
|
||||
updates: Partial<Feature>;
|
||||
descriptionHistorySource?: 'enhance' | 'edit';
|
||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer';
|
||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance';
|
||||
};
|
||||
|
||||
if (!projectPath || !featureId || !updates) {
|
||||
|
||||
@@ -2,23 +2,18 @@
|
||||
* POST /list endpoint - List all git worktrees
|
||||
*
|
||||
* Returns actual git worktrees from `git worktree list`.
|
||||
* Also scans .worktrees/ directory to discover worktrees that may have been
|
||||
* created externally or whose git state was corrupted.
|
||||
* Does NOT include tracked branches - only real worktrees with separate directories.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import path from 'path';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { isGitRepo } from '@automaker/git-utils';
|
||||
import { getErrorMessage, logError, normalizePath } from '../common.js';
|
||||
import { readAllWorktreeMetadata, type WorktreePRInfo } from '../../../lib/worktree-metadata.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const logger = createLogger('Worktree');
|
||||
|
||||
interface WorktreeInfo {
|
||||
path: string;
|
||||
@@ -40,87 +35,6 @@ async function getCurrentBranch(cwd: string): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan the .worktrees directory to discover worktrees that may exist on disk
|
||||
* but are not registered with git (e.g., created externally or corrupted state).
|
||||
*/
|
||||
async function scanWorktreesDirectory(
|
||||
projectPath: string,
|
||||
knownWorktreePaths: Set<string>
|
||||
): Promise<Array<{ path: string; branch: string }>> {
|
||||
const discovered: Array<{ path: string; branch: string }> = [];
|
||||
const worktreesDir = path.join(projectPath, '.worktrees');
|
||||
|
||||
try {
|
||||
// Check if .worktrees directory exists
|
||||
await secureFs.access(worktreesDir);
|
||||
} catch {
|
||||
// .worktrees directory doesn't exist
|
||||
return discovered;
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = await secureFs.readdir(worktreesDir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
|
||||
const worktreePath = path.join(worktreesDir, entry.name);
|
||||
const normalizedPath = normalizePath(worktreePath);
|
||||
|
||||
// Skip if already known from git worktree list
|
||||
if (knownWorktreePaths.has(normalizedPath)) continue;
|
||||
|
||||
// Check if this is a valid git repository
|
||||
const gitPath = path.join(worktreePath, '.git');
|
||||
try {
|
||||
const gitStat = await secureFs.stat(gitPath);
|
||||
|
||||
// Git worktrees have a .git FILE (not directory) that points to the parent repo
|
||||
// Regular repos have a .git DIRECTORY
|
||||
if (gitStat.isFile() || gitStat.isDirectory()) {
|
||||
// Try to get the branch name
|
||||
const branch = await getCurrentBranch(worktreePath);
|
||||
if (branch) {
|
||||
logger.info(
|
||||
`Discovered worktree in .worktrees/ not in git worktree list: ${entry.name} (branch: ${branch})`
|
||||
);
|
||||
discovered.push({
|
||||
path: normalizedPath,
|
||||
branch,
|
||||
});
|
||||
} else {
|
||||
// Try to get branch from HEAD if branch --show-current fails (detached HEAD)
|
||||
try {
|
||||
const { stdout: headRef } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
const headBranch = headRef.trim();
|
||||
if (headBranch && headBranch !== 'HEAD') {
|
||||
logger.info(
|
||||
`Discovered worktree in .worktrees/ not in git worktree list: ${entry.name} (branch: ${headBranch})`
|
||||
);
|
||||
discovered.push({
|
||||
path: normalizedPath,
|
||||
branch: headBranch,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Can't determine branch, skip this directory
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Not a git repo, skip
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to scan .worktrees directory: ${getErrorMessage(error)}`);
|
||||
}
|
||||
|
||||
return discovered;
|
||||
}
|
||||
|
||||
export function createListHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
@@ -202,22 +116,6 @@ export function createListHandler() {
|
||||
}
|
||||
}
|
||||
|
||||
// Scan .worktrees directory to discover worktrees that exist on disk
|
||||
// but are not registered with git (e.g., created externally)
|
||||
const knownPaths = new Set(worktrees.map((w) => w.path));
|
||||
const discoveredWorktrees = await scanWorktreesDirectory(projectPath, knownPaths);
|
||||
|
||||
// Add discovered worktrees to the list
|
||||
for (const discovered of discoveredWorktrees) {
|
||||
worktrees.push({
|
||||
path: discovered.path,
|
||||
branch: discovered.branch,
|
||||
isMain: false,
|
||||
isCurrent: discovered.branch === currentBranch,
|
||||
hasWorktree: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Read all worktree metadata to get PR info
|
||||
const allMetadata = await readAllWorktreeMetadata(projectPath);
|
||||
|
||||
|
||||
@@ -31,13 +31,7 @@ import {
|
||||
const logger = createLogger('AutoMode');
|
||||
import { resolveModelString, resolvePhaseModel, DEFAULT_MODELS } from '@automaker/model-resolver';
|
||||
import { resolveDependencies, areDependenciesSatisfied } from '@automaker/dependency-resolver';
|
||||
import {
|
||||
getFeatureDir,
|
||||
getAutomakerDir,
|
||||
getFeaturesDir,
|
||||
getExecutionStatePath,
|
||||
ensureAutomakerDir,
|
||||
} from '@automaker/platform';
|
||||
import { getFeatureDir, getAutomakerDir, getFeaturesDir } from '@automaker/platform';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import path from 'path';
|
||||
@@ -207,29 +201,6 @@ interface AutoModeConfig {
|
||||
projectPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execution state for recovery after server restart
|
||||
* Tracks which features were running and auto-loop configuration
|
||||
*/
|
||||
interface ExecutionState {
|
||||
version: 1;
|
||||
autoLoopWasRunning: boolean;
|
||||
maxConcurrency: number;
|
||||
projectPath: string;
|
||||
runningFeatureIds: string[];
|
||||
savedAt: string;
|
||||
}
|
||||
|
||||
// Default empty execution state
|
||||
const DEFAULT_EXECUTION_STATE: ExecutionState = {
|
||||
version: 1,
|
||||
autoLoopWasRunning: false,
|
||||
maxConcurrency: 3,
|
||||
projectPath: '',
|
||||
runningFeatureIds: [],
|
||||
savedAt: '',
|
||||
};
|
||||
|
||||
// Constants for consecutive failure tracking
|
||||
const CONSECUTIVE_FAILURE_THRESHOLD = 3; // Pause after 3 consecutive failures
|
||||
const FAILURE_WINDOW_MS = 60000; // Failures within 1 minute count as consecutive
|
||||
@@ -351,9 +322,6 @@ export class AutoModeService {
|
||||
projectPath,
|
||||
});
|
||||
|
||||
// Save execution state for recovery after restart
|
||||
await this.saveExecutionState(projectPath);
|
||||
|
||||
// Note: Memory folder initialization is now handled by loadContextFiles
|
||||
|
||||
// Run the loop in the background
|
||||
@@ -422,23 +390,17 @@ export class AutoModeService {
|
||||
*/
|
||||
async stopAutoLoop(): Promise<number> {
|
||||
const wasRunning = this.autoLoopRunning;
|
||||
const projectPath = this.config?.projectPath;
|
||||
this.autoLoopRunning = false;
|
||||
if (this.autoLoopAbortController) {
|
||||
this.autoLoopAbortController.abort();
|
||||
this.autoLoopAbortController = null;
|
||||
}
|
||||
|
||||
// Clear execution state when auto-loop is explicitly stopped
|
||||
if (projectPath) {
|
||||
await this.clearExecutionState(projectPath);
|
||||
}
|
||||
|
||||
// Emit stop event immediately when user explicitly stops
|
||||
if (wasRunning) {
|
||||
this.emitAutoModeEvent('auto_mode_stopped', {
|
||||
message: 'Auto mode stopped',
|
||||
projectPath,
|
||||
projectPath: this.config?.projectPath,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -479,11 +441,6 @@ export class AutoModeService {
|
||||
};
|
||||
this.runningFeatures.set(featureId, tempRunningFeature);
|
||||
|
||||
// Save execution state when feature starts
|
||||
if (isAutoMode) {
|
||||
await this.saveExecutionState(projectPath);
|
||||
}
|
||||
|
||||
try {
|
||||
// Validate that project path is allowed using centralized validation
|
||||
validateWorkingDirectory(projectPath);
|
||||
@@ -738,11 +695,6 @@ export class AutoModeService {
|
||||
`Pending approvals at cleanup: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}`
|
||||
);
|
||||
this.runningFeatures.delete(featureId);
|
||||
|
||||
// Update execution state after feature completes
|
||||
if (this.autoLoopRunning && projectPath) {
|
||||
await this.saveExecutionState(projectPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2998,149 +2950,6 @@ Begin implementing task ${task.id} now.`;
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Execution State Persistence - For recovery after server restart
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Save execution state to disk for recovery after server restart
|
||||
*/
|
||||
private async saveExecutionState(projectPath: string): Promise<void> {
|
||||
try {
|
||||
await ensureAutomakerDir(projectPath);
|
||||
const statePath = getExecutionStatePath(projectPath);
|
||||
const state: ExecutionState = {
|
||||
version: 1,
|
||||
autoLoopWasRunning: this.autoLoopRunning,
|
||||
maxConcurrency: this.config?.maxConcurrency ?? 3,
|
||||
projectPath,
|
||||
runningFeatureIds: Array.from(this.runningFeatures.keys()),
|
||||
savedAt: new Date().toISOString(),
|
||||
};
|
||||
await secureFs.writeFile(statePath, JSON.stringify(state, null, 2), 'utf-8');
|
||||
logger.info(`Saved execution state: ${state.runningFeatureIds.length} running features`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to save execution state:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load execution state from disk
|
||||
*/
|
||||
private async loadExecutionState(projectPath: string): Promise<ExecutionState> {
|
||||
try {
|
||||
const statePath = getExecutionStatePath(projectPath);
|
||||
const content = (await secureFs.readFile(statePath, 'utf-8')) as string;
|
||||
const state = JSON.parse(content) as ExecutionState;
|
||||
return state;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
logger.error('Failed to load execution state:', error);
|
||||
}
|
||||
return DEFAULT_EXECUTION_STATE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear execution state (called on successful shutdown or when auto-loop stops)
|
||||
*/
|
||||
private async clearExecutionState(projectPath: string): Promise<void> {
|
||||
try {
|
||||
const statePath = getExecutionStatePath(projectPath);
|
||||
await secureFs.unlink(statePath);
|
||||
logger.info('Cleared execution state');
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
logger.error('Failed to clear execution state:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for and resume interrupted features after server restart
|
||||
* This should be called during server initialization
|
||||
*/
|
||||
async resumeInterruptedFeatures(projectPath: string): Promise<void> {
|
||||
logger.info('Checking for interrupted features to resume...');
|
||||
|
||||
// Load all features and find those that were interrupted
|
||||
const featuresDir = getFeaturesDir(projectPath);
|
||||
|
||||
try {
|
||||
const entries = await secureFs.readdir(featuresDir, { withFileTypes: true });
|
||||
const interruptedFeatures: Feature[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
const featurePath = path.join(featuresDir, entry.name, 'feature.json');
|
||||
try {
|
||||
const data = (await secureFs.readFile(featurePath, 'utf-8')) as string;
|
||||
const feature = JSON.parse(data) as Feature;
|
||||
|
||||
// Check if feature was interrupted (in_progress or pipeline_*)
|
||||
if (
|
||||
feature.status === 'in_progress' ||
|
||||
(feature.status && feature.status.startsWith('pipeline_'))
|
||||
) {
|
||||
// Verify it has existing context (agent-output.md)
|
||||
const featureDir = getFeatureDir(projectPath, feature.id);
|
||||
const contextPath = path.join(featureDir, 'agent-output.md');
|
||||
try {
|
||||
await secureFs.access(contextPath);
|
||||
interruptedFeatures.push(feature);
|
||||
logger.info(
|
||||
`Found interrupted feature: ${feature.id} (${feature.title}) - status: ${feature.status}`
|
||||
);
|
||||
} catch {
|
||||
// No context file, skip this feature - it will be restarted fresh
|
||||
logger.info(`Interrupted feature ${feature.id} has no context, will restart fresh`);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid features
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (interruptedFeatures.length === 0) {
|
||||
logger.info('No interrupted features found');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Found ${interruptedFeatures.length} interrupted feature(s) to resume`);
|
||||
|
||||
// Emit event to notify UI
|
||||
this.emitAutoModeEvent('auto_mode_resuming_features', {
|
||||
message: `Resuming ${interruptedFeatures.length} interrupted feature(s) after server restart`,
|
||||
projectPath,
|
||||
featureIds: interruptedFeatures.map((f) => f.id),
|
||||
features: interruptedFeatures.map((f) => ({
|
||||
id: f.id,
|
||||
title: f.title,
|
||||
status: f.status,
|
||||
})),
|
||||
});
|
||||
|
||||
// Resume each interrupted feature
|
||||
for (const feature of interruptedFeatures) {
|
||||
try {
|
||||
logger.info(`Resuming feature: ${feature.id} (${feature.title})`);
|
||||
// Use resumeFeature which will detect the existing context and continue
|
||||
await this.resumeFeature(projectPath, feature.id, true);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to resume feature ${feature.id}:`, error);
|
||||
// Continue with other features
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
logger.info('No features directory found, nothing to resume');
|
||||
} else {
|
||||
logger.error('Error checking for interrupted features:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract and record learnings from a completed feature
|
||||
* Uses a quick Claude call to identify important decisions and patterns
|
||||
|
||||
@@ -2,7 +2,6 @@ import { spawn } from 'child_process';
|
||||
import * as os from 'os';
|
||||
import * as pty from 'node-pty';
|
||||
import { ClaudeUsage } from '../routes/claude/types.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
/**
|
||||
* Claude Usage Service
|
||||
@@ -15,8 +14,6 @@ import { createLogger } from '@automaker/utils';
|
||||
* - macOS: Uses 'expect' command for PTY
|
||||
* - Windows/Linux: Uses node-pty for PTY
|
||||
*/
|
||||
const logger = createLogger('ClaudeUsage');
|
||||
|
||||
export class ClaudeUsageService {
|
||||
private claudeBinary = 'claude';
|
||||
private timeout = 30000; // 30 second timeout
|
||||
@@ -167,40 +164,21 @@ export class ClaudeUsageService {
|
||||
const shell = this.isWindows ? 'cmd.exe' : '/bin/sh';
|
||||
const args = this.isWindows ? ['/c', 'claude', '/usage'] : ['-c', 'claude /usage'];
|
||||
|
||||
let ptyProcess: any = null;
|
||||
|
||||
try {
|
||||
ptyProcess = pty.spawn(shell, args, {
|
||||
name: 'xterm-256color',
|
||||
cols: 120,
|
||||
rows: 30,
|
||||
cwd: workingDirectory,
|
||||
env: {
|
||||
...process.env,
|
||||
TERM: 'xterm-256color',
|
||||
} as Record<string, string>,
|
||||
});
|
||||
} catch (spawnError) {
|
||||
// pty.spawn() can throw synchronously if the native module fails to load
|
||||
// or if PTY is not available in the current environment (e.g., containers without /dev/pts)
|
||||
const errorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError);
|
||||
logger.error('[executeClaudeUsageCommandPty] Failed to spawn PTY:', errorMessage);
|
||||
|
||||
// Return a user-friendly error instead of crashing
|
||||
reject(
|
||||
new Error(
|
||||
`Unable to access terminal: ${errorMessage}. Claude CLI may not be available or PTY support is limited in this environment.`
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
const ptyProcess = pty.spawn(shell, args, {
|
||||
name: 'xterm-256color',
|
||||
cols: 120,
|
||||
rows: 30,
|
||||
cwd: workingDirectory,
|
||||
env: {
|
||||
...process.env,
|
||||
TERM: 'xterm-256color',
|
||||
} as Record<string, string>,
|
||||
});
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
if (ptyProcess && !ptyProcess.killed) {
|
||||
ptyProcess.kill();
|
||||
}
|
||||
ptyProcess.kill();
|
||||
// Don't fail if we have data - return it instead
|
||||
if (output.includes('Current session')) {
|
||||
resolve(output);
|
||||
@@ -210,7 +188,7 @@ export class ClaudeUsageService {
|
||||
}
|
||||
}, this.timeout);
|
||||
|
||||
ptyProcess.onData((data: string) => {
|
||||
ptyProcess.onData((data) => {
|
||||
output += data;
|
||||
|
||||
// Check if we've seen the usage data (look for "Current session")
|
||||
@@ -218,12 +196,12 @@ export class ClaudeUsageService {
|
||||
hasSeenUsageData = true;
|
||||
// Wait for full output, then send escape to exit
|
||||
setTimeout(() => {
|
||||
if (!settled && ptyProcess && !ptyProcess.killed) {
|
||||
if (!settled) {
|
||||
ptyProcess.write('\x1b'); // Send escape key
|
||||
|
||||
// Fallback: if ESC doesn't exit (Linux), use SIGTERM after 2s
|
||||
setTimeout(() => {
|
||||
if (!settled && ptyProcess && !ptyProcess.killed) {
|
||||
if (!settled) {
|
||||
ptyProcess.kill('SIGTERM');
|
||||
}
|
||||
}, 2000);
|
||||
@@ -234,14 +212,14 @@ export class ClaudeUsageService {
|
||||
// Fallback: if we see "Esc to cancel" but haven't seen usage data yet
|
||||
if (!hasSeenUsageData && output.includes('Esc to cancel')) {
|
||||
setTimeout(() => {
|
||||
if (!settled && ptyProcess && !ptyProcess.killed) {
|
||||
if (!settled) {
|
||||
ptyProcess.write('\x1b'); // Send escape key
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
});
|
||||
|
||||
ptyProcess.onExit(({ exitCode }: { exitCode: number }) => {
|
||||
ptyProcess.onExit(({ exitCode }) => {
|
||||
clearTimeout(timeoutId);
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
import { spawn, type ChildProcess } from 'child_process';
|
||||
import readline from 'readline';
|
||||
import { findCodexCliPath } from '@automaker/platform';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type {
|
||||
AppServerModelResponse,
|
||||
AppServerAccountResponse,
|
||||
AppServerRateLimitsResponse,
|
||||
JsonRpcRequest,
|
||||
} from '@automaker/types';
|
||||
|
||||
const logger = createLogger('CodexAppServer');
|
||||
|
||||
/**
|
||||
* CodexAppServerService
|
||||
*
|
||||
* Centralized service for communicating with Codex CLI's app-server via JSON-RPC protocol.
|
||||
* Handles process spawning, JSON-RPC messaging, and cleanup.
|
||||
*
|
||||
* Connection strategy: Spawn on-demand (new process for each method call)
|
||||
*/
|
||||
export class CodexAppServerService {
|
||||
private cachedCliPath: string | null = null;
|
||||
|
||||
/**
|
||||
* Check if Codex CLI is available on the system
|
||||
*/
|
||||
async isAvailable(): Promise<boolean> {
|
||||
this.cachedCliPath = await findCodexCliPath();
|
||||
return Boolean(this.cachedCliPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch available models from app-server
|
||||
*/
|
||||
async getModels(): Promise<AppServerModelResponse | null> {
|
||||
const result = await this.executeJsonRpc<AppServerModelResponse>((sendRequest) => {
|
||||
return sendRequest('model/list', {});
|
||||
});
|
||||
|
||||
if (result) {
|
||||
logger.info(`[getModels] ✓ Fetched ${result.data.length} models`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch account information from app-server
|
||||
*/
|
||||
async getAccount(): Promise<AppServerAccountResponse | null> {
|
||||
return this.executeJsonRpc<AppServerAccountResponse>((sendRequest) => {
|
||||
return sendRequest('account/read', { refreshToken: false });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch rate limits from app-server
|
||||
*/
|
||||
async getRateLimits(): Promise<AppServerRateLimitsResponse | null> {
|
||||
return this.executeJsonRpc<AppServerRateLimitsResponse>((sendRequest) => {
|
||||
return sendRequest('account/rateLimits/read', {});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute JSON-RPC requests via Codex app-server
|
||||
*
|
||||
* This method:
|
||||
* 1. Spawns a new `codex app-server` process
|
||||
* 2. Handles JSON-RPC initialization handshake
|
||||
* 3. Executes user-provided requests
|
||||
* 4. Cleans up the process
|
||||
*
|
||||
* @param requestFn - Function that receives sendRequest helper and returns a promise
|
||||
* @returns Result of the JSON-RPC request or null on failure
|
||||
*/
|
||||
private async executeJsonRpc<T>(
|
||||
requestFn: (sendRequest: <R>(method: string, params?: unknown) => Promise<R>) => Promise<T>
|
||||
): Promise<T | null> {
|
||||
let childProcess: ChildProcess | null = null;
|
||||
|
||||
try {
|
||||
const cliPath = this.cachedCliPath || (await findCodexCliPath());
|
||||
|
||||
if (!cliPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// On Windows, .cmd files must be run through shell
|
||||
const needsShell = process.platform === 'win32' && cliPath.toLowerCase().endsWith('.cmd');
|
||||
|
||||
childProcess = spawn(cliPath, ['app-server'], {
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
TERM: 'dumb',
|
||||
},
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
shell: needsShell,
|
||||
});
|
||||
|
||||
if (!childProcess.stdin || !childProcess.stdout) {
|
||||
throw new Error('Failed to create stdio pipes');
|
||||
}
|
||||
|
||||
// Setup readline for reading JSONL responses
|
||||
const rl = readline.createInterface({
|
||||
input: childProcess.stdout,
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
|
||||
// Message ID counter for JSON-RPC
|
||||
let messageId = 0;
|
||||
const pendingRequests = new Map<
|
||||
number,
|
||||
{
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
timeout: NodeJS.Timeout;
|
||||
}
|
||||
>();
|
||||
|
||||
// Process incoming messages
|
||||
rl.on('line', (line) => {
|
||||
if (!line.trim()) return;
|
||||
|
||||
try {
|
||||
const message = JSON.parse(line);
|
||||
|
||||
// Handle response to our request
|
||||
if ('id' in message && message.id !== undefined) {
|
||||
const pending = pendingRequests.get(message.id);
|
||||
if (pending) {
|
||||
clearTimeout(pending.timeout);
|
||||
pendingRequests.delete(message.id);
|
||||
if (message.error) {
|
||||
pending.reject(new Error(message.error.message || 'Unknown error'));
|
||||
} else {
|
||||
pending.resolve(message.result);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Ignore notifications (no id field)
|
||||
} catch {
|
||||
// Ignore parse errors for non-JSON lines
|
||||
}
|
||||
});
|
||||
|
||||
// Helper to send JSON-RPC request and wait for response
|
||||
const sendRequest = <R>(method: string, params?: unknown): Promise<R> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = ++messageId;
|
||||
const request: JsonRpcRequest = {
|
||||
method,
|
||||
id,
|
||||
params: params ?? {},
|
||||
};
|
||||
|
||||
// Set timeout for request (10 seconds)
|
||||
const timeout = setTimeout(() => {
|
||||
pendingRequests.delete(id);
|
||||
reject(new Error(`Request timeout: ${method}`));
|
||||
}, 10000);
|
||||
|
||||
pendingRequests.set(id, {
|
||||
resolve: resolve as (value: unknown) => void,
|
||||
reject,
|
||||
timeout,
|
||||
});
|
||||
|
||||
childProcess!.stdin!.write(JSON.stringify(request) + '\n');
|
||||
});
|
||||
};
|
||||
|
||||
// Helper to send notification (no response expected)
|
||||
const sendNotification = (method: string, params?: unknown): void => {
|
||||
const notification = params ? { method, params } : { method };
|
||||
childProcess!.stdin!.write(JSON.stringify(notification) + '\n');
|
||||
};
|
||||
|
||||
// 1. Initialize the app-server
|
||||
await sendRequest('initialize', {
|
||||
clientInfo: {
|
||||
name: 'automaker',
|
||||
title: 'AutoMaker',
|
||||
version: '1.0.0',
|
||||
},
|
||||
});
|
||||
|
||||
// 2. Send initialized notification
|
||||
sendNotification('initialized');
|
||||
|
||||
// 3. Execute user-provided requests
|
||||
const result = await requestFn(sendRequest);
|
||||
|
||||
// Clean up
|
||||
rl.close();
|
||||
childProcess.kill('SIGTERM');
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error('[executeJsonRpc] Failed:', error);
|
||||
return null;
|
||||
} finally {
|
||||
// Ensure process is killed
|
||||
if (childProcess && !childProcess.killed) {
|
||||
childProcess.kill('SIGTERM');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
import path from 'path';
|
||||
import { secureFs } from '@automaker/platform';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { AppServerModel } from '@automaker/types';
|
||||
import type { CodexAppServerService } from './codex-app-server-service.js';
|
||||
|
||||
const logger = createLogger('CodexModelCache');
|
||||
|
||||
/**
|
||||
* Codex model with UI-compatible format
|
||||
*/
|
||||
export interface CodexModel {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
hasThinking: boolean;
|
||||
supportsVision: boolean;
|
||||
tier: 'premium' | 'standard' | 'basic';
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache structure stored on disk
|
||||
*/
|
||||
interface CodexModelCache {
|
||||
models: CodexModel[];
|
||||
cachedAt: number;
|
||||
ttl: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* CodexModelCacheService
|
||||
*
|
||||
* Caches Codex models fetched from app-server with TTL-based invalidation and disk persistence.
|
||||
*
|
||||
* Features:
|
||||
* - 1-hour TTL (configurable)
|
||||
* - Atomic file writes (temp file + rename)
|
||||
* - Thread-safe (deduplicates concurrent refresh requests)
|
||||
* - Auto-bootstrap on service creation
|
||||
* - Graceful fallback (returns empty array on errors)
|
||||
*/
|
||||
export class CodexModelCacheService {
|
||||
private cacheFilePath: string;
|
||||
private ttl: number;
|
||||
private appServerService: CodexAppServerService;
|
||||
private inFlightRefresh: Promise<CodexModel[]> | null = null;
|
||||
|
||||
constructor(
|
||||
dataDir: string,
|
||||
appServerService: CodexAppServerService,
|
||||
ttl: number = 3600000 // 1 hour default
|
||||
) {
|
||||
this.cacheFilePath = path.join(dataDir, 'codex-models-cache.json');
|
||||
this.ttl = ttl;
|
||||
this.appServerService = appServerService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get models from cache or fetch if stale
|
||||
*
|
||||
* @param forceRefresh - If true, bypass cache and fetch fresh data
|
||||
* @returns Array of Codex models (empty array if unavailable)
|
||||
*/
|
||||
async getModels(forceRefresh = false): Promise<CodexModel[]> {
|
||||
// If force refresh, skip cache
|
||||
if (forceRefresh) {
|
||||
return this.refreshModels();
|
||||
}
|
||||
|
||||
// Try to load from cache
|
||||
const cached = await this.loadFromCache();
|
||||
if (cached) {
|
||||
const age = Date.now() - cached.cachedAt;
|
||||
const isStale = age > cached.ttl;
|
||||
|
||||
if (!isStale) {
|
||||
logger.info(
|
||||
`[getModels] ✓ Using cached models (${cached.models.length} models, age: ${Math.round(age / 60000)}min)`
|
||||
);
|
||||
return cached.models;
|
||||
}
|
||||
}
|
||||
|
||||
// Cache is stale or missing, refresh
|
||||
return this.refreshModels();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get models with cache metadata
|
||||
*
|
||||
* @param forceRefresh - If true, bypass cache and fetch fresh data
|
||||
* @returns Object containing models and cache timestamp
|
||||
*/
|
||||
async getModelsWithMetadata(
|
||||
forceRefresh = false
|
||||
): Promise<{ models: CodexModel[]; cachedAt: number }> {
|
||||
const models = await this.getModels(forceRefresh);
|
||||
|
||||
// Try to get the actual cache timestamp
|
||||
const cached = await this.loadFromCache();
|
||||
const cachedAt = cached?.cachedAt ?? Date.now();
|
||||
|
||||
return { models, cachedAt };
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh models from app-server and update cache
|
||||
*
|
||||
* Thread-safe: Deduplicates concurrent refresh requests
|
||||
*/
|
||||
async refreshModels(): Promise<CodexModel[]> {
|
||||
// Deduplicate concurrent refresh requests
|
||||
if (this.inFlightRefresh) {
|
||||
return this.inFlightRefresh;
|
||||
}
|
||||
|
||||
// Start new refresh
|
||||
this.inFlightRefresh = this.doRefresh();
|
||||
|
||||
try {
|
||||
const models = await this.inFlightRefresh;
|
||||
return models;
|
||||
} finally {
|
||||
this.inFlightRefresh = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the cache file
|
||||
*/
|
||||
async clearCache(): Promise<void> {
|
||||
logger.info('[clearCache] Clearing cache...');
|
||||
|
||||
try {
|
||||
await secureFs.unlink(this.cacheFilePath);
|
||||
logger.info('[clearCache] Cache cleared');
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
logger.error('[clearCache] Failed to clear cache:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal method to perform the actual refresh
|
||||
*/
|
||||
private async doRefresh(): Promise<CodexModel[]> {
|
||||
try {
|
||||
// Check if app-server is available
|
||||
const isAvailable = await this.appServerService.isAvailable();
|
||||
if (!isAvailable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Fetch models from app-server
|
||||
const response = await this.appServerService.getModels();
|
||||
if (!response || !response.data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Transform models to UI format
|
||||
const models = response.data.map((model) => this.transformModel(model));
|
||||
|
||||
// Save to cache
|
||||
await this.saveToCache(models);
|
||||
|
||||
logger.info(`[refreshModels] ✓ Fetched fresh models (${models.length} models)`);
|
||||
|
||||
return models;
|
||||
} catch (error) {
|
||||
logger.error('[doRefresh] Refresh failed:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform app-server model to UI-compatible format
|
||||
*/
|
||||
private transformModel(appServerModel: AppServerModel): CodexModel {
|
||||
return {
|
||||
id: `codex-${appServerModel.id}`, // Add 'codex-' prefix for compatibility
|
||||
label: appServerModel.displayName,
|
||||
description: appServerModel.description,
|
||||
hasThinking: appServerModel.supportedReasoningEfforts.length > 0,
|
||||
supportsVision: true, // All Codex models support vision
|
||||
tier: this.inferTier(appServerModel.id),
|
||||
isDefault: appServerModel.isDefault,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer tier from model ID
|
||||
*/
|
||||
private inferTier(modelId: string): 'premium' | 'standard' | 'basic' {
|
||||
if (modelId.includes('max') || modelId.includes('gpt-5.2-codex')) {
|
||||
return 'premium';
|
||||
}
|
||||
if (modelId.includes('mini')) {
|
||||
return 'basic';
|
||||
}
|
||||
return 'standard';
|
||||
}
|
||||
|
||||
/**
|
||||
* Load cache from disk
|
||||
*/
|
||||
private async loadFromCache(): Promise<CodexModelCache | null> {
|
||||
try {
|
||||
const content = await secureFs.readFile(this.cacheFilePath, 'utf-8');
|
||||
const cache = JSON.parse(content.toString()) as CodexModelCache;
|
||||
|
||||
// Validate cache structure
|
||||
if (!Array.isArray(cache.models) || typeof cache.cachedAt !== 'number') {
|
||||
logger.warn('[loadFromCache] Invalid cache structure, ignoring');
|
||||
return null;
|
||||
}
|
||||
|
||||
return cache;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
logger.warn('[loadFromCache] Failed to read cache:', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save cache to disk (atomic write)
|
||||
*/
|
||||
private async saveToCache(models: CodexModel[]): Promise<void> {
|
||||
const cache: CodexModelCache = {
|
||||
models,
|
||||
cachedAt: Date.now(),
|
||||
ttl: this.ttl,
|
||||
};
|
||||
|
||||
const tempPath = `${this.cacheFilePath}.tmp.${Date.now()}`;
|
||||
|
||||
try {
|
||||
// Write to temp file
|
||||
const content = JSON.stringify(cache, null, 2);
|
||||
await secureFs.writeFile(tempPath, content, 'utf-8');
|
||||
|
||||
// Atomic rename
|
||||
await secureFs.rename(tempPath, this.cacheFilePath);
|
||||
} catch (error) {
|
||||
logger.error('[saveToCache] Failed to save cache:', error);
|
||||
|
||||
// Clean up temp file
|
||||
try {
|
||||
await secureFs.unlink(tempPath);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import {
|
||||
findCodexCliPath,
|
||||
spawnProcess,
|
||||
getCodexAuthPath,
|
||||
systemPathExists,
|
||||
systemPathReadFile,
|
||||
} from '@automaker/platform';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { CodexAppServerService } from './codex-app-server-service.js';
|
||||
|
||||
const logger = createLogger('CodexUsage');
|
||||
|
||||
@@ -18,12 +18,19 @@ export interface CodexRateLimitWindow {
|
||||
resetsAt: number;
|
||||
}
|
||||
|
||||
export interface CodexCreditsSnapshot {
|
||||
balance?: string;
|
||||
unlimited?: boolean;
|
||||
hasCredits?: boolean;
|
||||
}
|
||||
|
||||
export type CodexPlanType = 'free' | 'plus' | 'pro' | 'team' | 'enterprise' | 'edu' | 'unknown';
|
||||
|
||||
export interface CodexUsageData {
|
||||
rateLimits: {
|
||||
primary?: CodexRateLimitWindow;
|
||||
secondary?: CodexRateLimitWindow;
|
||||
credits?: CodexCreditsSnapshot;
|
||||
planType?: CodexPlanType;
|
||||
} | null;
|
||||
lastUpdated: string;
|
||||
@@ -32,24 +39,13 @@ export interface CodexUsageData {
|
||||
/**
|
||||
* Codex Usage Service
|
||||
*
|
||||
* Fetches usage data from Codex CLI using the app-server JSON-RPC API.
|
||||
* Falls back to auth file parsing if app-server is unavailable.
|
||||
* Attempts to fetch usage data from Codex CLI and OpenAI API.
|
||||
* Codex CLI doesn't provide a direct usage command, but we can:
|
||||
* 1. Parse usage info from error responses (rate limit errors contain plan info)
|
||||
* 2. Check for OpenAI API usage if API key is available
|
||||
*/
|
||||
export class CodexUsageService {
|
||||
private cachedCliPath: string | null = null;
|
||||
private appServerService: CodexAppServerService | null = null;
|
||||
private accountPlanTypeArray: CodexPlanType[] = [
|
||||
'free',
|
||||
'plus',
|
||||
'pro',
|
||||
'team',
|
||||
'enterprise',
|
||||
'edu',
|
||||
];
|
||||
|
||||
constructor(appServerService?: CodexAppServerService) {
|
||||
this.appServerService = appServerService || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Codex CLI is available on the system
|
||||
@@ -62,131 +58,60 @@ export class CodexUsageService {
|
||||
/**
|
||||
* Attempt to fetch usage data
|
||||
*
|
||||
* Priority order:
|
||||
* 1. Codex app-server JSON-RPC API (most reliable, provides real-time data)
|
||||
* 2. Auth file JWT parsing (fallback for plan type)
|
||||
* Tries multiple approaches:
|
||||
* 1. Always try to get plan type from auth file first (authoritative source)
|
||||
* 2. Check for OpenAI API key in environment for API usage
|
||||
* 3. Make a test request to capture rate limit headers from CLI
|
||||
* 4. Combine results from auth file and CLI
|
||||
*/
|
||||
async fetchUsageData(): Promise<CodexUsageData> {
|
||||
logger.info('[fetchUsageData] Starting...');
|
||||
const cliPath = this.cachedCliPath || (await findCodexCliPath());
|
||||
|
||||
if (!cliPath) {
|
||||
logger.error('[fetchUsageData] Codex CLI not found');
|
||||
throw new Error('Codex CLI not found. Please install it with: npm install -g @openai/codex');
|
||||
}
|
||||
|
||||
logger.info(`[fetchUsageData] Using CLI path: ${cliPath}`);
|
||||
// Always try to get plan type from auth file first - this is the authoritative source
|
||||
const authPlanType = await this.getPlanTypeFromAuthFile();
|
||||
|
||||
// Try to get usage from Codex app-server (most reliable method)
|
||||
const appServerUsage = await this.fetchFromAppServer();
|
||||
if (appServerUsage) {
|
||||
logger.info('[fetchUsageData] ✓ Fetched usage from app-server');
|
||||
return appServerUsage;
|
||||
// Check if user has an API key that we can use
|
||||
const hasApiKey = !!process.env.OPENAI_API_KEY;
|
||||
|
||||
if (hasApiKey) {
|
||||
// Try to get usage from OpenAI API
|
||||
const openaiUsage = await this.fetchOpenAIUsage();
|
||||
if (openaiUsage) {
|
||||
// Merge with auth file plan type if available
|
||||
if (authPlanType && openaiUsage.rateLimits) {
|
||||
openaiUsage.rateLimits.planType = authPlanType;
|
||||
}
|
||||
return openaiUsage;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('[fetchUsageData] App-server failed, trying auth file fallback...');
|
||||
// Try to get usage from Codex CLI by making a simple request
|
||||
const codexUsage = await this.fetchCodexUsage(cliPath, authPlanType);
|
||||
if (codexUsage) {
|
||||
return codexUsage;
|
||||
}
|
||||
|
||||
// Fallback: try to parse usage from auth file
|
||||
// Fallback: try to parse full usage from auth file
|
||||
const authUsage = await this.fetchFromAuthFile();
|
||||
if (authUsage) {
|
||||
logger.info('[fetchUsageData] ✓ Fetched usage from auth file');
|
||||
return authUsage;
|
||||
}
|
||||
|
||||
logger.info('[fetchUsageData] All methods failed, returning unknown');
|
||||
|
||||
// If all else fails, return unknown
|
||||
return {
|
||||
rateLimits: {
|
||||
planType: 'unknown',
|
||||
},
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch usage data from Codex app-server using JSON-RPC API
|
||||
* This is the most reliable method as it gets real-time data from OpenAI
|
||||
*/
|
||||
private async fetchFromAppServer(): Promise<CodexUsageData | null> {
|
||||
try {
|
||||
// Use CodexAppServerService if available
|
||||
if (!this.appServerService) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fetch account and rate limits in parallel
|
||||
const [accountResult, rateLimitsResult] = await Promise.all([
|
||||
this.appServerService.getAccount(),
|
||||
this.appServerService.getRateLimits(),
|
||||
]);
|
||||
|
||||
if (!accountResult) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build response
|
||||
// Prefer planType from rateLimits (more accurate/current) over account (can be stale)
|
||||
let planType: CodexPlanType = 'unknown';
|
||||
|
||||
// First try rate limits planType (most accurate)
|
||||
const rateLimitsPlanType = rateLimitsResult?.rateLimits?.planType;
|
||||
if (rateLimitsPlanType) {
|
||||
const normalizedType = rateLimitsPlanType.toLowerCase() as CodexPlanType;
|
||||
if (this.accountPlanTypeArray.includes(normalizedType)) {
|
||||
planType = normalizedType;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to account planType if rate limits didn't have it
|
||||
if (planType === 'unknown' && accountResult.account?.planType) {
|
||||
const normalizedType = accountResult.account.planType.toLowerCase() as CodexPlanType;
|
||||
if (this.accountPlanTypeArray.includes(normalizedType)) {
|
||||
planType = normalizedType;
|
||||
}
|
||||
}
|
||||
|
||||
const result: CodexUsageData = {
|
||||
rateLimits: {
|
||||
planType,
|
||||
},
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Add rate limit info if available
|
||||
if (rateLimitsResult?.rateLimits?.primary) {
|
||||
const primary = rateLimitsResult.rateLimits.primary;
|
||||
result.rateLimits!.primary = {
|
||||
limit: -1, // Not provided by API
|
||||
used: -1, // Not provided by API
|
||||
remaining: -1, // Not provided by API
|
||||
usedPercent: primary.usedPercent,
|
||||
windowDurationMins: primary.windowDurationMins,
|
||||
resetsAt: primary.resetsAt,
|
||||
};
|
||||
}
|
||||
|
||||
// Add secondary rate limit if available
|
||||
if (rateLimitsResult?.rateLimits?.secondary) {
|
||||
const secondary = rateLimitsResult.rateLimits.secondary;
|
||||
result.rateLimits!.secondary = {
|
||||
limit: -1, // Not provided by API
|
||||
used: -1, // Not provided by API
|
||||
remaining: -1, // Not provided by API
|
||||
usedPercent: secondary.usedPercent,
|
||||
windowDurationMins: secondary.windowDurationMins,
|
||||
resetsAt: secondary.resetsAt,
|
||||
};
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[fetchFromAppServer] ✓ Plan: ${planType}, Primary: ${result.rateLimits?.primary?.usedPercent || 'N/A'}%, Secondary: ${result.rateLimits?.secondary?.usedPercent || 'N/A'}%`
|
||||
);
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error('[fetchFromAppServer] Failed:', error);
|
||||
return null;
|
||||
}
|
||||
// If all else fails, return a message with helpful information
|
||||
throw new Error(
|
||||
'Codex usage statistics require additional configuration. ' +
|
||||
'To enable usage tracking:\n\n' +
|
||||
'1. Set your OpenAI API key in the environment:\n' +
|
||||
' export OPENAI_API_KEY=sk-...\n\n' +
|
||||
'2. Or check your usage at:\n' +
|
||||
' https://platform.openai.com/usage\n\n' +
|
||||
'Note: If using Codex CLI with ChatGPT OAuth authentication, ' +
|
||||
'usage data must be queried through your OpenAI account.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -196,11 +121,9 @@ export class CodexUsageService {
|
||||
private async getPlanTypeFromAuthFile(): Promise<CodexPlanType> {
|
||||
try {
|
||||
const authFilePath = getCodexAuthPath();
|
||||
logger.info(`[getPlanTypeFromAuthFile] Auth file path: ${authFilePath}`);
|
||||
const exists = systemPathExists(authFilePath);
|
||||
const exists = await systemPathExists(authFilePath);
|
||||
|
||||
if (!exists) {
|
||||
logger.warn('[getPlanTypeFromAuthFile] Auth file does not exist');
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
@@ -208,24 +131,16 @@ export class CodexUsageService {
|
||||
const authData = JSON.parse(authContent);
|
||||
|
||||
if (!authData.tokens?.id_token) {
|
||||
logger.info('[getPlanTypeFromAuthFile] No id_token in auth file');
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
const claims = this.parseJwt(authData.tokens.id_token);
|
||||
if (!claims) {
|
||||
logger.info('[getPlanTypeFromAuthFile] Failed to parse JWT');
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
logger.info('[getPlanTypeFromAuthFile] JWT claims keys:', Object.keys(claims));
|
||||
|
||||
// Extract plan type from nested OpenAI auth object with type validation
|
||||
const openaiAuthClaim = claims['https://api.openai.com/auth'];
|
||||
logger.info(
|
||||
'[getPlanTypeFromAuthFile] OpenAI auth claim:',
|
||||
JSON.stringify(openaiAuthClaim, null, 2)
|
||||
);
|
||||
|
||||
let accountType: string | undefined;
|
||||
let isSubscriptionExpired = false;
|
||||
@@ -273,51 +188,182 @@ export class CodexUsageService {
|
||||
}
|
||||
|
||||
if (accountType) {
|
||||
const normalizedType = accountType.toLowerCase() as CodexPlanType;
|
||||
logger.info(
|
||||
`[getPlanTypeFromAuthFile] Account type: "${accountType}", normalized: "${normalizedType}"`
|
||||
);
|
||||
if (this.accountPlanTypeArray.includes(normalizedType)) {
|
||||
logger.info(`[getPlanTypeFromAuthFile] Returning plan type: ${normalizedType}`);
|
||||
return normalizedType;
|
||||
const normalizedType = accountType.toLowerCase();
|
||||
if (['free', 'plus', 'pro', 'team', 'enterprise', 'edu'].includes(normalizedType)) {
|
||||
return normalizedType as CodexPlanType;
|
||||
}
|
||||
} else {
|
||||
logger.info('[getPlanTypeFromAuthFile] No account type found in claims');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[getPlanTypeFromAuthFile] Failed to get plan type from auth file:', error);
|
||||
logger.error('Failed to get plan type from auth file:', error);
|
||||
}
|
||||
|
||||
logger.info('[getPlanTypeFromAuthFile] Returning unknown');
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to fetch usage from OpenAI API using the API key
|
||||
*/
|
||||
private async fetchOpenAIUsage(): Promise<CodexUsageData | null> {
|
||||
const apiKey = process.env.OPENAI_API_KEY;
|
||||
if (!apiKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const endTime = Math.floor(Date.now() / 1000);
|
||||
const startTime = endTime - 7 * 24 * 60 * 60; // Last 7 days
|
||||
|
||||
const response = await fetch(
|
||||
`https://api.openai.com/v1/organization/usage/completions?start_time=${startTime}&end_time=${endTime}&limit=1`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
return this.parseOpenAIUsage(data);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch from OpenAI API:', error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse OpenAI usage API response
|
||||
*/
|
||||
private parseOpenAIUsage(data: any): CodexUsageData {
|
||||
let totalInputTokens = 0;
|
||||
let totalOutputTokens = 0;
|
||||
|
||||
if (data.data && Array.isArray(data.data)) {
|
||||
for (const bucket of data.data) {
|
||||
if (bucket.results && Array.isArray(bucket.results)) {
|
||||
for (const result of bucket.results) {
|
||||
totalInputTokens += result.input_tokens || 0;
|
||||
totalOutputTokens += result.output_tokens || 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
rateLimits: {
|
||||
planType: 'unknown',
|
||||
credits: {
|
||||
hasCredits: true,
|
||||
},
|
||||
},
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to fetch usage by making a test request to Codex CLI
|
||||
* and parsing rate limit information from the response
|
||||
*/
|
||||
private async fetchCodexUsage(
|
||||
cliPath: string,
|
||||
authPlanType: CodexPlanType
|
||||
): Promise<CodexUsageData | null> {
|
||||
try {
|
||||
// Make a simple request to trigger rate limit info if at limit
|
||||
const result = await spawnProcess({
|
||||
command: cliPath,
|
||||
args: ['exec', '--', 'echo', 'test'],
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
TERM: 'dumb',
|
||||
},
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Parse the output for rate limit information
|
||||
const combinedOutput = (result.stdout + result.stderr).toLowerCase();
|
||||
|
||||
// Check if we got a rate limit error
|
||||
const rateLimitMatch = combinedOutput.match(
|
||||
/usage_limit_reached.*?"plan_type":"([^"]+)".*?"resets_at":(\d+).*?"resets_in_seconds":(\d+)/
|
||||
);
|
||||
|
||||
if (rateLimitMatch) {
|
||||
// Rate limit error contains the plan type - use that as it's the most authoritative
|
||||
const planType = rateLimitMatch[1] as CodexPlanType;
|
||||
const resetsAt = parseInt(rateLimitMatch[2], 10);
|
||||
const resetsInSeconds = parseInt(rateLimitMatch[3], 10);
|
||||
|
||||
logger.info(
|
||||
`Rate limit hit - plan: ${planType}, resets in ${Math.ceil(resetsInSeconds / 60)} mins`
|
||||
);
|
||||
|
||||
return {
|
||||
rateLimits: {
|
||||
planType,
|
||||
primary: {
|
||||
limit: 0,
|
||||
used: 0,
|
||||
remaining: 0,
|
||||
usedPercent: 100,
|
||||
windowDurationMins: Math.ceil(resetsInSeconds / 60),
|
||||
resetsAt,
|
||||
},
|
||||
},
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// No rate limit error - use the plan type from auth file
|
||||
const isFreePlan = authPlanType === 'free';
|
||||
|
||||
return {
|
||||
rateLimits: {
|
||||
planType: authPlanType,
|
||||
credits: {
|
||||
hasCredits: true,
|
||||
unlimited: !isFreePlan && authPlanType !== 'unknown',
|
||||
},
|
||||
},
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch from Codex CLI:', error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to extract usage info from the Codex auth file
|
||||
* Reuses getPlanTypeFromAuthFile to avoid code duplication
|
||||
*/
|
||||
private async fetchFromAuthFile(): Promise<CodexUsageData | null> {
|
||||
logger.info('[fetchFromAuthFile] Starting...');
|
||||
try {
|
||||
const planType = await this.getPlanTypeFromAuthFile();
|
||||
logger.info(`[fetchFromAuthFile] Got plan type: ${planType}`);
|
||||
|
||||
if (planType === 'unknown') {
|
||||
logger.info('[fetchFromAuthFile] Plan type unknown, returning null');
|
||||
return null;
|
||||
}
|
||||
|
||||
const result: CodexUsageData = {
|
||||
const isFreePlan = planType === 'free';
|
||||
|
||||
return {
|
||||
rateLimits: {
|
||||
planType,
|
||||
credits: {
|
||||
hasCredits: true,
|
||||
unlimited: !isFreePlan,
|
||||
},
|
||||
},
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
|
||||
logger.info('[fetchFromAuthFile] Returning result:', JSON.stringify(result, null, 2));
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error('[fetchFromAuthFile] Failed to parse auth file:', error);
|
||||
logger.error('Failed to parse auth file:', error);
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -326,7 +372,7 @@ export class CodexUsageService {
|
||||
/**
|
||||
* Parse JWT token to extract claims
|
||||
*/
|
||||
private parseJwt(token: string): Record<string, unknown> | null {
|
||||
private parseJwt(token: string): any {
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
|
||||
@@ -337,8 +383,18 @@ export class CodexUsageService {
|
||||
const base64Url = parts[1];
|
||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
|
||||
// Use Buffer for Node.js environment
|
||||
const jsonPayload = Buffer.from(base64, 'base64').toString('utf-8');
|
||||
// Use Buffer for Node.js environment instead of atob
|
||||
let jsonPayload: string;
|
||||
if (typeof Buffer !== 'undefined') {
|
||||
jsonPayload = Buffer.from(base64, 'base64').toString('utf-8');
|
||||
} else {
|
||||
jsonPayload = decodeURIComponent(
|
||||
atob(base64)
|
||||
.split('')
|
||||
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
|
||||
.join('')
|
||||
);
|
||||
}
|
||||
|
||||
return JSON.parse(jsonPayload);
|
||||
} catch {
|
||||
|
||||
@@ -314,7 +314,7 @@ export class FeatureLoader {
|
||||
featureId: string,
|
||||
updates: Partial<Feature>,
|
||||
descriptionHistorySource?: 'enhance' | 'edit',
|
||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer'
|
||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance'
|
||||
): Promise<Feature> {
|
||||
const feature = await this.get(projectPath, featureId);
|
||||
if (!feature) {
|
||||
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
Credentials,
|
||||
ProjectSettings,
|
||||
KeyboardShortcuts,
|
||||
AIProfile,
|
||||
ProjectRef,
|
||||
TrashedProjectRef,
|
||||
BoardBackgroundSettings,
|
||||
@@ -298,6 +299,7 @@ export class SettingsService {
|
||||
ignoreEmptyArrayOverwrite('trashedProjects');
|
||||
ignoreEmptyArrayOverwrite('projectHistory');
|
||||
ignoreEmptyArrayOverwrite('recentFolders');
|
||||
ignoreEmptyArrayOverwrite('aiProfiles');
|
||||
ignoreEmptyArrayOverwrite('mcpServers');
|
||||
ignoreEmptyArrayOverwrite('enabledCursorModels');
|
||||
|
||||
@@ -600,6 +602,8 @@ export class SettingsService {
|
||||
theme: (appState.theme as GlobalSettings['theme']) || 'dark',
|
||||
sidebarOpen: appState.sidebarOpen !== undefined ? (appState.sidebarOpen as boolean) : true,
|
||||
chatHistoryOpen: (appState.chatHistoryOpen as boolean) || false,
|
||||
kanbanCardDetailLevel:
|
||||
(appState.kanbanCardDetailLevel as GlobalSettings['kanbanCardDetailLevel']) || 'standard',
|
||||
maxConcurrency: (appState.maxConcurrency as number) || 3,
|
||||
defaultSkipTests:
|
||||
appState.defaultSkipTests !== undefined ? (appState.defaultSkipTests as boolean) : true,
|
||||
@@ -613,15 +617,18 @@ export class SettingsService {
|
||||
: false,
|
||||
useWorktrees:
|
||||
appState.useWorktrees !== undefined ? (appState.useWorktrees as boolean) : true,
|
||||
showProfilesOnly: (appState.showProfilesOnly as boolean) || false,
|
||||
defaultPlanningMode:
|
||||
(appState.defaultPlanningMode as GlobalSettings['defaultPlanningMode']) || 'skip',
|
||||
defaultRequirePlanApproval: (appState.defaultRequirePlanApproval as boolean) || false,
|
||||
defaultAIProfileId: (appState.defaultAIProfileId as string | null) || null,
|
||||
muteDoneSound: (appState.muteDoneSound as boolean) || false,
|
||||
enhancementModel:
|
||||
(appState.enhancementModel as GlobalSettings['enhancementModel']) || 'sonnet',
|
||||
keyboardShortcuts:
|
||||
(appState.keyboardShortcuts as KeyboardShortcuts) ||
|
||||
DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts,
|
||||
aiProfiles: (appState.aiProfiles as AIProfile[]) || [],
|
||||
projects: (appState.projects as ProjectRef[]) || [],
|
||||
trashedProjects: (appState.trashedProjects as TrashedProjectRef[]) || [],
|
||||
projectHistory: (appState.projectHistory as string[]) || [],
|
||||
|
||||
@@ -7,11 +7,13 @@
|
||||
|
||||
export type {
|
||||
ThemeMode,
|
||||
KanbanCardDetailLevel,
|
||||
ModelAlias,
|
||||
PlanningMode,
|
||||
ThinkingLevel,
|
||||
ModelProvider,
|
||||
KeyboardShortcuts,
|
||||
AIProfile,
|
||||
ProjectRef,
|
||||
TrashedProjectRef,
|
||||
ChatSessionRef,
|
||||
|
||||
@@ -168,23 +168,41 @@ describe('opencode-provider.ts', () => {
|
||||
it('should build correct args with run subcommand', () => {
|
||||
const args = provider.buildCliArgs({
|
||||
prompt: 'Hello',
|
||||
model: 'opencode/big-pickle',
|
||||
cwd: '/tmp/project',
|
||||
});
|
||||
|
||||
expect(args[0]).toBe('run');
|
||||
});
|
||||
|
||||
it('should include --format json for streaming output', () => {
|
||||
it('should include --format stream-json for streaming output', () => {
|
||||
const args = provider.buildCliArgs({
|
||||
prompt: 'Hello',
|
||||
model: 'opencode/big-pickle',
|
||||
cwd: '/tmp/project',
|
||||
});
|
||||
|
||||
const formatIndex = args.indexOf('--format');
|
||||
expect(formatIndex).toBeGreaterThan(-1);
|
||||
expect(args[formatIndex + 1]).toBe('json');
|
||||
expect(args[formatIndex + 1]).toBe('stream-json');
|
||||
});
|
||||
|
||||
it('should include -q flag for quiet mode', () => {
|
||||
const args = provider.buildCliArgs({
|
||||
prompt: 'Hello',
|
||||
cwd: '/tmp/project',
|
||||
});
|
||||
|
||||
expect(args).toContain('-q');
|
||||
});
|
||||
|
||||
it('should include working directory with -c flag', () => {
|
||||
const args = provider.buildCliArgs({
|
||||
prompt: 'Hello',
|
||||
cwd: '/tmp/my-project',
|
||||
});
|
||||
|
||||
const cwdIndex = args.indexOf('-c');
|
||||
expect(cwdIndex).toBeGreaterThan(-1);
|
||||
expect(args[cwdIndex + 1]).toBe('/tmp/my-project');
|
||||
});
|
||||
|
||||
it('should include model with --model flag', () => {
|
||||
@@ -210,24 +228,30 @@ describe('opencode-provider.ts', () => {
|
||||
expect(args[modelIndex + 1]).toBe('anthropic/claude-sonnet-4-5');
|
||||
});
|
||||
|
||||
it('should include dash as final arg for stdin prompt', () => {
|
||||
const args = provider.buildCliArgs({
|
||||
prompt: 'Hello',
|
||||
cwd: '/tmp/project',
|
||||
});
|
||||
|
||||
expect(args[args.length - 1]).toBe('-');
|
||||
});
|
||||
|
||||
it('should handle missing cwd', () => {
|
||||
const args = provider.buildCliArgs({
|
||||
prompt: 'Hello',
|
||||
model: 'opencode/big-pickle',
|
||||
});
|
||||
|
||||
expect(args).not.toContain('-c');
|
||||
});
|
||||
|
||||
it('should handle model from opencode provider', () => {
|
||||
it('should handle missing model', () => {
|
||||
const args = provider.buildCliArgs({
|
||||
prompt: 'Hello',
|
||||
model: 'opencode/big-pickle',
|
||||
cwd: '/tmp/project',
|
||||
});
|
||||
|
||||
expect(args).toContain('--model');
|
||||
expect(args).toContain('opencode/big-pickle');
|
||||
expect(args).not.toContain('--model');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -236,15 +260,12 @@ describe('opencode-provider.ts', () => {
|
||||
// ==========================================================================
|
||||
|
||||
describe('normalizeEvent', () => {
|
||||
describe('text events (new OpenCode format)', () => {
|
||||
it('should convert text to assistant message with text content', () => {
|
||||
describe('text-delta events', () => {
|
||||
it('should convert text-delta to assistant message with text content', () => {
|
||||
const event = {
|
||||
type: 'text',
|
||||
part: {
|
||||
type: 'text',
|
||||
text: 'Hello, world!',
|
||||
},
|
||||
sessionID: 'test-session',
|
||||
type: 'text-delta',
|
||||
text: 'Hello, world!',
|
||||
session_id: 'test-session',
|
||||
};
|
||||
|
||||
const result = provider.normalizeEvent(event);
|
||||
@@ -264,13 +285,10 @@ describe('opencode-provider.ts', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null for empty text', () => {
|
||||
it('should return null for empty text-delta', () => {
|
||||
const event = {
|
||||
type: 'text',
|
||||
part: {
|
||||
type: 'text',
|
||||
text: '',
|
||||
},
|
||||
type: 'text-delta',
|
||||
text: '',
|
||||
};
|
||||
|
||||
const result = provider.normalizeEvent(event);
|
||||
@@ -278,10 +296,9 @@ describe('opencode-provider.ts', () => {
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for text with undefined text', () => {
|
||||
it('should return null for text-delta with undefined text', () => {
|
||||
const event = {
|
||||
type: 'text',
|
||||
part: {},
|
||||
type: 'text-delta',
|
||||
};
|
||||
|
||||
const result = provider.normalizeEvent(event);
|
||||
@@ -290,17 +307,27 @@ describe('opencode-provider.ts', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('tool_call events', () => {
|
||||
it('should convert tool_call to assistant message with tool_use content', () => {
|
||||
describe('text-end events', () => {
|
||||
it('should return null for text-end events (informational)', () => {
|
||||
const event = {
|
||||
type: 'tool_call',
|
||||
part: {
|
||||
type: 'tool-call',
|
||||
call_id: 'call-123',
|
||||
name: 'Read',
|
||||
args: { file_path: '/tmp/test.txt' },
|
||||
},
|
||||
sessionID: 'test-session',
|
||||
type: 'text-end',
|
||||
session_id: 'test-session',
|
||||
};
|
||||
|
||||
const result = provider.normalizeEvent(event);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('tool-call events', () => {
|
||||
it('should convert tool-call to assistant message with tool_use content', () => {
|
||||
const event = {
|
||||
type: 'tool-call',
|
||||
call_id: 'call-123',
|
||||
name: 'Read',
|
||||
args: { file_path: '/tmp/test.txt' },
|
||||
session_id: 'test-session',
|
||||
};
|
||||
|
||||
const result = provider.normalizeEvent(event);
|
||||
@@ -324,12 +351,9 @@ describe('opencode-provider.ts', () => {
|
||||
|
||||
it('should generate tool_use_id when call_id is missing', () => {
|
||||
const event = {
|
||||
type: 'tool_call',
|
||||
part: {
|
||||
type: 'tool-call',
|
||||
name: 'Write',
|
||||
args: { content: 'test' },
|
||||
},
|
||||
type: 'tool-call',
|
||||
name: 'Write',
|
||||
args: { content: 'test' },
|
||||
};
|
||||
|
||||
const result = provider.normalizeEvent(event);
|
||||
@@ -339,27 +363,21 @@ describe('opencode-provider.ts', () => {
|
||||
|
||||
// Second call should increment
|
||||
const result2 = provider.normalizeEvent({
|
||||
type: 'tool_call',
|
||||
part: {
|
||||
type: 'tool-call',
|
||||
name: 'Edit',
|
||||
args: {},
|
||||
},
|
||||
type: 'tool-call',
|
||||
name: 'Edit',
|
||||
args: {},
|
||||
});
|
||||
expect(result2?.message?.content[0].tool_use_id).toBe('opencode-tool-2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tool_result events', () => {
|
||||
it('should convert tool_result to assistant message with tool_result content', () => {
|
||||
describe('tool-result events', () => {
|
||||
it('should convert tool-result to assistant message with tool_result content', () => {
|
||||
const event = {
|
||||
type: 'tool_result',
|
||||
part: {
|
||||
type: 'tool-result',
|
||||
call_id: 'call-123',
|
||||
output: 'File contents here',
|
||||
},
|
||||
sessionID: 'test-session',
|
||||
type: 'tool-result',
|
||||
call_id: 'call-123',
|
||||
output: 'File contents here',
|
||||
session_id: 'test-session',
|
||||
};
|
||||
|
||||
const result = provider.normalizeEvent(event);
|
||||
@@ -380,13 +398,10 @@ describe('opencode-provider.ts', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle tool_result without call_id', () => {
|
||||
it('should handle tool-result without call_id', () => {
|
||||
const event = {
|
||||
type: 'tool_result',
|
||||
part: {
|
||||
type: 'tool-result',
|
||||
output: 'Result without ID',
|
||||
},
|
||||
type: 'tool-result',
|
||||
output: 'Result without ID',
|
||||
};
|
||||
|
||||
const result = provider.normalizeEvent(event);
|
||||
@@ -396,16 +411,13 @@ describe('opencode-provider.ts', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('tool_error events', () => {
|
||||
it('should convert tool_error to error message', () => {
|
||||
describe('tool-error events', () => {
|
||||
it('should convert tool-error to error message', () => {
|
||||
const event = {
|
||||
type: 'tool_error',
|
||||
part: {
|
||||
type: 'tool-error',
|
||||
call_id: 'call-123',
|
||||
error: 'File not found',
|
||||
},
|
||||
sessionID: 'test-session',
|
||||
type: 'tool-error',
|
||||
call_id: 'call-123',
|
||||
error: 'File not found',
|
||||
session_id: 'test-session',
|
||||
};
|
||||
|
||||
const result = provider.normalizeEvent(event);
|
||||
@@ -419,11 +431,8 @@ describe('opencode-provider.ts', () => {
|
||||
|
||||
it('should provide default error message when error is missing', () => {
|
||||
const event = {
|
||||
type: 'tool_error',
|
||||
part: {
|
||||
type: 'tool-error',
|
||||
call_id: 'call-123',
|
||||
},
|
||||
type: 'tool-error',
|
||||
call_id: 'call-123',
|
||||
};
|
||||
|
||||
const result = provider.normalizeEvent(event);
|
||||
@@ -433,14 +442,12 @@ describe('opencode-provider.ts', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('step_start events', () => {
|
||||
it('should return null for step_start events (informational)', () => {
|
||||
describe('start-step events', () => {
|
||||
it('should return null for start-step events (informational)', () => {
|
||||
const event = {
|
||||
type: 'step_start',
|
||||
part: {
|
||||
type: 'step-start',
|
||||
},
|
||||
sessionID: 'test-session',
|
||||
type: 'start-step',
|
||||
step: 1,
|
||||
session_id: 'test-session',
|
||||
};
|
||||
|
||||
const result = provider.normalizeEvent(event);
|
||||
@@ -449,16 +456,14 @@ describe('opencode-provider.ts', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('step_finish events', () => {
|
||||
it('should convert successful step_finish to result message', () => {
|
||||
describe('finish-step events', () => {
|
||||
it('should convert successful finish-step to result message', () => {
|
||||
const event = {
|
||||
type: 'step_finish',
|
||||
part: {
|
||||
type: 'step-finish',
|
||||
reason: 'stop',
|
||||
result: 'Task completed successfully',
|
||||
},
|
||||
sessionID: 'test-session',
|
||||
type: 'finish-step',
|
||||
step: 1,
|
||||
success: true,
|
||||
result: 'Task completed successfully',
|
||||
session_id: 'test-session',
|
||||
};
|
||||
|
||||
const result = provider.normalizeEvent(event);
|
||||
@@ -471,15 +476,13 @@ describe('opencode-provider.ts', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should convert step_finish with error to error message', () => {
|
||||
it('should convert finish-step with success=false to error message', () => {
|
||||
const event = {
|
||||
type: 'step_finish',
|
||||
part: {
|
||||
type: 'step-finish',
|
||||
reason: 'error',
|
||||
error: 'Something went wrong',
|
||||
},
|
||||
sessionID: 'test-session',
|
||||
type: 'finish-step',
|
||||
step: 1,
|
||||
success: false,
|
||||
error: 'Something went wrong',
|
||||
session_id: 'test-session',
|
||||
};
|
||||
|
||||
const result = provider.normalizeEvent(event);
|
||||
@@ -491,13 +494,11 @@ describe('opencode-provider.ts', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should convert step_finish with error property to error message', () => {
|
||||
it('should convert finish-step with error property to error message', () => {
|
||||
const event = {
|
||||
type: 'step_finish',
|
||||
part: {
|
||||
type: 'step-finish',
|
||||
error: 'Process failed',
|
||||
},
|
||||
type: 'finish-step',
|
||||
step: 1,
|
||||
error: 'Process failed',
|
||||
};
|
||||
|
||||
const result = provider.normalizeEvent(event);
|
||||
@@ -508,11 +509,9 @@ describe('opencode-provider.ts', () => {
|
||||
|
||||
it('should provide default error message for failed step without error text', () => {
|
||||
const event = {
|
||||
type: 'step_finish',
|
||||
part: {
|
||||
type: 'step-finish',
|
||||
reason: 'error',
|
||||
},
|
||||
type: 'finish-step',
|
||||
step: 1,
|
||||
success: false,
|
||||
};
|
||||
|
||||
const result = provider.normalizeEvent(event);
|
||||
@@ -521,14 +520,11 @@ describe('opencode-provider.ts', () => {
|
||||
expect(result?.error).toBe('Step execution failed');
|
||||
});
|
||||
|
||||
it('should treat step_finish with reason=stop as success', () => {
|
||||
it('should treat finish-step without success flag as success', () => {
|
||||
const event = {
|
||||
type: 'step_finish',
|
||||
part: {
|
||||
type: 'step-finish',
|
||||
reason: 'stop',
|
||||
result: 'Done',
|
||||
},
|
||||
type: 'finish-step',
|
||||
step: 1,
|
||||
result: 'Done',
|
||||
};
|
||||
|
||||
const result = provider.normalizeEvent(event);
|
||||
@@ -590,12 +586,13 @@ describe('opencode-provider.ts', () => {
|
||||
return mockedProvider;
|
||||
}
|
||||
|
||||
it('should stream text events as assistant messages', async () => {
|
||||
it('should stream text-delta events as assistant messages', async () => {
|
||||
const mockedProvider = setupMockedProvider();
|
||||
|
||||
const mockEvents = [
|
||||
{ type: 'text', part: { type: 'text', text: 'Hello ' } },
|
||||
{ type: 'text', part: { type: 'text', text: 'World!' } },
|
||||
{ type: 'text-delta', text: 'Hello ' },
|
||||
{ type: 'text-delta', text: 'World!' },
|
||||
{ type: 'text-end' },
|
||||
];
|
||||
|
||||
vi.mocked(spawnJSONLProcess).mockReturnValue(
|
||||
@@ -614,6 +611,7 @@ describe('opencode-provider.ts', () => {
|
||||
})
|
||||
);
|
||||
|
||||
// text-end should be filtered out (returns null)
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0].type).toBe('assistant');
|
||||
expect(results[0].message?.content[0].text).toBe('Hello ');
|
||||
@@ -625,21 +623,15 @@ describe('opencode-provider.ts', () => {
|
||||
|
||||
const mockEvents = [
|
||||
{
|
||||
type: 'tool_call',
|
||||
part: {
|
||||
type: 'tool-call',
|
||||
call_id: 'tool-1',
|
||||
name: 'Read',
|
||||
args: { file_path: '/tmp/test.txt' },
|
||||
},
|
||||
type: 'tool-call',
|
||||
call_id: 'tool-1',
|
||||
name: 'Read',
|
||||
args: { file_path: '/tmp/test.txt' },
|
||||
},
|
||||
{
|
||||
type: 'tool_result',
|
||||
part: {
|
||||
type: 'tool-result',
|
||||
call_id: 'tool-1',
|
||||
output: 'File contents',
|
||||
},
|
||||
type: 'tool-result',
|
||||
call_id: 'tool-1',
|
||||
output: 'File contents',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -726,7 +718,10 @@ describe('opencode-provider.ts', () => {
|
||||
const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0];
|
||||
expect(call.args).toContain('run');
|
||||
expect(call.args).toContain('--format');
|
||||
expect(call.args).toContain('json');
|
||||
expect(call.args).toContain('stream-json');
|
||||
expect(call.args).toContain('-q');
|
||||
expect(call.args).toContain('-c');
|
||||
expect(call.args).toContain('/tmp/workspace');
|
||||
expect(call.args).toContain('--model');
|
||||
expect(call.args).toContain('anthropic/claude-opus-4-5');
|
||||
});
|
||||
@@ -736,9 +731,9 @@ describe('opencode-provider.ts', () => {
|
||||
|
||||
const mockEvents = [
|
||||
{ type: 'unknown-internal-event', data: 'ignored' },
|
||||
{ type: 'text', part: { type: 'text', text: 'Valid text' } },
|
||||
{ type: 'text-delta', text: 'Valid text' },
|
||||
{ type: 'another-unknown', foo: 'bar' },
|
||||
{ type: 'step_finish', part: { type: 'step-finish', reason: 'stop', result: 'Done' } },
|
||||
{ type: 'finish-step', result: 'Done' },
|
||||
];
|
||||
|
||||
vi.mocked(spawnJSONLProcess).mockReturnValue(
|
||||
@@ -752,7 +747,6 @@ describe('opencode-provider.ts', () => {
|
||||
const results = await collectAsyncGenerator<ProviderMessage>(
|
||||
mockedProvider.executeQuery({
|
||||
prompt: 'Test',
|
||||
model: 'opencode/big-pickle',
|
||||
cwd: '/test',
|
||||
})
|
||||
);
|
||||
@@ -1045,22 +1039,10 @@ describe('opencode-provider.ts', () => {
|
||||
const sessionId = 'test-session-123';
|
||||
|
||||
const mockEvents = [
|
||||
{ type: 'text', part: { type: 'text', text: 'Hello ' }, sessionID: sessionId },
|
||||
{
|
||||
type: 'tool_call',
|
||||
part: { type: 'tool-call', name: 'Read', args: {}, call_id: 'c1' },
|
||||
sessionID: sessionId,
|
||||
},
|
||||
{
|
||||
type: 'tool_result',
|
||||
part: { type: 'tool-result', call_id: 'c1', output: 'file content' },
|
||||
sessionID: sessionId,
|
||||
},
|
||||
{
|
||||
type: 'step_finish',
|
||||
part: { type: 'step-finish', reason: 'stop', result: 'Done' },
|
||||
sessionID: sessionId,
|
||||
},
|
||||
{ type: 'text-delta', text: 'Hello ', session_id: sessionId },
|
||||
{ type: 'tool-call', name: 'Read', args: {}, call_id: 'c1', session_id: sessionId },
|
||||
{ type: 'tool-result', call_id: 'c1', output: 'file content', session_id: sessionId },
|
||||
{ type: 'finish-step', result: 'Done', session_id: sessionId },
|
||||
];
|
||||
|
||||
vi.mocked(spawnJSONLProcess).mockReturnValue(
|
||||
@@ -1074,7 +1056,6 @@ describe('opencode-provider.ts', () => {
|
||||
const results = await collectAsyncGenerator<ProviderMessage>(
|
||||
mockedProvider.executeQuery({
|
||||
prompt: 'Test',
|
||||
model: 'opencode/big-pickle',
|
||||
cwd: '/tmp',
|
||||
})
|
||||
);
|
||||
@@ -1088,15 +1069,12 @@ describe('opencode-provider.ts', () => {
|
||||
});
|
||||
|
||||
describe('normalizeEvent additional edge cases', () => {
|
||||
it('should handle tool_call with empty args object', () => {
|
||||
it('should handle tool-call with empty args object', () => {
|
||||
const event = {
|
||||
type: 'tool_call',
|
||||
part: {
|
||||
type: 'tool-call',
|
||||
call_id: 'call-123',
|
||||
name: 'Glob',
|
||||
args: {},
|
||||
},
|
||||
type: 'tool-call',
|
||||
call_id: 'call-123',
|
||||
name: 'Glob',
|
||||
args: {},
|
||||
};
|
||||
|
||||
const result = provider.normalizeEvent(event);
|
||||
@@ -1105,15 +1083,12 @@ describe('opencode-provider.ts', () => {
|
||||
expect(result?.message?.content[0].input).toEqual({});
|
||||
});
|
||||
|
||||
it('should handle tool_call with null args', () => {
|
||||
it('should handle tool-call with null args', () => {
|
||||
const event = {
|
||||
type: 'tool_call',
|
||||
part: {
|
||||
type: 'tool-call',
|
||||
call_id: 'call-123',
|
||||
name: 'Glob',
|
||||
args: null,
|
||||
},
|
||||
type: 'tool-call',
|
||||
call_id: 'call-123',
|
||||
name: 'Glob',
|
||||
args: null,
|
||||
};
|
||||
|
||||
const result = provider.normalizeEvent(event);
|
||||
@@ -1122,21 +1097,18 @@ describe('opencode-provider.ts', () => {
|
||||
expect(result?.message?.content[0].input).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle tool_call with complex nested args', () => {
|
||||
it('should handle tool-call with complex nested args', () => {
|
||||
const event = {
|
||||
type: 'tool_call',
|
||||
part: {
|
||||
type: 'tool-call',
|
||||
call_id: 'call-123',
|
||||
name: 'Edit',
|
||||
args: {
|
||||
file_path: '/tmp/test.ts',
|
||||
changes: [
|
||||
{ line: 10, old: 'foo', new: 'bar' },
|
||||
{ line: 20, old: 'baz', new: 'qux' },
|
||||
],
|
||||
options: { replace_all: true },
|
||||
},
|
||||
type: 'tool-call',
|
||||
call_id: 'call-123',
|
||||
name: 'Edit',
|
||||
args: {
|
||||
file_path: '/tmp/test.ts',
|
||||
changes: [
|
||||
{ line: 10, old: 'foo', new: 'bar' },
|
||||
{ line: 20, old: 'baz', new: 'qux' },
|
||||
],
|
||||
options: { replace_all: true },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1153,14 +1125,11 @@ describe('opencode-provider.ts', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle tool_result with empty output', () => {
|
||||
it('should handle tool-result with empty output', () => {
|
||||
const event = {
|
||||
type: 'tool_result',
|
||||
part: {
|
||||
type: 'tool-result',
|
||||
call_id: 'call-123',
|
||||
output: '',
|
||||
},
|
||||
type: 'tool-result',
|
||||
call_id: 'call-123',
|
||||
output: '',
|
||||
};
|
||||
|
||||
const result = provider.normalizeEvent(event);
|
||||
@@ -1169,13 +1138,10 @@ describe('opencode-provider.ts', () => {
|
||||
expect(result?.message?.content[0].content).toBe('');
|
||||
});
|
||||
|
||||
it('should handle text with whitespace-only text', () => {
|
||||
it('should handle text-delta with whitespace-only text', () => {
|
||||
const event = {
|
||||
type: 'text',
|
||||
part: {
|
||||
type: 'text',
|
||||
text: ' ',
|
||||
},
|
||||
type: 'text-delta',
|
||||
text: ' ',
|
||||
};
|
||||
|
||||
const result = provider.normalizeEvent(event);
|
||||
@@ -1185,13 +1151,10 @@ describe('opencode-provider.ts', () => {
|
||||
expect(result?.message?.content[0].text).toBe(' ');
|
||||
});
|
||||
|
||||
it('should handle text with newlines', () => {
|
||||
it('should handle text-delta with newlines', () => {
|
||||
const event = {
|
||||
type: 'text',
|
||||
part: {
|
||||
type: 'text',
|
||||
text: 'Line 1\nLine 2\nLine 3',
|
||||
},
|
||||
type: 'text-delta',
|
||||
text: 'Line 1\nLine 2\nLine 3',
|
||||
};
|
||||
|
||||
const result = provider.normalizeEvent(event);
|
||||
@@ -1199,15 +1162,12 @@ describe('opencode-provider.ts', () => {
|
||||
expect(result?.message?.content[0].text).toBe('Line 1\nLine 2\nLine 3');
|
||||
});
|
||||
|
||||
it('should handle step_finish with both result and error (error takes precedence)', () => {
|
||||
it('should handle finish-step with both result and error (error takes precedence)', () => {
|
||||
const event = {
|
||||
type: 'step_finish',
|
||||
part: {
|
||||
type: 'step-finish',
|
||||
reason: 'stop',
|
||||
result: 'Some result',
|
||||
error: 'But also an error',
|
||||
},
|
||||
type: 'finish-step',
|
||||
result: 'Some result',
|
||||
error: 'But also an error',
|
||||
success: false,
|
||||
};
|
||||
|
||||
const result = provider.normalizeEvent(event);
|
||||
@@ -1271,14 +1231,13 @@ describe('opencode-provider.ts', () => {
|
||||
const longPrompt = 'a'.repeat(10000);
|
||||
const args = provider.buildCliArgs({
|
||||
prompt: longPrompt,
|
||||
model: 'opencode/big-pickle',
|
||||
cwd: '/tmp',
|
||||
});
|
||||
|
||||
// The prompt is NOT in args (it's passed via stdin)
|
||||
// Just verify the args structure is correct
|
||||
expect(args).toContain('run');
|
||||
expect(args).not.toContain('-');
|
||||
expect(args).toContain('-');
|
||||
expect(args.join(' ')).not.toContain(longPrompt);
|
||||
});
|
||||
|
||||
@@ -1286,25 +1245,22 @@ describe('opencode-provider.ts', () => {
|
||||
const specialPrompt = 'Test $HOME $(rm -rf /) `command` "quotes" \'single\'';
|
||||
const args = provider.buildCliArgs({
|
||||
prompt: specialPrompt,
|
||||
model: 'opencode/big-pickle',
|
||||
cwd: '/tmp',
|
||||
});
|
||||
|
||||
// Special chars in prompt should not affect args (prompt is via stdin)
|
||||
expect(args).toContain('run');
|
||||
expect(args).not.toContain('-');
|
||||
expect(args).toContain('-');
|
||||
});
|
||||
|
||||
it('should handle cwd with spaces', () => {
|
||||
const args = provider.buildCliArgs({
|
||||
prompt: 'Test',
|
||||
model: 'opencode/big-pickle',
|
||||
cwd: '/tmp/path with spaces/project',
|
||||
});
|
||||
|
||||
// cwd is set at subprocess level, not via CLI args
|
||||
expect(args).not.toContain('-c');
|
||||
expect(args).not.toContain('/tmp/path with spaces/project');
|
||||
const cwdIndex = args.indexOf('-c');
|
||||
expect(args[cwdIndex + 1]).toBe('/tmp/path with spaces/project');
|
||||
});
|
||||
|
||||
it('should handle model with unusual characters', () => {
|
||||
|
||||
@@ -41,13 +41,16 @@ const E2E_SETTINGS = {
|
||||
theme: 'dark',
|
||||
sidebarOpen: true,
|
||||
chatHistoryOpen: false,
|
||||
kanbanCardDetailLevel: 'standard',
|
||||
maxConcurrency: 3,
|
||||
defaultSkipTests: true,
|
||||
enableDependencyBlocking: true,
|
||||
skipVerificationInAutoMode: false,
|
||||
useWorktrees: true,
|
||||
showProfilesOnly: false,
|
||||
defaultPlanningMode: 'skip',
|
||||
defaultRequirePlanApproval: false,
|
||||
defaultAIProfileId: null,
|
||||
muteDoneSound: false,
|
||||
phaseModels: {
|
||||
enhancementModel: { model: 'sonnet' },
|
||||
@@ -70,6 +73,7 @@ const E2E_SETTINGS = {
|
||||
spec: 'D',
|
||||
context: 'C',
|
||||
settings: 'S',
|
||||
profiles: 'M',
|
||||
terminal: 'T',
|
||||
toggleSidebar: '`',
|
||||
addFeature: 'N',
|
||||
@@ -80,6 +84,7 @@ const E2E_SETTINGS = {
|
||||
projectPicker: 'P',
|
||||
cyclePrevProject: 'Q',
|
||||
cycleNextProject: 'E',
|
||||
addProfile: 'N',
|
||||
splitTerminalRight: 'Alt+D',
|
||||
splitTerminalDown: 'Alt+S',
|
||||
closeTerminal: 'Alt+W',
|
||||
@@ -89,6 +94,48 @@ const E2E_SETTINGS = {
|
||||
githubPrs: 'R',
|
||||
newTerminalTab: 'Alt+T',
|
||||
},
|
||||
aiProfiles: [
|
||||
{
|
||||
id: 'profile-heavy-task',
|
||||
name: 'Heavy Task',
|
||||
description:
|
||||
'Claude Opus with Ultrathink for complex architecture, migrations, or deep debugging.',
|
||||
model: 'opus',
|
||||
thinkingLevel: 'ultrathink',
|
||||
provider: 'claude',
|
||||
isBuiltIn: true,
|
||||
icon: 'Brain',
|
||||
},
|
||||
{
|
||||
id: 'profile-balanced',
|
||||
name: 'Balanced',
|
||||
description: 'Claude Sonnet with medium thinking for typical development tasks.',
|
||||
model: 'sonnet',
|
||||
thinkingLevel: 'medium',
|
||||
provider: 'claude',
|
||||
isBuiltIn: true,
|
||||
icon: 'Scale',
|
||||
},
|
||||
{
|
||||
id: 'profile-quick-edit',
|
||||
name: 'Quick Edit',
|
||||
description: 'Claude Haiku for fast, simple edits and minor fixes.',
|
||||
model: 'haiku',
|
||||
thinkingLevel: 'none',
|
||||
provider: 'claude',
|
||||
isBuiltIn: true,
|
||||
icon: 'Zap',
|
||||
},
|
||||
{
|
||||
id: 'profile-cursor-refactoring',
|
||||
name: 'Cursor Refactoring',
|
||||
description: 'Cursor Composer 1 for refactoring tasks.',
|
||||
provider: 'cursor',
|
||||
cursorModel: 'composer-1',
|
||||
isBuiltIn: true,
|
||||
icon: 'Sparkles',
|
||||
},
|
||||
],
|
||||
// Default test project using the fixture path - tests can override via route mocking if needed
|
||||
projects: [
|
||||
{
|
||||
|
||||
163
apps/ui/src/components/command-palette/command-palette.tsx
Normal file
163
apps/ui/src/components/command-palette/command-palette.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from '@/components/ui/command';
|
||||
import {
|
||||
Plus,
|
||||
Sparkles,
|
||||
Play,
|
||||
Square,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
Terminal,
|
||||
Bot,
|
||||
Settings,
|
||||
Github,
|
||||
BookOpen,
|
||||
Wand2,
|
||||
Search,
|
||||
LayoutGrid,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface CommandPaletteProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
|
||||
const navigate = useNavigate();
|
||||
const { currentProject, getAutoModeState, setAutoModeRunning } = useAppStore();
|
||||
|
||||
const autoModeState = currentProject ? getAutoModeState(currentProject.id) : null;
|
||||
const isAutoModeRunning = autoModeState?.isRunning ?? false;
|
||||
|
||||
const runCommand = useCallback(
|
||||
(command: () => void) => {
|
||||
onOpenChange(false);
|
||||
command();
|
||||
},
|
||||
[onOpenChange]
|
||||
);
|
||||
|
||||
const handleNavigate = useCallback(
|
||||
(path: string) => {
|
||||
runCommand(() => navigate({ to: path }));
|
||||
},
|
||||
[navigate, runCommand]
|
||||
);
|
||||
|
||||
const handleToggleAutoMode = useCallback(() => {
|
||||
if (currentProject) {
|
||||
runCommand(() => setAutoModeRunning(currentProject.id, !isAutoModeRunning));
|
||||
}
|
||||
}, [currentProject, isAutoModeRunning, setAutoModeRunning, runCommand]);
|
||||
|
||||
return (
|
||||
<CommandDialog open={open} onOpenChange={onOpenChange}>
|
||||
<CommandInput placeholder="Search commands..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
|
||||
{currentProject && (
|
||||
<>
|
||||
<CommandGroup heading="Quick Actions">
|
||||
<CommandItem onSelect={() => handleNavigate('/board')}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
<span>Add Feature</span>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={() => handleNavigate('/ideation')}>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
<span>Generate Ideas</span>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={handleToggleAutoMode}>
|
||||
{isAutoModeRunning ? (
|
||||
<>
|
||||
<Square className="mr-2 h-4 w-4" />
|
||||
<span>Stop Auto Mode</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
<span>Start Auto Mode</span>
|
||||
</>
|
||||
)}
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
|
||||
<CommandSeparator />
|
||||
|
||||
<CommandGroup heading="Navigation">
|
||||
<CommandItem onSelect={() => handleNavigate('/board')}>
|
||||
<LayoutGrid className="mr-2 h-4 w-4" />
|
||||
<span>Kanban Board</span>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={() => handleNavigate('/running-agents')}>
|
||||
<Bot className="mr-2 h-4 w-4" />
|
||||
<span>Running Agents</span>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={() => handleNavigate('/terminal')}>
|
||||
<Terminal className="mr-2 h-4 w-4" />
|
||||
<span>Terminal</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
|
||||
<CommandSeparator />
|
||||
|
||||
<CommandGroup heading="Project">
|
||||
<CommandItem onSelect={() => handleNavigate('/spec')}>
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
<span>App Specification</span>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={() => handleNavigate('/context')}>
|
||||
<FolderOpen className="mr-2 h-4 w-4" />
|
||||
<span>Context Files</span>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={() => handleNavigate('/github-issues')}>
|
||||
<Github className="mr-2 h-4 w-4" />
|
||||
<span>GitHub Issues</span>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={() => handleNavigate('/github-prs')}>
|
||||
<Github className="mr-2 h-4 w-4" />
|
||||
<span>Pull Requests</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
|
||||
<CommandSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
<CommandGroup heading="Settings">
|
||||
<CommandItem onSelect={() => handleNavigate('/profiles')}>
|
||||
<Wand2 className="mr-2 h-4 w-4" />
|
||||
<span>AI Profiles</span>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={() => handleNavigate('/settings')}>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
<span>Settings</span>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={() => handleNavigate('/wiki')}>
|
||||
<BookOpen className="mr-2 h-4 w-4" />
|
||||
<span>Documentation</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
|
||||
<CommandSeparator />
|
||||
|
||||
<CommandGroup heading="Projects">
|
||||
<CommandItem onSelect={() => handleNavigate('/dashboard')}>
|
||||
<Search className="mr-2 h-4 w-4" />
|
||||
<span>All Projects</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
);
|
||||
}
|
||||
1
apps/ui/src/components/command-palette/index.ts
Normal file
1
apps/ui/src/components/command-palette/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { CommandPalette } from './command-palette';
|
||||
@@ -0,0 +1 @@
|
||||
export { OnboardingWizard } from './onboarding-wizard';
|
||||
@@ -0,0 +1,386 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppStore, type ThemeMode } from '@/store/app-store';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} 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 { Switch } from '@/components/ui/switch';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import {
|
||||
FolderOpen,
|
||||
FileText,
|
||||
Sparkles,
|
||||
ArrowRight,
|
||||
ArrowLeft,
|
||||
Check,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { initializeProject, hasAutomakerDir, hasAppSpec } from '@/lib/project-init';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
type OnboardingStep = 'select-folder' | 'project-name' | 'app-spec' | 'complete';
|
||||
type OnboardingMode = 'new' | 'existing';
|
||||
|
||||
interface OnboardingWizardProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
mode: OnboardingMode;
|
||||
initialPath?: string;
|
||||
}
|
||||
|
||||
export function OnboardingWizard({ open, onOpenChange, mode, initialPath }: OnboardingWizardProps) {
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
upsertAndSetCurrentProject,
|
||||
theme: globalTheme,
|
||||
trashedProjects,
|
||||
setSpecCreatingForProject,
|
||||
} = useAppStore();
|
||||
|
||||
const [step, setStep] = useState<OnboardingStep>(initialPath ? 'project-name' : 'select-folder');
|
||||
const [projectPath, setProjectPath] = useState(initialPath || '');
|
||||
const [projectName, setProjectName] = useState('');
|
||||
const [projectOverview, setProjectOverview] = useState('');
|
||||
const [generateFeatures, setGenerateFeatures] = useState(true);
|
||||
const [featureCount, setFeatureCount] = useState(5);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
const handleSelectFolder = useCallback(async () => {
|
||||
const api = getElectronAPI();
|
||||
const result = await api.openDirectory();
|
||||
|
||||
if (!result.canceled && result.filePaths[0]) {
|
||||
const path = result.filePaths[0];
|
||||
const name = path.split(/[/\\]/).filter(Boolean).pop() || 'Untitled Project';
|
||||
|
||||
setProjectPath(path);
|
||||
setProjectName(name);
|
||||
|
||||
// Check if it's an existing automaker project
|
||||
const hadAutomakerDir = await hasAutomakerDir(path);
|
||||
const specExists = await hasAppSpec(path);
|
||||
|
||||
if (hadAutomakerDir && specExists) {
|
||||
// Existing project with spec - skip to complete
|
||||
try {
|
||||
const initResult = await initializeProject(path);
|
||||
if (initResult.success) {
|
||||
const trashedProject = trashedProjects.find((p) => p.path === path);
|
||||
const effectiveTheme = (trashedProject?.theme as ThemeMode | undefined) || globalTheme;
|
||||
upsertAndSetCurrentProject(path, name, effectiveTheme);
|
||||
toast.success('Project opened', { description: `Opened ${name}` });
|
||||
onOpenChange(false);
|
||||
navigate({ to: '/board' });
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to open project');
|
||||
}
|
||||
} else {
|
||||
setStep('project-name');
|
||||
}
|
||||
}
|
||||
}, [trashedProjects, globalTheme, upsertAndSetCurrentProject, onOpenChange, navigate]);
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
if (step === 'project-name') {
|
||||
setStep('app-spec');
|
||||
}
|
||||
}, [step]);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
if (step === 'app-spec') {
|
||||
setStep('project-name');
|
||||
} else if (step === 'project-name') {
|
||||
setStep('select-folder');
|
||||
}
|
||||
}, [step]);
|
||||
|
||||
const handleSkipSpec = useCallback(async () => {
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
const initResult = await initializeProject(projectPath);
|
||||
if (!initResult.success) {
|
||||
toast.error('Failed to initialize project');
|
||||
return;
|
||||
}
|
||||
|
||||
const trashedProject = trashedProjects.find((p) => p.path === projectPath);
|
||||
const effectiveTheme = (trashedProject?.theme as ThemeMode | undefined) || globalTheme;
|
||||
upsertAndSetCurrentProject(projectPath, projectName, effectiveTheme);
|
||||
|
||||
toast.success('Project created', { description: `Created ${projectName}` });
|
||||
onOpenChange(false);
|
||||
navigate({ to: '/board' });
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}, [
|
||||
projectPath,
|
||||
projectName,
|
||||
trashedProjects,
|
||||
globalTheme,
|
||||
upsertAndSetCurrentProject,
|
||||
onOpenChange,
|
||||
navigate,
|
||||
]);
|
||||
|
||||
const handleGenerateSpec = useCallback(async () => {
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
const initResult = await initializeProject(projectPath);
|
||||
if (!initResult.success) {
|
||||
toast.error('Failed to initialize project');
|
||||
return;
|
||||
}
|
||||
|
||||
const trashedProject = trashedProjects.find((p) => p.path === projectPath);
|
||||
const effectiveTheme = (trashedProject?.theme as ThemeMode | undefined) || globalTheme;
|
||||
upsertAndSetCurrentProject(projectPath, projectName, effectiveTheme);
|
||||
|
||||
// Start spec generation in background
|
||||
setSpecCreatingForProject(projectPath);
|
||||
|
||||
onOpenChange(false);
|
||||
navigate({ to: '/board' });
|
||||
|
||||
// Use the spec regeneration API
|
||||
const api = getElectronAPI();
|
||||
if (api.specRegeneration && projectOverview.trim()) {
|
||||
const result = await api.specRegeneration.create(
|
||||
projectPath,
|
||||
projectOverview.trim(),
|
||||
generateFeatures,
|
||||
true, // analyzeProject
|
||||
generateFeatures ? featureCount : undefined
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
setSpecCreatingForProject(null);
|
||||
toast.error('Failed to create specification', {
|
||||
description: result.error,
|
||||
});
|
||||
} else {
|
||||
toast.info('Generating app specification...', {
|
||||
description: "This may take a minute. You'll be notified when complete.",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
toast.success('Project created', { description: `Created ${projectName}` });
|
||||
setSpecCreatingForProject(null);
|
||||
}
|
||||
} catch (error) {
|
||||
setSpecCreatingForProject(null);
|
||||
toast.error('Failed to create project', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}, [
|
||||
projectPath,
|
||||
projectName,
|
||||
projectOverview,
|
||||
generateFeatures,
|
||||
featureCount,
|
||||
trashedProjects,
|
||||
globalTheme,
|
||||
upsertAndSetCurrentProject,
|
||||
setSpecCreatingForProject,
|
||||
onOpenChange,
|
||||
navigate,
|
||||
]);
|
||||
|
||||
const renderStep = () => {
|
||||
switch (step) {
|
||||
case 'select-folder':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center py-8">
|
||||
<div className="inline-flex items-center justify-center h-16 w-16 rounded-2xl bg-primary/10 mb-4">
|
||||
<FolderOpen className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium mb-2">Select Root Directory</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-sm mx-auto">
|
||||
Select the root directory of your project. This can be an empty directory for a new
|
||||
project or an existing codebase.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleSelectFolder} className="w-full" size="lg">
|
||||
<FolderOpen className="h-4 w-4 mr-2" />
|
||||
Browse Folders
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'project-name':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="project-name">Project Name</Label>
|
||||
<Input
|
||||
id="project-name"
|
||||
value={projectName}
|
||||
onChange={(e) => setProjectName(e.target.value)}
|
||||
placeholder="My Awesome Project"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">Location</Label>
|
||||
<p className="text-sm bg-muted/50 rounded-md p-2 font-mono truncate">{projectPath}</p>
|
||||
</div>
|
||||
<div className="flex justify-between pt-4">
|
||||
<Button variant="ghost" onClick={handleBack}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={handleNext} disabled={!projectName.trim()}>
|
||||
Next
|
||||
<ArrowRight className="h-4 w-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'app-spec':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="project-overview">
|
||||
Project Description <span className="text-muted-foreground">(optional)</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="project-overview"
|
||||
value={projectOverview}
|
||||
onChange={(e) => setProjectOverview(e.target.value)}
|
||||
placeholder="Describe your project in a few sentences. This helps the AI understand what you're building."
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 p-4 bg-muted/30 rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="generate-features" className="font-medium">
|
||||
Generate initial features
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
AI will suggest features based on your project
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="generate-features"
|
||||
checked={generateFeatures}
|
||||
onCheckedChange={setGenerateFeatures}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{generateFeatures && (
|
||||
<div className="space-y-2 pt-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm">Number of features</Label>
|
||||
<span className="text-sm font-medium">{featureCount}</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[featureCount]}
|
||||
onValueChange={([val]) => setFeatureCount(val)}
|
||||
min={1}
|
||||
max={15}
|
||||
step={1}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between pt-4">
|
||||
<Button variant="ghost" onClick={handleBack} disabled={isProcessing}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleSkipSpec} disabled={isProcessing}>
|
||||
Skip for now
|
||||
</Button>
|
||||
<Button onClick={handleGenerateSpec} disabled={isProcessing}>
|
||||
{isProcessing ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{isProcessing ? 'Creating...' : 'Create Project'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getStepTitle = () => {
|
||||
switch (step) {
|
||||
case 'select-folder':
|
||||
return 'Create New Project';
|
||||
case 'project-name':
|
||||
return 'Name Your Project';
|
||||
case 'app-spec':
|
||||
return 'Project Setup';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const getStepDescription = () => {
|
||||
switch (step) {
|
||||
case 'select-folder':
|
||||
return 'Start by selecting the root directory of your project';
|
||||
case 'project-name':
|
||||
return 'Give your project a memorable name';
|
||||
case 'app-spec':
|
||||
return 'Help the AI understand your project better';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{getStepTitle()}</DialogTitle>
|
||||
<DialogDescription>{getStepDescription()}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Progress indicator */}
|
||||
<div className="flex items-center gap-2 py-2">
|
||||
{['select-folder', 'project-name', 'app-spec'].map((s, i) => (
|
||||
<div
|
||||
key={s}
|
||||
className={cn(
|
||||
'h-1 flex-1 rounded-full transition-colors',
|
||||
step === s
|
||||
? 'bg-primary'
|
||||
: ['project-name', 'app-spec'].indexOf(step) > i
|
||||
? 'bg-primary/50'
|
||||
: 'bg-muted'
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{renderStep()}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
1
apps/ui/src/components/dialogs/settings-dialog/index.ts
Normal file
1
apps/ui/src/components/dialogs/settings-dialog/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { SettingsDialog } from './settings-dialog';
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Settings } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import { SettingsContent } from '@/components/views/settings-view/settings-content';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface SettingsDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="max-w-5xl h-[85vh] flex flex-col p-0 gap-0 overflow-hidden"
|
||||
data-testid="settings-dialog"
|
||||
>
|
||||
{/* Header */}
|
||||
<DialogHeader
|
||||
className={cn(
|
||||
'shrink-0 px-6 py-4',
|
||||
'border-b border-border/50',
|
||||
'bg-gradient-to-r from-card/90 via-card/70 to-card/80'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
'w-10 h-10 rounded-xl flex items-center justify-center',
|
||||
'bg-gradient-to-br from-brand-500 to-brand-600',
|
||||
'shadow-lg shadow-brand-500/25',
|
||||
'ring-1 ring-white/10'
|
||||
)}
|
||||
>
|
||||
<Settings className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<DialogTitle className="text-xl font-bold">Settings</DialogTitle>
|
||||
<DialogDescription className="text-sm text-muted-foreground/80 mt-0.5">
|
||||
Configure your API keys and preferences
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden min-h-0">
|
||||
<SettingsContent compact />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
655
apps/ui/src/components/layout/bottom-dock/bottom-dock.tsx
Normal file
655
apps/ui/src/components/layout/bottom-dock/bottom-dock.tsx
Normal file
@@ -0,0 +1,655 @@
|
||||
import { useState, useCallback, useSyncExternalStore, useRef, useEffect } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useKeyboardShortcuts, useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Terminal,
|
||||
Bot,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
Github,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
MessageSquare,
|
||||
Sparkles,
|
||||
PanelBottom,
|
||||
PanelRight,
|
||||
PanelLeft,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
GitHubPanel,
|
||||
AgentsPanel,
|
||||
SpecPanel,
|
||||
ContextPanel,
|
||||
TerminalPanelDock,
|
||||
ChatPanel,
|
||||
IdeationPanel,
|
||||
} from './panels';
|
||||
|
||||
type DockTab = 'terminal' | 'agents' | 'spec' | 'context' | 'github' | 'chat' | 'ideation';
|
||||
export type DockPosition = 'bottom' | 'right' | 'left';
|
||||
|
||||
const DOCK_POSITION_STORAGE_KEY = 'automaker:dock-position';
|
||||
|
||||
// Event emitter for dock state changes
|
||||
const stateListeners = new Set<() => void>();
|
||||
|
||||
function emitStateChange() {
|
||||
stateListeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
// Cached dock state
|
||||
interface DockState {
|
||||
position: DockPosition;
|
||||
isExpanded: boolean;
|
||||
isMaximized: boolean;
|
||||
}
|
||||
|
||||
let cachedState: DockState = {
|
||||
position: 'bottom',
|
||||
isExpanded: false,
|
||||
isMaximized: false,
|
||||
};
|
||||
|
||||
// Initialize position from localStorage
|
||||
try {
|
||||
const stored = localStorage.getItem(DOCK_POSITION_STORAGE_KEY) as DockPosition | null;
|
||||
if (stored && ['bottom', 'right', 'left'].includes(stored)) {
|
||||
cachedState.position = stored;
|
||||
}
|
||||
} catch {
|
||||
// Ignore localStorage errors
|
||||
}
|
||||
|
||||
function getDockState(): DockState {
|
||||
return cachedState;
|
||||
}
|
||||
|
||||
function updatePosition(position: DockPosition) {
|
||||
if (cachedState.position !== position) {
|
||||
cachedState = { ...cachedState, position };
|
||||
try {
|
||||
localStorage.setItem(DOCK_POSITION_STORAGE_KEY, position);
|
||||
} catch {
|
||||
// Ignore localStorage errors
|
||||
}
|
||||
emitStateChange();
|
||||
}
|
||||
}
|
||||
|
||||
function updateExpanded(isExpanded: boolean) {
|
||||
if (cachedState.isExpanded !== isExpanded) {
|
||||
cachedState = { ...cachedState, isExpanded };
|
||||
emitStateChange();
|
||||
}
|
||||
}
|
||||
|
||||
function updateMaximized(isMaximized: boolean) {
|
||||
if (cachedState.isMaximized !== isMaximized) {
|
||||
cachedState = { ...cachedState, isMaximized };
|
||||
emitStateChange();
|
||||
}
|
||||
}
|
||||
|
||||
// Hook for external components to read dock state
|
||||
export function useDockState(): DockState {
|
||||
return useSyncExternalStore(
|
||||
(callback) => {
|
||||
stateListeners.add(callback);
|
||||
return () => stateListeners.delete(callback);
|
||||
},
|
||||
getDockState,
|
||||
getDockState
|
||||
);
|
||||
}
|
||||
|
||||
interface BottomDockProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function BottomDock({ className }: BottomDockProps) {
|
||||
const { currentProject, getAutoModeState } = useAppStore();
|
||||
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<DockTab | null>(null);
|
||||
const [isMaximized, setIsMaximized] = useState(false);
|
||||
|
||||
// Use external store for position - single source of truth
|
||||
const position = useSyncExternalStore(
|
||||
(callback) => {
|
||||
stateListeners.add(callback);
|
||||
return () => stateListeners.delete(callback);
|
||||
},
|
||||
() => getDockState().position,
|
||||
() => getDockState().position
|
||||
);
|
||||
|
||||
// Sync local expanded/maximized state to external store for other components
|
||||
useEffect(() => {
|
||||
updateExpanded(isExpanded);
|
||||
}, [isExpanded]);
|
||||
|
||||
useEffect(() => {
|
||||
updateMaximized(isMaximized);
|
||||
}, [isMaximized]);
|
||||
|
||||
const autoModeState = currentProject ? getAutoModeState(currentProject.id) : null;
|
||||
const runningAgentsCount = autoModeState?.runningTasks?.length ?? 0;
|
||||
|
||||
// Ref for click-outside detection
|
||||
const dockRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Handle click outside to close the panel
|
||||
useEffect(() => {
|
||||
if (!isExpanded) return;
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dockRef.current && !dockRef.current.contains(event.target as Node)) {
|
||||
setIsExpanded(false);
|
||||
setIsMaximized(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Use mousedown for more responsive feel
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [isExpanded]);
|
||||
|
||||
const handleTabClick = useCallback(
|
||||
(tab: DockTab) => {
|
||||
if (activeTab === tab) {
|
||||
setIsExpanded(!isExpanded);
|
||||
} else {
|
||||
setActiveTab(tab);
|
||||
setIsExpanded(true);
|
||||
}
|
||||
},
|
||||
[activeTab, isExpanded]
|
||||
);
|
||||
|
||||
// Get keyboard shortcuts from config
|
||||
const shortcuts = useKeyboardShortcutsConfig();
|
||||
|
||||
// Register keyboard shortcuts for dock tabs
|
||||
useKeyboardShortcuts([
|
||||
{
|
||||
key: shortcuts.terminal,
|
||||
action: () => handleTabClick('terminal'),
|
||||
description: 'Toggle Terminal panel',
|
||||
},
|
||||
{
|
||||
key: shortcuts.ideation,
|
||||
action: () => handleTabClick('ideation'),
|
||||
description: 'Toggle Ideation panel',
|
||||
},
|
||||
{
|
||||
key: shortcuts.spec,
|
||||
action: () => handleTabClick('spec'),
|
||||
description: 'Toggle Spec panel',
|
||||
},
|
||||
{
|
||||
key: shortcuts.context,
|
||||
action: () => handleTabClick('context'),
|
||||
description: 'Toggle Context panel',
|
||||
},
|
||||
{
|
||||
key: shortcuts.githubIssues,
|
||||
action: () => handleTabClick('github'),
|
||||
description: 'Toggle GitHub panel',
|
||||
},
|
||||
{
|
||||
key: shortcuts.agent,
|
||||
action: () => handleTabClick('agents'),
|
||||
description: 'Toggle Agents panel',
|
||||
},
|
||||
]);
|
||||
|
||||
const handleDoubleClick = useCallback(() => {
|
||||
if (isExpanded) {
|
||||
setIsMaximized(!isMaximized);
|
||||
} else {
|
||||
setIsExpanded(true);
|
||||
if (!activeTab) {
|
||||
setActiveTab('terminal');
|
||||
}
|
||||
}
|
||||
}, [isExpanded, isMaximized, activeTab]);
|
||||
|
||||
// All tabs combined for easier rendering
|
||||
const allTabs = [
|
||||
{
|
||||
id: 'terminal' as DockTab,
|
||||
label: 'Terminal',
|
||||
icon: Terminal,
|
||||
badge: null,
|
||||
badgeColor: undefined,
|
||||
group: 'operations',
|
||||
},
|
||||
{
|
||||
id: 'chat' as DockTab,
|
||||
label: 'Chat',
|
||||
icon: MessageSquare,
|
||||
badge: null,
|
||||
badgeColor: undefined,
|
||||
group: 'operations',
|
||||
},
|
||||
{
|
||||
id: 'ideation' as DockTab,
|
||||
label: 'Ideate',
|
||||
icon: Sparkles,
|
||||
badge: null,
|
||||
badgeColor: undefined,
|
||||
group: 'planning',
|
||||
},
|
||||
{
|
||||
id: 'spec' as DockTab,
|
||||
label: 'Spec',
|
||||
icon: FileText,
|
||||
badge: null,
|
||||
badgeColor: undefined,
|
||||
group: 'planning',
|
||||
},
|
||||
{
|
||||
id: 'context' as DockTab,
|
||||
label: 'Context',
|
||||
icon: FolderOpen,
|
||||
badge: null,
|
||||
badgeColor: undefined,
|
||||
group: 'planning',
|
||||
},
|
||||
{
|
||||
id: 'github' as DockTab,
|
||||
label: 'GitHub',
|
||||
icon: Github,
|
||||
badge: null,
|
||||
badgeColor: undefined,
|
||||
group: 'planning',
|
||||
},
|
||||
{
|
||||
id: 'agents' as DockTab,
|
||||
label: 'Agents',
|
||||
icon: Bot,
|
||||
badge: runningAgentsCount > 0 ? runningAgentsCount : null,
|
||||
badgeColor: 'bg-green-500',
|
||||
group: 'agents',
|
||||
},
|
||||
];
|
||||
|
||||
const isRightDock = position === 'right';
|
||||
const isLeftDock = position === 'left';
|
||||
const isSideDock = isRightDock || isLeftDock;
|
||||
|
||||
// Render panel content directly to avoid remounting on state changes
|
||||
const renderPanelContent = () => (
|
||||
<>
|
||||
{activeTab === 'terminal' && <TerminalPanelDock />}
|
||||
{activeTab === 'agents' && <AgentsPanel />}
|
||||
{activeTab === 'spec' && <SpecPanel />}
|
||||
{activeTab === 'context' && <ContextPanel />}
|
||||
{activeTab === 'github' && <GitHubPanel />}
|
||||
{activeTab === 'chat' && <ChatPanel />}
|
||||
{activeTab === 'ideation' && <IdeationPanel />}
|
||||
</>
|
||||
);
|
||||
|
||||
// Side dock layout (left or right)
|
||||
if (isSideDock) {
|
||||
const dockWidth = isMaximized ? 'w-[50vw]' : isExpanded ? 'w-96' : 'w-10';
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={dockRef}
|
||||
className={cn(
|
||||
'bg-background/95 backdrop-blur-sm',
|
||||
'transition-all duration-300 ease-in-out flex',
|
||||
'fixed top-12 bottom-0 z-30',
|
||||
isLeftDock ? 'left-0 border-r border-border' : 'right-0 border-l border-border',
|
||||
dockWidth,
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Vertical Tab Bar */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col w-10 py-2 cursor-pointer select-none shrink-0',
|
||||
isLeftDock ? 'border-r border-border/50' : 'border-r border-border/50'
|
||||
)}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
>
|
||||
{/* Tab Icons */}
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
{allTabs.map((tab, index) => {
|
||||
const Icon = tab.icon;
|
||||
const isActive = activeTab === tab.id && isExpanded;
|
||||
const showDivider = (index === 1 || index === 5) && index < allTabs.length - 1;
|
||||
|
||||
return (
|
||||
<div key={tab.id}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleTabClick(tab.id);
|
||||
}}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
className={cn(
|
||||
'relative flex items-center justify-center w-7 h-7 rounded-md',
|
||||
'transition-colors',
|
||||
isActive
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50'
|
||||
)}
|
||||
title={tab.label}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{tab.badge && (
|
||||
<span
|
||||
className={cn(
|
||||
'absolute -top-1 -right-1 flex items-center justify-center h-3.5 min-w-3.5 px-0.5 rounded-full text-[9px] text-white',
|
||||
tab.badgeColor || 'bg-primary'
|
||||
)}
|
||||
>
|
||||
{tab.badge}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{showDivider && <div className="w-5 h-px bg-border my-1 mx-auto" />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Dock Controls */}
|
||||
<div className="flex flex-col items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
{/* Position buttons - show other positions (not current) */}
|
||||
{position !== 'left' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => updatePosition('left')}
|
||||
title="Dock to left"
|
||||
>
|
||||
<PanelLeft className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{position !== 'bottom' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => updatePosition('bottom')}
|
||||
title="Dock to bottom"
|
||||
>
|
||||
<PanelBottom className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{position !== 'right' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => updatePosition('right')}
|
||||
title="Dock to right"
|
||||
>
|
||||
<PanelRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isExpanded && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => setIsMaximized(!isMaximized)}
|
||||
title={isMaximized ? 'Restore' : 'Maximize'}
|
||||
>
|
||||
{isMaximized ? (
|
||||
<Minimize2 className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Maximize2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => {
|
||||
if (isExpanded) {
|
||||
setIsMaximized(false);
|
||||
}
|
||||
setIsExpanded(!isExpanded);
|
||||
}}
|
||||
title={isExpanded ? 'Collapse' : 'Expand'}
|
||||
>
|
||||
{isExpanded ? (
|
||||
isLeftDock ? (
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)
|
||||
) : isLeftDock ? (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Panel Content */}
|
||||
{isExpanded && <div className="flex-1 h-full overflow-hidden">{renderPanelContent()}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Bottom dock layout - uses fixed positioning like side docks
|
||||
const dockHeight = isMaximized ? 'h-[70vh]' : isExpanded ? 'h-72' : 'h-10';
|
||||
|
||||
// Group tabs for bottom layout
|
||||
const operationsTabs = allTabs.filter((t) => t.group === 'operations');
|
||||
const planningTabs = allTabs.filter((t) => t.group === 'planning');
|
||||
const agentTab = allTabs.find((t) => t.group === 'agents')!;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={dockRef}
|
||||
className={cn(
|
||||
'fixed left-0 right-0 bottom-0 border-t border-border bg-background/95 backdrop-blur-sm z-30',
|
||||
'transition-all duration-300 ease-in-out flex flex-col',
|
||||
dockHeight,
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Tab Bar - double click to expand/maximize */}
|
||||
<div
|
||||
className="flex items-center h-10 px-2 border-b border-border/50 cursor-pointer select-none shrink-0"
|
||||
onDoubleClick={handleDoubleClick}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Operations tabs */}
|
||||
{operationsTabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const isActive = activeTab === tab.id && isExpanded;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleTabClick(tab.id);
|
||||
}}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm',
|
||||
'transition-colors',
|
||||
isActive
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50'
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span>{tab.label}</span>
|
||||
{tab.badge && (
|
||||
<span
|
||||
className={cn(
|
||||
'flex items-center justify-center h-4 min-w-4 px-1 rounded-full text-[10px] text-white',
|
||||
tab.badgeColor || 'bg-primary'
|
||||
)}
|
||||
>
|
||||
{tab.badge}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Divider */}
|
||||
<div className="h-5 w-px bg-border mx-1" />
|
||||
|
||||
{/* Planning tabs */}
|
||||
{planningTabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const isActive = activeTab === tab.id && isExpanded;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleTabClick(tab.id);
|
||||
}}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm',
|
||||
'transition-colors',
|
||||
isActive
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50'
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span>{tab.label}</span>
|
||||
{tab.badge && (
|
||||
<span
|
||||
className={cn(
|
||||
'flex items-center justify-center h-4 min-w-4 px-1 rounded-full text-[10px] text-white',
|
||||
tab.badgeColor || 'bg-primary'
|
||||
)}
|
||||
>
|
||||
{tab.badge}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Divider */}
|
||||
<div className="h-5 w-px bg-border mx-1" />
|
||||
|
||||
{/* Agents tab (separate section) */}
|
||||
{(() => {
|
||||
const Icon = agentTab.icon;
|
||||
const isActive = activeTab === agentTab.id && isExpanded;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleTabClick(agentTab.id);
|
||||
}}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm',
|
||||
'transition-colors',
|
||||
isActive
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50'
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span>{agentTab.label}</span>
|
||||
{agentTab.badge && (
|
||||
<span
|
||||
className={cn(
|
||||
'flex items-center justify-center h-4 min-w-4 px-1 rounded-full text-[10px] text-white',
|
||||
agentTab.badgeColor || 'bg-primary'
|
||||
)}
|
||||
>
|
||||
{agentTab.badge}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Dock Controls */}
|
||||
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
{/* Position buttons - show other positions (not current) */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => updatePosition('left')}
|
||||
title="Dock to left"
|
||||
>
|
||||
<PanelLeft className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => updatePosition('right')}
|
||||
title="Dock to right"
|
||||
>
|
||||
<PanelRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
|
||||
{isExpanded && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => setIsMaximized(!isMaximized)}
|
||||
title={isMaximized ? 'Restore' : 'Maximize'}
|
||||
>
|
||||
{isMaximized ? (
|
||||
<Minimize2 className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Maximize2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => {
|
||||
if (isExpanded) {
|
||||
setIsMaximized(false);
|
||||
}
|
||||
setIsExpanded(!isExpanded);
|
||||
}}
|
||||
title={isExpanded ? 'Collapse' : 'Expand'}
|
||||
>
|
||||
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronUp className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Panel Content */}
|
||||
{isExpanded && <div className="flex-1 overflow-hidden min-h-0">{renderPanelContent()}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
2
apps/ui/src/components/layout/bottom-dock/index.ts
Normal file
2
apps/ui/src/components/layout/bottom-dock/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { BottomDock, useDockState } from './bottom-dock';
|
||||
export type { DockPosition } from './bottom-dock';
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Bot, Square, Loader2, Activity } from 'lucide-react';
|
||||
import { getElectronAPI, RunningAgent } from '@/lib/electron';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export function AgentsPanel() {
|
||||
const { currentProject } = useAppStore();
|
||||
const [runningAgents, setRunningAgents] = useState<RunningAgent[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [stoppingAgents, setStoppingAgents] = useState<Set<string>>(new Set());
|
||||
|
||||
const fetchRunningAgents = useCallback(async () => {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (api.runningAgents) {
|
||||
const result = await api.runningAgents.getAll();
|
||||
if (result.success && result.runningAgents) {
|
||||
// Filter to current project if one is selected
|
||||
const agents = currentProject?.path
|
||||
? result.runningAgents.filter((a) => a.projectPath === currentProject.path)
|
||||
: result.runningAgents;
|
||||
setRunningAgents(agents);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching running agents:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [currentProject?.path]);
|
||||
|
||||
// Initial fetch and auto-refresh
|
||||
useEffect(() => {
|
||||
fetchRunningAgents();
|
||||
const interval = setInterval(fetchRunningAgents, 2000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchRunningAgents]);
|
||||
|
||||
// Subscribe to auto-mode events
|
||||
useEffect(() => {
|
||||
const api = getElectronAPI();
|
||||
if (!api.autoMode) return;
|
||||
|
||||
const unsubscribe = api.autoMode.onEvent((event) => {
|
||||
if (event.type === 'auto_mode_feature_complete' || event.type === 'auto_mode_error') {
|
||||
fetchRunningAgents();
|
||||
}
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, [fetchRunningAgents]);
|
||||
|
||||
const handleStopAgent = useCallback(async (featureId: string) => {
|
||||
setStoppingAgents((prev) => new Set(prev).add(featureId));
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (api.autoMode) {
|
||||
await api.autoMode.stopFeature(featureId);
|
||||
toast.success('Agent stopped');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to stop agent');
|
||||
} finally {
|
||||
setStoppingAgents((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(featureId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-border/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="h-3.5 w-3.5 text-green-500" />
|
||||
<span className="text-xs font-medium">{runningAgents.length} Running</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="p-2 space-y-2">
|
||||
{runningAgents.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Bot className="h-8 w-8 mx-auto text-muted-foreground/50 mb-2" />
|
||||
<p className="text-xs text-muted-foreground">No agents running</p>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
Enable Auto Mode to start processing features
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
runningAgents.map((agent) => (
|
||||
<div key={agent.featureId} className="p-2 rounded-md border border-border bg-card">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium truncate">{agent.featureTitle}</p>
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5">
|
||||
{agent.status === 'running' ? 'In progress...' : agent.status}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
|
||||
onClick={() => handleStopAgent(agent.featureId)}
|
||||
disabled={stoppingAgents.has(agent.featureId)}
|
||||
>
|
||||
{stoppingAgents.has(agent.featureId) ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Square className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{agent.currentPhase && (
|
||||
<div className="flex items-center gap-1.5 mt-2">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-green-500 animate-pulse" />
|
||||
<span className="text-[10px] text-muted-foreground capitalize">
|
||||
{agent.currentPhase}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
697
apps/ui/src/components/layout/bottom-dock/panels/chat-panel.tsx
Normal file
697
apps/ui/src/components/layout/bottom-dock/panels/chat-panel.tsx
Normal file
@@ -0,0 +1,697 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
MessageSquare,
|
||||
Plus,
|
||||
Loader2,
|
||||
Archive,
|
||||
ArchiveRestore,
|
||||
Trash2,
|
||||
X,
|
||||
Send,
|
||||
Square,
|
||||
Bot,
|
||||
User,
|
||||
AlertCircle,
|
||||
ArchiveX,
|
||||
} from 'lucide-react';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useElectronAgent } from '@/hooks/use-electron-agent';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Markdown } from '@/components/ui/markdown';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AgentModelSelector } from '@/components/views/agent-view/shared/agent-model-selector';
|
||||
import { DeleteSessionDialog } from '@/components/dialogs/delete-session-dialog';
|
||||
import type { SessionListItem } from '@/types/electron';
|
||||
import type { Message } from '@/types/electron';
|
||||
import type { PhaseModelEntry } from '@automaker/types';
|
||||
|
||||
// Random session name generator
|
||||
const adjectives = [
|
||||
'Swift',
|
||||
'Bright',
|
||||
'Clever',
|
||||
'Dynamic',
|
||||
'Eager',
|
||||
'Focused',
|
||||
'Gentle',
|
||||
'Happy',
|
||||
'Inventive',
|
||||
'Jolly',
|
||||
'Keen',
|
||||
'Lively',
|
||||
'Mighty',
|
||||
'Noble',
|
||||
'Optimal',
|
||||
'Peaceful',
|
||||
];
|
||||
|
||||
const nouns = [
|
||||
'Agent',
|
||||
'Builder',
|
||||
'Coder',
|
||||
'Developer',
|
||||
'Explorer',
|
||||
'Forge',
|
||||
'Garden',
|
||||
'Helper',
|
||||
'Journey',
|
||||
'Mission',
|
||||
'Navigator',
|
||||
'Project',
|
||||
'Quest',
|
||||
'Runner',
|
||||
'Spark',
|
||||
'Task',
|
||||
];
|
||||
|
||||
function generateRandomSessionName(): string {
|
||||
const adjective = adjectives[Math.floor(Math.random() * adjectives.length)];
|
||||
const noun = nouns[Math.floor(Math.random() * nouns.length)];
|
||||
const number = Math.floor(Math.random() * 100);
|
||||
return `${adjective} ${noun} ${number}`;
|
||||
}
|
||||
|
||||
// Compact message bubble for dock panel
|
||||
function CompactMessageBubble({ message }: { message: Message }) {
|
||||
const isError = message.isError && message.role === 'assistant';
|
||||
|
||||
return (
|
||||
<div className={cn('flex gap-2', message.role === 'user' ? 'flex-row-reverse' : '')}>
|
||||
{/* Avatar */}
|
||||
<div
|
||||
className={cn(
|
||||
'w-6 h-6 rounded-lg flex items-center justify-center shrink-0',
|
||||
isError ? 'bg-red-500/10' : message.role === 'assistant' ? 'bg-primary/10' : 'bg-muted'
|
||||
)}
|
||||
>
|
||||
{isError ? (
|
||||
<AlertCircle className="w-3 h-3 text-red-500" />
|
||||
) : message.role === 'assistant' ? (
|
||||
<Bot className="w-3 h-3 text-primary" />
|
||||
) : (
|
||||
<User className="w-3 h-3 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex-1 max-w-[85%] rounded-lg px-2.5 py-1.5 text-xs',
|
||||
isError
|
||||
? 'bg-red-500/10 border border-red-500/30'
|
||||
: message.role === 'user'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-card border border-border'
|
||||
)}
|
||||
>
|
||||
{message.role === 'assistant' ? (
|
||||
<Markdown
|
||||
className={cn(
|
||||
'text-xs prose-p:leading-relaxed prose-p:my-1 prose-headings:text-sm prose-headings:my-1',
|
||||
isError ? 'text-red-600 dark:text-red-400' : 'text-foreground'
|
||||
)}
|
||||
>
|
||||
{message.content}
|
||||
</Markdown>
|
||||
) : (
|
||||
<p className="whitespace-pre-wrap leading-relaxed">{message.content}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Compact thinking indicator
|
||||
function CompactThinkingIndicator() {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<div className="w-6 h-6 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
|
||||
<Bot className="w-3 h-3 text-primary" />
|
||||
</div>
|
||||
<div className="bg-card border border-border rounded-lg px-2.5 py-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-0.5">
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse"
|
||||
style={{ animationDelay: '0ms' }}
|
||||
/>
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse"
|
||||
style={{ animationDelay: '150ms' }}
|
||||
/>
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse"
|
||||
style={{ animationDelay: '300ms' }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">Thinking...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Embedded chat component for a session
|
||||
function EmbeddedChat({ sessionId, projectPath }: { sessionId: string; projectPath: string }) {
|
||||
const [input, setInput] = useState('');
|
||||
const [modelSelection, setModelSelection] = useState<PhaseModelEntry>({ model: 'sonnet' });
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { messages, isProcessing, isConnected, sendMessage, stopExecution } = useElectronAgent({
|
||||
sessionId,
|
||||
workingDirectory: projectPath,
|
||||
model: modelSelection.model,
|
||||
thinkingLevel: modelSelection.thinkingLevel,
|
||||
});
|
||||
|
||||
// Auto-scroll to bottom when new messages arrive
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages, isProcessing]);
|
||||
|
||||
// Focus input on mount
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, [sessionId]);
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
if (!input.trim() || isProcessing) return;
|
||||
const messageContent = input;
|
||||
setInput('');
|
||||
await sendMessage(messageContent);
|
||||
}, [input, isProcessing, sendMessage]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
// Show welcome message if no messages
|
||||
const displayMessages =
|
||||
messages.length === 0
|
||||
? [
|
||||
{
|
||||
id: 'welcome',
|
||||
role: 'assistant' as const,
|
||||
content: "Hello! I'm the Automaker Agent. How can I help you today?",
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
]
|
||||
: messages;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Messages area */}
|
||||
<div className="flex-1 overflow-y-auto px-3 py-2 space-y-3">
|
||||
{displayMessages.map((message) => (
|
||||
<CompactMessageBubble key={message.id} message={message} />
|
||||
))}
|
||||
{isProcessing && <CompactThinkingIndicator />}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input area */}
|
||||
<div className="border-t border-border/50 p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={isConnected ? 'Type a message...' : 'Connecting...'}
|
||||
disabled={!isConnected}
|
||||
className={cn(
|
||||
'flex-1 h-8 rounded-md border border-border bg-background px-3 text-xs',
|
||||
'placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary'
|
||||
)}
|
||||
/>
|
||||
<AgentModelSelector
|
||||
value={modelSelection}
|
||||
onChange={setModelSelection}
|
||||
disabled={isProcessing}
|
||||
triggerClassName="h-8"
|
||||
/>
|
||||
{isProcessing ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={stopExecution}
|
||||
title="Stop"
|
||||
>
|
||||
<Square className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || !isConnected}
|
||||
title="Send"
|
||||
>
|
||||
<Send className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatPanel() {
|
||||
const { currentProject } = useAppStore();
|
||||
const [sessions, setSessions] = useState<SessionListItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [archivingAll, setArchivingAll] = useState(false);
|
||||
|
||||
// Delete dialog state
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [sessionToDelete, setSessionToDelete] = useState<SessionListItem | null>(null);
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (api?.sessions) {
|
||||
const result = await api.sessions.list(true);
|
||||
if (result.success && result.sessions) {
|
||||
setSessions(result.sessions);
|
||||
// Set active session to first active session if none selected
|
||||
const activeSessions = result.sessions.filter((s) => !s.isArchived);
|
||||
if (!activeSessionId && activeSessions.length > 0) {
|
||||
setActiveSessionId(activeSessions[0].id);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching sessions:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [activeSessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadSessions();
|
||||
}, [loadSessions]);
|
||||
|
||||
const handleCreateSession = useCallback(async () => {
|
||||
if (!currentProject) return;
|
||||
|
||||
setCreating(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (api?.sessions) {
|
||||
const sessionName = generateRandomSessionName();
|
||||
const result = await api.sessions.create(
|
||||
sessionName,
|
||||
currentProject.path,
|
||||
currentProject.path
|
||||
);
|
||||
if (result.success && result.session?.id) {
|
||||
await loadSessions();
|
||||
setActiveSessionId(result.session.id);
|
||||
setShowArchived(false);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating session:', error);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}, [currentProject, loadSessions]);
|
||||
|
||||
const handleArchiveSession = useCallback(
|
||||
async (sessionId: string, e?: React.MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (api?.sessions) {
|
||||
await api.sessions.archive(sessionId);
|
||||
await loadSessions();
|
||||
// If archived session was active, switch to first active session
|
||||
if (sessionId === activeSessionId) {
|
||||
const updatedSessions = sessions.filter((s) => s.id !== sessionId && !s.isArchived);
|
||||
setActiveSessionId(updatedSessions.length > 0 ? updatedSessions[0].id : null);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error archiving session:', error);
|
||||
}
|
||||
},
|
||||
[loadSessions, activeSessionId, sessions]
|
||||
);
|
||||
|
||||
const handleArchiveAll = useCallback(async () => {
|
||||
const activeSessions = sessions.filter((s) => !s.isArchived);
|
||||
if (activeSessions.length === 0) return;
|
||||
|
||||
setArchivingAll(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (api?.sessions) {
|
||||
for (const session of activeSessions) {
|
||||
await api.sessions.archive(session.id);
|
||||
}
|
||||
await loadSessions();
|
||||
setActiveSessionId(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error archiving all sessions:', error);
|
||||
} finally {
|
||||
setArchivingAll(false);
|
||||
}
|
||||
}, [sessions, loadSessions]);
|
||||
|
||||
const handleUnarchiveSession = useCallback(
|
||||
async (sessionId: string, e?: React.MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (api?.sessions) {
|
||||
await api.sessions.unarchive(sessionId);
|
||||
await loadSessions();
|
||||
setActiveSessionId(sessionId);
|
||||
setShowArchived(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error unarchiving session:', error);
|
||||
}
|
||||
},
|
||||
[loadSessions]
|
||||
);
|
||||
|
||||
const handleDeleteSession = useCallback((session: SessionListItem, e?: React.MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
setSessionToDelete(session);
|
||||
setDeleteDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const confirmDeleteSession = useCallback(
|
||||
async (sessionId: string) => {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (api?.sessions) {
|
||||
await api.sessions.delete(sessionId);
|
||||
await loadSessions();
|
||||
// If deleted session was active, switch to first available session
|
||||
if (sessionId === activeSessionId) {
|
||||
const remainingSessions = sessions.filter((s) => s.id !== sessionId);
|
||||
const activeSessions = remainingSessions.filter((s) => !s.isArchived);
|
||||
setActiveSessionId(activeSessions.length > 0 ? activeSessions[0].id : null);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting session:', error);
|
||||
} finally {
|
||||
setDeleteDialogOpen(false);
|
||||
setSessionToDelete(null);
|
||||
}
|
||||
},
|
||||
[loadSessions, activeSessionId, sessions]
|
||||
);
|
||||
|
||||
const activeSessions = sessions.filter((s) => !s.isArchived);
|
||||
const archivedSessions = sessions.filter((s) => s.isArchived);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!currentProject) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<MessageSquare className="h-8 w-8 mx-auto text-muted-foreground/50 mb-2" />
|
||||
<p className="text-xs text-muted-foreground">Select a project to start chatting</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show archived sessions list view
|
||||
if (showArchived) {
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-border/50 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Archive className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-xs font-medium">{archivedSessions.length} Archived</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 text-xs"
|
||||
onClick={() => setShowArchived(false)}
|
||||
>
|
||||
<MessageSquare className="h-3 w-3 mr-1" />
|
||||
Active
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Archived Sessions List */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="p-2 space-y-1">
|
||||
{archivedSessions.length === 0 ? (
|
||||
<div className="text-center py-6">
|
||||
<Archive className="h-8 w-8 mx-auto text-muted-foreground/50 mb-2" />
|
||||
<p className="text-xs text-muted-foreground">No archived sessions</p>
|
||||
</div>
|
||||
) : (
|
||||
archivedSessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className={cn(
|
||||
'p-2 rounded-md border border-border bg-card',
|
||||
'hover:bg-accent/50 transition-colors group'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<MessageSquare className="h-3 w-3 text-muted-foreground shrink-0" />
|
||||
<p className="text-xs font-medium truncate">{session.name}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1 ml-4">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{session.messageCount} messages
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={(e) => handleUnarchiveSession(session.id, e)}
|
||||
title="Restore"
|
||||
>
|
||||
<ArchiveRestore className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-destructive"
|
||||
onClick={(e) => handleDeleteSession(session, e)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete Dialog */}
|
||||
<DeleteSessionDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
session={sessionToDelete}
|
||||
onConfirm={confirmDeleteSession}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// No active sessions - show empty state
|
||||
if (activeSessions.length === 0) {
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-border/50 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare className="h-3.5 w-3.5" />
|
||||
<span className="text-xs font-medium">Chat</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{archivedSessions.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 text-xs"
|
||||
onClick={() => setShowArchived(true)}
|
||||
>
|
||||
<Archive className="h-3 w-3 mr-1" />
|
||||
{archivedSessions.length}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={handleCreateSession}
|
||||
disabled={creating}
|
||||
title="New session"
|
||||
>
|
||||
{creating ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Empty State */}
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<MessageSquare className="h-12 w-12 mx-auto text-muted-foreground/30 mb-3" />
|
||||
<p className="text-sm text-muted-foreground mb-1">No chat sessions</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
onClick={handleCreateSession}
|
||||
disabled={creating}
|
||||
>
|
||||
{creating ? (
|
||||
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
)}
|
||||
New Chat Session
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Active sessions view with tabs and embedded chat
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Tab bar */}
|
||||
<div className="flex items-center px-2 py-1 border-b border-border/50 shrink-0 gap-1 overflow-x-auto">
|
||||
{activeSessions.map((session) => (
|
||||
<button
|
||||
key={session.id}
|
||||
onClick={() => setActiveSessionId(session.id)}
|
||||
className={cn(
|
||||
'flex items-center gap-1 px-2 py-1 text-xs rounded transition-colors shrink-0',
|
||||
session.id === activeSessionId
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50'
|
||||
)}
|
||||
>
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
<span className="max-w-20 truncate">{session.name}</span>
|
||||
<button
|
||||
className="ml-0.5 p-0.5 rounded hover:bg-background/50 text-muted-foreground hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleArchiveSession(session.id, e);
|
||||
}}
|
||||
title="Archive session"
|
||||
>
|
||||
<X className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
</button>
|
||||
))}
|
||||
|
||||
<button
|
||||
className="flex items-center justify-center p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground shrink-0"
|
||||
onClick={handleCreateSession}
|
||||
disabled={creating}
|
||||
title="New Session"
|
||||
>
|
||||
{creating ? <Loader2 className="h-3 w-3 animate-spin" /> : <Plus className="h-3 w-3" />}
|
||||
</button>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
{activeSessions.length > 1 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 text-xs px-2"
|
||||
onClick={handleArchiveAll}
|
||||
disabled={archivingAll}
|
||||
title="Archive all sessions"
|
||||
>
|
||||
{archivingAll ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<ArchiveX className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{archivedSessions.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 text-xs px-2"
|
||||
onClick={() => setShowArchived(true)}
|
||||
title="View archived sessions"
|
||||
>
|
||||
<Archive className="h-3 w-3 mr-1" />
|
||||
{archivedSessions.length}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Embedded chat content */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{activeSessionId && currentProject ? (
|
||||
<EmbeddedChat
|
||||
key={activeSessionId}
|
||||
sessionId={activeSessionId}
|
||||
projectPath={currentProject.path}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<MessageSquare className="h-8 w-8 mx-auto text-muted-foreground/30 mb-2" />
|
||||
<p className="text-xs text-muted-foreground">Select a session</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete Dialog */}
|
||||
<DeleteSessionDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
session={sessionToDelete}
|
||||
onConfirm={confirmDeleteSession}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,978 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
FolderOpen,
|
||||
FileText,
|
||||
Image,
|
||||
Loader2,
|
||||
Upload,
|
||||
FilePlus,
|
||||
Save,
|
||||
Trash2,
|
||||
Pencil,
|
||||
Eye,
|
||||
MoreVertical,
|
||||
ArrowLeft,
|
||||
} from 'lucide-react';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { sanitizeFilename } from '@/lib/image-utils';
|
||||
import { toast } from 'sonner';
|
||||
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 { Card } from '@/components/ui/card';
|
||||
import { Markdown } from '@/components/ui/markdown';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
|
||||
interface ContextFile {
|
||||
name: string;
|
||||
type: 'text' | 'image';
|
||||
path: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface ContextMetadata {
|
||||
files: Record<string, { description: string }>;
|
||||
}
|
||||
|
||||
export function ContextPanel() {
|
||||
const { currentProject } = useAppStore();
|
||||
const [files, setFiles] = useState<ContextFile[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedFile, setSelectedFile] = useState<ContextFile | null>(null);
|
||||
const [fileContent, setFileContent] = useState<string>('');
|
||||
const [originalContent, setOriginalContent] = useState<string>('');
|
||||
const [isDropHovering, setIsDropHovering] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isPreviewMode, setIsPreviewMode] = useState(false);
|
||||
const [generatingDescriptions, setGeneratingDescriptions] = useState<Set<string>>(new Set());
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Dialog states
|
||||
const [isCreateMarkdownOpen, setIsCreateMarkdownOpen] = useState(false);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
|
||||
const [isEditDescriptionOpen, setIsEditDescriptionOpen] = useState(false);
|
||||
|
||||
// Dialog form values
|
||||
const [newMarkdownName, setNewMarkdownName] = useState('');
|
||||
const [newMarkdownDescription, setNewMarkdownDescription] = useState('');
|
||||
const [newMarkdownContent, setNewMarkdownContent] = useState('');
|
||||
const [renameFileName, setRenameFileName] = useState('');
|
||||
const [editDescriptionValue, setEditDescriptionValue] = useState('');
|
||||
const [editDescriptionFileName, setEditDescriptionFileName] = useState('');
|
||||
|
||||
const hasChanges = fileContent !== originalContent;
|
||||
|
||||
// Helper functions
|
||||
const isImageFile = (filename: string): boolean => {
|
||||
const imageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp'];
|
||||
const ext = filename.toLowerCase().substring(filename.lastIndexOf('.'));
|
||||
return imageExtensions.includes(ext);
|
||||
};
|
||||
|
||||
const isMarkdownFile = (filename: string): boolean => {
|
||||
const ext = filename.toLowerCase().substring(filename.lastIndexOf('.'));
|
||||
return ext === '.md' || ext === '.markdown';
|
||||
};
|
||||
|
||||
const getContextPath = useCallback(() => {
|
||||
if (!currentProject) return null;
|
||||
return `${currentProject.path}/.automaker/context`;
|
||||
}, [currentProject]);
|
||||
|
||||
// Load context metadata
|
||||
const loadMetadata = useCallback(async (): Promise<ContextMetadata> => {
|
||||
const contextPath = getContextPath();
|
||||
if (!contextPath) return { files: {} };
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
const metadataPath = `${contextPath}/context-metadata.json`;
|
||||
const result = await api.readFile(metadataPath);
|
||||
if (result.success && result.content) {
|
||||
return JSON.parse(result.content);
|
||||
}
|
||||
} catch {
|
||||
// Metadata file doesn't exist yet
|
||||
}
|
||||
return { files: {} };
|
||||
}, [getContextPath]);
|
||||
|
||||
// Save context metadata
|
||||
const saveMetadata = useCallback(
|
||||
async (metadata: ContextMetadata) => {
|
||||
const contextPath = getContextPath();
|
||||
if (!contextPath) return;
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
const metadataPath = `${contextPath}/context-metadata.json`;
|
||||
await api.writeFile(metadataPath, JSON.stringify(metadata, null, 2));
|
||||
} catch (error) {
|
||||
console.error('Failed to save metadata:', error);
|
||||
}
|
||||
},
|
||||
[getContextPath]
|
||||
);
|
||||
|
||||
const loadContextFiles = useCallback(async () => {
|
||||
const contextPath = getContextPath();
|
||||
if (!contextPath) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
|
||||
// Ensure context directory exists
|
||||
await api.mkdir(contextPath);
|
||||
|
||||
// Load metadata for descriptions
|
||||
const metadata = await loadMetadata();
|
||||
|
||||
// Read directory contents
|
||||
const result = await api.readdir(contextPath);
|
||||
if (result.success && result.entries) {
|
||||
const contextFiles: ContextFile[] = result.entries
|
||||
.filter((entry) => entry.isFile && entry.name !== 'context-metadata.json')
|
||||
.map((entry) => ({
|
||||
name: entry.name,
|
||||
type: isImageFile(entry.name) ? 'image' : 'text',
|
||||
path: `${contextPath}/${entry.name}`,
|
||||
description: metadata.files[entry.name]?.description,
|
||||
}));
|
||||
setFiles(contextFiles);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading context files:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [getContextPath, loadMetadata]);
|
||||
|
||||
useEffect(() => {
|
||||
loadContextFiles();
|
||||
}, [loadContextFiles]);
|
||||
|
||||
const handleSelectFile = useCallback(async (file: ContextFile) => {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
const result = await api.readFile(file.path);
|
||||
if (result.success && result.content !== undefined) {
|
||||
setSelectedFile(file);
|
||||
setFileContent(result.content);
|
||||
setOriginalContent(result.content);
|
||||
setIsPreviewMode(isMarkdownFile(file.name));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error reading file:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Save file content
|
||||
const handleSaveFile = useCallback(async () => {
|
||||
if (!selectedFile || !hasChanges) return;
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
await api.writeFile(selectedFile.path, fileContent);
|
||||
setOriginalContent(fileContent);
|
||||
toast.success('File saved');
|
||||
} catch (error) {
|
||||
console.error('Failed to save file:', error);
|
||||
toast.error('Failed to save file');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [selectedFile, fileContent, hasChanges]);
|
||||
|
||||
// Generate description for a file
|
||||
const generateDescription = async (
|
||||
filePath: string,
|
||||
fileName: string,
|
||||
isImage: boolean
|
||||
): Promise<string | undefined> => {
|
||||
try {
|
||||
const httpClient = getHttpApiClient();
|
||||
const result = isImage
|
||||
? await httpClient.context.describeImage(filePath)
|
||||
: await httpClient.context.describeFile(filePath);
|
||||
|
||||
if (result.success && result.description) {
|
||||
return result.description;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to generate description:', error);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// Generate description in background and update metadata
|
||||
const generateDescriptionAsync = useCallback(
|
||||
async (filePath: string, fileName: string, isImage: boolean) => {
|
||||
setGeneratingDescriptions((prev) => new Set(prev).add(fileName));
|
||||
|
||||
try {
|
||||
const description = await generateDescription(filePath, fileName, isImage);
|
||||
|
||||
if (description) {
|
||||
const metadata = await loadMetadata();
|
||||
metadata.files[fileName] = { description };
|
||||
await saveMetadata(metadata);
|
||||
await loadContextFiles();
|
||||
|
||||
setSelectedFile((current) => {
|
||||
if (current?.name === fileName) {
|
||||
return { ...current, description };
|
||||
}
|
||||
return current;
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to generate description:', error);
|
||||
} finally {
|
||||
setGeneratingDescriptions((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(fileName);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
},
|
||||
[loadMetadata, saveMetadata, loadContextFiles]
|
||||
);
|
||||
|
||||
// Upload a file
|
||||
const uploadFile = async (file: globalThis.File) => {
|
||||
const contextPath = getContextPath();
|
||||
if (!contextPath) return;
|
||||
|
||||
setIsUploading(true);
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
const isImage = isImageFile(file.name);
|
||||
|
||||
let filePath: string;
|
||||
let fileName: string;
|
||||
let imagePathForDescription: string | undefined;
|
||||
|
||||
if (isImage) {
|
||||
fileName = sanitizeFilename(file.name);
|
||||
|
||||
const dataUrl = await new Promise<string>((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => resolve(event.target?.result as string);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
const base64Data = dataUrl.split(',')[1] || dataUrl;
|
||||
const mimeType = file.type || 'image/png';
|
||||
|
||||
const saveResult = await api.saveImageToTemp?.(
|
||||
base64Data,
|
||||
fileName,
|
||||
mimeType,
|
||||
currentProject!.path
|
||||
);
|
||||
|
||||
if (!saveResult?.success || !saveResult.path) {
|
||||
throw new Error(saveResult?.error || 'Failed to save image');
|
||||
}
|
||||
|
||||
imagePathForDescription = saveResult.path;
|
||||
filePath = `${contextPath}/${fileName}`;
|
||||
await api.writeFile(filePath, dataUrl);
|
||||
} else {
|
||||
fileName = file.name;
|
||||
filePath = `${contextPath}/${fileName}`;
|
||||
|
||||
const content = await new Promise<string>((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => resolve(event.target?.result as string);
|
||||
reader.readAsText(file);
|
||||
});
|
||||
|
||||
await api.writeFile(filePath, content);
|
||||
}
|
||||
|
||||
await loadContextFiles();
|
||||
generateDescriptionAsync(imagePathForDescription || filePath, fileName, isImage);
|
||||
} catch (error) {
|
||||
console.error('Failed to upload file:', error);
|
||||
toast.error('Failed to upload file', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle file drop
|
||||
const handleDrop = async (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDropHovering(false);
|
||||
|
||||
const droppedFiles = Array.from(e.dataTransfer.files);
|
||||
if (droppedFiles.length === 0) return;
|
||||
|
||||
for (const file of droppedFiles) {
|
||||
await uploadFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDropHovering(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDropHovering(false);
|
||||
};
|
||||
|
||||
// Handle file import via button
|
||||
const handleImportClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileInputChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const inputFiles = e.target.files;
|
||||
if (!inputFiles || inputFiles.length === 0) return;
|
||||
|
||||
for (const file of Array.from(inputFiles)) {
|
||||
await uploadFile(file);
|
||||
}
|
||||
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
// Create markdown file
|
||||
const handleCreateMarkdown = async () => {
|
||||
const contextPath = getContextPath();
|
||||
if (!contextPath || !newMarkdownName.trim()) return;
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
let filename = newMarkdownName.trim();
|
||||
|
||||
if (!filename.includes('.')) {
|
||||
filename += '.md';
|
||||
}
|
||||
|
||||
const filePath = `${contextPath}/${filename}`;
|
||||
await api.writeFile(filePath, newMarkdownContent);
|
||||
|
||||
if (newMarkdownDescription.trim()) {
|
||||
const metadata = await loadMetadata();
|
||||
metadata.files[filename] = { description: newMarkdownDescription.trim() };
|
||||
await saveMetadata(metadata);
|
||||
}
|
||||
|
||||
await loadContextFiles();
|
||||
setIsCreateMarkdownOpen(false);
|
||||
setNewMarkdownName('');
|
||||
setNewMarkdownDescription('');
|
||||
setNewMarkdownContent('');
|
||||
toast.success('Markdown file created');
|
||||
} catch (error) {
|
||||
console.error('Failed to create markdown:', error);
|
||||
toast.error('Failed to create file');
|
||||
}
|
||||
};
|
||||
|
||||
// Delete selected file
|
||||
const handleDeleteFile = async () => {
|
||||
if (!selectedFile) return;
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
await api.deleteFile(selectedFile.path);
|
||||
|
||||
const metadata = await loadMetadata();
|
||||
delete metadata.files[selectedFile.name];
|
||||
await saveMetadata(metadata);
|
||||
|
||||
setIsDeleteDialogOpen(false);
|
||||
setSelectedFile(null);
|
||||
setFileContent('');
|
||||
setOriginalContent('');
|
||||
await loadContextFiles();
|
||||
toast.success('File deleted');
|
||||
} catch (error) {
|
||||
console.error('Failed to delete file:', error);
|
||||
toast.error('Failed to delete file');
|
||||
}
|
||||
};
|
||||
|
||||
// Rename selected file
|
||||
const handleRenameFile = async () => {
|
||||
const contextPath = getContextPath();
|
||||
if (!selectedFile || !contextPath || !renameFileName.trim()) return;
|
||||
|
||||
const newName = renameFileName.trim();
|
||||
if (newName === selectedFile.name) {
|
||||
setIsRenameDialogOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
const newPath = `${contextPath}/${newName}`;
|
||||
|
||||
const exists = await api.exists(newPath);
|
||||
if (exists) {
|
||||
toast.error('A file with this name already exists');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await api.readFile(selectedFile.path);
|
||||
if (!result.success || result.content === undefined) {
|
||||
toast.error('Failed to read file for rename');
|
||||
return;
|
||||
}
|
||||
|
||||
await api.writeFile(newPath, result.content);
|
||||
await api.deleteFile(selectedFile.path);
|
||||
|
||||
const metadata = await loadMetadata();
|
||||
if (metadata.files[selectedFile.name]) {
|
||||
metadata.files[newName] = metadata.files[selectedFile.name];
|
||||
delete metadata.files[selectedFile.name];
|
||||
await saveMetadata(metadata);
|
||||
}
|
||||
|
||||
setIsRenameDialogOpen(false);
|
||||
setRenameFileName('');
|
||||
await loadContextFiles();
|
||||
|
||||
const renamedFile: ContextFile = {
|
||||
name: newName,
|
||||
type: isImageFile(newName) ? 'image' : 'text',
|
||||
path: newPath,
|
||||
description: metadata.files[newName]?.description,
|
||||
};
|
||||
setSelectedFile(renamedFile);
|
||||
toast.success('File renamed');
|
||||
} catch (error) {
|
||||
console.error('Failed to rename file:', error);
|
||||
toast.error('Failed to rename file');
|
||||
}
|
||||
};
|
||||
|
||||
// Save edited description
|
||||
const handleSaveDescription = async () => {
|
||||
if (!editDescriptionFileName) return;
|
||||
|
||||
try {
|
||||
const metadata = await loadMetadata();
|
||||
metadata.files[editDescriptionFileName] = { description: editDescriptionValue.trim() };
|
||||
await saveMetadata(metadata);
|
||||
|
||||
if (selectedFile?.name === editDescriptionFileName) {
|
||||
setSelectedFile({ ...selectedFile, description: editDescriptionValue.trim() });
|
||||
}
|
||||
|
||||
await loadContextFiles();
|
||||
setIsEditDescriptionOpen(false);
|
||||
setEditDescriptionValue('');
|
||||
setEditDescriptionFileName('');
|
||||
toast.success('Description saved');
|
||||
} catch (error) {
|
||||
console.error('Failed to save description:', error);
|
||||
toast.error('Failed to save description');
|
||||
}
|
||||
};
|
||||
|
||||
// Delete file from list (dropdown action)
|
||||
const handleDeleteFromList = async (file: ContextFile) => {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
await api.deleteFile(file.path);
|
||||
|
||||
const metadata = await loadMetadata();
|
||||
delete metadata.files[file.name];
|
||||
await saveMetadata(metadata);
|
||||
|
||||
if (selectedFile?.path === file.path) {
|
||||
setSelectedFile(null);
|
||||
setFileContent('');
|
||||
setOriginalContent('');
|
||||
}
|
||||
|
||||
await loadContextFiles();
|
||||
toast.success('File deleted');
|
||||
} catch (error) {
|
||||
console.error('Failed to delete file:', error);
|
||||
toast.error('Failed to delete file');
|
||||
}
|
||||
};
|
||||
|
||||
// Go back to file list
|
||||
const handleBack = useCallback(() => {
|
||||
setSelectedFile(null);
|
||||
setFileContent('');
|
||||
setOriginalContent('');
|
||||
setIsPreviewMode(false);
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'h-full flex flex-col relative',
|
||||
isDropHovering && 'ring-2 ring-primary ring-inset'
|
||||
)}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
>
|
||||
{/* Hidden file input */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleFileInputChange}
|
||||
/>
|
||||
|
||||
{/* Drop overlay */}
|
||||
{isDropHovering && (
|
||||
<div className="absolute inset-0 bg-primary/10 z-50 flex items-center justify-center pointer-events-none">
|
||||
<div className="flex flex-col items-center text-primary">
|
||||
<Upload className="w-8 h-8 mb-1" />
|
||||
<span className="text-sm font-medium">Drop files to upload</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Uploading overlay */}
|
||||
{isUploading && (
|
||||
<div className="absolute inset-0 bg-background/80 z-50 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-primary mb-1" />
|
||||
<span className="text-xs font-medium">Uploading...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Single View: Either File List OR File Content */}
|
||||
{!selectedFile ? (
|
||||
/* File List View */
|
||||
<>
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-border/50">
|
||||
<span className="text-xs font-medium">Context Files</span>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setIsCreateMarkdownOpen(true)}
|
||||
title="Create markdown"
|
||||
>
|
||||
<FilePlus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={handleImportClick}
|
||||
disabled={isUploading}
|
||||
title="Import file"
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{files.length === 0 ? (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<FolderOpen className="h-8 w-8 mx-auto text-muted-foreground/50 mb-2" />
|
||||
<p className="text-xs text-muted-foreground">No context files</p>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
Drop files here or click + to add
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2 space-y-1">
|
||||
{files.map((file) => {
|
||||
const isGenerating = generatingDescriptions.has(file.name);
|
||||
return (
|
||||
<div
|
||||
key={file.name}
|
||||
className={cn(
|
||||
'group flex items-center gap-2 px-3 py-2 rounded-md cursor-pointer',
|
||||
'text-sm transition-colors',
|
||||
'hover:bg-accent/50'
|
||||
)}
|
||||
onClick={() => handleSelectFile(file)}
|
||||
>
|
||||
{file.type === 'image' ? (
|
||||
<Image className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="truncate block font-medium">{file.name}</span>
|
||||
{isGenerating ? (
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Generating description...
|
||||
</span>
|
||||
) : file.description ? (
|
||||
<span className="text-xs text-muted-foreground line-clamp-1">
|
||||
{file.description}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="opacity-0 group-hover:opacity-100 p-1 hover:bg-accent rounded transition-opacity"
|
||||
>
|
||||
<MoreVertical className="w-4 h-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setRenameFileName(file.name);
|
||||
setSelectedFile(file);
|
||||
setIsRenameDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Pencil className="w-3 h-3 mr-2" />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditDescriptionFileName(file.name);
|
||||
setEditDescriptionValue(file.description || '');
|
||||
setIsEditDescriptionOpen(true);
|
||||
}}
|
||||
>
|
||||
<FileText className="w-3 h-3 mr-2" />
|
||||
Edit Description
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteFromList(file);
|
||||
}}
|
||||
className="text-red-500 focus:text-red-500"
|
||||
>
|
||||
<Trash2 className="w-3 h-3 mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
/* File Content View */
|
||||
<>
|
||||
<div className="flex items-center justify-between px-2 py-2 border-b border-border/50">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0"
|
||||
onClick={handleBack}
|
||||
title="Back to files"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-xs font-medium truncate">{selectedFile.name}</span>
|
||||
{hasChanges && <span className="text-[10px] text-amber-500 shrink-0">Unsaved</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{selectedFile.type === 'text' && isMarkdownFile(selectedFile.name) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setIsPreviewMode(!isPreviewMode)}
|
||||
title={isPreviewMode ? 'Edit' : 'Preview'}
|
||||
>
|
||||
{isPreviewMode ? (
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{selectedFile.type === 'text' && hasChanges && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 text-xs px-2"
|
||||
onClick={handleSaveFile}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-3.5 w-3.5 mr-1" />
|
||||
Save
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-red-500 hover:text-red-400"
|
||||
onClick={() => setIsDeleteDialogOpen(true)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description section */}
|
||||
<div className="px-2 pt-2">
|
||||
<div className="bg-muted/30 rounded p-2 text-xs">
|
||||
<div className="flex items-start justify-between gap-1">
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase">
|
||||
Description
|
||||
</span>
|
||||
{generatingDescriptions.has(selectedFile.name) ? (
|
||||
<div className="flex items-center gap-1 text-muted-foreground mt-0.5">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
<span>Generating...</span>
|
||||
</div>
|
||||
) : selectedFile.description ? (
|
||||
<p className="text-xs mt-0.5">{selectedFile.description}</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 italic">No description</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 shrink-0"
|
||||
onClick={() => {
|
||||
setEditDescriptionFileName(selectedFile.name);
|
||||
setEditDescriptionValue(selectedFile.description || '');
|
||||
setIsEditDescriptionOpen(true);
|
||||
}}
|
||||
>
|
||||
<Pencil className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content area */}
|
||||
<div className="flex-1 overflow-auto p-2">
|
||||
{selectedFile.type === 'image' ? (
|
||||
<div className="h-full flex items-center justify-center bg-muted/20 rounded">
|
||||
<img
|
||||
src={fileContent}
|
||||
alt={selectedFile.name}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
) : isPreviewMode && isMarkdownFile(selectedFile.name) ? (
|
||||
<Card className="h-full overflow-auto p-3">
|
||||
<Markdown>{fileContent}</Markdown>
|
||||
</Card>
|
||||
) : (
|
||||
<textarea
|
||||
value={fileContent}
|
||||
onChange={(e) => setFileContent(e.target.value)}
|
||||
className={cn(
|
||||
'w-full h-full p-2 font-mono text-xs bg-muted/30 rounded resize-none',
|
||||
'focus:outline-none focus:ring-1 focus:ring-ring'
|
||||
)}
|
||||
placeholder="Enter content..."
|
||||
spellCheck={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Create Markdown Dialog */}
|
||||
<Dialog open={isCreateMarkdownOpen} onOpenChange={setIsCreateMarkdownOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Markdown File</DialogTitle>
|
||||
<DialogDescription>Create a new markdown file for AI context.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 py-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="md-filename" className="text-xs">
|
||||
File Name
|
||||
</Label>
|
||||
<Input
|
||||
id="md-filename"
|
||||
value={newMarkdownName}
|
||||
onChange={(e) => setNewMarkdownName(e.target.value)}
|
||||
placeholder="context-file.md"
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="md-description" className="text-xs">
|
||||
Description
|
||||
</Label>
|
||||
<Input
|
||||
id="md-description"
|
||||
value={newMarkdownDescription}
|
||||
onChange={(e) => setNewMarkdownDescription(e.target.value)}
|
||||
placeholder="e.g., Coding style guidelines"
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="md-content" className="text-xs">
|
||||
Content
|
||||
</Label>
|
||||
<Textarea
|
||||
id="md-content"
|
||||
value={newMarkdownContent}
|
||||
onChange={(e) => setNewMarkdownContent(e.target.value)}
|
||||
placeholder="Enter markdown content..."
|
||||
className="h-32 text-sm font-mono resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" size="sm" onClick={() => setIsCreateMarkdownOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleCreateMarkdown} disabled={!newMarkdownName.trim()}>
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete File</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete "{selectedFile?.name}"? This cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" size="sm" onClick={() => setIsDeleteDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={handleDeleteFile}>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Rename Dialog */}
|
||||
<Dialog open={isRenameDialogOpen} onOpenChange={setIsRenameDialogOpen}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rename File</DialogTitle>
|
||||
<DialogDescription>Enter a new name for "{selectedFile?.name}".</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-2">
|
||||
<Input
|
||||
value={renameFileName}
|
||||
onChange={(e) => setRenameFileName(e.target.value)}
|
||||
placeholder="Enter new filename"
|
||||
className="h-8 text-sm"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && renameFileName.trim()) {
|
||||
handleRenameFile();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" size="sm" onClick={() => setIsRenameDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleRenameFile}
|
||||
disabled={!renameFileName.trim() || renameFileName === selectedFile?.name}
|
||||
>
|
||||
Rename
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Edit Description Dialog */}
|
||||
<Dialog open={isEditDescriptionOpen} onOpenChange={setIsEditDescriptionOpen}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Description</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update the description for "{editDescriptionFileName}".
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-2">
|
||||
<Textarea
|
||||
value={editDescriptionValue}
|
||||
onChange={(e) => setEditDescriptionValue(e.target.value)}
|
||||
placeholder="Enter description..."
|
||||
className="h-24 text-sm resize-none"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" size="sm" onClick={() => setIsEditDescriptionOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSaveDescription}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import {
|
||||
CircleDot,
|
||||
GitPullRequest,
|
||||
RefreshCw,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
Wand2,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
getElectronAPI,
|
||||
GitHubIssue,
|
||||
GitHubPR,
|
||||
IssueValidationResult,
|
||||
StoredValidation,
|
||||
} from '@/lib/electron';
|
||||
import { useAppStore, GitHubCacheIssue, GitHubCachePR } from '@/store/app-store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useIssueValidation } from '@/components/views/github-issues-view/hooks';
|
||||
import { ValidationDialog } from '@/components/views/github-issues-view/dialogs';
|
||||
import { useModelOverride } from '@/components/shared';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
type GitHubTab = 'issues' | 'prs';
|
||||
|
||||
// Cache duration: 5 minutes
|
||||
const CACHE_DURATION_MS = 5 * 60 * 1000;
|
||||
|
||||
// Check if validation is stale (> 24 hours)
|
||||
function isValidationStale(validatedAt: string): boolean {
|
||||
const VALIDATION_CACHE_TTL_HOURS = 24;
|
||||
const validatedTime = new Date(validatedAt).getTime();
|
||||
const hoursSinceValidation = (Date.now() - validatedTime) / (1000 * 60 * 60);
|
||||
return hoursSinceValidation > VALIDATION_CACHE_TTL_HOURS;
|
||||
}
|
||||
|
||||
export function GitHubPanel() {
|
||||
const { currentProject, getGitHubCache, setGitHubCache, setGitHubCacheFetching } = useAppStore();
|
||||
const [activeTab, setActiveTab] = useState<GitHubTab>('issues');
|
||||
const [selectedIssue, setSelectedIssue] = useState<GitHubIssue | null>(null);
|
||||
const [validationResult, setValidationResult] = useState<IssueValidationResult | null>(null);
|
||||
const [showValidationDialog, setShowValidationDialog] = useState(false);
|
||||
const fetchingRef = useRef(false);
|
||||
|
||||
const projectPath = currentProject?.path || '';
|
||||
const cache = getGitHubCache(projectPath);
|
||||
|
||||
const issues = cache?.issues || [];
|
||||
const prs = cache?.prs || [];
|
||||
const isFetching = cache?.isFetching || false;
|
||||
const lastFetched = cache?.lastFetched || null;
|
||||
const hasCache = issues.length > 0 || prs.length > 0 || lastFetched !== null;
|
||||
|
||||
// Model override for validation
|
||||
const validationModelOverride = useModelOverride({ phase: 'validationModel' });
|
||||
|
||||
// Use the issue validation hook
|
||||
const { validatingIssues, cachedValidations, handleValidateIssue, handleViewCachedValidation } =
|
||||
useIssueValidation({
|
||||
selectedIssue,
|
||||
showValidationDialog,
|
||||
onValidationResultChange: setValidationResult,
|
||||
onShowValidationDialogChange: setShowValidationDialog,
|
||||
});
|
||||
|
||||
const fetchData = useCallback(
|
||||
async (isBackgroundRefresh = false) => {
|
||||
if (!projectPath || fetchingRef.current) return;
|
||||
|
||||
fetchingRef.current = true;
|
||||
if (!isBackgroundRefresh) {
|
||||
setGitHubCacheFetching(projectPath, true);
|
||||
}
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
const fetchedIssues: GitHubCacheIssue[] = [];
|
||||
const fetchedPrs: GitHubCachePR[] = [];
|
||||
|
||||
// Fetch issues
|
||||
if (api.github?.listIssues) {
|
||||
const issuesResult = await api.github.listIssues(projectPath);
|
||||
if (issuesResult.success && issuesResult.openIssues) {
|
||||
// Map to cache format
|
||||
fetchedIssues.push(
|
||||
...issuesResult.openIssues.slice(0, 20).map((issue: GitHubIssue) => ({
|
||||
number: issue.number,
|
||||
title: issue.title,
|
||||
url: issue.url,
|
||||
author: issue.author,
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch PRs
|
||||
if (api.github?.listPRs) {
|
||||
const prsResult = await api.github.listPRs(projectPath);
|
||||
if (prsResult.success && prsResult.openPRs) {
|
||||
// Map to cache format
|
||||
fetchedPrs.push(
|
||||
...prsResult.openPRs.slice(0, 20).map((pr: GitHubPR) => ({
|
||||
number: pr.number,
|
||||
title: pr.title,
|
||||
url: pr.url,
|
||||
author: pr.author,
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
setGitHubCache(projectPath, { issues: fetchedIssues, prs: fetchedPrs });
|
||||
} catch (error) {
|
||||
console.error('Error fetching GitHub data:', error);
|
||||
// On error, just mark as not fetching but keep existing cache
|
||||
setGitHubCacheFetching(projectPath, false);
|
||||
} finally {
|
||||
fetchingRef.current = false;
|
||||
}
|
||||
},
|
||||
[projectPath, setGitHubCache, setGitHubCacheFetching]
|
||||
);
|
||||
|
||||
// Initial fetch or refresh if cache is stale
|
||||
useEffect(() => {
|
||||
if (!projectPath) return;
|
||||
|
||||
const isCacheStale = !lastFetched || Date.now() - lastFetched > CACHE_DURATION_MS;
|
||||
|
||||
if (!hasCache) {
|
||||
// No cache, do initial fetch (show spinner)
|
||||
fetchData(false);
|
||||
} else if (isCacheStale && !isFetching) {
|
||||
// Cache is stale, refresh in background (no spinner, show cached data)
|
||||
fetchData(true);
|
||||
}
|
||||
}, [projectPath, hasCache, lastFetched, isFetching, fetchData]);
|
||||
|
||||
// Auto-refresh interval
|
||||
useEffect(() => {
|
||||
if (!projectPath) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
const currentCache = getGitHubCache(projectPath);
|
||||
const isStale =
|
||||
!currentCache?.lastFetched || Date.now() - currentCache.lastFetched > CACHE_DURATION_MS;
|
||||
|
||||
if (isStale && !fetchingRef.current) {
|
||||
fetchData(true);
|
||||
}
|
||||
}, CACHE_DURATION_MS);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [projectPath, getGitHubCache, fetchData]);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
fetchData(false);
|
||||
}, [fetchData]);
|
||||
|
||||
const handleOpenInGitHub = useCallback((url: string) => {
|
||||
const api = getElectronAPI();
|
||||
api.openExternalLink(url);
|
||||
}, []);
|
||||
|
||||
// Handle validation for an issue (converts cache issue to GitHubIssue format)
|
||||
const handleValidate = useCallback(
|
||||
(cacheIssue: GitHubCacheIssue) => {
|
||||
// Convert cache issue to GitHubIssue format for validation
|
||||
const issue: GitHubIssue = {
|
||||
number: cacheIssue.number,
|
||||
title: cacheIssue.title,
|
||||
url: cacheIssue.url,
|
||||
author: cacheIssue.author || { login: 'unknown' },
|
||||
state: 'OPEN',
|
||||
body: '',
|
||||
createdAt: new Date().toISOString(),
|
||||
labels: [],
|
||||
comments: { totalCount: 0 },
|
||||
};
|
||||
setSelectedIssue(issue);
|
||||
handleValidateIssue(issue, {
|
||||
modelEntry: validationModelOverride.effectiveModelEntry,
|
||||
});
|
||||
},
|
||||
[handleValidateIssue, validationModelOverride.effectiveModelEntry]
|
||||
);
|
||||
|
||||
// Handle viewing cached validation
|
||||
const handleViewValidation = useCallback(
|
||||
(cacheIssue: GitHubCacheIssue) => {
|
||||
// Convert cache issue to GitHubIssue format
|
||||
const issue: GitHubIssue = {
|
||||
number: cacheIssue.number,
|
||||
title: cacheIssue.title,
|
||||
url: cacheIssue.url,
|
||||
author: cacheIssue.author || { login: 'unknown' },
|
||||
state: 'OPEN',
|
||||
body: '',
|
||||
createdAt: new Date().toISOString(),
|
||||
labels: [],
|
||||
comments: { totalCount: 0 },
|
||||
};
|
||||
setSelectedIssue(issue);
|
||||
handleViewCachedValidation(issue);
|
||||
},
|
||||
[handleViewCachedValidation]
|
||||
);
|
||||
|
||||
// Get validation status for an issue
|
||||
const getValidationStatus = useCallback(
|
||||
(issueNumber: number) => {
|
||||
const isValidating = validatingIssues.has(issueNumber);
|
||||
const cached = cachedValidations.get(issueNumber);
|
||||
const isStale = cached ? isValidationStale(cached.validatedAt) : false;
|
||||
return { isValidating, cached, isStale };
|
||||
},
|
||||
[validatingIssues, cachedValidations]
|
||||
);
|
||||
|
||||
// Only show loading spinner if no cached data AND fetching
|
||||
if (!hasCache && isFetching) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header with tabs */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-border/50">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setActiveTab('issues')}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium transition-colors',
|
||||
activeTab === 'issues'
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<CircleDot className="h-3 w-3" />
|
||||
Issues ({issues.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('prs')}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium transition-colors',
|
||||
activeTab === 'prs'
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<GitPullRequest className="h-3 w-3" />
|
||||
PRs ({prs.length})
|
||||
</button>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={handleRefresh}
|
||||
disabled={isFetching}
|
||||
>
|
||||
<RefreshCw className={cn('h-3 w-3', isFetching && 'animate-spin')} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="p-2 space-y-1">
|
||||
{activeTab === 'issues' ? (
|
||||
issues.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground text-center py-4">No open issues</p>
|
||||
) : (
|
||||
issues.map((issue) => {
|
||||
const { isValidating, cached, isStale } = getValidationStatus(issue.number);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={issue.number}
|
||||
className="flex items-start gap-2 p-2 rounded-md hover:bg-accent/50 group"
|
||||
>
|
||||
<CircleDot className="h-3.5 w-3.5 mt-0.5 text-green-500 shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium truncate">{issue.title}</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
#{issue.number} opened by {issue.author?.login}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{/* Validation status/action */}
|
||||
{isValidating ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-primary" />
|
||||
) : cached && !isStale ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-1.5"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleViewValidation(issue);
|
||||
}}
|
||||
title="View validation result"
|
||||
>
|
||||
<CheckCircle className="h-3.5 w-3.5 text-green-500" />
|
||||
</Button>
|
||||
) : cached && isStale ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-1.5"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleValidate(issue);
|
||||
}}
|
||||
title="Re-validate (stale)"
|
||||
>
|
||||
<Clock className="h-3.5 w-3.5 text-yellow-500" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-1.5 opacity-0 group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleValidate(issue);
|
||||
}}
|
||||
title="Validate with AI"
|
||||
>
|
||||
<Wand2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{/* Open in GitHub */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-1.5 opacity-0 group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleOpenInGitHub(issue.url);
|
||||
}}
|
||||
title="Open in GitHub"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)
|
||||
) : prs.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground text-center py-4">No open pull requests</p>
|
||||
) : (
|
||||
prs.map((pr) => (
|
||||
<div
|
||||
key={pr.number}
|
||||
className="flex items-start gap-2 p-2 rounded-md hover:bg-accent/50 cursor-pointer group"
|
||||
onClick={() => handleOpenInGitHub(pr.url)}
|
||||
>
|
||||
<GitPullRequest className="h-3.5 w-3.5 mt-0.5 text-purple-500 shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium truncate">{pr.title}</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
#{pr.number} by {pr.author?.login}
|
||||
</p>
|
||||
</div>
|
||||
<ExternalLink className="h-3 w-3 opacity-0 group-hover:opacity-100 text-muted-foreground" />
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Validation Dialog */}
|
||||
<ValidationDialog
|
||||
open={showValidationDialog}
|
||||
onOpenChange={setShowValidationDialog}
|
||||
issue={selectedIssue}
|
||||
validationResult={validationResult}
|
||||
onConvertToTask={() => {
|
||||
// Task conversion not supported in dock panel - need to go to full view
|
||||
toast.info('Open GitHub Issues view for task conversion');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,592 @@
|
||||
/**
|
||||
* IdeationPanel - Bottom dock panel for brainstorming and idea generation
|
||||
* Embeds the full ideation flow: dashboard, category selection, and prompt selection
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
Sparkles,
|
||||
Lightbulb,
|
||||
ArrowLeft,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
Plus,
|
||||
X,
|
||||
ChevronRight,
|
||||
Zap,
|
||||
Palette,
|
||||
Code,
|
||||
TrendingUp,
|
||||
Cpu,
|
||||
Shield,
|
||||
Gauge,
|
||||
Accessibility,
|
||||
BarChart3,
|
||||
CheckCircle2,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useIdeationStore, type GenerationJob } from '@/store/ideation-store';
|
||||
import { useGuidedPrompts } from '@/hooks/use-guided-prompts';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { toast } from 'sonner';
|
||||
import type { IdeaCategory, IdeationPrompt, AnalysisSuggestion } from '@automaker/types';
|
||||
|
||||
type PanelMode = 'dashboard' | 'categories' | 'prompts';
|
||||
|
||||
const iconMap: Record<string, typeof Zap> = {
|
||||
Zap,
|
||||
Palette,
|
||||
Code,
|
||||
TrendingUp,
|
||||
Cpu,
|
||||
Shield,
|
||||
Gauge,
|
||||
Accessibility,
|
||||
BarChart3,
|
||||
};
|
||||
|
||||
// Suggestion card for dashboard view
|
||||
function SuggestionCard({
|
||||
suggestion,
|
||||
job,
|
||||
onAccept,
|
||||
onRemove,
|
||||
isAdding,
|
||||
}: {
|
||||
suggestion: AnalysisSuggestion;
|
||||
job: GenerationJob;
|
||||
onAccept: () => void;
|
||||
onRemove: () => void;
|
||||
isAdding: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Card className="transition-all hover:border-primary/50">
|
||||
<CardContent className="p-2.5">
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Title and remove button */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h4 className="font-medium text-sm leading-tight">{suggestion.title}</h4>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onRemove}
|
||||
disabled={isAdding}
|
||||
className="h-6 w-6 p-0 text-muted-foreground hover:text-destructive shrink-0"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
{/* Badges */}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Badge variant="outline" className="text-[10px] h-5">
|
||||
{suggestion.priority}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="text-[10px] h-5">
|
||||
{job.prompt.title}
|
||||
</Badge>
|
||||
</div>
|
||||
{/* Description */}
|
||||
<p className="text-xs text-muted-foreground line-clamp-2">{suggestion.description}</p>
|
||||
{/* Accept button */}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onAccept}
|
||||
disabled={isAdding}
|
||||
className="h-7 gap-1 text-xs w-full"
|
||||
>
|
||||
{isAdding ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Plus className="w-3 h-3" />
|
||||
Accept
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Generating card for active jobs
|
||||
function GeneratingCard({ job }: { job: GenerationJob }) {
|
||||
const { removeJob } = useIdeationStore();
|
||||
const isError = job.status === 'error';
|
||||
|
||||
return (
|
||||
<Card className={cn('transition-all', isError ? 'border-red-500/50' : 'border-blue-500/50')}>
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{isError ? (
|
||||
<AlertCircle className="w-4 h-4 text-red-500" />
|
||||
) : (
|
||||
<Loader2 className="w-4 h-4 text-blue-500 animate-spin" />
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium text-sm">{job.prompt.title}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isError ? job.error || 'Failed to generate' : 'Generating ideas...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeJob(job.id)}
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Dashboard view - shows generated ideas
|
||||
function DashboardView({ onGenerateIdeas }: { onGenerateIdeas: () => void }) {
|
||||
const currentProject = useAppStore((s) => s.currentProject);
|
||||
const generationJobs = useIdeationStore((s) => s.generationJobs);
|
||||
const removeSuggestionFromJob = useIdeationStore((s) => s.removeSuggestionFromJob);
|
||||
const [addingId, setAddingId] = useState<string | null>(null);
|
||||
|
||||
const projectJobs = useMemo(
|
||||
() =>
|
||||
currentProject?.path
|
||||
? generationJobs.filter((job) => job.projectPath === currentProject.path)
|
||||
: [],
|
||||
[generationJobs, currentProject?.path]
|
||||
);
|
||||
|
||||
const { activeJobs, readyJobs } = useMemo(() => {
|
||||
const active: GenerationJob[] = [];
|
||||
const ready: GenerationJob[] = [];
|
||||
|
||||
for (const job of projectJobs) {
|
||||
if (job.status === 'generating' || job.status === 'error') {
|
||||
active.push(job);
|
||||
} else if (job.status === 'ready' && job.suggestions.length > 0) {
|
||||
ready.push(job);
|
||||
}
|
||||
}
|
||||
|
||||
return { activeJobs: active, readyJobs: ready };
|
||||
}, [projectJobs]);
|
||||
|
||||
const allSuggestions = useMemo(
|
||||
() => readyJobs.flatMap((job) => job.suggestions.map((suggestion) => ({ suggestion, job }))),
|
||||
[readyJobs]
|
||||
);
|
||||
|
||||
const handleAccept = async (suggestion: AnalysisSuggestion, jobId: string) => {
|
||||
if (!currentProject?.path) {
|
||||
toast.error('No project selected');
|
||||
return;
|
||||
}
|
||||
|
||||
setAddingId(suggestion.id);
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
const result = await api.ideation?.addSuggestionToBoard(currentProject.path, suggestion);
|
||||
|
||||
if (result?.success) {
|
||||
toast.success(`Added "${suggestion.title}" to board`);
|
||||
removeSuggestionFromJob(jobId, suggestion.id);
|
||||
} else {
|
||||
toast.error(result?.error || 'Failed to add to board');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to add to board:', error);
|
||||
toast.error((error as Error).message);
|
||||
} finally {
|
||||
setAddingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = (suggestionId: string, jobId: string) => {
|
||||
removeSuggestionFromJob(jobId, suggestionId);
|
||||
toast.info('Idea removed');
|
||||
};
|
||||
|
||||
const isEmpty = allSuggestions.length === 0 && activeJobs.length === 0;
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-auto p-3 space-y-2">
|
||||
{/* Active jobs */}
|
||||
{activeJobs.map((job) => (
|
||||
<GeneratingCard key={job.id} job={job} />
|
||||
))}
|
||||
|
||||
{/* Suggestions */}
|
||||
{allSuggestions.map(({ suggestion, job }) => (
|
||||
<SuggestionCard
|
||||
key={suggestion.id}
|
||||
suggestion={suggestion}
|
||||
job={job}
|
||||
onAccept={() => handleAccept(suggestion, job.id)}
|
||||
onRemove={() => handleRemove(suggestion.id, job.id)}
|
||||
isAdding={addingId === suggestion.id}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Empty state */}
|
||||
{isEmpty && (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Sparkles className="w-8 h-8 text-muted-foreground/50 mb-3" />
|
||||
<h3 className="text-sm font-medium mb-1">No ideas yet</h3>
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
Generate ideas by selecting a category and prompt
|
||||
</p>
|
||||
<Button onClick={onGenerateIdeas} size="sm" className="gap-2">
|
||||
<Lightbulb className="w-4 h-4" />
|
||||
Generate Ideas
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Generate more button */}
|
||||
{!isEmpty && (
|
||||
<Button onClick={onGenerateIdeas} variant="outline" size="sm" className="w-full gap-2 mt-2">
|
||||
<Lightbulb className="w-4 h-4" />
|
||||
Generate More Ideas
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Category grid view
|
||||
function CategoryGridView({
|
||||
onSelect,
|
||||
onBack,
|
||||
}: {
|
||||
onSelect: (category: IdeaCategory) => void;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const { categories, isLoading, error } = useGuidedPrompts();
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-auto p-3">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors mb-3"
|
||||
>
|
||||
<ArrowLeft className="w-3.5 h-3.5" />
|
||||
<span>Back to dashboard</span>
|
||||
</button>
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-sm text-muted-foreground">Loading categories...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="text-center py-8 text-destructive text-sm">
|
||||
<p>Failed to load categories: {error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{categories.map((category) => {
|
||||
const Icon = iconMap[category.icon] || Zap;
|
||||
return (
|
||||
<Card
|
||||
key={category.id}
|
||||
className="cursor-pointer transition-all hover:border-primary hover:shadow-sm"
|
||||
onClick={() => onSelect(category.id)}
|
||||
>
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-md bg-primary/10">
|
||||
<Icon className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-medium text-sm truncate">{category.name}</h3>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{category.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Prompt list view
|
||||
function PromptListView({
|
||||
category,
|
||||
onBack,
|
||||
onDone,
|
||||
}: {
|
||||
category: IdeaCategory;
|
||||
onBack: () => void;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const currentProject = useAppStore((s) => s.currentProject);
|
||||
const generationJobs = useIdeationStore((s) => s.generationJobs);
|
||||
const addGenerationJob = useIdeationStore((s) => s.addGenerationJob);
|
||||
const updateJobStatus = useIdeationStore((s) => s.updateJobStatus);
|
||||
const [loadingPromptId, setLoadingPromptId] = useState<string | null>(null);
|
||||
const [startedPrompts, setStartedPrompts] = useState<Set<string>>(new Set());
|
||||
|
||||
const { getPromptsByCategory, getCategoryById, isLoading, error } = useGuidedPrompts();
|
||||
const prompts = getPromptsByCategory(category);
|
||||
const categoryInfo = getCategoryById(category);
|
||||
|
||||
const projectJobs = useMemo(
|
||||
() =>
|
||||
currentProject?.path
|
||||
? generationJobs.filter((job) => job.projectPath === currentProject.path)
|
||||
: [],
|
||||
[generationJobs, currentProject?.path]
|
||||
);
|
||||
|
||||
const generatingPromptIds = useMemo(
|
||||
() => new Set(projectJobs.filter((j) => j.status === 'generating').map((j) => j.prompt.id)),
|
||||
[projectJobs]
|
||||
);
|
||||
|
||||
const handleSelectPrompt = async (prompt: IdeationPrompt) => {
|
||||
if (!currentProject?.path) {
|
||||
toast.error('No project selected');
|
||||
return;
|
||||
}
|
||||
|
||||
if (loadingPromptId || generatingPromptIds.has(prompt.id)) return;
|
||||
|
||||
setLoadingPromptId(prompt.id);
|
||||
const jobId = addGenerationJob(currentProject.path, prompt);
|
||||
setStartedPrompts((prev) => new Set(prev).add(prompt.id));
|
||||
|
||||
toast.info(`Generating ideas for "${prompt.title}"...`);
|
||||
onDone(); // Navigate back to dashboard
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
const result = await api.ideation?.generateSuggestions(
|
||||
currentProject.path,
|
||||
prompt.id,
|
||||
category
|
||||
);
|
||||
|
||||
if (result?.success && result.suggestions) {
|
||||
updateJobStatus(jobId, 'ready', result.suggestions);
|
||||
toast.success(`Generated ${result.suggestions.length} ideas for "${prompt.title}"`);
|
||||
} else {
|
||||
updateJobStatus(
|
||||
jobId,
|
||||
'error',
|
||||
undefined,
|
||||
result?.error || 'Failed to generate suggestions'
|
||||
);
|
||||
toast.error(result?.error || 'Failed to generate suggestions');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to generate suggestions:', error);
|
||||
updateJobStatus(jobId, 'error', undefined, (error as Error).message);
|
||||
toast.error((error as Error).message);
|
||||
} finally {
|
||||
setLoadingPromptId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-auto p-3">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors mb-3"
|
||||
>
|
||||
<ArrowLeft className="w-3.5 h-3.5" />
|
||||
<span>Back to categories</span>
|
||||
</button>
|
||||
|
||||
{categoryInfo && (
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
Select a prompt from{' '}
|
||||
<span className="font-medium text-foreground">{categoryInfo.name}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-sm text-muted-foreground">Loading prompts...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="text-center py-8 text-destructive text-sm">
|
||||
<p>Failed to load prompts: {error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && (
|
||||
<div className="space-y-2">
|
||||
{prompts.map((prompt) => {
|
||||
const isLoading = loadingPromptId === prompt.id;
|
||||
const isGenerating = generatingPromptIds.has(prompt.id);
|
||||
const isStarted = startedPrompts.has(prompt.id);
|
||||
const isDisabled = loadingPromptId !== null || isGenerating;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={prompt.id}
|
||||
className={cn(
|
||||
'transition-all',
|
||||
isDisabled
|
||||
? 'opacity-60 cursor-not-allowed'
|
||||
: 'cursor-pointer hover:border-primary hover:shadow-sm',
|
||||
(isLoading || isGenerating) && 'border-blue-500 ring-1 ring-blue-500',
|
||||
isStarted && !isGenerating && 'border-green-500/50'
|
||||
)}
|
||||
onClick={() => !isDisabled && handleSelectPrompt(prompt)}
|
||||
>
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
'p-1.5 rounded-md mt-0.5',
|
||||
isLoading || isGenerating
|
||||
? 'bg-blue-500/10'
|
||||
: isStarted
|
||||
? 'bg-green-500/10'
|
||||
: 'bg-primary/10'
|
||||
)}
|
||||
>
|
||||
{isLoading || isGenerating ? (
|
||||
<Loader2 className="w-3.5 h-3.5 text-blue-500 animate-spin" />
|
||||
) : isStarted ? (
|
||||
<CheckCircle2 className="w-3.5 h-3.5 text-green-500" />
|
||||
) : (
|
||||
<Lightbulb className="w-3.5 h-3.5 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium text-sm">{prompt.title}</h3>
|
||||
<p className="text-xs text-muted-foreground line-clamp-2">
|
||||
{prompt.description}
|
||||
</p>
|
||||
{(isLoading || isGenerating) && (
|
||||
<p className="text-blue-500 text-xs mt-1">Generating...</p>
|
||||
)}
|
||||
{isStarted && !isGenerating && (
|
||||
<p className="text-green-500 text-xs mt-1">Generated - check dashboard</p>
|
||||
)}
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground shrink-0 mt-1" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function IdeationPanel() {
|
||||
const { currentProject } = useAppStore();
|
||||
const [mode, setMode] = useState<PanelMode>('dashboard');
|
||||
const [selectedCategory, setSelectedCategory] = useState<IdeaCategory | null>(null);
|
||||
|
||||
const handleGenerateIdeas = useCallback(() => {
|
||||
setMode('categories');
|
||||
setSelectedCategory(null);
|
||||
}, []);
|
||||
|
||||
const handleSelectCategory = useCallback((category: IdeaCategory) => {
|
||||
setSelectedCategory(category);
|
||||
setMode('prompts');
|
||||
}, []);
|
||||
|
||||
const handleBackFromCategories = useCallback(() => {
|
||||
setMode('dashboard');
|
||||
}, []);
|
||||
|
||||
const handleBackFromPrompts = useCallback(() => {
|
||||
setMode('categories');
|
||||
setSelectedCategory(null);
|
||||
}, []);
|
||||
|
||||
const handlePromptDone = useCallback(() => {
|
||||
setMode('dashboard');
|
||||
setSelectedCategory(null);
|
||||
}, []);
|
||||
|
||||
if (!currentProject) {
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-border/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-3.5 w-3.5 text-primary" />
|
||||
<span className="text-xs font-medium">Ideation</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">Open a project to start brainstorming</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-border/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-3.5 w-3.5 text-primary" />
|
||||
<span className="text-xs font-medium">Ideation</span>
|
||||
{mode === 'dashboard' && (
|
||||
<span className="text-xs text-muted-foreground">- Review and accept ideas</span>
|
||||
)}
|
||||
{mode === 'categories' && (
|
||||
<span className="text-xs text-muted-foreground">- Select a category</span>
|
||||
)}
|
||||
{mode === 'prompts' && selectedCategory && (
|
||||
<span className="text-xs text-muted-foreground">- Select a prompt</span>
|
||||
)}
|
||||
</div>
|
||||
{mode === 'dashboard' && (
|
||||
<Button
|
||||
onClick={handleGenerateIdeas}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 gap-1.5 text-xs"
|
||||
>
|
||||
<Lightbulb className="w-3.5 h-3.5" />
|
||||
Generate
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{mode === 'dashboard' && <DashboardView onGenerateIdeas={handleGenerateIdeas} />}
|
||||
{mode === 'categories' && (
|
||||
<CategoryGridView onSelect={handleSelectCategory} onBack={handleBackFromCategories} />
|
||||
)}
|
||||
{mode === 'prompts' && selectedCategory && (
|
||||
<PromptListView
|
||||
category={selectedCategory}
|
||||
onBack={handleBackFromPrompts}
|
||||
onDone={handlePromptDone}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export { GitHubPanel } from './github-panel';
|
||||
export { AgentsPanel } from './agents-panel';
|
||||
export { SpecPanel } from './spec-panel';
|
||||
export { ContextPanel } from './context-panel';
|
||||
export { TerminalPanelDock } from './terminal-panel';
|
||||
export { ChatPanel } from './chat-panel';
|
||||
export { IdeationPanel } from './ideation-panel';
|
||||
586
apps/ui/src/components/layout/bottom-dock/panels/spec-panel.tsx
Normal file
586
apps/ui/src/components/layout/bottom-dock/panels/spec-panel.tsx
Normal file
@@ -0,0 +1,586 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
FileText,
|
||||
Loader2,
|
||||
Save,
|
||||
Sparkles,
|
||||
RefreshCw,
|
||||
FilePlus2,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { XmlSyntaxEditor } from '@/components/ui/xml-syntax-editor';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import type { SpecRegenerationEvent } from '@/types/electron';
|
||||
|
||||
// Feature count options
|
||||
type FeatureCount = 20 | 50 | 100;
|
||||
|
||||
const FEATURE_COUNT_OPTIONS: { value: FeatureCount; label: string; warning?: string }[] = [
|
||||
{ value: 20, label: '20' },
|
||||
{ value: 50, label: '50', warning: 'May take up to 5 minutes' },
|
||||
{ value: 100, label: '100', warning: 'May take up to 5 minutes' },
|
||||
];
|
||||
|
||||
const PHASE_LABELS: Record<string, string> = {
|
||||
initialization: 'Initializing...',
|
||||
setup: 'Setting up tools...',
|
||||
analysis: 'Analyzing project...',
|
||||
spec_complete: 'Spec created! Generating features...',
|
||||
feature_generation: 'Creating features...',
|
||||
complete: 'Complete!',
|
||||
error: 'Error occurred',
|
||||
};
|
||||
|
||||
const SPEC_FILE_WRITE_DELAY = 500;
|
||||
|
||||
export function SpecPanel() {
|
||||
const { currentProject, appSpec, setAppSpec } = useAppStore();
|
||||
const [specContent, setSpecContent] = useState('');
|
||||
const [originalContent, setOriginalContent] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [specExists, setSpecExists] = useState(false);
|
||||
|
||||
// Generation state
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||
const [showRegenerateDialog, setShowRegenerateDialog] = useState(false);
|
||||
const [projectOverview, setProjectOverview] = useState('');
|
||||
const [projectDefinition, setProjectDefinition] = useState('');
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [generateFeatures, setGenerateFeatures] = useState(true);
|
||||
const [analyzeProject, setAnalyzeProject] = useState(true);
|
||||
const [featureCount, setFeatureCount] = useState<FeatureCount>(50);
|
||||
const [currentPhase, setCurrentPhase] = useState('');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
|
||||
const hasChanges = specContent !== originalContent;
|
||||
|
||||
// Load spec from file
|
||||
const loadSpec = useCallback(async () => {
|
||||
if (!currentProject?.path) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
|
||||
// Check if generation is running
|
||||
if (api.specRegeneration) {
|
||||
const status = await api.specRegeneration.status();
|
||||
if (status.success && status.isRunning) {
|
||||
setIsGenerating(true);
|
||||
if (status.currentPhase) {
|
||||
setCurrentPhase(status.currentPhase);
|
||||
}
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Read the spec file using the correct API
|
||||
const specPath = `${currentProject.path}/.automaker/app_spec.txt`;
|
||||
const result = await api.readFile(specPath);
|
||||
|
||||
if (result.success && result.content) {
|
||||
setSpecContent(result.content);
|
||||
setOriginalContent(result.content);
|
||||
setAppSpec(result.content);
|
||||
setSpecExists(true);
|
||||
} else {
|
||||
setSpecContent('');
|
||||
setOriginalContent('');
|
||||
setSpecExists(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading spec:', error);
|
||||
setSpecExists(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [currentProject?.path, setAppSpec]);
|
||||
|
||||
useEffect(() => {
|
||||
loadSpec();
|
||||
}, [loadSpec]);
|
||||
|
||||
// Sync with store
|
||||
useEffect(() => {
|
||||
if (appSpec && appSpec !== specContent && !hasChanges) {
|
||||
setSpecContent(appSpec);
|
||||
setOriginalContent(appSpec);
|
||||
setSpecExists(true);
|
||||
}
|
||||
}, [appSpec, specContent, hasChanges]);
|
||||
|
||||
// Subscribe to spec regeneration events
|
||||
useEffect(() => {
|
||||
if (!currentProject?.path) return;
|
||||
|
||||
const api = getElectronAPI();
|
||||
if (!api.specRegeneration) return;
|
||||
|
||||
const unsubscribe = api.specRegeneration.onEvent((event: SpecRegenerationEvent) => {
|
||||
if (event.projectPath !== currentProject.path) return;
|
||||
|
||||
if (event.type === 'spec_regeneration_progress') {
|
||||
setIsGenerating(true);
|
||||
const phaseMatch = event.content.match(/\[Phase:\s*([^\]]+)\]/);
|
||||
if (phaseMatch) {
|
||||
setCurrentPhase(phaseMatch[1]);
|
||||
}
|
||||
if (event.content.includes('All tasks completed')) {
|
||||
setIsGenerating(false);
|
||||
setCurrentPhase('');
|
||||
setTimeout(() => loadSpec(), SPEC_FILE_WRITE_DELAY);
|
||||
}
|
||||
} else if (event.type === 'spec_regeneration_complete') {
|
||||
const isFinal =
|
||||
event.message?.includes('All tasks completed') ||
|
||||
event.message === 'Spec regeneration complete!' ||
|
||||
event.message === 'Initial spec creation complete!';
|
||||
|
||||
if (isFinal) {
|
||||
setIsGenerating(false);
|
||||
setCurrentPhase('');
|
||||
setShowCreateDialog(false);
|
||||
setShowRegenerateDialog(false);
|
||||
setProjectOverview('');
|
||||
setProjectDefinition('');
|
||||
setErrorMessage('');
|
||||
setTimeout(() => loadSpec(), SPEC_FILE_WRITE_DELAY);
|
||||
toast.success('Spec Generation Complete', {
|
||||
description: 'Your app specification has been saved.',
|
||||
});
|
||||
}
|
||||
} else if (event.type === 'spec_regeneration_error') {
|
||||
setIsGenerating(false);
|
||||
setCurrentPhase('error');
|
||||
setErrorMessage(event.error);
|
||||
}
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, [currentProject?.path, loadSpec]);
|
||||
|
||||
// Save spec
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!currentProject?.path || !hasChanges) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
const specPath = `${currentProject.path}/.automaker/app_spec.txt`;
|
||||
await api.writeFile(specPath, specContent);
|
||||
setOriginalContent(specContent);
|
||||
setAppSpec(specContent);
|
||||
toast.success('Spec saved');
|
||||
} catch (error) {
|
||||
toast.error('Failed to save spec');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [currentProject?.path, specContent, hasChanges, setAppSpec]);
|
||||
|
||||
// Create spec
|
||||
const handleCreateSpec = useCallback(async () => {
|
||||
if (!currentProject?.path || !projectOverview.trim()) return;
|
||||
|
||||
setIsGenerating(true);
|
||||
setShowCreateDialog(false);
|
||||
setCurrentPhase('initialization');
|
||||
setErrorMessage('');
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api.specRegeneration) {
|
||||
setIsGenerating(false);
|
||||
toast.error('Spec generation not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await api.specRegeneration.create(
|
||||
currentProject.path,
|
||||
projectOverview.trim(),
|
||||
generateFeatures,
|
||||
analyzeProject,
|
||||
generateFeatures ? featureCount : undefined
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
setIsGenerating(false);
|
||||
setCurrentPhase('error');
|
||||
setErrorMessage(result.error || 'Failed to create spec');
|
||||
}
|
||||
} catch (error) {
|
||||
setIsGenerating(false);
|
||||
setCurrentPhase('error');
|
||||
setErrorMessage(error instanceof Error ? error.message : 'Failed to create spec');
|
||||
}
|
||||
}, [currentProject?.path, projectOverview, generateFeatures, analyzeProject, featureCount]);
|
||||
|
||||
// Regenerate spec
|
||||
const handleRegenerate = useCallback(async () => {
|
||||
if (!currentProject?.path || !projectDefinition.trim()) return;
|
||||
|
||||
setIsGenerating(true);
|
||||
setShowRegenerateDialog(false);
|
||||
setCurrentPhase('initialization');
|
||||
setErrorMessage('');
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api.specRegeneration) {
|
||||
setIsGenerating(false);
|
||||
toast.error('Spec generation not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await api.specRegeneration.generate(
|
||||
currentProject.path,
|
||||
projectDefinition.trim(),
|
||||
generateFeatures,
|
||||
analyzeProject,
|
||||
generateFeatures ? featureCount : undefined
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
setIsGenerating(false);
|
||||
setCurrentPhase('error');
|
||||
setErrorMessage(result.error || 'Failed to regenerate spec');
|
||||
}
|
||||
} catch (error) {
|
||||
setIsGenerating(false);
|
||||
setCurrentPhase('error');
|
||||
setErrorMessage(error instanceof Error ? error.message : 'Failed to regenerate spec');
|
||||
}
|
||||
}, [currentProject?.path, projectDefinition, generateFeatures, analyzeProject, featureCount]);
|
||||
|
||||
const selectedOption = FEATURE_COUNT_OPTIONS.find((o) => o.value === featureCount);
|
||||
const phaseLabel = PHASE_LABELS[currentPhase] || currentPhase;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Generation in progress view
|
||||
if (isGenerating) {
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-border/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-primary" />
|
||||
<span className="text-xs font-medium">Generating Spec...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center p-4">
|
||||
<div className="text-center">
|
||||
<div className="mb-4">
|
||||
<div className="p-3 rounded-full bg-primary/10 inline-block">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm font-medium mb-2">
|
||||
{currentPhase === 'feature_generation'
|
||||
? 'Creating Features...'
|
||||
: 'Generating Specification'}
|
||||
</p>
|
||||
{currentPhase && <p className="text-xs text-muted-foreground">{phaseLabel}</p>}
|
||||
{errorMessage && (
|
||||
<div className="mt-3 p-2 rounded bg-destructive/10 border border-destructive/20">
|
||||
<p className="text-xs text-destructive">{errorMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Empty state - no spec exists
|
||||
if (!specExists) {
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-border/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
<span className="text-xs font-medium">App Specification</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center p-4">
|
||||
<div className="text-center">
|
||||
<FilePlus2 className="h-10 w-10 mx-auto text-muted-foreground/50 mb-3" />
|
||||
<p className="text-sm font-medium mb-1">No Spec Found</p>
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
Create an app specification to help AI understand your project.
|
||||
</p>
|
||||
<Button size="sm" onClick={() => setShowCreateDialog(true)}>
|
||||
<Sparkles className="h-3.5 w-3.5 mr-1.5" />
|
||||
Create Spec
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create Spec Dialog */}
|
||||
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create App Specification</DialogTitle>
|
||||
<DialogDescription>
|
||||
Describe your project and we'll generate a comprehensive specification.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Project Overview</label>
|
||||
<Textarea
|
||||
value={projectOverview}
|
||||
onChange={(e) => setProjectOverview(e.target.value)}
|
||||
placeholder="Describe what your project does and what features you want to build..."
|
||||
className="h-32 resize-none font-mono text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="analyze-project"
|
||||
checked={analyzeProject}
|
||||
onCheckedChange={(checked) => setAnalyzeProject(checked === true)}
|
||||
/>
|
||||
<div className="space-y-0.5">
|
||||
<label htmlFor="analyze-project" className="text-sm font-medium cursor-pointer">
|
||||
Analyze current project
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Research your codebase to understand the tech stack.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="generate-features"
|
||||
checked={generateFeatures}
|
||||
onCheckedChange={(checked) => setGenerateFeatures(checked === true)}
|
||||
/>
|
||||
<div className="space-y-0.5">
|
||||
<label htmlFor="generate-features" className="text-sm font-medium cursor-pointer">
|
||||
Generate feature list
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Automatically create features from the spec.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{generateFeatures && (
|
||||
<div className="space-y-2 pl-6">
|
||||
<label className="text-sm font-medium">Number of Features</label>
|
||||
<div className="flex gap-2">
|
||||
{FEATURE_COUNT_OPTIONS.map((option) => (
|
||||
<Button
|
||||
key={option.value}
|
||||
type="button"
|
||||
variant={featureCount === option.value ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setFeatureCount(option.value)}
|
||||
className="flex-1"
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{selectedOption?.warning && (
|
||||
<p className="text-xs text-amber-500 flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{selectedOption.warning}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setShowCreateDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreateSpec} disabled={!projectOverview.trim()}>
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
Generate Spec
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Main view - spec exists
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-border/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
<span className="text-xs font-medium">App Specification</span>
|
||||
{hasChanges && <span className="text-[10px] text-amber-500">Unsaved</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setShowRegenerateDialog(true)}
|
||||
title="Regenerate spec"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
</Button>
|
||||
{hasChanges && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 text-xs"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin mr-1" />
|
||||
) : (
|
||||
<Save className="h-3 w-3 mr-1" />
|
||||
)}
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-hidden bg-muted/30 rounded-md m-2">
|
||||
<XmlSyntaxEditor
|
||||
value={specContent}
|
||||
onChange={(value) => setSpecContent(value)}
|
||||
placeholder="Enter your app specification..."
|
||||
className="h-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Regenerate Spec Dialog */}
|
||||
<Dialog open={showRegenerateDialog} onOpenChange={setShowRegenerateDialog}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Regenerate App Specification</DialogTitle>
|
||||
<DialogDescription>
|
||||
We'll regenerate your spec based on your project description.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Describe your project</label>
|
||||
<Textarea
|
||||
value={projectDefinition}
|
||||
onChange={(e) => setProjectDefinition(e.target.value)}
|
||||
placeholder="Describe what your app should do..."
|
||||
className="h-32 resize-none font-mono text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="regen-analyze-project"
|
||||
checked={analyzeProject}
|
||||
onCheckedChange={(checked) => setAnalyzeProject(checked === true)}
|
||||
/>
|
||||
<div className="space-y-0.5">
|
||||
<label
|
||||
htmlFor="regen-analyze-project"
|
||||
className="text-sm font-medium cursor-pointer"
|
||||
>
|
||||
Analyze current project
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Research your codebase to understand the tech stack.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="regen-generate-features"
|
||||
checked={generateFeatures}
|
||||
onCheckedChange={(checked) => setGenerateFeatures(checked === true)}
|
||||
/>
|
||||
<div className="space-y-0.5">
|
||||
<label
|
||||
htmlFor="regen-generate-features"
|
||||
className="text-sm font-medium cursor-pointer"
|
||||
>
|
||||
Generate feature list
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Automatically create features from the spec.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{generateFeatures && (
|
||||
<div className="space-y-2 pl-6">
|
||||
<label className="text-sm font-medium">Number of Features</label>
|
||||
<div className="flex gap-2">
|
||||
{FEATURE_COUNT_OPTIONS.map((option) => (
|
||||
<Button
|
||||
key={option.value}
|
||||
type="button"
|
||||
variant={featureCount === option.value ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setFeatureCount(option.value)}
|
||||
className="flex-1"
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{selectedOption?.warning && (
|
||||
<p className="text-xs text-amber-500 flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{selectedOption.warning}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setShowRegenerateDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleRegenerate} disabled={!projectDefinition.trim()}>
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
Regenerate Spec
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,551 @@
|
||||
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import {
|
||||
Terminal,
|
||||
Plus,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
SplitSquareHorizontal,
|
||||
SplitSquareVertical,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useAppStore, type TerminalPanelContent, type TerminalTab } from '@/store/app-store';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { TerminalPanel as XTermPanel } from '@/components/views/terminal-view/terminal-panel';
|
||||
import { TerminalErrorBoundary } from '@/components/views/terminal-view/terminal-error-boundary';
|
||||
import { apiFetch, apiGet, apiDeleteRaw } from '@/lib/api-fetch';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { toast } from 'sonner';
|
||||
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
|
||||
|
||||
const logger = createLogger('DockTerminal');
|
||||
|
||||
interface TerminalStatus {
|
||||
enabled: boolean;
|
||||
passwordRequired: boolean;
|
||||
}
|
||||
|
||||
const CREATE_COOLDOWN_MS = 500;
|
||||
|
||||
export function TerminalPanelDock() {
|
||||
// Use useShallow for terminal state to prevent unnecessary re-renders
|
||||
const terminalState = useAppStore(useShallow((state) => state.terminalState));
|
||||
|
||||
const {
|
||||
tabs,
|
||||
activeTabId,
|
||||
activeSessionId,
|
||||
authToken,
|
||||
isUnlocked,
|
||||
defaultFontSize,
|
||||
maximizedSessionId,
|
||||
} = terminalState;
|
||||
|
||||
// Get stable action references (these don't change between renders)
|
||||
const currentProject = useAppStore((state) => state.currentProject);
|
||||
const setTerminalUnlocked = useAppStore((state) => state.setTerminalUnlocked);
|
||||
const addTerminalToLayout = useAppStore((state) => state.addTerminalToLayout);
|
||||
const removeTerminalFromLayout = useAppStore((state) => state.removeTerminalFromLayout);
|
||||
const setActiveTerminalSession = useAppStore((state) => state.setActiveTerminalSession);
|
||||
const addTerminalTab = useAppStore((state) => state.addTerminalTab);
|
||||
const removeTerminalTab = useAppStore((state) => state.removeTerminalTab);
|
||||
const setActiveTerminalTab = useAppStore((state) => state.setActiveTerminalTab);
|
||||
const setTerminalPanelFontSize = useAppStore((state) => state.setTerminalPanelFontSize);
|
||||
const toggleTerminalMaximized = useAppStore((state) => state.toggleTerminalMaximized);
|
||||
const updateTerminalPanelSizes = useAppStore((state) => state.updateTerminalPanelSizes);
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<TerminalStatus | null>(null);
|
||||
const isCreatingRef = useRef(false);
|
||||
const lastCreateTimeRef = useRef(0);
|
||||
|
||||
// Refs to stabilize callbacks and prevent cascading re-renders
|
||||
const createTerminalRef = useRef<
|
||||
((direction?: 'horizontal' | 'vertical', targetSessionId?: string) => Promise<void>) | null
|
||||
>(null);
|
||||
const killTerminalRef = useRef<((sessionId: string) => Promise<void>) | null>(null);
|
||||
const createTerminalInNewTabRef = useRef<(() => Promise<void>) | null>(null);
|
||||
const navigateToTerminalRef = useRef<
|
||||
((direction: 'up' | 'down' | 'left' | 'right') => void) | null
|
||||
>(null);
|
||||
|
||||
// Fetch terminal status
|
||||
const fetchStatus = useCallback(async () => {
|
||||
try {
|
||||
const data = await apiGet<{ success: boolean; data?: TerminalStatus; error?: string }>(
|
||||
'/api/terminal/status'
|
||||
);
|
||||
if (data.success && data.data) {
|
||||
setStatus(data.data);
|
||||
if (!data.data.passwordRequired) {
|
||||
setTerminalUnlocked(true);
|
||||
}
|
||||
} else {
|
||||
setError(data.error || 'Failed to get terminal status');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to connect to server');
|
||||
logger.error('Status fetch error:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [setTerminalUnlocked]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus();
|
||||
}, [fetchStatus]);
|
||||
|
||||
// Helper to check if terminal creation should be debounced
|
||||
const canCreateTerminal = (): boolean => {
|
||||
const now = Date.now();
|
||||
if (now - lastCreateTimeRef.current < CREATE_COOLDOWN_MS || isCreatingRef.current) {
|
||||
return false;
|
||||
}
|
||||
lastCreateTimeRef.current = now;
|
||||
isCreatingRef.current = true;
|
||||
return true;
|
||||
};
|
||||
|
||||
// Create a new terminal session
|
||||
const createTerminal = useCallback(
|
||||
async (direction?: 'horizontal' | 'vertical', targetSessionId?: string) => {
|
||||
if (!canCreateTerminal()) return;
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
if (authToken) {
|
||||
headers['X-Terminal-Token'] = authToken;
|
||||
}
|
||||
|
||||
const response = await apiFetch('/api/terminal/sessions', 'POST', {
|
||||
headers,
|
||||
body: { cwd: currentProject?.path || undefined, cols: 80, rows: 24 },
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
addTerminalToLayout(data.data.id, direction, targetSessionId);
|
||||
} else {
|
||||
if (response.status === 429 || data.error?.includes('Maximum')) {
|
||||
toast.error('Terminal session limit reached', {
|
||||
description: data.details || 'Please close unused terminals.',
|
||||
});
|
||||
} else {
|
||||
toast.error('Failed to create terminal', { description: data.error });
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Create session error:', err);
|
||||
toast.error('Failed to create terminal');
|
||||
} finally {
|
||||
isCreatingRef.current = false;
|
||||
}
|
||||
},
|
||||
[currentProject?.path, authToken, addTerminalToLayout]
|
||||
);
|
||||
|
||||
// Create terminal in new tab
|
||||
const createTerminalInNewTab = useCallback(async () => {
|
||||
if (!canCreateTerminal()) return;
|
||||
|
||||
const tabId = addTerminalTab();
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
if (authToken) {
|
||||
headers['X-Terminal-Token'] = authToken;
|
||||
}
|
||||
|
||||
const response = await apiFetch('/api/terminal/sessions', 'POST', {
|
||||
headers,
|
||||
body: { cwd: currentProject?.path || undefined, cols: 80, rows: 24 },
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
const { addTerminalToTab } = useAppStore.getState();
|
||||
addTerminalToTab(data.data.id, tabId);
|
||||
} else {
|
||||
removeTerminalTab(tabId);
|
||||
toast.error('Failed to create terminal', { description: data.error });
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Create session error:', err);
|
||||
removeTerminalTab(tabId);
|
||||
toast.error('Failed to create terminal');
|
||||
} finally {
|
||||
isCreatingRef.current = false;
|
||||
}
|
||||
}, [currentProject?.path, authToken, addTerminalTab, removeTerminalTab]);
|
||||
|
||||
// Kill a terminal session
|
||||
const killTerminal = useCallback(
|
||||
async (sessionId: string) => {
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
if (authToken) {
|
||||
headers['X-Terminal-Token'] = authToken;
|
||||
}
|
||||
|
||||
await apiDeleteRaw(`/api/terminal/sessions/${sessionId}`, { headers });
|
||||
removeTerminalFromLayout(sessionId);
|
||||
} catch (err) {
|
||||
logger.error('Kill session error:', err);
|
||||
removeTerminalFromLayout(sessionId);
|
||||
}
|
||||
},
|
||||
[authToken, removeTerminalFromLayout]
|
||||
);
|
||||
|
||||
// Kill all terminals in a tab
|
||||
const killTerminalTab = useCallback(
|
||||
async (tabId: string) => {
|
||||
const tab = tabs.find((t) => t.id === tabId);
|
||||
if (!tab) return;
|
||||
|
||||
const collectSessionIds = (node: TerminalPanelContent | null): string[] => {
|
||||
if (!node) return [];
|
||||
if (node.type === 'terminal') return [node.sessionId];
|
||||
return node.panels.flatMap(collectSessionIds);
|
||||
};
|
||||
|
||||
const sessionIds = collectSessionIds(tab.layout);
|
||||
const headers: Record<string, string> = {};
|
||||
if (authToken) {
|
||||
headers['X-Terminal-Token'] = authToken;
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
sessionIds.map(async (sessionId) => {
|
||||
try {
|
||||
await apiDeleteRaw(`/api/terminal/sessions/${sessionId}`, { headers });
|
||||
} catch (err) {
|
||||
logger.error(`Failed to kill session ${sessionId}:`, err);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
removeTerminalTab(tabId);
|
||||
},
|
||||
[tabs, authToken, removeTerminalTab]
|
||||
);
|
||||
|
||||
// Get panel key for stable rendering
|
||||
const getPanelKey = (panel: TerminalPanelContent): string => {
|
||||
if (panel.type === 'terminal') return panel.sessionId;
|
||||
return panel.id;
|
||||
};
|
||||
|
||||
// Navigate between terminals
|
||||
const navigateToTerminal = useCallback(
|
||||
(direction: 'up' | 'down' | 'left' | 'right') => {
|
||||
const activeTab = tabs.find((t) => t.id === activeTabId);
|
||||
if (!activeTab?.layout) return;
|
||||
|
||||
const currentSessionId = activeSessionId;
|
||||
if (!currentSessionId) return;
|
||||
|
||||
const getTerminalIds = (panel: TerminalPanelContent): string[] => {
|
||||
if (panel.type === 'terminal') return [panel.sessionId];
|
||||
return panel.panels.flatMap(getTerminalIds);
|
||||
};
|
||||
|
||||
const terminalIds = getTerminalIds(activeTab.layout);
|
||||
const currentIndex = terminalIds.indexOf(currentSessionId);
|
||||
if (currentIndex === -1) return;
|
||||
|
||||
let nextIndex = currentIndex;
|
||||
if (direction === 'right' || direction === 'down') {
|
||||
nextIndex = (currentIndex + 1) % terminalIds.length;
|
||||
} else {
|
||||
nextIndex = (currentIndex - 1 + terminalIds.length) % terminalIds.length;
|
||||
}
|
||||
|
||||
if (terminalIds[nextIndex]) {
|
||||
setActiveTerminalSession(terminalIds[nextIndex]);
|
||||
}
|
||||
},
|
||||
[tabs, activeTabId, activeSessionId, setActiveTerminalSession]
|
||||
);
|
||||
|
||||
// Keep refs updated with latest callbacks
|
||||
createTerminalRef.current = createTerminal;
|
||||
killTerminalRef.current = killTerminal;
|
||||
createTerminalInNewTabRef.current = createTerminalInNewTab;
|
||||
navigateToTerminalRef.current = navigateToTerminal;
|
||||
|
||||
// Render panel content recursively - use refs for callbacks to prevent re-renders
|
||||
const renderPanelContent = useCallback(
|
||||
(content: TerminalPanelContent, activeTabData: TerminalTab): React.ReactNode => {
|
||||
if (content.type === 'terminal') {
|
||||
const terminalFontSize = content.fontSize ?? defaultFontSize;
|
||||
return (
|
||||
<TerminalErrorBoundary
|
||||
key={`boundary-${content.sessionId}`}
|
||||
sessionId={content.sessionId}
|
||||
onRestart={() => {
|
||||
killTerminalRef.current?.(content.sessionId);
|
||||
createTerminalRef.current?.();
|
||||
}}
|
||||
>
|
||||
<XTermPanel
|
||||
key={content.sessionId}
|
||||
sessionId={content.sessionId}
|
||||
authToken={authToken}
|
||||
isActive={activeSessionId === content.sessionId}
|
||||
onFocus={() => setActiveTerminalSession(content.sessionId)}
|
||||
onClose={() => killTerminalRef.current?.(content.sessionId)}
|
||||
onSplitHorizontal={() => createTerminalRef.current?.('horizontal', content.sessionId)}
|
||||
onSplitVertical={() => createTerminalRef.current?.('vertical', content.sessionId)}
|
||||
onNewTab={() => createTerminalInNewTabRef.current?.()}
|
||||
onNavigateUp={() => navigateToTerminalRef.current?.('up')}
|
||||
onNavigateDown={() => navigateToTerminalRef.current?.('down')}
|
||||
onNavigateLeft={() => navigateToTerminalRef.current?.('left')}
|
||||
onNavigateRight={() => navigateToTerminalRef.current?.('right')}
|
||||
onSessionInvalid={() => killTerminalRef.current?.(content.sessionId)}
|
||||
fontSize={terminalFontSize}
|
||||
onFontSizeChange={(size) => setTerminalPanelFontSize(content.sessionId, size)}
|
||||
isMaximized={maximizedSessionId === content.sessionId}
|
||||
onToggleMaximize={() => toggleTerminalMaximized(content.sessionId)}
|
||||
/>
|
||||
</TerminalErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
const isHorizontal = content.direction === 'horizontal';
|
||||
const defaultSizePerPanel = 100 / content.panels.length;
|
||||
|
||||
const handleLayoutChange = (sizes: number[]) => {
|
||||
const panelKeys = content.panels.map(getPanelKey);
|
||||
updateTerminalPanelSizes(activeTabData.id, panelKeys, sizes);
|
||||
};
|
||||
|
||||
return (
|
||||
<PanelGroup direction={content.direction} onLayout={handleLayoutChange}>
|
||||
{content.panels.map((panel, index) => {
|
||||
const panelSize =
|
||||
panel.type === 'terminal' && panel.size ? panel.size : defaultSizePerPanel;
|
||||
const panelKey = getPanelKey(panel);
|
||||
return (
|
||||
<React.Fragment key={panelKey}>
|
||||
{index > 0 && (
|
||||
<PanelResizeHandle
|
||||
className={
|
||||
isHorizontal
|
||||
? 'w-1 h-full bg-border hover:bg-brand-500 transition-colors'
|
||||
: 'h-1 w-full bg-border hover:bg-brand-500 transition-colors'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Panel id={panelKey} order={index} defaultSize={panelSize} minSize={20}>
|
||||
{renderPanelContent(panel, activeTabData)}
|
||||
</Panel>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</PanelGroup>
|
||||
);
|
||||
},
|
||||
[
|
||||
defaultFontSize,
|
||||
authToken,
|
||||
activeSessionId,
|
||||
maximizedSessionId,
|
||||
setActiveTerminalSession,
|
||||
setTerminalPanelFontSize,
|
||||
toggleTerminalMaximized,
|
||||
updateTerminalPanelSizes,
|
||||
]
|
||||
);
|
||||
|
||||
const activeTab = tabs.find((t) => t.id === activeTabId);
|
||||
|
||||
// Header component for all states
|
||||
const Header = ({ children }: { children?: React.ReactNode }) => (
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-border/50 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="h-3.5 w-3.5" />
|
||||
<span className="text-xs font-medium">Terminal</span>
|
||||
</div>
|
||||
{children && <div className="flex items-center gap-1">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<Header />
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<Header />
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<AlertCircle className="h-8 w-8 mx-auto text-destructive/50 mb-2" />
|
||||
<p className="text-xs text-muted-foreground mb-2">{error}</p>
|
||||
<Button variant="outline" size="sm" className="text-xs h-7" onClick={fetchStatus}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Password required
|
||||
if (status?.passwordRequired && !isUnlocked) {
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<Header />
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Terminal className="h-12 w-12 mx-auto text-muted-foreground/30 mb-3" />
|
||||
<p className="text-sm text-muted-foreground mb-1">Terminal requires authentication</p>
|
||||
<p className="text-xs text-muted-foreground/70">Password required to use terminal</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// No project selected
|
||||
if (!currentProject) {
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<Header />
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Terminal className="h-12 w-12 mx-auto text-muted-foreground/30 mb-3" />
|
||||
<p className="text-sm text-muted-foreground">No project selected</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// No terminals yet
|
||||
if (tabs.length === 0) {
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<Header>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => createTerminal()}
|
||||
title="New terminal"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</Header>
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Terminal className="h-12 w-12 mx-auto text-muted-foreground/30 mb-3" />
|
||||
<p className="text-sm text-muted-foreground mb-1">No terminals open</p>
|
||||
<Button variant="outline" size="sm" className="mt-2" onClick={() => createTerminal()}>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
New Terminal
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Terminal view with tabs
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Tab bar */}
|
||||
<div className="flex items-center px-2 py-1 border-b border-border/50 shrink-0 gap-1 overflow-x-auto">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTerminalTab(tab.id)}
|
||||
className={cn(
|
||||
'flex items-center gap-1 px-2 py-1 text-xs rounded transition-colors shrink-0',
|
||||
tab.id === activeTabId
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50'
|
||||
)}
|
||||
>
|
||||
<Terminal className="h-3 w-3" />
|
||||
<span className="max-w-16 truncate">{tab.name}</span>
|
||||
<button
|
||||
className="ml-0.5 p-0.5 rounded hover:bg-background/50 text-muted-foreground hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
killTerminalTab(tab.id);
|
||||
}}
|
||||
>
|
||||
<X className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
</button>
|
||||
))}
|
||||
|
||||
<button
|
||||
className="flex items-center justify-center p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground shrink-0"
|
||||
onClick={createTerminalInNewTab}
|
||||
title="New Tab"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</button>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => createTerminal('horizontal')}
|
||||
title="Split Right"
|
||||
>
|
||||
<SplitSquareHorizontal className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => createTerminal('vertical')}
|
||||
title="Split Down"
|
||||
>
|
||||
<SplitSquareVertical className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Terminal content */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{activeTab?.layout ? (
|
||||
renderPanelContent(activeTab.layout, activeTab)
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Terminal className="h-8 w-8 mx-auto text-muted-foreground/30 mb-2" />
|
||||
<p className="text-xs text-muted-foreground">No terminal in this tab</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2 text-xs h-7"
|
||||
onClick={() => createTerminal()}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
Add Terminal
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { CreateSpecDialog } from '@/components/views/spec-view/dialogs';
|
||||
import {
|
||||
CollapseToggleButton,
|
||||
SidebarHeader,
|
||||
ProjectActions,
|
||||
SidebarNavigation,
|
||||
ProjectSelectorWithOptions,
|
||||
SidebarFooter,
|
||||
@@ -58,7 +59,7 @@ export function Sidebar() {
|
||||
} = useAppStore();
|
||||
|
||||
// Environment variable flags for hiding sidebar items
|
||||
const { hideTerminal, hideWiki, hideRunningAgents, hideContext, hideSpecEditor } =
|
||||
const { hideTerminal, hideWiki, hideRunningAgents, hideContext, hideSpecEditor, hideAiProfiles } =
|
||||
SIDEBAR_FEATURE_FLAGS;
|
||||
|
||||
// Get customizable keyboard shortcuts
|
||||
@@ -126,9 +127,6 @@ export function Sidebar() {
|
||||
// Derive isCreatingSpec from store state
|
||||
const isCreatingSpec = specCreatingForProject !== null;
|
||||
const creatingSpecProjectPath = specCreatingForProject;
|
||||
// Check if the current project is specifically the one generating spec
|
||||
const isCurrentProjectGeneratingSpec =
|
||||
specCreatingForProject !== null && specCreatingForProject === currentProject?.path;
|
||||
|
||||
// Auto-collapse sidebar on small screens and update Electron window minWidth
|
||||
useSidebarAutoCollapse({ sidebarOpen, toggleSidebar });
|
||||
@@ -234,6 +232,7 @@ export function Sidebar() {
|
||||
hideSpecEditor,
|
||||
hideContext,
|
||||
hideTerminal,
|
||||
hideAiProfiles,
|
||||
currentProject,
|
||||
projects,
|
||||
projectHistory,
|
||||
@@ -244,7 +243,6 @@ export function Sidebar() {
|
||||
cyclePrevProject,
|
||||
cycleNextProject,
|
||||
unviewedValidationsCount,
|
||||
isSpecGenerating: isCurrentProjectGeneratingSpec,
|
||||
});
|
||||
|
||||
// Register keyboard shortcuts
|
||||
@@ -279,6 +277,17 @@ export function Sidebar() {
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<SidebarHeader sidebarOpen={sidebarOpen} navigate={navigate} />
|
||||
|
||||
{/* Project Actions - Moved above project selector */}
|
||||
{sidebarOpen && (
|
||||
<ProjectActions
|
||||
setShowNewProjectModal={setShowNewProjectModal}
|
||||
handleOpenFolder={handleOpenFolder}
|
||||
setShowTrashDialog={setShowTrashDialog}
|
||||
trashedProjects={trashedProjects}
|
||||
shortcuts={{ openProject: shortcuts.openProject }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ProjectSelectorWithOptions
|
||||
sidebarOpen={sidebarOpen}
|
||||
isProjectPickerOpen={isProjectPickerOpen}
|
||||
|
||||
@@ -32,7 +32,7 @@ export function AutomakerLogo({ sidebarOpen, navigate }: AutomakerLogoProps) {
|
||||
'flex items-center gap-3 titlebar-no-drag cursor-pointer group',
|
||||
!sidebarOpen && 'flex-col gap-1'
|
||||
)}
|
||||
onClick={() => navigate({ to: '/dashboard' })}
|
||||
onClick={() => navigate({ to: '/' })}
|
||||
data-testid="logo-button"
|
||||
>
|
||||
{/* Collapsed logo - shown when sidebar is closed OR on small screens when sidebar is open */}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { NavigateOptions } from '@tanstack/react-router';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatShortcut } from '@/store/app-store';
|
||||
import type { NavSection } from '../types';
|
||||
@@ -81,23 +80,14 @@ export function SidebarNavigation({
|
||||
data-testid={`nav-${item.id}`}
|
||||
>
|
||||
<div className="relative">
|
||||
{item.isLoading ? (
|
||||
<Loader2
|
||||
className={cn(
|
||||
'w-[18px] h-[18px] shrink-0 animate-spin',
|
||||
isActive ? 'text-brand-500' : 'text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Icon
|
||||
className={cn(
|
||||
'w-[18px] h-[18px] shrink-0 transition-all duration-200',
|
||||
isActive
|
||||
? 'text-brand-500 drop-shadow-sm'
|
||||
: 'group-hover:text-brand-400 group-hover:scale-110'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Icon
|
||||
className={cn(
|
||||
'w-[18px] h-[18px] shrink-0 transition-all duration-200',
|
||||
isActive
|
||||
? 'text-brand-500 drop-shadow-sm'
|
||||
: 'group-hover:text-brand-400 group-hover:scale-110'
|
||||
)}
|
||||
/>
|
||||
{/* Count badge for collapsed state */}
|
||||
{!sidebarOpen && item.count !== undefined && item.count > 0 && (
|
||||
<span
|
||||
|
||||
@@ -20,4 +20,5 @@ export const SIDEBAR_FEATURE_FLAGS = {
|
||||
hideRunningAgents: import.meta.env.VITE_HIDE_RUNNING_AGENTS === 'true',
|
||||
hideContext: import.meta.env.VITE_HIDE_CONTEXT === 'true',
|
||||
hideSpecEditor: import.meta.env.VITE_HIDE_SPEC_EDITOR === 'true',
|
||||
hideAiProfiles: import.meta.env.VITE_HIDE_AI_PROFILES === 'true',
|
||||
} as const;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useRef } from 'react';
|
||||
import { Rocket, CheckCircle2, Zap, FileText, Sparkles, ArrowRight } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -25,25 +24,13 @@ export function OnboardingDialog({
|
||||
onSkip,
|
||||
onGenerateSpec,
|
||||
}: OnboardingDialogProps) {
|
||||
// Track if we're closing because user clicked "Generate App Spec"
|
||||
// to avoid incorrectly calling onSkip
|
||||
const isGeneratingRef = useRef(false);
|
||||
|
||||
const handleGenerateSpec = () => {
|
||||
isGeneratingRef.current = true;
|
||||
onGenerateSpec();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen && !isGeneratingRef.current) {
|
||||
// Only call onSkip when user dismisses dialog (escape, click outside, or skip button)
|
||||
// NOT when they click "Generate App Spec"
|
||||
if (!isOpen) {
|
||||
onSkip();
|
||||
}
|
||||
isGeneratingRef.current = false;
|
||||
onOpenChange(isOpen);
|
||||
}}
|
||||
>
|
||||
@@ -121,7 +108,7 @@ export function OnboardingDialog({
|
||||
Skip for now
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleGenerateSpec}
|
||||
onClick={onGenerateSpec}
|
||||
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" />
|
||||
|
||||
@@ -5,12 +5,12 @@ import {
|
||||
LayoutGrid,
|
||||
Bot,
|
||||
BookOpen,
|
||||
UserCircle,
|
||||
Terminal,
|
||||
CircleDot,
|
||||
GitPullRequest,
|
||||
Zap,
|
||||
Lightbulb,
|
||||
Brain,
|
||||
Network,
|
||||
} from 'lucide-react';
|
||||
import type { NavSection, NavItem } from '../types';
|
||||
import type { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts';
|
||||
@@ -26,9 +26,8 @@ interface UseNavigationProps {
|
||||
cycleNextProject: string;
|
||||
spec: string;
|
||||
context: string;
|
||||
memory: string;
|
||||
profiles: string;
|
||||
board: string;
|
||||
graph: string;
|
||||
agent: string;
|
||||
terminal: string;
|
||||
settings: string;
|
||||
@@ -39,6 +38,7 @@ interface UseNavigationProps {
|
||||
hideSpecEditor: boolean;
|
||||
hideContext: boolean;
|
||||
hideTerminal: boolean;
|
||||
hideAiProfiles: boolean;
|
||||
currentProject: Project | null;
|
||||
projects: Project[];
|
||||
projectHistory: string[];
|
||||
@@ -50,8 +50,6 @@ interface UseNavigationProps {
|
||||
cycleNextProject: () => void;
|
||||
/** Count of unviewed validations to show on GitHub Issues nav item */
|
||||
unviewedValidationsCount?: number;
|
||||
/** Whether spec generation is currently running for the current project */
|
||||
isSpecGenerating?: boolean;
|
||||
}
|
||||
|
||||
export function useNavigation({
|
||||
@@ -59,6 +57,7 @@ export function useNavigation({
|
||||
hideSpecEditor,
|
||||
hideContext,
|
||||
hideTerminal,
|
||||
hideAiProfiles,
|
||||
currentProject,
|
||||
projects,
|
||||
projectHistory,
|
||||
@@ -69,7 +68,6 @@ export function useNavigation({
|
||||
cyclePrevProject,
|
||||
cycleNextProject,
|
||||
unviewedValidationsCount,
|
||||
isSpecGenerating,
|
||||
}: UseNavigationProps) {
|
||||
// Track if current project has a GitHub remote
|
||||
const [hasGitHubRemote, setHasGitHubRemote] = useState(false);
|
||||
@@ -109,7 +107,6 @@ export function useNavigation({
|
||||
label: 'Spec Editor',
|
||||
icon: FileText,
|
||||
shortcut: shortcuts.spec,
|
||||
isLoading: isSpecGenerating,
|
||||
},
|
||||
{
|
||||
id: 'context',
|
||||
@@ -118,10 +115,10 @@ export function useNavigation({
|
||||
shortcut: shortcuts.context,
|
||||
},
|
||||
{
|
||||
id: 'memory',
|
||||
label: 'Memory',
|
||||
icon: Brain,
|
||||
shortcut: shortcuts.memory,
|
||||
id: 'profiles',
|
||||
label: 'AI Profiles',
|
||||
icon: UserCircle,
|
||||
shortcut: shortcuts.profiles,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -133,6 +130,9 @@ export function useNavigation({
|
||||
if (item.id === 'context' && hideContext) {
|
||||
return false;
|
||||
}
|
||||
if (item.id === 'profiles' && hideAiProfiles) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -144,12 +144,6 @@ export function useNavigation({
|
||||
icon: LayoutGrid,
|
||||
shortcut: shortcuts.board,
|
||||
},
|
||||
{
|
||||
id: 'graph',
|
||||
label: 'Graph View',
|
||||
icon: Network,
|
||||
shortcut: shortcuts.graph,
|
||||
},
|
||||
{
|
||||
id: 'agent',
|
||||
label: 'Agent Runner',
|
||||
@@ -207,9 +201,9 @@ export function useNavigation({
|
||||
hideSpecEditor,
|
||||
hideContext,
|
||||
hideTerminal,
|
||||
hideAiProfiles,
|
||||
hasGitHubRemote,
|
||||
unviewedValidationsCount,
|
||||
isSpecGenerating,
|
||||
]);
|
||||
|
||||
// Build keyboard shortcuts for navigation
|
||||
|
||||
@@ -13,8 +13,6 @@ export interface NavItem {
|
||||
shortcut?: string;
|
||||
/** Optional count badge to display next to the nav item */
|
||||
count?: number;
|
||||
/** Whether this nav item is in a loading state (shows spinner) */
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export interface SortableProjectItemProps {
|
||||
|
||||
4
apps/ui/src/components/layout/top-bar/index.ts
Normal file
4
apps/ui/src/components/layout/top-bar/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { TopBar } from './top-bar';
|
||||
export { PinnedProjects } from './pinned-projects';
|
||||
export { ProjectSwitcher } from './project-switcher';
|
||||
export { TopBarActions } from './top-bar-actions';
|
||||
128
apps/ui/src/components/layout/top-bar/pinned-projects.tsx
Normal file
128
apps/ui/src/components/layout/top-bar/pinned-projects.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import type { Project } from '@/lib/electron';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from '@/components/ui/context-menu';
|
||||
import { Star, Settings, Trash2 } from 'lucide-react';
|
||||
|
||||
interface PinnedProjectsProps {
|
||||
pinnedProjects: Project[];
|
||||
currentProject: Project | null;
|
||||
}
|
||||
|
||||
export function PinnedProjects({ pinnedProjects, currentProject }: PinnedProjectsProps) {
|
||||
const navigate = useNavigate();
|
||||
const { setCurrentProject, unpinProject, moveProjectToTrash } = useAppStore();
|
||||
|
||||
const handleProjectClick = useCallback(
|
||||
(project: Project) => {
|
||||
setCurrentProject(project);
|
||||
navigate({ to: '/board' });
|
||||
},
|
||||
[setCurrentProject, navigate]
|
||||
);
|
||||
|
||||
const handleUnpin = useCallback(
|
||||
(projectId: string) => {
|
||||
unpinProject(projectId);
|
||||
},
|
||||
[unpinProject]
|
||||
);
|
||||
|
||||
const handleProjectSettings = useCallback(
|
||||
(project: Project) => {
|
||||
setCurrentProject(project);
|
||||
navigate({ to: '/settings' });
|
||||
},
|
||||
[setCurrentProject, navigate]
|
||||
);
|
||||
|
||||
const handleRemoveProject = useCallback(
|
||||
(projectId: string) => {
|
||||
moveProjectToTrash(projectId);
|
||||
},
|
||||
[moveProjectToTrash]
|
||||
);
|
||||
|
||||
if (pinnedProjects.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center gap-1">
|
||||
{pinnedProjects.map((project) => {
|
||||
const isActive = currentProject?.id === project.id;
|
||||
// TODO: Get running agent count from store
|
||||
const runningCount = 0;
|
||||
|
||||
return (
|
||||
<ContextMenu key={project.id}>
|
||||
<ContextMenuTrigger>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => handleProjectClick(project)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium',
|
||||
'transition-all duration-200',
|
||||
'hover:bg-accent/50',
|
||||
isActive && 'bg-accent text-accent-foreground',
|
||||
!isActive && 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<span className="truncate max-w-[120px]">{project.name}</span>
|
||||
{runningCount > 0 && (
|
||||
<span className="flex h-2 w-2 rounded-full bg-green-500 animate-pulse" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
<div className="font-medium">{project.name}</div>
|
||||
<div className="text-muted-foreground">{project.path}</div>
|
||||
{runningCount > 0 && (
|
||||
<div className="text-green-500 mt-1">
|
||||
{runningCount} agent{runningCount > 1 ? 's' : ''} running
|
||||
</div>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={() => handleProjectClick(project)}>Open</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={() => handleUnpin(project.id)}>
|
||||
<Star className="h-4 w-4 mr-2" />
|
||||
Unpin from bar
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => handleProjectSettings(project)}>
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
Project Settings
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
onClick={() => handleRemoveProject(project.id)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Remove Project
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Separator after pinned projects */}
|
||||
<div className="h-6 w-px bg-border/60 mx-2" />
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
202
apps/ui/src/components/layout/top-bar/project-switcher.tsx
Normal file
202
apps/ui/src/components/layout/top-bar/project-switcher.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import type { Project } from '@/lib/electron';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuLabel,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { ChevronDown, Star, Plus, FolderOpen, Check } from 'lucide-react';
|
||||
|
||||
interface ProjectSwitcherProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
currentProject: Project | null;
|
||||
projects: Project[];
|
||||
pinnedProjectIds: string[];
|
||||
onNewProject: () => void;
|
||||
onOpenFolder: () => void;
|
||||
showCurrentProjectName?: boolean;
|
||||
}
|
||||
|
||||
export function ProjectSwitcher({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
currentProject,
|
||||
projects,
|
||||
pinnedProjectIds,
|
||||
onNewProject,
|
||||
onOpenFolder,
|
||||
showCurrentProjectName = true,
|
||||
}: ProjectSwitcherProps) {
|
||||
const navigate = useNavigate();
|
||||
const { setCurrentProject, pinProject, unpinProject } = useAppStore();
|
||||
|
||||
const pinnedProjects = projects.filter((p) => pinnedProjectIds.includes(p.id));
|
||||
const unpinnedProjects = projects.filter((p) => !pinnedProjectIds.includes(p.id));
|
||||
|
||||
const handleSelectProject = useCallback(
|
||||
(project: Project) => {
|
||||
setCurrentProject(project);
|
||||
navigate({ to: '/board' });
|
||||
onOpenChange(false);
|
||||
},
|
||||
[setCurrentProject, navigate, onOpenChange]
|
||||
);
|
||||
|
||||
const handleTogglePin = useCallback(
|
||||
(e: React.MouseEvent, projectId: string) => {
|
||||
e.stopPropagation();
|
||||
if (pinnedProjectIds.includes(projectId)) {
|
||||
unpinProject(projectId);
|
||||
} else {
|
||||
pinProject(projectId);
|
||||
}
|
||||
},
|
||||
[pinnedProjectIds, pinProject, unpinProject]
|
||||
);
|
||||
|
||||
const handleNewProject = useCallback(() => {
|
||||
onOpenChange(false);
|
||||
onNewProject();
|
||||
}, [onOpenChange, onNewProject]);
|
||||
|
||||
const handleOpenFolder = useCallback(() => {
|
||||
onOpenChange(false);
|
||||
onOpenFolder();
|
||||
}, [onOpenChange, onOpenFolder]);
|
||||
|
||||
const handleAllProjects = useCallback(() => {
|
||||
onOpenChange(false);
|
||||
navigate({ to: '/dashboard' });
|
||||
}, [onOpenChange, navigate]);
|
||||
|
||||
// TODO: Get running agent counts from store
|
||||
const getRunningCount = (projectId: string) => 0;
|
||||
|
||||
// Determine if we should show the current project name in the trigger
|
||||
// Don't show if it's already visible as a pinned project
|
||||
const currentProjectIsPinned = currentProject && pinnedProjectIds.includes(currentProject.id);
|
||||
const shouldShowProjectName = showCurrentProjectName && currentProject && !currentProjectIsPinned;
|
||||
|
||||
return (
|
||||
<DropdownMenu open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium',
|
||||
'transition-all duration-200',
|
||||
'hover:bg-accent/50',
|
||||
'text-foreground'
|
||||
)}
|
||||
>
|
||||
{shouldShowProjectName && (
|
||||
<span className="truncate max-w-[200px]">{currentProject.name}</span>
|
||||
)}
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-64">
|
||||
{/* Pinned Projects */}
|
||||
{pinnedProjects.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">Pinned</DropdownMenuLabel>
|
||||
{pinnedProjects.map((project) => {
|
||||
const isActive = currentProject?.id === project.id;
|
||||
const runningCount = getRunningCount(project.id);
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={project.id}
|
||||
onClick={() => handleSelectProject(project)}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{isActive && <Check className="h-4 w-4 text-primary shrink-0" />}
|
||||
<span className={cn('truncate', !isActive && 'ml-6')}>{project.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{runningCount > 0 && (
|
||||
<span className="text-xs text-green-500 flex items-center gap-1">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-green-500 animate-pulse" />
|
||||
{runningCount}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => handleTogglePin(e, project.id)}
|
||||
className="p-0.5 hover:bg-accent rounded"
|
||||
>
|
||||
<Star className="h-3.5 w-3.5 fill-yellow-500 text-yellow-500" />
|
||||
</button>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Other Projects */}
|
||||
{unpinnedProjects.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
||||
Other Projects
|
||||
</DropdownMenuLabel>
|
||||
{unpinnedProjects.map((project) => {
|
||||
const isActive = currentProject?.id === project.id;
|
||||
const runningCount = getRunningCount(project.id);
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={project.id}
|
||||
onClick={() => handleSelectProject(project)}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{isActive && <Check className="h-4 w-4 text-primary shrink-0" />}
|
||||
<span className={cn('truncate', !isActive && 'ml-6')}>{project.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{runningCount > 0 && (
|
||||
<span className="text-xs text-green-500 flex items-center gap-1">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-green-500 animate-pulse" />
|
||||
{runningCount}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => handleTogglePin(e, project.id)}
|
||||
className="p-0.5 hover:bg-accent rounded"
|
||||
>
|
||||
<Star className="h-3.5 w-3.5 text-muted-foreground hover:text-yellow-500" />
|
||||
</button>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<DropdownMenuItem onClick={handleNewProject}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
New Project
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleOpenFolder}>
|
||||
<FolderOpen className="h-4 w-4 mr-2" />
|
||||
Open Folder
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleAllProjects}>
|
||||
<FolderOpen className="h-4 w-4 mr-2" />
|
||||
All Projects
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
389
apps/ui/src/components/layout/top-bar/top-bar-actions.tsx
Normal file
389
apps/ui/src/components/layout/top-bar/top-bar-actions.tsx
Normal file
@@ -0,0 +1,389 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { useNavigate, useLocation } from '@tanstack/react-router';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import type { Project } from '@/lib/electron';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import {
|
||||
Settings,
|
||||
Bot,
|
||||
Bell,
|
||||
Wand2,
|
||||
GitBranch,
|
||||
Search,
|
||||
X,
|
||||
ImageIcon,
|
||||
Archive,
|
||||
Minimize2,
|
||||
Square,
|
||||
Maximize2,
|
||||
Columns3,
|
||||
Network,
|
||||
} from 'lucide-react';
|
||||
import { SettingsDialog } from '@/components/dialogs/settings-dialog';
|
||||
interface TopBarActionsProps {
|
||||
currentProject: Project | null;
|
||||
}
|
||||
|
||||
export function TopBarActions({ currentProject }: TopBarActionsProps) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const {
|
||||
getAutoModeState,
|
||||
setAutoModeRunning,
|
||||
maxConcurrency,
|
||||
setMaxConcurrency,
|
||||
worktreePanelCollapsed,
|
||||
setWorktreePanelCollapsed,
|
||||
boardSearchQuery,
|
||||
setBoardSearchQuery,
|
||||
kanbanCardDetailLevel,
|
||||
setKanbanCardDetailLevel,
|
||||
boardViewMode,
|
||||
setBoardViewMode,
|
||||
} = useAppStore();
|
||||
|
||||
const [showAgentSettings, setShowAgentSettings] = useState(false);
|
||||
const [showSettingsDialog, setShowSettingsDialog] = useState(false);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const autoModeState = currentProject ? getAutoModeState(currentProject.id) : null;
|
||||
const isAutoModeRunning = autoModeState?.isRunning ?? false;
|
||||
const runningAgentsCount = autoModeState?.runningTasks?.length ?? 0;
|
||||
|
||||
const isOnBoardView = location.pathname === '/board';
|
||||
|
||||
// Focus search input when "/" is pressed (only on board view)
|
||||
useEffect(() => {
|
||||
if (!isOnBoardView) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (
|
||||
e.key === '/' &&
|
||||
!(e.target instanceof HTMLInputElement) &&
|
||||
!(e.target instanceof HTMLTextAreaElement)
|
||||
) {
|
||||
e.preventDefault();
|
||||
searchInputRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOnBoardView]);
|
||||
|
||||
const handlePlan = useCallback(() => {
|
||||
if (isOnBoardView) {
|
||||
// Dispatch custom event for board-view to handle
|
||||
window.dispatchEvent(new CustomEvent('automaker:open-plan-dialog'));
|
||||
} else {
|
||||
// Navigate to board first, then open plan dialog
|
||||
navigate({ to: '/board' });
|
||||
setTimeout(() => {
|
||||
window.dispatchEvent(new CustomEvent('automaker:open-plan-dialog'));
|
||||
}, 100);
|
||||
}
|
||||
}, [isOnBoardView, navigate]);
|
||||
|
||||
const handleAutoModeToggle = useCallback(
|
||||
(enabled: boolean) => {
|
||||
if (currentProject) {
|
||||
setAutoModeRunning(currentProject.id, enabled);
|
||||
}
|
||||
},
|
||||
[currentProject, setAutoModeRunning]
|
||||
);
|
||||
|
||||
const handleSettings = useCallback(() => {
|
||||
setShowSettingsDialog(true);
|
||||
}, []);
|
||||
|
||||
const handleNotifications = useCallback(() => {
|
||||
// TODO: Open notifications panel
|
||||
}, []);
|
||||
|
||||
const handleShowBoardBackground = useCallback(() => {
|
||||
window.dispatchEvent(new CustomEvent('automaker:open-board-background'));
|
||||
}, []);
|
||||
|
||||
const handleShowCompletedFeatures = useCallback(() => {
|
||||
window.dispatchEvent(new CustomEvent('automaker:open-completed-features'));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center gap-1">
|
||||
{currentProject && (
|
||||
<>
|
||||
{/* Worktree Panel Toggle */}
|
||||
{isOnBoardView && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={worktreePanelCollapsed ? 'ghost' : 'secondary'}
|
||||
size="sm"
|
||||
onClick={() => setWorktreePanelCollapsed(!worktreePanelCollapsed)}
|
||||
className="gap-2"
|
||||
>
|
||||
<GitBranch className="h-4 w-4" />
|
||||
<span>Worktrees</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{worktreePanelCollapsed ? 'Show worktree panel' : 'Hide worktree panel'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Board Controls - only show on board view */}
|
||||
{isOnBoardView && (
|
||||
<>
|
||||
<div className="h-6 w-px bg-border/60 mx-1" />
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="relative w-48">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
value={boardSearchQuery}
|
||||
onChange={(e) => setBoardSearchQuery(e.target.value)}
|
||||
className="h-8 pl-8 pr-8 text-sm border-border"
|
||||
data-testid="topbar-search-input"
|
||||
/>
|
||||
{boardSearchQuery ? (
|
||||
<button
|
||||
onClick={() => setBoardSearchQuery('')}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-0.5 rounded-sm hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
) : (
|
||||
<span className="absolute right-2 top-1/2 -translate-y-1/2 px-1 py-0.5 text-[9px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70">
|
||||
/
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* View Mode Toggle */}
|
||||
<div className="flex items-center rounded-md bg-secondary border border-border ml-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => setBoardViewMode('kanban')}
|
||||
className={cn(
|
||||
'p-1.5 rounded-l-md transition-colors',
|
||||
boardViewMode === 'kanban'
|
||||
? 'bg-brand-500/20 text-brand-500'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||
)}
|
||||
>
|
||||
<Columns3 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Kanban Board View</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => setBoardViewMode('graph')}
|
||||
className={cn(
|
||||
'p-1.5 rounded-r-md transition-colors',
|
||||
boardViewMode === 'graph'
|
||||
? 'bg-brand-500/20 text-brand-500'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||
)}
|
||||
>
|
||||
<Network className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Dependency Graph View</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Board Background */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleShowBoardBackground}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<ImageIcon className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Board Background</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Completed Features */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleShowCompletedFeatures}
|
||||
className="h-8 w-8 p-0 relative"
|
||||
>
|
||||
<Archive className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Completed Features</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Detail Level Toggle */}
|
||||
<div className="flex items-center rounded-md bg-secondary border border-border">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => setKanbanCardDetailLevel('minimal')}
|
||||
className={cn(
|
||||
'p-1.5 rounded-l-md transition-colors',
|
||||
kanbanCardDetailLevel === 'minimal'
|
||||
? 'bg-brand-500/20 text-brand-500'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||
)}
|
||||
>
|
||||
<Minimize2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Minimal</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => setKanbanCardDetailLevel('standard')}
|
||||
className={cn(
|
||||
'p-1.5 transition-colors',
|
||||
kanbanCardDetailLevel === 'standard'
|
||||
? 'bg-brand-500/20 text-brand-500'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||
)}
|
||||
>
|
||||
<Square className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Standard</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => setKanbanCardDetailLevel('detailed')}
|
||||
className={cn(
|
||||
'p-1.5 rounded-r-md transition-colors',
|
||||
kanbanCardDetailLevel === 'detailed'
|
||||
? 'bg-brand-500/20 text-brand-500'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||
)}
|
||||
>
|
||||
<Maximize2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Detailed</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="h-6 w-px bg-border/60 mx-1" />
|
||||
|
||||
{/* Agents Control */}
|
||||
<Popover open={showAgentSettings} onOpenChange={setShowAgentSettings}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn('gap-2 px-3', runningAgentsCount > 0 && 'text-green-500')}
|
||||
>
|
||||
<Bot className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">
|
||||
{runningAgentsCount}/{maxConcurrency}
|
||||
</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64" align="end">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Max Agents</span>
|
||||
<span className="text-sm text-muted-foreground">{maxConcurrency}</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[maxConcurrency]}
|
||||
onValueChange={(value) => setMaxConcurrency(value[0])}
|
||||
min={1}
|
||||
max={10}
|
||||
step={1}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Maximum concurrent agents when auto mode is running
|
||||
</p>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Auto Mode Toggle */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-1.5 rounded-md',
|
||||
'transition-colors',
|
||||
isAutoModeRunning ? 'bg-green-500/20 text-green-500' : 'hover:bg-accent/50'
|
||||
)}
|
||||
>
|
||||
<span className="text-sm font-medium">Auto</span>
|
||||
<Switch
|
||||
checked={isAutoModeRunning}
|
||||
onCheckedChange={handleAutoModeToggle}
|
||||
className="data-[state=checked]:bg-green-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-6 w-px bg-border/60 mx-1" />
|
||||
|
||||
{/* Plan Button */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" size="sm" onClick={handlePlan} className="gap-2">
|
||||
<Wand2 className="h-4 w-4" />
|
||||
<span>Plan</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Plan features with AI</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Notifications */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" onClick={handleNotifications} className="relative">
|
||||
<Bell className="h-4 w-4" />
|
||||
{/* Notification badge - show when there are unread notifications */}
|
||||
{/* <span className="absolute -top-1 -right-1 h-4 w-4 rounded-full bg-red-500 text-[10px] text-white flex items-center justify-center">3</span> */}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Notifications</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Settings */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" onClick={handleSettings}>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Settings</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Settings Dialog */}
|
||||
<SettingsDialog open={showSettingsDialog} onOpenChange={setShowSettingsDialog} />
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
157
apps/ui/src/components/layout/top-bar/top-bar.tsx
Normal file
157
apps/ui/src/components/layout/top-bar/top-bar.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppStore, type ThemeMode } from '@/store/app-store';
|
||||
import type { Project } from '@/lib/electron';
|
||||
import { ProjectSwitcher } from './project-switcher';
|
||||
import { PinnedProjects } from './pinned-projects';
|
||||
import { TopBarActions } from './top-bar-actions';
|
||||
import { OnboardingWizard } from '@/components/dialogs/onboarding-wizard';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { initializeProject, hasAppSpec, hasAutomakerDir } from '@/lib/project-init';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export function TopBar() {
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
currentProject,
|
||||
projects,
|
||||
pinnedProjectIds,
|
||||
trashedProjects,
|
||||
theme: globalTheme,
|
||||
upsertAndSetCurrentProject,
|
||||
} = useAppStore();
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const [showOnboarding, setShowOnboarding] = useState(false);
|
||||
const [onboardingMode, setOnboardingMode] = useState<'new' | 'existing'>('new');
|
||||
const [pendingProjectPath, setPendingProjectPath] = useState<string | undefined>(undefined);
|
||||
|
||||
const pinnedProjects = projects.filter((p) => pinnedProjectIds.includes(p.id));
|
||||
|
||||
const handleLogoClick = useCallback(() => {
|
||||
navigate({ to: '/dashboard' });
|
||||
}, [navigate]);
|
||||
|
||||
const handleNewProject = useCallback(() => {
|
||||
setPendingProjectPath(undefined);
|
||||
setOnboardingMode('new');
|
||||
setShowOnboarding(true);
|
||||
}, []);
|
||||
|
||||
const handleOpenFolder = useCallback(async () => {
|
||||
const api = getElectronAPI();
|
||||
const result = await api.openDirectory();
|
||||
|
||||
if (!result.canceled && result.filePaths[0]) {
|
||||
const path = result.filePaths[0];
|
||||
const name = path.split(/[/\\]/).filter(Boolean).pop() || 'Untitled Project';
|
||||
|
||||
try {
|
||||
const hadAutomakerDir = await hasAutomakerDir(path);
|
||||
const initResult = await initializeProject(path);
|
||||
|
||||
if (!initResult.success) {
|
||||
toast.error('Failed to initialize project', {
|
||||
description: initResult.error || 'Unknown error occurred',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const trashedProject = trashedProjects.find((p) => p.path === path);
|
||||
const effectiveTheme =
|
||||
(trashedProject?.theme as ThemeMode | undefined) ||
|
||||
(currentProject?.theme as ThemeMode | undefined) ||
|
||||
globalTheme;
|
||||
|
||||
upsertAndSetCurrentProject(path, name, effectiveTheme);
|
||||
|
||||
const specExists = await hasAppSpec(path);
|
||||
|
||||
if (!hadAutomakerDir || !specExists) {
|
||||
setPendingProjectPath(path);
|
||||
setOnboardingMode(hadAutomakerDir ? 'existing' : 'new');
|
||||
setShowOnboarding(true);
|
||||
} else {
|
||||
navigate({ to: '/board' });
|
||||
toast.success('Project opened', { description: `Opened ${name}` });
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to open project', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject, navigate]);
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
'flex items-center h-12 px-4 border-b border-border/60',
|
||||
'bg-gradient-to-r from-sidebar/95 via-sidebar/90 to-sidebar/95 backdrop-blur-xl',
|
||||
'shadow-sm'
|
||||
)}
|
||||
>
|
||||
{/* Logo */}
|
||||
<button
|
||||
onClick={handleLogoClick}
|
||||
className="flex items-center gap-2 mr-4 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" className="h-7 w-7">
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="topbar-logo-bg"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="256"
|
||||
y2="256"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0%" style={{ stopColor: 'var(--brand-400)' }} />
|
||||
<stop offset="100%" style={{ stopColor: 'var(--brand-600)' }} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="16" y="16" width="224" height="224" rx="56" fill="url(#topbar-logo-bg)" />
|
||||
<g
|
||||
fill="none"
|
||||
stroke="#FFFFFF"
|
||||
strokeWidth="20"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M92 92 L52 128 L92 164" />
|
||||
<path d="M144 72 L116 184" />
|
||||
<path d="M164 92 L204 128 L164 164" />
|
||||
</g>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Pinned Projects */}
|
||||
<PinnedProjects pinnedProjects={pinnedProjects} currentProject={currentProject} />
|
||||
|
||||
{/* Project Dropdown */}
|
||||
<ProjectSwitcher
|
||||
isOpen={isDropdownOpen}
|
||||
onOpenChange={setIsDropdownOpen}
|
||||
currentProject={currentProject}
|
||||
projects={projects}
|
||||
pinnedProjectIds={pinnedProjectIds}
|
||||
onNewProject={handleNewProject}
|
||||
onOpenFolder={handleOpenFolder}
|
||||
/>
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Actions */}
|
||||
<TopBarActions currentProject={currentProject} />
|
||||
|
||||
{/* Onboarding Wizard */}
|
||||
<OnboardingWizard
|
||||
open={showOnboarding}
|
||||
onOpenChange={setShowOnboarding}
|
||||
mode={onboardingMode}
|
||||
initialPath={pendingProjectPath}
|
||||
/>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -5,17 +5,3 @@ export {
|
||||
type UseModelOverrideOptions,
|
||||
type UseModelOverrideResult,
|
||||
} from './use-model-override';
|
||||
|
||||
// Onboarding Wizard Components
|
||||
export {
|
||||
OnboardingWizard,
|
||||
useOnboardingWizard,
|
||||
ONBOARDING_STORAGE_PREFIX,
|
||||
ONBOARDING_TARGET_ATTRIBUTE,
|
||||
ONBOARDING_ANALYTICS,
|
||||
type OnboardingStep,
|
||||
type OnboardingState,
|
||||
type OnboardingWizardProps,
|
||||
type UseOnboardingWizardOptions,
|
||||
type UseOnboardingWizardResult,
|
||||
} from './onboarding';
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
/**
|
||||
* Shared Onboarding Wizard Constants
|
||||
*
|
||||
* Layout, positioning, and timing constants for the onboarding wizard.
|
||||
*/
|
||||
|
||||
/** Storage key prefix for onboarding state */
|
||||
export const ONBOARDING_STORAGE_PREFIX = 'automaker:onboarding';
|
||||
|
||||
/** Padding around spotlight highlight elements (px) */
|
||||
export const SPOTLIGHT_PADDING = 8;
|
||||
|
||||
/** Padding between target element and tooltip (px) */
|
||||
export const TOOLTIP_OFFSET = 16;
|
||||
|
||||
/** Vertical offset from top of target to tooltip (px) */
|
||||
export const TOOLTIP_TOP_OFFSET = 40;
|
||||
|
||||
/** Maximum tooltip width (px) */
|
||||
export const TOOLTIP_MAX_WIDTH = 400;
|
||||
|
||||
/** Minimum safe margin from viewport edges (px) */
|
||||
export const VIEWPORT_SAFE_MARGIN = 16;
|
||||
|
||||
/** Threshold for placing tooltip to the right of target (30% of viewport) */
|
||||
export const TOOLTIP_POSITION_RIGHT_THRESHOLD = 0.3;
|
||||
|
||||
/** Threshold for placing tooltip to the left of target (70% of viewport) */
|
||||
export const TOOLTIP_POSITION_LEFT_THRESHOLD = 0.7;
|
||||
|
||||
/** Threshold from bottom of viewport to trigger alternate positioning (px) */
|
||||
export const BOTTOM_THRESHOLD = 450;
|
||||
|
||||
/** Debounce delay for resize handler (ms) */
|
||||
export const RESIZE_DEBOUNCE_MS = 100;
|
||||
|
||||
/** Animation duration for step transitions (ms) */
|
||||
export const STEP_TRANSITION_DURATION = 200;
|
||||
|
||||
/** ID for the wizard description element (for aria-describedby) */
|
||||
export const WIZARD_DESCRIPTION_ID = 'onboarding-wizard-description';
|
||||
|
||||
/** ID for the wizard title element (for aria-labelledby) */
|
||||
export const WIZARD_TITLE_ID = 'onboarding-wizard-title';
|
||||
|
||||
/** Data attribute name for targeting elements */
|
||||
export const ONBOARDING_TARGET_ATTRIBUTE = 'data-onboarding-target';
|
||||
|
||||
/** Analytics event names for onboarding tracking */
|
||||
export const ONBOARDING_ANALYTICS = {
|
||||
STARTED: 'onboarding_started',
|
||||
COMPLETED: 'onboarding_completed',
|
||||
SKIPPED: 'onboarding_skipped',
|
||||
STEP_VIEWED: 'onboarding_step_viewed',
|
||||
} as const;
|
||||
@@ -1,21 +0,0 @@
|
||||
/**
|
||||
* Shared Onboarding Components
|
||||
*
|
||||
* Generic onboarding wizard infrastructure for building
|
||||
* interactive tutorials across different views.
|
||||
*/
|
||||
|
||||
export { OnboardingWizard } from './onboarding-wizard';
|
||||
export { useOnboardingWizard } from './use-onboarding-wizard';
|
||||
export type {
|
||||
OnboardingStep,
|
||||
OnboardingState,
|
||||
OnboardingWizardProps,
|
||||
UseOnboardingWizardOptions,
|
||||
UseOnboardingWizardResult,
|
||||
} from './types';
|
||||
export {
|
||||
ONBOARDING_STORAGE_PREFIX,
|
||||
ONBOARDING_TARGET_ATTRIBUTE,
|
||||
ONBOARDING_ANALYTICS,
|
||||
} from './constants';
|
||||
@@ -1,545 +0,0 @@
|
||||
/**
|
||||
* Generic Onboarding Wizard Component
|
||||
*
|
||||
* A multi-step wizard overlay that guides users through features
|
||||
* with visual highlighting (spotlight effect) on target elements.
|
||||
*
|
||||
* Features:
|
||||
* - Spotlight overlay targeting elements via data-onboarding-target
|
||||
* - Responsive tooltip positioning (left/right/bottom)
|
||||
* - Step navigation (keyboard & mouse)
|
||||
* - Configurable children slot for view-specific content
|
||||
* - Completion celebration animation
|
||||
* - Full accessibility (ARIA, focus management)
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X, ChevronLeft, ChevronRight, CheckCircle2, PartyPopper, Sparkles } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
SPOTLIGHT_PADDING,
|
||||
TOOLTIP_OFFSET,
|
||||
TOOLTIP_TOP_OFFSET,
|
||||
TOOLTIP_MAX_WIDTH,
|
||||
VIEWPORT_SAFE_MARGIN,
|
||||
TOOLTIP_POSITION_RIGHT_THRESHOLD,
|
||||
TOOLTIP_POSITION_LEFT_THRESHOLD,
|
||||
BOTTOM_THRESHOLD,
|
||||
RESIZE_DEBOUNCE_MS,
|
||||
STEP_TRANSITION_DURATION,
|
||||
WIZARD_DESCRIPTION_ID,
|
||||
WIZARD_TITLE_ID,
|
||||
ONBOARDING_TARGET_ATTRIBUTE,
|
||||
} from './constants';
|
||||
import type { OnboardingWizardProps, OnboardingStep } from './types';
|
||||
|
||||
interface HighlightRect {
|
||||
top: number;
|
||||
left: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export function OnboardingWizard({
|
||||
isVisible,
|
||||
currentStep,
|
||||
currentStepData,
|
||||
totalSteps,
|
||||
onNext,
|
||||
onPrevious,
|
||||
onSkip,
|
||||
onComplete,
|
||||
steps,
|
||||
children,
|
||||
}: OnboardingWizardProps) {
|
||||
const [highlightRect, setHighlightRect] = useState<HighlightRect | null>(null);
|
||||
const [tooltipPosition, setTooltipPosition] = useState<'left' | 'right' | 'bottom'>('bottom');
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const [showCompletionCelebration, setShowCompletionCelebration] = useState(false);
|
||||
|
||||
// Refs for focus management
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const nextButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Detect if user is on a touch device
|
||||
const [isTouchDevice, setIsTouchDevice] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsTouchDevice('ontouchstart' in window || navigator.maxTouchPoints > 0);
|
||||
}, []);
|
||||
|
||||
// Lock scroll when wizard is visible
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
|
||||
const originalOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = originalOverflow;
|
||||
};
|
||||
}, [isVisible]);
|
||||
|
||||
// Focus management - move focus to dialog when opened
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
nextButtonRef.current?.focus();
|
||||
}, STEP_TRANSITION_DURATION);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [isVisible]);
|
||||
|
||||
// Animate step transitions
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
|
||||
setIsAnimating(true);
|
||||
const timer = setTimeout(() => {
|
||||
setIsAnimating(false);
|
||||
}, STEP_TRANSITION_DURATION);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [currentStep, isVisible]);
|
||||
|
||||
// Find and highlight the target element
|
||||
useEffect(() => {
|
||||
if (!isVisible || !currentStepData) {
|
||||
setHighlightRect(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const updateHighlight = () => {
|
||||
// Find target element by data-onboarding-target attribute
|
||||
const targetEl = document.querySelector(
|
||||
`[${ONBOARDING_TARGET_ATTRIBUTE}="${currentStepData.targetId}"]`
|
||||
);
|
||||
|
||||
if (targetEl) {
|
||||
const rect = targetEl.getBoundingClientRect();
|
||||
setHighlightRect({
|
||||
top: rect.top,
|
||||
left: rect.left,
|
||||
right: rect.right,
|
||||
bottom: rect.bottom,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
});
|
||||
|
||||
// Determine tooltip position based on target position and available space
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const targetCenter = rect.left + rect.width / 2;
|
||||
const tooltipWidth = Math.min(TOOLTIP_MAX_WIDTH, viewportWidth - VIEWPORT_SAFE_MARGIN * 2);
|
||||
|
||||
const spaceAtBottom = viewportHeight - rect.bottom - TOOLTIP_OFFSET;
|
||||
const spaceAtRight = viewportWidth - rect.right - TOOLTIP_OFFSET;
|
||||
const spaceAtLeft = rect.left - TOOLTIP_OFFSET;
|
||||
|
||||
// For leftmost targets, prefer right position
|
||||
if (
|
||||
targetCenter < viewportWidth * TOOLTIP_POSITION_RIGHT_THRESHOLD &&
|
||||
spaceAtRight >= tooltipWidth
|
||||
) {
|
||||
setTooltipPosition('right');
|
||||
}
|
||||
// For rightmost targets, prefer left position
|
||||
else if (
|
||||
targetCenter > viewportWidth * TOOLTIP_POSITION_LEFT_THRESHOLD &&
|
||||
spaceAtLeft >= tooltipWidth
|
||||
) {
|
||||
setTooltipPosition('left');
|
||||
}
|
||||
// For middle targets, check if bottom position would work
|
||||
else if (spaceAtBottom >= BOTTOM_THRESHOLD) {
|
||||
setTooltipPosition('bottom');
|
||||
}
|
||||
// Fallback logic
|
||||
else if (spaceAtRight > spaceAtLeft && spaceAtRight >= tooltipWidth * 0.6) {
|
||||
setTooltipPosition('right');
|
||||
} else if (spaceAtLeft >= tooltipWidth * 0.6) {
|
||||
setTooltipPosition('left');
|
||||
} else {
|
||||
setTooltipPosition('bottom');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
updateHighlight();
|
||||
|
||||
// Debounced resize handler
|
||||
let resizeTimeout: ReturnType<typeof setTimeout>;
|
||||
const handleResize = () => {
|
||||
clearTimeout(resizeTimeout);
|
||||
resizeTimeout = setTimeout(updateHighlight, RESIZE_DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
clearTimeout(resizeTimeout);
|
||||
};
|
||||
}, [isVisible, currentStepData]);
|
||||
|
||||
// Keyboard navigation
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onSkip();
|
||||
} else if (e.key === 'ArrowRight' || e.key === 'Enter') {
|
||||
if (currentStep < totalSteps - 1) {
|
||||
onNext();
|
||||
} else {
|
||||
handleComplete();
|
||||
}
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
onPrevious();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isVisible, currentStep, totalSteps, onNext, onPrevious, onSkip]);
|
||||
|
||||
// Calculate tooltip styles based on position and highlight rect
|
||||
const getTooltipStyles = useCallback((): React.CSSProperties => {
|
||||
if (!highlightRect) return {};
|
||||
|
||||
const viewportHeight = window.innerHeight;
|
||||
const viewportWidth = window.innerWidth;
|
||||
const tooltipWidth = Math.min(TOOLTIP_MAX_WIDTH, viewportWidth - VIEWPORT_SAFE_MARGIN * 2);
|
||||
|
||||
switch (tooltipPosition) {
|
||||
case 'right': {
|
||||
const topPos = Math.max(VIEWPORT_SAFE_MARGIN, highlightRect.top + TOOLTIP_TOP_OFFSET);
|
||||
const availableHeight = viewportHeight - topPos - VIEWPORT_SAFE_MARGIN;
|
||||
return {
|
||||
position: 'fixed',
|
||||
top: topPos,
|
||||
left: highlightRect.right + TOOLTIP_OFFSET,
|
||||
width: tooltipWidth,
|
||||
maxWidth: `calc(100vw - ${highlightRect.right + TOOLTIP_OFFSET * 2}px)`,
|
||||
maxHeight: Math.max(200, availableHeight),
|
||||
};
|
||||
}
|
||||
case 'left': {
|
||||
const topPos = Math.max(VIEWPORT_SAFE_MARGIN, highlightRect.top + TOOLTIP_TOP_OFFSET);
|
||||
const availableHeight = viewportHeight - topPos - VIEWPORT_SAFE_MARGIN;
|
||||
return {
|
||||
position: 'fixed',
|
||||
top: topPos,
|
||||
right: viewportWidth - highlightRect.left + TOOLTIP_OFFSET,
|
||||
width: tooltipWidth,
|
||||
maxWidth: `calc(${highlightRect.left - TOOLTIP_OFFSET * 2}px)`,
|
||||
maxHeight: Math.max(200, availableHeight),
|
||||
};
|
||||
}
|
||||
case 'bottom':
|
||||
default: {
|
||||
const idealTop = highlightRect.bottom + TOOLTIP_OFFSET;
|
||||
const availableHeight = viewportHeight - idealTop - VIEWPORT_SAFE_MARGIN;
|
||||
|
||||
const minTop = 100;
|
||||
const topPos =
|
||||
availableHeight < 250
|
||||
? Math.max(
|
||||
minTop,
|
||||
viewportHeight - Math.max(300, availableHeight) - VIEWPORT_SAFE_MARGIN
|
||||
)
|
||||
: idealTop;
|
||||
|
||||
const idealLeft = highlightRect.left + highlightRect.width / 2 - tooltipWidth / 2;
|
||||
const leftPos = Math.max(
|
||||
VIEWPORT_SAFE_MARGIN,
|
||||
Math.min(idealLeft, viewportWidth - tooltipWidth - VIEWPORT_SAFE_MARGIN)
|
||||
);
|
||||
|
||||
return {
|
||||
position: 'fixed',
|
||||
top: topPos,
|
||||
left: leftPos,
|
||||
width: tooltipWidth,
|
||||
maxHeight: Math.max(200, viewportHeight - topPos - VIEWPORT_SAFE_MARGIN),
|
||||
};
|
||||
}
|
||||
}
|
||||
}, [highlightRect, tooltipPosition]);
|
||||
|
||||
// Handle completion with celebration
|
||||
const handleComplete = useCallback(() => {
|
||||
setShowCompletionCelebration(true);
|
||||
setTimeout(() => {
|
||||
setShowCompletionCelebration(false);
|
||||
onComplete();
|
||||
}, 1200);
|
||||
}, [onComplete]);
|
||||
|
||||
// Handle step indicator click for direct navigation
|
||||
const handleStepClick = useCallback(
|
||||
(stepIndex: number) => {
|
||||
if (stepIndex === currentStep) return;
|
||||
|
||||
if (stepIndex > currentStep) {
|
||||
for (let i = currentStep; i < stepIndex; i++) {
|
||||
onNext();
|
||||
}
|
||||
} else {
|
||||
for (let i = currentStep; i > stepIndex; i--) {
|
||||
onPrevious();
|
||||
}
|
||||
}
|
||||
},
|
||||
[currentStep, onNext, onPrevious]
|
||||
);
|
||||
|
||||
if (!isVisible || !currentStepData) return null;
|
||||
|
||||
const StepIcon = currentStepData.icon || Sparkles;
|
||||
const isLastStep = currentStep === totalSteps - 1;
|
||||
const isFirstStep = currentStep === 0;
|
||||
|
||||
const content = (
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className="fixed inset-0 z-[100]"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={WIZARD_TITLE_ID}
|
||||
aria-describedby={WIZARD_DESCRIPTION_ID}
|
||||
>
|
||||
{/* Completion celebration overlay */}
|
||||
{showCompletionCelebration && (
|
||||
<div className="absolute inset-0 z-[102] flex items-center justify-center pointer-events-none">
|
||||
<div className="animate-in zoom-in-50 fade-in duration-300 flex flex-col items-center gap-4 text-white">
|
||||
<PartyPopper className="w-16 h-16 text-yellow-400 animate-bounce" />
|
||||
<p className="text-2xl font-bold">You're all set!</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dark overlay with cutout for highlighted element */}
|
||||
<svg
|
||||
className="absolute inset-0 w-full h-full pointer-events-none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<mask id="spotlight-mask">
|
||||
<rect x="0" y="0" width="100%" height="100%" fill="white" />
|
||||
{highlightRect && (
|
||||
<rect
|
||||
x={highlightRect.left - SPOTLIGHT_PADDING}
|
||||
y={highlightRect.top - SPOTLIGHT_PADDING}
|
||||
width={highlightRect.width + SPOTLIGHT_PADDING * 2}
|
||||
height={highlightRect.height + SPOTLIGHT_PADDING * 2}
|
||||
rx="16"
|
||||
fill="black"
|
||||
/>
|
||||
)}
|
||||
</mask>
|
||||
</defs>
|
||||
<rect
|
||||
x="0"
|
||||
y="0"
|
||||
width="100%"
|
||||
height="100%"
|
||||
fill="rgba(0, 0, 0, 0.75)"
|
||||
mask="url(#spotlight-mask)"
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Highlight border around the target element */}
|
||||
{highlightRect && (
|
||||
<div
|
||||
className="absolute pointer-events-none transition-all duration-300 ease-out"
|
||||
style={{
|
||||
left: highlightRect.left - SPOTLIGHT_PADDING,
|
||||
top: highlightRect.top - SPOTLIGHT_PADDING,
|
||||
width: highlightRect.width + SPOTLIGHT_PADDING * 2,
|
||||
height: highlightRect.height + SPOTLIGHT_PADDING * 2,
|
||||
borderRadius: '16px',
|
||||
border: '2px solid hsl(var(--primary))',
|
||||
boxShadow:
|
||||
'0 0 20px 4px hsl(var(--primary) / 0.3), inset 0 0 20px 4px hsl(var(--primary) / 0.1)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Skip button - top right */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
'fixed top-4 right-4 z-[101]',
|
||||
'text-white/70 hover:text-white hover:bg-white/10',
|
||||
'focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-transparent',
|
||||
'min-h-[44px] min-w-[44px] px-3'
|
||||
)}
|
||||
onClick={onSkip}
|
||||
aria-label="Skip the onboarding tour"
|
||||
>
|
||||
<X className="w-4 h-4 mr-1.5" aria-hidden="true" />
|
||||
<span>Skip Tour</span>
|
||||
</Button>
|
||||
|
||||
{/* Tooltip card with step content */}
|
||||
<div
|
||||
className={cn(
|
||||
'z-[101] bg-popover/95 backdrop-blur-xl rounded-xl shadow-2xl border border-border/50',
|
||||
'p-6 animate-in fade-in-0 slide-in-from-bottom-4 duration-300',
|
||||
'max-h-[calc(100vh-100px)] overflow-y-auto',
|
||||
isAnimating && 'opacity-90 scale-[0.98]',
|
||||
'transition-all duration-200 ease-out'
|
||||
)}
|
||||
style={getTooltipStyles()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-4 mb-4">
|
||||
<div className="flex items-center justify-center w-12 h-12 rounded-xl bg-primary/10 border border-primary/20 shrink-0">
|
||||
<StepIcon className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 id={WIZARD_TITLE_ID} className="text-lg font-semibold text-foreground truncate">
|
||||
{currentStepData.title}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 mt-1.5">
|
||||
<span className="text-xs text-muted-foreground" aria-live="polite">
|
||||
Step {currentStep + 1} of {totalSteps}
|
||||
</span>
|
||||
{/* Step indicators - clickable for navigation */}
|
||||
<nav aria-label="Wizard steps" className="flex items-center gap-1">
|
||||
{Array.from({ length: totalSteps }).map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => handleStepClick(i)}
|
||||
className={cn(
|
||||
'relative flex items-center justify-center',
|
||||
'w-6 h-6',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1 focus-visible:rounded-full',
|
||||
'transition-transform duration-200 hover:scale-110'
|
||||
)}
|
||||
aria-label={`Go to step ${i + 1}: ${steps[i]?.title}`}
|
||||
aria-current={i === currentStep ? 'step' : undefined}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'block rounded-full transition-all duration-200',
|
||||
i === currentStep
|
||||
? 'w-2.5 h-2.5 bg-primary ring-2 ring-primary/30 ring-offset-1 ring-offset-popover'
|
||||
: i < currentStep
|
||||
? 'w-2 h-2 bg-primary/60'
|
||||
: 'w-2 h-2 bg-muted-foreground/40'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p
|
||||
id={WIZARD_DESCRIPTION_ID}
|
||||
className="text-sm text-muted-foreground leading-relaxed mb-4"
|
||||
>
|
||||
{currentStepData.description}
|
||||
</p>
|
||||
|
||||
{/* Tip box */}
|
||||
{currentStepData.tip && (
|
||||
<div className="rounded-lg bg-primary/5 border border-primary/10 p-3 mb-4">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<span className="font-medium text-foreground">Tip: </span>
|
||||
{currentStepData.tip}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom content slot (e.g., Quick Start section) */}
|
||||
{children}
|
||||
|
||||
{/* Navigation buttons */}
|
||||
<div className="flex items-center justify-between gap-3 pt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onPrevious}
|
||||
disabled={isFirstStep}
|
||||
className={cn(
|
||||
'text-muted-foreground min-h-[44px]',
|
||||
'focus-visible:ring-2 focus-visible:ring-primary',
|
||||
isFirstStep && 'invisible'
|
||||
)}
|
||||
aria-label="Go to previous step"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 mr-1" aria-hidden="true" />
|
||||
<span>Previous</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
ref={nextButtonRef}
|
||||
size="sm"
|
||||
onClick={isLastStep ? handleComplete : onNext}
|
||||
disabled={showCompletionCelebration}
|
||||
className={cn(
|
||||
'bg-primary hover:bg-primary/90 text-primary-foreground',
|
||||
'min-w-[120px] min-h-[44px]',
|
||||
'focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',
|
||||
'transition-all duration-200'
|
||||
)}
|
||||
aria-label={isLastStep ? 'Complete the tour and get started' : 'Go to next step'}
|
||||
>
|
||||
{isLastStep ? (
|
||||
<>
|
||||
<span>Get Started</span>
|
||||
<CheckCircle2 className="w-4 h-4 ml-1.5" aria-hidden="true" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>Next</span>
|
||||
<ChevronRight className="w-4 h-4 ml-1" aria-hidden="true" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Keyboard hints - hidden on touch devices */}
|
||||
{!isTouchDevice && (
|
||||
<div
|
||||
className="mt-4 pt-3 border-t border-border/50 flex items-center justify-center gap-4 text-xs text-muted-foreground/70"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<kbd className="px-2 py-1 rounded bg-muted text-muted-foreground font-mono text-[11px] shadow-sm">
|
||||
ESC
|
||||
</kbd>
|
||||
<span>to skip</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<kbd className="px-2 py-1 rounded bg-muted text-muted-foreground font-mono text-[11px] shadow-sm">
|
||||
←
|
||||
</kbd>
|
||||
<kbd className="px-2 py-1 rounded bg-muted text-muted-foreground font-mono text-[11px] shadow-sm">
|
||||
→
|
||||
</kbd>
|
||||
<span>to navigate</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Render in a portal to ensure it's above everything
|
||||
return createPortal(content, document.body);
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
/**
|
||||
* Shared Onboarding Wizard Types
|
||||
*
|
||||
* Generic types for building onboarding wizards across different views.
|
||||
*/
|
||||
|
||||
import type { ComponentType } from 'react';
|
||||
|
||||
/**
|
||||
* Represents a single step in the onboarding wizard
|
||||
*/
|
||||
export interface OnboardingStep {
|
||||
/** Unique identifier for this step */
|
||||
id: string;
|
||||
/** Target element ID - matches data-onboarding-target attribute */
|
||||
targetId: string;
|
||||
/** Step title displayed in the wizard */
|
||||
title: string;
|
||||
/** Main description explaining this step */
|
||||
description: string;
|
||||
/** Optional tip shown in a highlighted box */
|
||||
tip?: string;
|
||||
/** Optional icon component for visual identification */
|
||||
icon?: ComponentType<{ className?: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persisted onboarding state structure
|
||||
*/
|
||||
export interface OnboardingState {
|
||||
/** Whether the wizard has been completed */
|
||||
completed: boolean;
|
||||
/** ISO timestamp when completed */
|
||||
completedAt?: string;
|
||||
/** Whether the wizard has been skipped */
|
||||
skipped: boolean;
|
||||
/** ISO timestamp when skipped */
|
||||
skippedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for the useOnboardingWizard hook
|
||||
*/
|
||||
export interface UseOnboardingWizardOptions {
|
||||
/** Unique storage key for localStorage persistence */
|
||||
storageKey: string;
|
||||
/** Array of wizard steps to display */
|
||||
steps: OnboardingStep[];
|
||||
/** Optional callback when wizard is completed */
|
||||
onComplete?: () => void;
|
||||
/** Optional callback when wizard is skipped */
|
||||
onSkip?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return type for the useOnboardingWizard hook
|
||||
*/
|
||||
export interface UseOnboardingWizardResult {
|
||||
/** Whether the wizard is currently visible */
|
||||
isVisible: boolean;
|
||||
/** Current step index (0-based) */
|
||||
currentStep: number;
|
||||
/** Current step data or null if not available */
|
||||
currentStepData: OnboardingStep | null;
|
||||
/** Total number of steps */
|
||||
totalSteps: number;
|
||||
/** Navigate to the next step */
|
||||
goToNextStep: () => void;
|
||||
/** Navigate to the previous step */
|
||||
goToPreviousStep: () => void;
|
||||
/** Navigate to a specific step by index */
|
||||
goToStep: (step: number) => void;
|
||||
/** Start/show the wizard from the beginning */
|
||||
startWizard: () => void;
|
||||
/** Complete the wizard and hide it */
|
||||
completeWizard: () => void;
|
||||
/** Skip the wizard and hide it */
|
||||
skipWizard: () => void;
|
||||
/** Whether the wizard has been completed */
|
||||
isCompleted: boolean;
|
||||
/** Whether the wizard has been skipped */
|
||||
isSkipped: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the OnboardingWizard component
|
||||
*/
|
||||
export interface OnboardingWizardProps {
|
||||
/** Whether the wizard is visible */
|
||||
isVisible: boolean;
|
||||
/** Current step index */
|
||||
currentStep: number;
|
||||
/** Current step data */
|
||||
currentStepData: OnboardingStep | null;
|
||||
/** Total number of steps */
|
||||
totalSteps: number;
|
||||
/** Handler for next step navigation */
|
||||
onNext: () => void;
|
||||
/** Handler for previous step navigation */
|
||||
onPrevious: () => void;
|
||||
/** Handler for skipping the wizard */
|
||||
onSkip: () => void;
|
||||
/** Handler for completing the wizard */
|
||||
onComplete: () => void;
|
||||
/** Array of all steps (for step indicator navigation) */
|
||||
steps: OnboardingStep[];
|
||||
/** Optional content to render before navigation buttons (e.g., Quick Start) */
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
/**
|
||||
* Generic Onboarding Wizard Hook
|
||||
*
|
||||
* Manages the state and logic for interactive onboarding wizards.
|
||||
* Can be used to create onboarding experiences for any view.
|
||||
*
|
||||
* Features:
|
||||
* - Persists completion status to localStorage
|
||||
* - Step navigation (next, previous, jump to step)
|
||||
* - Analytics tracking hooks
|
||||
* - No auto-show logic - wizard only shows via startWizard()
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { getItem, setItem } from '@/lib/storage';
|
||||
import { ONBOARDING_STORAGE_PREFIX, ONBOARDING_ANALYTICS } from './constants';
|
||||
import type {
|
||||
OnboardingState,
|
||||
OnboardingStep,
|
||||
UseOnboardingWizardOptions,
|
||||
UseOnboardingWizardResult,
|
||||
} from './types';
|
||||
|
||||
const logger = createLogger('OnboardingWizard');
|
||||
|
||||
/** Default state for new wizards */
|
||||
const DEFAULT_ONBOARDING_STATE: OnboardingState = {
|
||||
completed: false,
|
||||
skipped: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Load onboarding state from localStorage
|
||||
*/
|
||||
function loadOnboardingState(storageKey: string): OnboardingState {
|
||||
try {
|
||||
const fullKey = `${ONBOARDING_STORAGE_PREFIX}:${storageKey}`;
|
||||
const stored = getItem(fullKey);
|
||||
if (stored) {
|
||||
return JSON.parse(stored) as OnboardingState;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to load onboarding state:', error);
|
||||
}
|
||||
return { ...DEFAULT_ONBOARDING_STATE };
|
||||
}
|
||||
|
||||
/**
|
||||
* Save onboarding state to localStorage
|
||||
*/
|
||||
function saveOnboardingState(storageKey: string, state: OnboardingState): void {
|
||||
try {
|
||||
const fullKey = `${ONBOARDING_STORAGE_PREFIX}:${storageKey}`;
|
||||
setItem(fullKey, JSON.stringify(state));
|
||||
} catch (error) {
|
||||
logger.error('Failed to save onboarding state:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track analytics event (placeholder - integrate with actual analytics service)
|
||||
*/
|
||||
function trackAnalytics(event: string, data?: Record<string, unknown>): void {
|
||||
logger.debug(`[Analytics] ${event}`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic hook for managing onboarding wizard state.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const wizard = useOnboardingWizard({
|
||||
* storageKey: 'my-view-onboarding',
|
||||
* steps: MY_WIZARD_STEPS,
|
||||
* onComplete: () => console.log('Done!'),
|
||||
* });
|
||||
*
|
||||
* // Start the wizard when user clicks help button
|
||||
* <button onClick={wizard.startWizard}>Help</button>
|
||||
*
|
||||
* // Render the wizard
|
||||
* <OnboardingWizard
|
||||
* isVisible={wizard.isVisible}
|
||||
* currentStep={wizard.currentStep}
|
||||
* currentStepData={wizard.currentStepData}
|
||||
* totalSteps={wizard.totalSteps}
|
||||
* onNext={wizard.goToNextStep}
|
||||
* onPrevious={wizard.goToPreviousStep}
|
||||
* onSkip={wizard.skipWizard}
|
||||
* onComplete={wizard.completeWizard}
|
||||
* steps={MY_WIZARD_STEPS}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function useOnboardingWizard({
|
||||
storageKey,
|
||||
steps,
|
||||
onComplete,
|
||||
onSkip,
|
||||
}: UseOnboardingWizardOptions): UseOnboardingWizardResult {
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [isWizardVisible, setIsWizardVisible] = useState(false);
|
||||
const [onboardingState, setOnboardingState] = useState<OnboardingState>(DEFAULT_ONBOARDING_STATE);
|
||||
|
||||
// Load persisted state on mount
|
||||
useEffect(() => {
|
||||
const state = loadOnboardingState(storageKey);
|
||||
setOnboardingState(state);
|
||||
}, [storageKey]);
|
||||
|
||||
// Update persisted state helper
|
||||
const updateState = useCallback(
|
||||
(updates: Partial<OnboardingState>) => {
|
||||
setOnboardingState((prev) => {
|
||||
const newState = { ...prev, ...updates };
|
||||
saveOnboardingState(storageKey, newState);
|
||||
return newState;
|
||||
});
|
||||
},
|
||||
[storageKey]
|
||||
);
|
||||
|
||||
// Current step data
|
||||
const currentStepData = useMemo(() => steps[currentStep] || null, [steps, currentStep]);
|
||||
const totalSteps = steps.length;
|
||||
|
||||
// Navigation handlers
|
||||
const goToNextStep = useCallback(() => {
|
||||
if (currentStep < totalSteps - 1) {
|
||||
const nextStep = currentStep + 1;
|
||||
setCurrentStep(nextStep);
|
||||
trackAnalytics(ONBOARDING_ANALYTICS.STEP_VIEWED, {
|
||||
storageKey,
|
||||
step: nextStep,
|
||||
stepId: steps[nextStep]?.id,
|
||||
});
|
||||
}
|
||||
}, [currentStep, totalSteps, storageKey, steps]);
|
||||
|
||||
const goToPreviousStep = useCallback(() => {
|
||||
if (currentStep > 0) {
|
||||
setCurrentStep(currentStep - 1);
|
||||
}
|
||||
}, [currentStep]);
|
||||
|
||||
const goToStep = useCallback(
|
||||
(step: number) => {
|
||||
if (step >= 0 && step < totalSteps) {
|
||||
setCurrentStep(step);
|
||||
trackAnalytics(ONBOARDING_ANALYTICS.STEP_VIEWED, {
|
||||
storageKey,
|
||||
step,
|
||||
stepId: steps[step]?.id,
|
||||
});
|
||||
}
|
||||
},
|
||||
[totalSteps, storageKey, steps]
|
||||
);
|
||||
|
||||
// Wizard lifecycle handlers
|
||||
const startWizard = useCallback(() => {
|
||||
setCurrentStep(0);
|
||||
setIsWizardVisible(true);
|
||||
trackAnalytics(ONBOARDING_ANALYTICS.STARTED, { storageKey });
|
||||
}, [storageKey]);
|
||||
|
||||
const completeWizard = useCallback(() => {
|
||||
setIsWizardVisible(false);
|
||||
setCurrentStep(0);
|
||||
updateState({
|
||||
completed: true,
|
||||
completedAt: new Date().toISOString(),
|
||||
});
|
||||
trackAnalytics(ONBOARDING_ANALYTICS.COMPLETED, { storageKey });
|
||||
onComplete?.();
|
||||
}, [storageKey, updateState, onComplete]);
|
||||
|
||||
const skipWizard = useCallback(() => {
|
||||
setIsWizardVisible(false);
|
||||
setCurrentStep(0);
|
||||
updateState({
|
||||
skipped: true,
|
||||
skippedAt: new Date().toISOString(),
|
||||
});
|
||||
trackAnalytics(ONBOARDING_ANALYTICS.SKIPPED, {
|
||||
storageKey,
|
||||
skippedAtStep: currentStep,
|
||||
});
|
||||
onSkip?.();
|
||||
}, [storageKey, currentStep, updateState, onSkip]);
|
||||
|
||||
return {
|
||||
// Visibility
|
||||
isVisible: isWizardVisible,
|
||||
|
||||
// Steps
|
||||
currentStep,
|
||||
currentStepData,
|
||||
totalSteps,
|
||||
|
||||
// Navigation
|
||||
goToNextStep,
|
||||
goToPreviousStep,
|
||||
goToStep,
|
||||
|
||||
// Actions
|
||||
startWizard,
|
||||
completeWizard,
|
||||
skipWizard,
|
||||
|
||||
// State
|
||||
isCompleted: onboardingState.completed,
|
||||
isSkipped: onboardingState.skipped,
|
||||
};
|
||||
}
|
||||
186
apps/ui/src/components/ui/context-menu.tsx
Normal file
186
apps/ui/src/components/ui/context-menu.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import * as React from 'react';
|
||||
import * as ContextMenuPrimitive from '@radix-ui/react-context-menu';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Check, ChevronRight, Circle } from 'lucide-react';
|
||||
|
||||
const ContextMenu = ContextMenuPrimitive.Root;
|
||||
|
||||
const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
|
||||
|
||||
const ContextMenuGroup = ContextMenuPrimitive.Group;
|
||||
|
||||
const ContextMenuPortal = ContextMenuPrimitive.Portal;
|
||||
|
||||
const ContextMenuSub = ContextMenuPrimitive.Sub;
|
||||
|
||||
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
|
||||
|
||||
const ContextMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
));
|
||||
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const ContextMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const ContextMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Portal>
|
||||
<ContextMenuPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ContextMenuPrimitive.Portal>
|
||||
));
|
||||
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
|
||||
|
||||
const ContextMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
|
||||
|
||||
const ContextMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
ContextMenuCheckboxItem.displayName = ContextMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const ContextMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.RadioItem>
|
||||
));
|
||||
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const ContextMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn('px-2 py-1.5 text-sm font-semibold text-foreground', inset && 'pl-8', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
|
||||
|
||||
const ContextMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-border', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
|
||||
|
||||
const ContextMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
ContextMenuShortcut.displayName = 'ContextMenuShortcut';
|
||||
|
||||
export {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuGroup,
|
||||
ContextMenuPortal,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuRadioGroup,
|
||||
};
|
||||
@@ -84,12 +84,11 @@ const KEYBOARD_ROWS = [
|
||||
// Map shortcut names to human-readable labels
|
||||
const SHORTCUT_LABELS: Record<keyof KeyboardShortcuts, string> = {
|
||||
board: 'Kanban Board',
|
||||
graph: 'Graph View',
|
||||
agent: 'Agent Runner',
|
||||
spec: 'Spec Editor',
|
||||
context: 'Context',
|
||||
memory: 'Memory',
|
||||
settings: 'Settings',
|
||||
profiles: 'AI Profiles',
|
||||
terminal: 'Terminal',
|
||||
ideation: 'Ideation',
|
||||
githubIssues: 'GitHub Issues',
|
||||
@@ -103,6 +102,7 @@ const SHORTCUT_LABELS: Record<keyof KeyboardShortcuts, string> = {
|
||||
projectPicker: 'Project Picker',
|
||||
cyclePrevProject: 'Prev Project',
|
||||
cycleNextProject: 'Next Project',
|
||||
addProfile: 'Add Profile',
|
||||
splitTerminalRight: 'Split Right',
|
||||
splitTerminalDown: 'Split Down',
|
||||
closeTerminal: 'Close Terminal',
|
||||
@@ -112,12 +112,11 @@ const SHORTCUT_LABELS: Record<keyof KeyboardShortcuts, string> = {
|
||||
// Categorize shortcuts for color coding
|
||||
const SHORTCUT_CATEGORIES: Record<keyof KeyboardShortcuts, 'navigation' | 'ui' | 'action'> = {
|
||||
board: 'navigation',
|
||||
graph: 'navigation',
|
||||
agent: 'navigation',
|
||||
spec: 'navigation',
|
||||
context: 'navigation',
|
||||
memory: 'navigation',
|
||||
settings: 'navigation',
|
||||
profiles: 'navigation',
|
||||
terminal: 'navigation',
|
||||
ideation: 'navigation',
|
||||
githubIssues: 'navigation',
|
||||
@@ -131,6 +130,7 @@ const SHORTCUT_CATEGORIES: Record<keyof KeyboardShortcuts, 'navigation' | 'ui' |
|
||||
projectPicker: 'action',
|
||||
cyclePrevProject: 'action',
|
||||
cycleNextProject: 'action',
|
||||
addProfile: 'action',
|
||||
splitTerminalRight: 'action',
|
||||
splitTerminalDown: 'action',
|
||||
closeTerminal: 'action',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ComponentType, SVGProps } from 'react';
|
||||
import { Cpu } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { AgentModel, ModelProvider } from '@automaker/types';
|
||||
import { getProviderFromModel } from '@/lib/utils';
|
||||
@@ -9,15 +10,6 @@ const PROVIDER_ICON_KEYS = {
|
||||
cursor: 'cursor',
|
||||
gemini: 'gemini',
|
||||
grok: 'grok',
|
||||
opencode: 'opencode',
|
||||
deepseek: 'deepseek',
|
||||
qwen: 'qwen',
|
||||
nova: 'nova',
|
||||
meta: 'meta',
|
||||
mistral: 'mistral',
|
||||
minimax: 'minimax',
|
||||
glm: 'glm',
|
||||
bigpickle: 'bigpickle',
|
||||
} as const;
|
||||
|
||||
type ProviderIconKey = keyof typeof PROVIDER_ICON_KEYS;
|
||||
@@ -25,8 +17,6 @@ type ProviderIconKey = keyof typeof PROVIDER_ICON_KEYS;
|
||||
interface ProviderIconDefinition {
|
||||
viewBox: string;
|
||||
path: string;
|
||||
fillRule?: 'nonzero' | 'evenodd';
|
||||
fill?: string;
|
||||
}
|
||||
|
||||
const PROVIDER_ICON_DEFINITIONS: Record<ProviderIconKey, ProviderIconDefinition> = {
|
||||
@@ -34,18 +24,15 @@ const PROVIDER_ICON_DEFINITIONS: Record<ProviderIconKey, ProviderIconDefinition>
|
||||
viewBox: '0 0 248 248',
|
||||
// Official Claude logo from claude.ai favicon
|
||||
path: 'M52.4285 162.873L98.7844 136.879L99.5485 134.602L98.7844 133.334H96.4921L88.7237 132.862L62.2346 132.153L39.3113 131.207L17.0249 130.026L11.4214 128.844L6.2 121.873L6.7094 118.447L11.4214 115.257L18.171 115.847L33.0711 116.911L55.485 118.447L71.6586 119.392L95.728 121.873H99.5485L100.058 120.337L98.7844 119.392L97.7656 118.447L74.5877 102.732L49.4995 86.1905L36.3823 76.62L29.3779 71.7757L25.8121 67.2858L24.2839 57.3608L30.6515 50.2716L39.3113 50.8623L41.4763 51.4531L50.2636 58.1879L68.9842 72.7209L93.4357 90.6804L97.0015 93.6343L98.4374 92.6652L98.6571 91.9801L97.0015 89.2625L83.757 65.2772L69.621 40.8192L63.2534 30.6579L61.5978 24.632C60.9565 22.1032 60.579 20.0111 60.579 17.4246L67.8381 7.49965L71.9133 6.19995L81.7193 7.49965L85.7946 11.0443L91.9074 24.9865L101.714 46.8451L116.996 76.62L121.453 85.4816L123.873 93.6343L124.764 96.1155H126.292V94.6976L127.566 77.9197L129.858 57.3608L132.15 30.8942L132.915 23.4505L136.608 14.4708L143.994 9.62643L149.725 12.344L154.437 19.0788L153.8 23.4505L150.998 41.6463L145.522 70.1215L141.957 89.2625H143.994L146.414 86.7813L156.093 74.0206L172.266 53.698L179.398 45.6635L187.803 36.802L193.152 32.5484H203.34L210.726 43.6549L207.415 55.1159L196.972 68.3492L188.312 79.5739L175.896 96.2095L168.191 109.585L168.882 110.689L170.738 110.53L198.755 104.504L213.91 101.787L231.994 98.7149L240.144 102.496L241.036 106.395L237.852 114.311L218.495 119.037L195.826 123.645L162.07 131.592L161.696 131.893L162.137 132.547L177.36 133.925L183.855 134.279H199.774L229.447 136.524L237.215 141.605L241.8 147.867L241.036 152.711L229.065 158.737L213.019 154.956L175.45 145.977L162.587 142.787H160.805V143.85L171.502 154.366L191.242 172.089L215.82 195.011L217.094 200.682L213.91 205.172L210.599 204.699L188.949 188.394L180.544 181.069L161.696 165.118H160.422V166.772L164.752 173.152L187.803 207.771L188.949 218.405L187.294 221.832L181.308 223.959L174.813 222.777L161.187 203.754L147.305 182.486L136.098 163.345L134.745 164.2L128.075 235.42L125.019 239.082L117.887 241.8L111.902 237.31L108.718 229.984L111.902 215.452L115.722 196.547L118.779 181.541L121.58 162.873L123.291 156.636L123.14 156.219L121.773 156.449L107.699 175.752L86.304 204.699L69.3663 222.777L65.291 224.431L58.2867 220.768L58.9235 214.27L62.8713 208.48L86.304 178.705L100.44 160.155L109.551 149.507L109.462 147.967L108.959 147.924L46.6977 188.512L35.6182 189.93L30.7788 185.44L31.4156 178.115L33.7079 175.752L52.4285 162.873Z',
|
||||
fill: '#d97757',
|
||||
},
|
||||
openai: {
|
||||
viewBox: '0 0 158.7128 157.296',
|
||||
path: 'M60.8734,57.2556v-14.9432c0-1.2586.4722-2.2029,1.5728-2.8314l30.0443-17.3023c4.0899-2.3593,8.9662-3.4599,13.9988-3.4599,18.8759,0,30.8307,14.6289,30.8307,30.2006,0,1.1007,0,2.3593-.158,3.6178l-31.1446-18.2467c-1.8872-1.1006-3.7754-1.1006-5.6629,0l-39.4812,22.9651ZM131.0276,115.4561v-35.7074c0-2.2028-.9446-3.7756-2.8318-4.8763l-39.481-22.9651,12.8982-7.3934c1.1007-.6285,2.0453-.6285,3.1458,0l30.0441,17.3024c8.6523,5.0341,14.4708,15.7296,14.4708,26.1107,0,11.9539-7.0769,22.965-18.2461,27.527v.0021ZM51.593,83.9964l-12.8982-7.5497c-1.1007-.6285-1.5728-1.5728-1.5728-2.8314v-34.6048c0-16.8303,12.8982-29.5722,30.3585-29.5722,6.607,0,12.7403,2.2029,17.9324,6.1349l-30.987,17.9324c-1.8871,1.1007-2.8314,2.6735-2.8314,4.8764v45.6159l-.0014-.0015ZM79.3562,100.0403l-18.4829-10.3811v-22.0209l18.4829-10.3811,18.4812,10.3811v22.0209l-18.4812,10.3811ZM91.2319,147.8591c-6.607,0-12.7403-2.2031-17.9324-6.1344l30.9866-17.9333c1.8872-1.1005,2.8318-2.6728,2.8318-4.8759v-45.616l13.0564,7.5498c1.1005.6285,1.5723,1.5728,1.5723,2.8314v34.6051c0,16.8297-13.0564,29.5723-30.5147,29.5723v.001ZM53.9522,112.7822l-30.0443-17.3024c-8.652-5.0343-14.471-15.7296-14.471-26.1107,0-12.1119,7.2356-22.9652,18.403-27.5272v35.8634c0,2.2028.9443,3.7756,2.8314,4.8763l39.3248,22.8068-12.8982,7.3938c-1.1007.6287-2.045.6287-3.1456,0ZM52.2229,138.5791c-17.7745,0-30.8306-13.3713-30.8306-29.8871,0-1.2585.1578-2.5169.3143-3.7754l30.987,17.9323c1.8871,1.1005,3.7757,1.1005,5.6628,0l39.4811-22.807v14.9435c0,1.2585-.4721,2.2021-1.5728,2.8308l-30.0443,17.3025c-4.0898,2.359-8.9662,3.4605-13.9989,3.4605h.0014ZM91.2319,157.296c19.0327,0,34.9188-13.5272,38.5383-31.4594,17.6164-4.562,28.9425-21.0779,28.9425-37.908,0-11.0112-4.719-21.7066-13.2133-29.4143.7867-3.3035,1.2595-6.607,1.2595-9.909,0-22.4929-18.2471-39.3247-39.3251-39.3247-4.2461,0-8.3363.6285-12.4262,2.045-7.0792-6.9213-16.8318-11.3254-27.5271-11.3254-19.0331,0-34.9191,13.5268-38.5384,31.4591C11.3255,36.0212,0,52.5373,0,69.3675c0,11.0112,4.7184,21.7065,13.2125,29.4142-.7865,3.3035-1.2586,6.6067-1.2586,9.9092,0,22.4923,18.2466,39.3241,39.3248,39.3241,4.2462,0,8.3362-.6277,12.426-2.0441,7.0776,6.921,16.8302,11.3251,27.5271,11.3251Z',
|
||||
fill: '#74aa9c',
|
||||
},
|
||||
cursor: {
|
||||
viewBox: '0 0 512 512',
|
||||
// Official Cursor logo - hexagonal shape with triangular wedge
|
||||
path: 'M415.035 156.35l-151.503-87.4695c-4.865-2.8094-10.868-2.8094-15.733 0l-151.4969 87.4695c-4.0897 2.362-6.6146 6.729-6.6146 11.459v176.383c0 4.73 2.5249 9.097 6.6146 11.458l151.5039 87.47c4.865 2.809 10.868 2.809 15.733 0l151.504-87.47c4.089-2.361 6.614-6.728 6.614-11.458v-176.383c0-4.73-2.525-9.097-6.614-11.459zm-9.516 18.528l-146.255 253.32c-.988 1.707-3.599 1.01-3.599-.967v-165.872c0-3.314-1.771-6.379-4.644-8.044l-143.645-82.932c-1.707-.988-1.01-3.599.968-3.599h292.509c4.154 0 6.75 4.503 4.673 8.101h-.007z',
|
||||
fill: '#5E9EFF',
|
||||
},
|
||||
gemini: {
|
||||
viewBox: '0 0 192 192',
|
||||
@@ -57,55 +44,6 @@ const PROVIDER_ICON_DEFINITIONS: Record<ProviderIconKey, ProviderIconDefinition>
|
||||
// Official Grok/xAI logo - stylized symbol from grok.com
|
||||
path: 'M213.235 306.019l178.976-180.002v.169l51.695-51.763c-.924 1.32-1.86 2.605-2.785 3.89-39.281 54.164-58.46 80.649-43.07 146.922l-.09-.101c10.61 45.11-.744 95.137-37.398 131.836-46.216 46.306-120.167 56.611-181.063 14.928l42.462-19.675c38.863 15.278 81.392 8.57 111.947-22.03 30.566-30.6 37.432-75.159 22.065-112.252-2.92-7.025-11.67-8.795-17.792-4.263l-124.947 92.341zm-25.786 22.437l-.033.034L68.094 435.217c7.565-10.429 16.957-20.294 26.327-30.149 26.428-27.803 52.653-55.359 36.654-94.302-21.422-52.112-8.952-113.177 30.724-152.898 41.243-41.254 101.98-51.661 152.706-30.758 11.23 4.172 21.016 10.114 28.638 15.639l-42.359 19.584c-39.44-16.563-84.629-5.299-112.207 22.313-37.298 37.308-44.84 102.003-1.128 143.81z',
|
||||
},
|
||||
opencode: {
|
||||
viewBox: '0 0 512 512',
|
||||
// Official OpenCode favicon - geometric icon from opencode.ai
|
||||
path: 'M384 416H128V96H384V416ZM320 160H192V352H320V160Z',
|
||||
fillRule: 'evenodd',
|
||||
fill: '#6366F1',
|
||||
},
|
||||
deepseek: {
|
||||
viewBox: '0 0 24 24',
|
||||
// Official DeepSeek logo - whale icon from lobehub/lobe-icons
|
||||
path: 'M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z',
|
||||
},
|
||||
qwen: {
|
||||
viewBox: '0 0 24 24',
|
||||
// Official Qwen logo - geometric star from lobehub/lobe-icons
|
||||
path: 'M12.604 1.34c.393.69.784 1.382 1.174 2.075a.18.18 0 00.157.091h5.552c.174 0 .322.11.446.327l1.454 2.57c.19.337.24.478.024.837-.26.43-.513.864-.76 1.3l-.367.658c-.106.196-.223.28-.04.512l2.652 4.637c.172.301.111.494-.043.77-.437.785-.882 1.564-1.335 2.34-.159.272-.352.375-.68.37-.777-.016-1.552-.01-2.327.016a.099.099 0 00-.081.05 575.097 575.097 0 01-2.705 4.74c-.169.293-.38.363-.725.364-.997.003-2.002.004-3.017.002a.537.537 0 01-.465-.271l-1.335-2.323a.09.09 0 00-.083-.049H4.982c-.285.03-.553-.001-.805-.092l-1.603-2.77a.543.543 0 01-.002-.54l1.207-2.12a.198.198 0 000-.197 550.951 550.951 0 01-1.875-3.272l-.79-1.395c-.16-.31-.173-.496.095-.965.465-.813.927-1.625 1.387-2.436.132-.234.304-.334.584-.335a338.3 338.3 0 012.589-.001.124.124 0 00.107-.063l2.806-4.895a.488.488 0 01.422-.246c.524-.001 1.053 0 1.583-.006L11.704 1c.341-.003.724.032.9.34zm-3.432.403a.06.06 0 00-.052.03L6.254 6.788a.157.157 0 01-.135.078H3.253c-.056 0-.07.025-.041.074l5.81 10.156c.025.042.013.062-.034.063l-2.795.015a.218.218 0 00-.2.116l-1.32 2.31c-.044.078-.021.118.068.118l5.716.008c.046 0 .08.02.104.061l1.403 2.454c.046.081.092.082.139 0l5.006-8.76.783-1.382a.055.055 0 01.096 0l1.424 2.53a.122.122 0 00.107.062l2.763-.02a.04.04 0 00.035-.02.041.041 0 000-.04l-2.9-5.086a.108.108 0 010-.113l.293-.507 1.12-1.977c.024-.041.012-.062-.035-.062H9.2c-.059 0-.073-.026-.043-.077l1.434-2.505a.107.107 0 000-.114L9.225 1.774a.06.06 0 00-.053-.031zm6.29 8.02c.046 0 .058.02.034.06l-.832 1.465-2.613 4.585a.056.056 0 01-.05.029.058.058 0 01-.05-.029L8.498 9.841c-.02-.034-.01-.052.028-.054l.216-.012 6.722-.012z',
|
||||
},
|
||||
nova: {
|
||||
viewBox: '0 0 33 32',
|
||||
// Official Amazon Nova logo from lobehub/lobe-icons
|
||||
path: 'm17.865 23.28 1.533 1.543c.07.07.092.175.055.267l-2.398 6.118A1.24 1.24 0 0 1 15.9 32c-.51 0-.969-.315-1.155-.793l-3.451-8.804-5.582 5.617a.246.246 0 0 1-.35 0l-1.407-1.415a.25.25 0 0 1 0-.352l6.89-6.932a1.3 1.3 0 0 1 .834-.398 1.25 1.25 0 0 1 1.232.79l2.992 7.63 1.557-3.977a.248.248 0 0 1 .408-.085zm8.224-19.3-5.583 5.617-3.45-8.805a1.24 1.24 0 0 0-1.43-.762c-.414.092-.744.407-.899.805l-2.38 6.072a.25.25 0 0 0 .055.267l1.533 1.543c.127.127.34.082.407-.085L15.9 4.655l2.991 7.629a1.24 1.24 0 0 0 2.035.425l6.922-6.965a.25.25 0 0 0 0-.352L26.44 3.977a.246.246 0 0 0-.35 0zM8.578 17.566l-3.953-1.567 7.582-3.01c.49-.195.815-.685.785-1.24a1.3 1.3 0 0 0-.395-.84l-6.886-6.93a.246.246 0 0 0-.35 0L3.954 5.395a.25.25 0 0 0 0 .353l5.583 5.617-8.75 3.472a1.25 1.25 0 0 0 0 2.325l6.079 2.412a.24.24 0 0 0 .266-.055l1.533-1.542a.25.25 0 0 0-.085-.41zm22.434-2.73-6.08-2.412a.24.24 0 0 0-.265.055l-1.533 1.542a.25.25 0 0 0 .084.41L27.172 16l-7.583 3.01a1.255 1.255 0 0 0-.785 1.24c.018.317.172.614.395.84l6.89 6.931a.246.246 0 0 0 .35 0l1.406-1.415a.25.25 0 0 0 0-.352l-5.582-5.617 8.75-3.472a1.25 1.25 0 0 0 0-2.325z',
|
||||
fill: '#FF9900',
|
||||
},
|
||||
// Meta and Mistral use custom standalone SVG components
|
||||
// These placeholder entries prevent TypeScript errors
|
||||
meta: {
|
||||
viewBox: '0 0 24 24',
|
||||
path: '',
|
||||
},
|
||||
mistral: {
|
||||
viewBox: '0 0 24 24',
|
||||
path: '',
|
||||
},
|
||||
minimax: {
|
||||
viewBox: '0 0 24 24',
|
||||
// Official MiniMax logo from lobehub/lobe-icons
|
||||
path: 'M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z',
|
||||
},
|
||||
glm: {
|
||||
viewBox: '0 0 24 24',
|
||||
// Official Z.ai logo from lobehub/lobe-icons (GLM provider)
|
||||
path: 'M12.105 2L9.927 4.953H.653L2.83 2h9.276zM23.254 19.048L21.078 22h-9.242l2.174-2.952h9.244zM24 2L9.264 22H0L14.736 2H24z',
|
||||
},
|
||||
bigpickle: {
|
||||
viewBox: '0 0 24 24',
|
||||
// Big Pickle logo - stylized shape with dots
|
||||
path: 'M8 4c-2.21 0-4 1.79-4 4v8c0 2.21 1.79 4 4 4h8c2.21 0 4-1.79 4-4V8c0-2.21-1.79-4-4-4H8zm0 2h8c1.103 0 2 .897 2 2v8c0 1.103-.897 2-2 2H8c-1.103 0-2-.897-2-2V8c0-1.103.897-2 2-2zm2 3a1 1 0 100 2 1 1 0 000-2zm4 0a1 1 0 100 2 1 1 0 000-2zm-4 4a1 1 0 100 2 1 1 0 000-2zm4 0a1 1 0 100 2 1 1 0 000-2z',
|
||||
fill: '#4ADE80',
|
||||
},
|
||||
};
|
||||
|
||||
export interface ProviderIconProps extends Omit<SVGProps<SVGSVGElement>, 'viewBox'> {
|
||||
@@ -134,11 +72,7 @@ export function ProviderIcon({ provider, title, className, ...props }: ProviderI
|
||||
{...rest}
|
||||
>
|
||||
{title && <title>{title}</title>}
|
||||
<path
|
||||
d={definition.path}
|
||||
fill={definition.fill || 'currentColor'}
|
||||
fillRule={definition.fillRule}
|
||||
/>
|
||||
<path d={definition.path} fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -163,217 +97,8 @@ export function GrokIcon(props: Omit<ProviderIconProps, 'provider'>) {
|
||||
return <ProviderIcon provider={PROVIDER_ICON_KEYS.grok} {...props} />;
|
||||
}
|
||||
|
||||
export function OpenCodeIcon(props: Omit<ProviderIconProps, 'provider'>) {
|
||||
return <ProviderIcon provider={PROVIDER_ICON_KEYS.opencode} {...props} />;
|
||||
}
|
||||
|
||||
export function DeepSeekIcon({
|
||||
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="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z"
|
||||
fill="#4D6BFE"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function QwenIcon({ 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>}
|
||||
<defs>
|
||||
<linearGradient id="qwen-gradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style={{ stopColor: '#6336E7', stopOpacity: 0.84 }} />
|
||||
<stop offset="100%" style={{ stopColor: '#6F69F7', stopOpacity: 0.84 }} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
d="M12.604 1.34c.393.69.784 1.382 1.174 2.075a.18.18 0 00.157.091h5.552c.174 0 .322.11.446.327l1.454 2.57c.19.337.24.478.024.837-.26.43-.513.864-.76 1.3l-.367.658c-.106.196-.223.28-.04.512l2.652 4.637c.172.301.111.494-.043.77-.437.785-.882 1.564-1.335 2.34-.159.272-.352.375-.68.37-.777-.016-1.552-.01-2.327.016a.099.099 0 00-.081.05 575.097 575.097 0 01-2.705 4.74c-.169.293-.38.363-.725.364-.997.003-2.002.004-3.017.002a.537.537 0 01-.465-.271l-1.335-2.323a.09.09 0 00-.083-.049H4.982c-.285.03-.553-.001-.805-.092l-1.603-2.77a.543.543 0 01-.002-.54l1.207-2.12a.198.198 0 000-.197 550.951 550.951 0 01-1.875-3.272l-.79-1.395c-.16-.31-.173-.496.095-.965.465-.813.927-1.625 1.387-2.436.132-.234.304-.334.584-.335a338.3 338.3 0 012.589-.001.124.124 0 00.107-.063l2.806-4.895a.488.488 0 01.422-.246c.524-.001 1.053 0 1.583-.006L11.704 1c.341-.003.724.032.9.34zm-3.432.403a.06.06 0 00-.052.03L6.254 6.788a.157.157 0 01-.135.078H3.253c-.056 0-.07.025-.041.074l5.81 10.156c.025.042.013.062-.034.063l-2.795.015a.218.218 0 00-.2.116l-1.32 2.31c-.044.078-.021.118.068.118l5.716.008c.046 0 .08.02.104.061l1.403 2.454c.046.081.092.082.139 0l5.006-8.76.783-1.382a.055.055 0 01.096 0l1.424 2.53a.122.122 0 00.107.062l2.763-.02a.04.04 0 00.035-.02.041.041 0 000-.04l-2.9-5.086a.108.108 0 010-.113l.293-.507 1.12-1.977c.024-.041.012-.062-.035-.062H9.2c-.059 0-.073-.026-.043-.077l1.434-2.505a.107.107 0 000-.114L9.225 1.774a.06.06 0 00-.053-.031zm6.29 8.02c.046 0 .058.02.034.06l-.832 1.465-2.613 4.585a.056.056 0 01-.05.029.058.058 0 01-.05-.029L8.498 9.841c-.02-.034-.01-.052.028-.054l.216-.012 6.722-.012z"
|
||||
fill="url(#qwen-gradient)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function NovaIcon({ className, title, ...props }: { className?: string; title?: string }) {
|
||||
const hasAccessibleLabel = Boolean(title);
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 33 32"
|
||||
className={cn('inline-block', className)}
|
||||
role={hasAccessibleLabel ? 'img' : 'presentation'}
|
||||
aria-hidden={!hasAccessibleLabel}
|
||||
focusable="false"
|
||||
{...props}
|
||||
>
|
||||
{title && <title>{title}</title>}
|
||||
<path
|
||||
d="m17.865 23.28 1.533 1.543c.07.07.092.175.055.267l-2.398 6.118A1.24 1.24 0 0 1 15.9 32c-.51 0-.969-.315-1.155-.793l-3.451-8.804-5.582 5.617a.246.246 0 0 1-.35 0l-1.407-1.415a.25.25 0 0 1 0-.352l6.89-6.932a1.3 1.3 0 0 1 .834-.398 1.25 1.25 0 0 1 1.232.79l2.992 7.63 1.557-3.977a.248.248 0 0 1 .408-.085zm8.224-19.3-5.583 5.617-3.45-8.805a1.24 1.24 0 0 0-1.43-.762c-.414.092-.744.407-.899.805l-2.38 6.072a.25.25 0 0 0 .055.267l1.533 1.543c.127.127.34.082.407-.085L15.9 4.655l2.991 7.629a1.24 1.24 0 0 0 2.035.425l6.922-6.965a.25.25 0 0 0 0-.352L26.44 3.977a.246.246 0 0 0-.35 0zM8.578 17.566l-3.953-1.567 7.582-3.01c.49-.195.815-.685.785-1.24a1.3 1.3 0 0 0-.395-.84l-6.886-6.93a.246.246 0 0 0-.35 0L3.954 5.395a.25.25 0 0 0 0 .353l5.583 5.617-8.75 3.472a1.25 1.25 0 0 0 0 2.325l6.079 2.412a.24.24 0 0 0 .266-.055l1.533-1.542a.25.25 0 0 0-.085-.41zm22.434-2.73-6.08-2.412a.24.24 0 0 0-.265.055l-1.533 1.542a.25.25 0 0 0 .084.41L27.172 16l-7.583 3.01a1.255 1.255 0 0 0-.785 1.24c.018.317.172.614.395.84l6.89 6.931a.246.246 0 0 0 .35 0l1.406-1.415a.25.25 0 0 0 0-.352l-5.582-5.617 8.75-3.472a1.25 1.25 0 0 0 0-2.325z"
|
||||
fill="#FF9900"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function MistralIcon({
|
||||
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="M3.428 3.4h3.429v3.428H3.428V3.4zm13.714 0h3.43v3.428h-3.43V3.4z" fill="gold" />
|
||||
<path
|
||||
d="M3.428 6.828h6.857v3.429H3.429V6.828zm10.286 0h6.857v3.429h-6.857V6.828z"
|
||||
fill="#FFAF00"
|
||||
/>
|
||||
<path d="M3.428 10.258h17.144v3.428H3.428v-3.428z" fill="#FF8205" />
|
||||
<path
|
||||
d="M3.428 13.686h3.429v3.428H3.428v-3.428zm6.858 0h3.429v3.428h-3.429v-3.428zm6.856 0h3.43v3.428h-3.43v-3.428z"
|
||||
fill="#FA500F"
|
||||
/>
|
||||
<path d="M0 17.114h10.286v3.429H0v-3.429zm13.714 0H24v3.429H13.714v-3.429z" fill="#E10500" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function MetaIcon({ 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="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"
|
||||
fill="#1877F2"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
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 function OpenCodeIcon({ className, ...props }: { className?: string }) {
|
||||
return <Cpu className={cn('inline-block', className)} {...props} />;
|
||||
}
|
||||
|
||||
export const PROVIDER_ICON_COMPONENTS: Record<
|
||||
@@ -381,7 +106,7 @@ export const PROVIDER_ICON_COMPONENTS: Record<
|
||||
ComponentType<{ className?: string }>
|
||||
> = {
|
||||
claude: AnthropicIcon,
|
||||
cursor: CursorIcon,
|
||||
cursor: CursorIcon, // Default for Cursor provider (will be overridden by getProviderIconForModel)
|
||||
codex: OpenAIIcon,
|
||||
opencode: OpenCodeIcon,
|
||||
};
|
||||
@@ -395,53 +120,6 @@ function getUnderlyingModelIcon(model?: AgentModel | string): ProviderIconKey {
|
||||
|
||||
const modelStr = typeof model === 'string' ? model.toLowerCase() : model;
|
||||
|
||||
// Check for Amazon Bedrock models first (amazon-bedrock/...)
|
||||
if (modelStr.startsWith('amazon-bedrock/')) {
|
||||
// Bedrock-hosted models - detect the specific provider
|
||||
if (modelStr.includes('anthropic') || modelStr.includes('claude')) {
|
||||
return 'anthropic';
|
||||
}
|
||||
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';
|
||||
}
|
||||
|
||||
// Check for Cursor-specific models with underlying providers
|
||||
if (modelStr.includes('sonnet') || modelStr.includes('opus') || modelStr.includes('claude')) {
|
||||
return 'anthropic';
|
||||
@@ -463,7 +141,6 @@ function getUnderlyingModelIcon(model?: AgentModel | string): ProviderIconKey {
|
||||
const provider = getProviderFromModel(model);
|
||||
if (provider === 'codex') return 'openai';
|
||||
if (provider === 'cursor') return 'cursor';
|
||||
if (provider === 'opencode') return 'opencode';
|
||||
return 'anthropic';
|
||||
}
|
||||
|
||||
@@ -478,15 +155,6 @@ export function getProviderIconForModel(
|
||||
cursor: CursorIcon,
|
||||
gemini: GeminiIcon,
|
||||
grok: GrokIcon,
|
||||
opencode: OpenCodeIcon,
|
||||
deepseek: DeepSeekIcon,
|
||||
qwen: QwenIcon,
|
||||
nova: NovaIcon,
|
||||
meta: MetaIcon,
|
||||
mistral: MistralIcon,
|
||||
minimax: MiniMaxIcon,
|
||||
glm: GlmIcon,
|
||||
bigpickle: BigPickleIcon,
|
||||
};
|
||||
|
||||
return iconMap[iconKey] || AnthropicIcon;
|
||||
|
||||
@@ -16,10 +16,24 @@ interface AgentModelSelectorProps {
|
||||
onChange: (entry: PhaseModelEntry) => void;
|
||||
/** Disabled state */
|
||||
disabled?: boolean;
|
||||
/** Custom trigger class name */
|
||||
triggerClassName?: string;
|
||||
}
|
||||
|
||||
export function AgentModelSelector({ value, onChange, disabled }: AgentModelSelectorProps) {
|
||||
export function AgentModelSelector({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
triggerClassName,
|
||||
}: AgentModelSelectorProps) {
|
||||
return (
|
||||
<PhaseModelSelector value={value} onChange={onChange} disabled={disabled} compact align="end" />
|
||||
<PhaseModelSelector
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
compact
|
||||
align="end"
|
||||
triggerClassName={triggerClassName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,24 +7,7 @@ import {
|
||||
useSensors,
|
||||
rectIntersection,
|
||||
pointerWithin,
|
||||
type PointerEvent as DndPointerEvent,
|
||||
} from '@dnd-kit/core';
|
||||
|
||||
// Custom pointer sensor that ignores drag events from within dialogs
|
||||
class DialogAwarePointerSensor extends PointerSensor {
|
||||
static activators = [
|
||||
{
|
||||
eventName: 'onPointerDown' as const,
|
||||
handler: ({ nativeEvent: event }: { nativeEvent: DndPointerEvent }) => {
|
||||
// Don't start drag if the event originated from inside a dialog
|
||||
if ((event.target as Element)?.closest?.('[role="dialog"]')) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
import { useAppStore, Feature } from '@/store/app-store';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
@@ -39,8 +22,8 @@ import { useAutoMode } from '@/hooks/use-auto-mode';
|
||||
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
|
||||
import { useWindowState } from '@/hooks/use-window-state';
|
||||
// Board-view specific imports
|
||||
import { BoardHeader } from './board-view/board-header';
|
||||
import { KanbanBoard } from './board-view/kanban-board';
|
||||
import { GraphView } from './graph-view';
|
||||
import {
|
||||
AddFeatureDialog,
|
||||
AgentOutputModal,
|
||||
@@ -72,11 +55,9 @@ import {
|
||||
useBoardPersistence,
|
||||
useFollowUpState,
|
||||
useSelectionMode,
|
||||
useBoardOnboarding,
|
||||
} from './board-view/hooks';
|
||||
import { SelectionActionBar, BoardOnboardingWizard } from './board-view/components';
|
||||
import { SelectionActionBar } from './board-view/components';
|
||||
import { MassEditDialog } from './board-view/dialogs';
|
||||
import { generateSampleFeatures, isSampleFeature } from './board-view/constants';
|
||||
|
||||
// Stable empty array to avoid infinite loop in selector
|
||||
const EMPTY_WORKTREES: ReturnType<ReturnType<typeof useAppStore.getState>['getWorktrees']> = [];
|
||||
@@ -89,6 +70,12 @@ export function BoardView() {
|
||||
maxConcurrency,
|
||||
setMaxConcurrency,
|
||||
defaultSkipTests,
|
||||
showProfilesOnly,
|
||||
aiProfiles,
|
||||
kanbanCardDetailLevel,
|
||||
setKanbanCardDetailLevel,
|
||||
boardViewMode,
|
||||
setBoardViewMode,
|
||||
specCreatingForProject,
|
||||
setSpecCreatingForProject,
|
||||
pendingPlanApproval,
|
||||
@@ -107,8 +94,6 @@ export function BoardView() {
|
||||
} = useAppStore();
|
||||
// Subscribe to pipelineConfigByProject to trigger re-renders when it changes
|
||||
const pipelineConfigByProject = useAppStore((state) => state.pipelineConfigByProject);
|
||||
// Subscribe to worktreePanelVisibleByProject to trigger re-renders when it changes
|
||||
const worktreePanelVisibleByProject = useAppStore((state) => state.worktreePanelVisibleByProject);
|
||||
const shortcuts = useKeyboardShortcutsConfig();
|
||||
const {
|
||||
features: hookFeatures,
|
||||
@@ -184,12 +169,11 @@ export function BoardView() {
|
||||
} = useSelectionMode();
|
||||
const [showMassEditDialog, setShowMassEditDialog] = useState(false);
|
||||
|
||||
// Search filter for Kanban cards
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
// Search filter for Kanban cards - using store state for top bar integration
|
||||
const searchQuery = useAppStore((state) => state.boardSearchQuery);
|
||||
const setSearchQuery = useAppStore((state) => state.setBoardSearchQuery);
|
||||
// Plan approval loading state
|
||||
const [isPlanApprovalLoading, setIsPlanApprovalLoading] = useState(false);
|
||||
// Quick start loading state for onboarding
|
||||
const [isQuickStartLoading, setIsQuickStartLoading] = useState(false);
|
||||
// Derive spec creation state from store - check if current project is the one being created
|
||||
const isCreatingSpec = specCreatingForProject === currentProject?.path;
|
||||
const creatingSpecProjectPath = specCreatingForProject ?? undefined;
|
||||
@@ -261,8 +245,28 @@ export function BoardView() {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
// Listen for custom events from top bar to open dialogs
|
||||
useEffect(() => {
|
||||
const handleOpenAddFeature = () => setShowAddDialog(true);
|
||||
const handleOpenPlanDialog = () => setShowPlanDialog(true);
|
||||
const handleOpenBoardBackground = () => setShowBoardBackgroundModal(true);
|
||||
const handleOpenCompletedFeatures = () => setShowCompletedModal(true);
|
||||
|
||||
window.addEventListener('automaker:open-add-feature-dialog', handleOpenAddFeature);
|
||||
window.addEventListener('automaker:open-plan-dialog', handleOpenPlanDialog);
|
||||
window.addEventListener('automaker:open-board-background', handleOpenBoardBackground);
|
||||
window.addEventListener('automaker:open-completed-features', handleOpenCompletedFeatures);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('automaker:open-add-feature-dialog', handleOpenAddFeature);
|
||||
window.removeEventListener('automaker:open-plan-dialog', handleOpenPlanDialog);
|
||||
window.removeEventListener('automaker:open-board-background', handleOpenBoardBackground);
|
||||
window.removeEventListener('automaker:open-completed-features', handleOpenCompletedFeatures);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(DialogAwarePointerSensor, {
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
@@ -509,45 +513,6 @@ export function BoardView() {
|
||||
[currentProject, selectedFeatureIds, updateFeature, exitSelectionMode]
|
||||
);
|
||||
|
||||
// Handler for bulk deleting multiple features
|
||||
const handleBulkDelete = useCallback(async () => {
|
||||
if (!currentProject || selectedFeatureIds.size === 0) return;
|
||||
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const featureIds = Array.from(selectedFeatureIds);
|
||||
const result = await api.features.bulkDelete(currentProject.path, featureIds);
|
||||
|
||||
const successfullyDeletedIds =
|
||||
result.results?.filter((r) => r.success).map((r) => r.featureId) ?? [];
|
||||
|
||||
if (successfullyDeletedIds.length > 0) {
|
||||
// Delete from local state without calling the API again
|
||||
successfullyDeletedIds.forEach((featureId) => {
|
||||
useAppStore.getState().removeFeature(featureId);
|
||||
});
|
||||
toast.success(`Deleted ${successfullyDeletedIds.length} features`);
|
||||
}
|
||||
|
||||
if (result.failedCount && result.failedCount > 0) {
|
||||
toast.error('Failed to delete some features', {
|
||||
description: `${result.failedCount} features failed to delete`,
|
||||
});
|
||||
}
|
||||
|
||||
// Exit selection mode and reload if the operation was at least partially processed.
|
||||
if (result.results) {
|
||||
exitSelectionMode();
|
||||
loadFeatures();
|
||||
} else if (!result.success) {
|
||||
toast.error('Failed to delete features', { description: result.error });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Bulk delete failed:', error);
|
||||
toast.error('Failed to delete features');
|
||||
}
|
||||
}, [currentProject, selectedFeatureIds, exitSelectionMode, loadFeatures]);
|
||||
|
||||
// Get selected features for mass edit dialog
|
||||
const selectedFeatures = useMemo(() => {
|
||||
return hookFeatures.filter((f) => selectedFeatureIds.has(f.id));
|
||||
@@ -1032,76 +997,6 @@ export function BoardView() {
|
||||
currentProject,
|
||||
});
|
||||
|
||||
// Use onboarding wizard hook - triggered manually via help button
|
||||
const onboarding = useBoardOnboarding({
|
||||
projectPath: currentProject?.path || null,
|
||||
});
|
||||
|
||||
// Handler for Quick Start - create sample features
|
||||
const handleQuickStart = useCallback(async () => {
|
||||
if (!currentProject) return;
|
||||
|
||||
setIsQuickStartLoading(true);
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const sampleFeatures = generateSampleFeatures();
|
||||
|
||||
// Create each sample feature
|
||||
for (const featureData of sampleFeatures) {
|
||||
const result = await api.features.create(currentProject.path, featureData);
|
||||
if (result.success && result.feature) {
|
||||
useAppStore.getState().addFeature(result.feature);
|
||||
}
|
||||
}
|
||||
|
||||
onboarding.markQuickStartUsed();
|
||||
toast.success('Sample tasks added!', {
|
||||
description: 'Explore the board to see tasks at different stages.',
|
||||
});
|
||||
|
||||
// Reload features to ensure state is in sync
|
||||
loadFeatures();
|
||||
} catch (error) {
|
||||
logger.error('Failed to create sample features:', error);
|
||||
toast.error('Failed to add sample tasks');
|
||||
} finally {
|
||||
setIsQuickStartLoading(false);
|
||||
}
|
||||
}, [currentProject, loadFeatures, onboarding]);
|
||||
|
||||
// Handler for clearing sample data
|
||||
const handleClearSampleData = useCallback(async () => {
|
||||
if (!currentProject) return;
|
||||
|
||||
const sampleFeatures = hookFeatures.filter((f) => isSampleFeature(f));
|
||||
if (sampleFeatures.length === 0) {
|
||||
onboarding.setHasSampleData(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const featureIds = sampleFeatures.map((f) => f.id);
|
||||
const result = await api.features.bulkDelete(currentProject.path, featureIds);
|
||||
|
||||
if (result.success || (result.results && result.results.some((r) => r.success))) {
|
||||
// Remove from local state
|
||||
const successfullyDeletedIds =
|
||||
result.results?.filter((r) => r.success).map((r) => r.featureId) ?? featureIds;
|
||||
successfullyDeletedIds.forEach((id) => {
|
||||
useAppStore.getState().removeFeature(id);
|
||||
});
|
||||
|
||||
onboarding.setHasSampleData(false);
|
||||
toast.success('Sample tasks removed');
|
||||
loadFeatures();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to clear sample data:', error);
|
||||
toast.error('Failed to remove sample tasks');
|
||||
}
|
||||
}, [currentProject, hookFeatures, loadFeatures, onboarding]);
|
||||
|
||||
// Find feature for pending plan approval
|
||||
const pendingApprovalFeature = useMemo(() => {
|
||||
if (!pendingPlanApproval) return null;
|
||||
@@ -1261,108 +1156,103 @@ export function BoardView() {
|
||||
className="flex-1 flex flex-col overflow-hidden content-bg relative"
|
||||
data-testid="board-view"
|
||||
>
|
||||
{/* Header */}
|
||||
<BoardHeader
|
||||
{/* Worktree Panel */}
|
||||
<WorktreePanel
|
||||
refreshTrigger={worktreeRefreshKey}
|
||||
projectPath={currentProject.path}
|
||||
maxConcurrency={maxConcurrency}
|
||||
runningAgentsCount={runningAutoTasks.length}
|
||||
onConcurrencyChange={setMaxConcurrency}
|
||||
isAutoModeRunning={autoMode.isRunning}
|
||||
onAutoModeToggle={(enabled) => {
|
||||
if (enabled) {
|
||||
autoMode.start();
|
||||
} else {
|
||||
autoMode.stop();
|
||||
}
|
||||
onCreateWorktree={() => setShowCreateWorktreeDialog(true)}
|
||||
onDeleteWorktree={(worktree) => {
|
||||
setSelectedWorktreeForAction(worktree);
|
||||
setShowDeleteWorktreeDialog(true);
|
||||
}}
|
||||
onOpenPlanDialog={() => setShowPlanDialog(true)}
|
||||
isMounted={isMounted}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
isCreatingSpec={isCreatingSpec}
|
||||
creatingSpecProjectPath={creatingSpecProjectPath}
|
||||
onShowBoardBackground={() => setShowBoardBackgroundModal(true)}
|
||||
onShowCompletedModal={() => setShowCompletedModal(true)}
|
||||
completedCount={completedFeatures.length}
|
||||
onStartTour={onboarding.startWizard}
|
||||
onCommit={(worktree) => {
|
||||
setSelectedWorktreeForAction(worktree);
|
||||
setShowCommitWorktreeDialog(true);
|
||||
}}
|
||||
onCreatePR={(worktree) => {
|
||||
setSelectedWorktreeForAction(worktree);
|
||||
setShowCreatePRDialog(true);
|
||||
}}
|
||||
onCreateBranch={(worktree) => {
|
||||
setSelectedWorktreeForAction(worktree);
|
||||
setShowCreateBranchDialog(true);
|
||||
}}
|
||||
onAddressPRComments={handleAddressPRComments}
|
||||
onResolveConflicts={handleResolveConflicts}
|
||||
onRemovedWorktrees={handleRemovedWorktrees}
|
||||
runningFeatureIds={runningAutoTasks}
|
||||
branchCardCounts={branchCardCounts}
|
||||
features={hookFeatures.map((f) => ({
|
||||
id: f.id,
|
||||
branchName: f.branchName,
|
||||
}))}
|
||||
/>
|
||||
|
||||
{/* Worktree Panel - conditionally rendered based on visibility setting */}
|
||||
{(worktreePanelVisibleByProject[currentProject.path] ?? true) && (
|
||||
<WorktreePanel
|
||||
refreshTrigger={worktreeRefreshKey}
|
||||
projectPath={currentProject.path}
|
||||
onCreateWorktree={() => setShowCreateWorktreeDialog(true)}
|
||||
onDeleteWorktree={(worktree) => {
|
||||
setSelectedWorktreeForAction(worktree);
|
||||
setShowDeleteWorktreeDialog(true);
|
||||
}}
|
||||
onCommit={(worktree) => {
|
||||
setSelectedWorktreeForAction(worktree);
|
||||
setShowCommitWorktreeDialog(true);
|
||||
}}
|
||||
onCreatePR={(worktree) => {
|
||||
setSelectedWorktreeForAction(worktree);
|
||||
setShowCreatePRDialog(true);
|
||||
}}
|
||||
onCreateBranch={(worktree) => {
|
||||
setSelectedWorktreeForAction(worktree);
|
||||
setShowCreateBranchDialog(true);
|
||||
}}
|
||||
onAddressPRComments={handleAddressPRComments}
|
||||
onResolveConflicts={handleResolveConflicts}
|
||||
onRemovedWorktrees={handleRemovedWorktrees}
|
||||
runningFeatureIds={runningAutoTasks}
|
||||
branchCardCounts={branchCardCounts}
|
||||
features={hookFeatures.map((f) => ({
|
||||
id: f.id,
|
||||
branchName: f.branchName,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* View Content - Kanban Board */}
|
||||
<KanbanBoard
|
||||
sensors={sensors}
|
||||
collisionDetectionStrategy={collisionDetectionStrategy}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
activeFeature={activeFeature}
|
||||
getColumnFeatures={getColumnFeatures}
|
||||
backgroundImageStyle={backgroundImageStyle}
|
||||
backgroundSettings={backgroundSettings}
|
||||
onEdit={(feature) => setEditingFeature(feature)}
|
||||
onDelete={(featureId) => handleDeleteFeature(featureId)}
|
||||
onViewOutput={handleViewOutput}
|
||||
onVerify={handleVerifyFeature}
|
||||
onResume={handleResumeFeature}
|
||||
onForceStop={handleForceStopFeature}
|
||||
onManualVerify={handleManualVerify}
|
||||
onMoveBackToInProgress={handleMoveBackToInProgress}
|
||||
onFollowUp={handleOpenFollowUp}
|
||||
onComplete={handleCompleteFeature}
|
||||
onImplement={handleStartImplementation}
|
||||
onViewPlan={(feature) => setViewPlanFeature(feature)}
|
||||
onApprovePlan={handleOpenApprovalDialog}
|
||||
onSpawnTask={(feature) => {
|
||||
setSpawnParentFeature(feature);
|
||||
setShowAddDialog(true);
|
||||
}}
|
||||
featuresWithContext={featuresWithContext}
|
||||
runningAutoTasks={runningAutoTasks}
|
||||
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
|
||||
onAddFeature={() => setShowAddDialog(true)}
|
||||
pipelineConfig={
|
||||
currentProject?.path ? pipelineConfigByProject[currentProject.path] || null : null
|
||||
}
|
||||
onOpenPipelineSettings={() => setShowPipelineSettings(true)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
selectedFeatureIds={selectedFeatureIds}
|
||||
onToggleFeatureSelection={toggleFeatureSelection}
|
||||
onToggleSelectionMode={toggleSelectionMode}
|
||||
/>
|
||||
{/* View Content - Kanban or Graph */}
|
||||
{boardViewMode === 'kanban' ? (
|
||||
<KanbanBoard
|
||||
sensors={sensors}
|
||||
collisionDetectionStrategy={collisionDetectionStrategy}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
activeFeature={activeFeature}
|
||||
getColumnFeatures={getColumnFeatures}
|
||||
backgroundImageStyle={backgroundImageStyle}
|
||||
backgroundSettings={backgroundSettings}
|
||||
onEdit={(feature) => setEditingFeature(feature)}
|
||||
onDelete={(featureId) => handleDeleteFeature(featureId)}
|
||||
onViewOutput={handleViewOutput}
|
||||
onVerify={handleVerifyFeature}
|
||||
onResume={handleResumeFeature}
|
||||
onForceStop={handleForceStopFeature}
|
||||
onManualVerify={handleManualVerify}
|
||||
onMoveBackToInProgress={handleMoveBackToInProgress}
|
||||
onFollowUp={handleOpenFollowUp}
|
||||
onComplete={handleCompleteFeature}
|
||||
onImplement={handleStartImplementation}
|
||||
onViewPlan={(feature) => setViewPlanFeature(feature)}
|
||||
onApprovePlan={handleOpenApprovalDialog}
|
||||
onSpawnTask={(feature) => {
|
||||
setSpawnParentFeature(feature);
|
||||
setShowAddDialog(true);
|
||||
}}
|
||||
featuresWithContext={featuresWithContext}
|
||||
runningAutoTasks={runningAutoTasks}
|
||||
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
|
||||
pipelineConfig={
|
||||
currentProject?.path ? pipelineConfigByProject[currentProject.path] || null : null
|
||||
}
|
||||
onOpenPipelineSettings={() => setShowPipelineSettings(true)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
selectedFeatureIds={selectedFeatureIds}
|
||||
onToggleFeatureSelection={toggleFeatureSelection}
|
||||
onToggleSelectionMode={toggleSelectionMode}
|
||||
onAddFeature={() => setShowAddDialog(true)}
|
||||
/>
|
||||
) : (
|
||||
<GraphView
|
||||
features={hookFeatures}
|
||||
runningAutoTasks={runningAutoTasks}
|
||||
currentWorktreePath={currentWorktreePath}
|
||||
currentWorktreeBranch={currentWorktreeBranch}
|
||||
projectPath={currentProject?.path || null}
|
||||
searchQuery={searchQuery}
|
||||
onSearchQueryChange={setSearchQuery}
|
||||
onEditFeature={(feature) => setEditingFeature(feature)}
|
||||
onViewOutput={handleViewOutput}
|
||||
onStartTask={handleStartImplementation}
|
||||
onStopTask={handleForceStopFeature}
|
||||
onResumeTask={handleResumeFeature}
|
||||
onUpdateFeature={updateFeature}
|
||||
onSpawnTask={(feature) => {
|
||||
setSpawnParentFeature(feature);
|
||||
setShowAddDialog(true);
|
||||
}}
|
||||
onDeleteTask={(feature) => handleDeleteFeature(feature.id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selection Action Bar */}
|
||||
@@ -1371,7 +1261,6 @@ export function BoardView() {
|
||||
selectedCount={selectedCount}
|
||||
totalCount={allSelectableFeatureIds.length}
|
||||
onEdit={() => setShowMassEditDialog(true)}
|
||||
onDelete={handleBulkDelete}
|
||||
onClear={clearSelection}
|
||||
onSelectAll={() => selectAll(allSelectableFeatureIds)}
|
||||
/>
|
||||
@@ -1383,6 +1272,8 @@ export function BoardView() {
|
||||
onClose={() => setShowMassEditDialog(false)}
|
||||
selectedFeatures={selectedFeatures}
|
||||
onApply={handleBulkUpdate}
|
||||
showProfilesOnly={showProfilesOnly}
|
||||
aiProfiles={aiProfiles}
|
||||
/>
|
||||
|
||||
{/* Board Background Modal */}
|
||||
@@ -1430,6 +1321,8 @@ export function BoardView() {
|
||||
defaultBranch={selectedWorktreeBranch}
|
||||
currentBranch={currentWorktreeBranch || undefined}
|
||||
isMaximized={isMaximized}
|
||||
showProfilesOnly={showProfilesOnly}
|
||||
aiProfiles={aiProfiles}
|
||||
parentFeature={spawnParentFeature}
|
||||
allFeatures={hookFeatures}
|
||||
/>
|
||||
@@ -1444,6 +1337,8 @@ export function BoardView() {
|
||||
branchCardCounts={branchCardCounts}
|
||||
currentBranch={currentWorktreeBranch || undefined}
|
||||
isMaximized={isMaximized}
|
||||
showProfilesOnly={showProfilesOnly}
|
||||
aiProfiles={aiProfiles}
|
||||
allFeatures={hookFeatures}
|
||||
/>
|
||||
|
||||
@@ -1643,23 +1538,6 @@ export function BoardView() {
|
||||
setSelectedWorktreeForAction(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Board Onboarding Wizard */}
|
||||
<BoardOnboardingWizard
|
||||
isVisible={onboarding.isWizardVisible}
|
||||
currentStep={onboarding.currentStep}
|
||||
currentStepData={onboarding.currentStepData}
|
||||
totalSteps={onboarding.totalSteps}
|
||||
onNext={onboarding.goToNextStep}
|
||||
onPrevious={onboarding.goToPreviousStep}
|
||||
onSkip={onboarding.skipWizard}
|
||||
onComplete={onboarding.completeWizard}
|
||||
onQuickStart={handleQuickStart}
|
||||
hasSampleData={onboarding.hasSampleData}
|
||||
onClearSampleData={handleClearSampleData}
|
||||
isQuickStartLoading={isQuickStartLoading}
|
||||
steps={onboarding.steps}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { ImageIcon, Archive, HelpCircle } from 'lucide-react';
|
||||
import { ImageIcon, Archive, Minimize2, Square, Maximize2, Columns3, Network } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { BoardViewMode } from '@/store/app-store';
|
||||
|
||||
interface BoardControlsProps {
|
||||
isMounted: boolean;
|
||||
onShowBoardBackground: () => void;
|
||||
onShowCompletedModal: () => void;
|
||||
completedCount: number;
|
||||
/** Callback to start the onboarding wizard tour */
|
||||
onStartTour?: () => void;
|
||||
kanbanCardDetailLevel: 'minimal' | 'standard' | 'detailed';
|
||||
onDetailLevelChange: (level: 'minimal' | 'standard' | 'detailed') => void;
|
||||
boardViewMode: BoardViewMode;
|
||||
onBoardViewModeChange: (mode: BoardViewMode) => void;
|
||||
}
|
||||
|
||||
export function BoardControls({
|
||||
@@ -16,33 +20,60 @@ export function BoardControls({
|
||||
onShowBoardBackground,
|
||||
onShowCompletedModal,
|
||||
completedCount,
|
||||
onStartTour,
|
||||
kanbanCardDetailLevel,
|
||||
onDetailLevelChange,
|
||||
boardViewMode,
|
||||
onBoardViewModeChange,
|
||||
}: BoardControlsProps) {
|
||||
if (!isMounted) return null;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Board Tour Button - always visible when handler is provided */}
|
||||
{onStartTour && (
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
{/* View Mode Toggle - Kanban / Graph */}
|
||||
<div
|
||||
className="flex items-center rounded-lg bg-secondary border border-border"
|
||||
data-testid="view-mode-toggle"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onStartTour}
|
||||
className="h-8 px-2 min-w-[32px] focus-visible:ring-2 focus-visible:ring-primary"
|
||||
data-testid="board-tour-button"
|
||||
aria-label="Take a board tour - learn how to use the kanban board"
|
||||
<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"
|
||||
>
|
||||
<HelpCircle className="w-4 h-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Columns3 className="w-4 h-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Take a Board Tour</p>
|
||||
<p>Kanban Board View</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => onBoardViewModeChange('graph')}
|
||||
className={cn(
|
||||
'p-2 rounded-r-lg transition-colors',
|
||||
boardViewMode === 'graph'
|
||||
? 'bg-brand-500/20 text-brand-500'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||
)}
|
||||
data-testid="view-mode-graph"
|
||||
>
|
||||
<Network className="w-4 h-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Dependency Graph View</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Board Background Button */}
|
||||
<Tooltip>
|
||||
@@ -84,6 +115,70 @@ export function BoardControls({
|
||||
<p>Completed Features ({completedCount})</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Kanban Card Detail Level Toggle */}
|
||||
<div
|
||||
className="flex items-center rounded-lg bg-secondary border border-border"
|
||||
data-testid="kanban-detail-toggle"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => onDetailLevelChange('minimal')}
|
||||
className={cn(
|
||||
'p-2 rounded-l-lg transition-colors',
|
||||
kanbanCardDetailLevel === 'minimal'
|
||||
? 'bg-brand-500/20 text-brand-500'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||
)}
|
||||
data-testid="kanban-toggle-minimal"
|
||||
>
|
||||
<Minimize2 className="w-4 h-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Minimal - Title & category only</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => onDetailLevelChange('standard')}
|
||||
className={cn(
|
||||
'p-2 transition-colors',
|
||||
kanbanCardDetailLevel === 'standard'
|
||||
? 'bg-brand-500/20 text-brand-500'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||
)}
|
||||
data-testid="kanban-toggle-standard"
|
||||
>
|
||||
<Square className="w-4 h-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Standard - Steps & progress</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => onDetailLevelChange('detailed')}
|
||||
className={cn(
|
||||
'p-2 rounded-r-lg transition-colors',
|
||||
kanbanCardDetailLevel === 'detailed'
|
||||
? 'bg-brand-500/20 text-brand-500'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||
)}
|
||||
data-testid="kanban-toggle-detailed"
|
||||
>
|
||||
<Maximize2 className="w-4 h-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Detailed - Model, tools & tasks</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
@@ -1,92 +1,28 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { HotkeyButton } from '@/components/ui/hotkey-button';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Bot, Wand2, Settings2, GitBranch } from 'lucide-react';
|
||||
import { Plus, Wand2 } from 'lucide-react';
|
||||
import { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts';
|
||||
import { UsagePopover } from '@/components/usage-popover';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useSetupStore } from '@/store/setup-store';
|
||||
import { AutoModeSettingsDialog } from './dialogs/auto-mode-settings-dialog';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import { BoardSearchBar } from './board-search-bar';
|
||||
import { BoardControls } from './board-controls';
|
||||
|
||||
interface BoardHeaderProps {
|
||||
projectPath: string;
|
||||
maxConcurrency: number;
|
||||
runningAgentsCount: number;
|
||||
onConcurrencyChange: (value: number) => void;
|
||||
isAutoModeRunning: boolean;
|
||||
onAutoModeToggle: (enabled: boolean) => void;
|
||||
onAddFeature: () => void;
|
||||
onOpenPlanDialog: () => void;
|
||||
addFeatureShortcut: KeyboardShortcut;
|
||||
isMounted: boolean;
|
||||
// Search bar props
|
||||
searchQuery: string;
|
||||
onSearchChange: (query: string) => void;
|
||||
isCreatingSpec: boolean;
|
||||
creatingSpecProjectPath?: string;
|
||||
// Board controls props
|
||||
onShowBoardBackground: () => void;
|
||||
onShowCompletedModal: () => void;
|
||||
completedCount: number;
|
||||
// Tour/onboarding props
|
||||
onStartTour?: () => void;
|
||||
}
|
||||
|
||||
// Shared styles for header control containers
|
||||
const controlContainerClass =
|
||||
'flex items-center gap-1.5 px-3 h-8 rounded-md bg-secondary border border-border';
|
||||
|
||||
export function BoardHeader({
|
||||
projectPath,
|
||||
maxConcurrency,
|
||||
runningAgentsCount,
|
||||
onConcurrencyChange,
|
||||
isAutoModeRunning,
|
||||
onAutoModeToggle,
|
||||
onAddFeature,
|
||||
onOpenPlanDialog,
|
||||
addFeatureShortcut,
|
||||
isMounted,
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
isCreatingSpec,
|
||||
creatingSpecProjectPath,
|
||||
onShowBoardBackground,
|
||||
onShowCompletedModal,
|
||||
completedCount,
|
||||
onStartTour,
|
||||
}: BoardHeaderProps) {
|
||||
const [showAutoModeSettings, setShowAutoModeSettings] = useState(false);
|
||||
const apiKeys = useAppStore((state) => state.apiKeys);
|
||||
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
|
||||
const skipVerificationInAutoMode = useAppStore((state) => state.skipVerificationInAutoMode);
|
||||
const setSkipVerificationInAutoMode = useAppStore((state) => state.setSkipVerificationInAutoMode);
|
||||
const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus);
|
||||
|
||||
// Worktree panel visibility (per-project)
|
||||
const worktreePanelVisibleByProject = useAppStore((state) => state.worktreePanelVisibleByProject);
|
||||
const setWorktreePanelVisible = useAppStore((state) => state.setWorktreePanelVisible);
|
||||
const isWorktreePanelVisible = worktreePanelVisibleByProject[projectPath] ?? true;
|
||||
|
||||
const handleWorktreePanelToggle = useCallback(
|
||||
async (visible: boolean) => {
|
||||
// Update local store
|
||||
setWorktreePanelVisible(projectPath, visible);
|
||||
|
||||
// Persist to server
|
||||
try {
|
||||
const httpClient = getHttpApiClient();
|
||||
await httpClient.settings.updateProject(projectPath, {
|
||||
worktreePanelVisible: visible,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to persist worktree panel visibility:', error);
|
||||
}
|
||||
},
|
||||
[projectPath, setWorktreePanelVisible]
|
||||
);
|
||||
|
||||
// Claude usage tracking visibility logic
|
||||
// Hide when using API key (only show for Claude Code CLI users)
|
||||
// Also hide on Windows for now (CLI usage command not supported)
|
||||
@@ -102,127 +38,30 @@ export function BoardHeader({
|
||||
const showCodexUsage = !!codexAuthStatus?.authenticated;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
|
||||
<div className="flex items-center gap-4">
|
||||
<BoardSearchBar
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={onSearchChange}
|
||||
isCreatingSpec={isCreatingSpec}
|
||||
creatingSpecProjectPath={creatingSpecProjectPath}
|
||||
currentProjectPath={projectPath}
|
||||
/>
|
||||
<BoardControls
|
||||
isMounted={isMounted}
|
||||
onShowBoardBackground={onShowBoardBackground}
|
||||
onShowCompletedModal={onShowCompletedModal}
|
||||
completedCount={completedCount}
|
||||
onStartTour={onStartTour}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
{/* Usage Popover - show if either provider is authenticated */}
|
||||
{isMounted && (showClaudeUsage || showCodexUsage) && <UsagePopover />}
|
||||
<div className="flex items-center justify-end gap-2 px-4 py-2 border-b border-border bg-glass backdrop-blur-md">
|
||||
{/* Usage Popover - show if either provider is authenticated */}
|
||||
{isMounted && (showClaudeUsage || showCodexUsage) && <UsagePopover />}
|
||||
|
||||
{/* Worktrees Toggle - only show after mount to prevent hydration issues */}
|
||||
{isMounted && (
|
||||
<div className={controlContainerClass} data-testid="worktrees-toggle-container">
|
||||
<GitBranch className="w-4 h-4 text-muted-foreground" />
|
||||
<Label htmlFor="worktrees-toggle" className="text-sm font-medium cursor-pointer">
|
||||
Worktrees
|
||||
</Label>
|
||||
<Switch
|
||||
id="worktrees-toggle"
|
||||
checked={isWorktreePanelVisible}
|
||||
onCheckedChange={handleWorktreePanelToggle}
|
||||
data-testid="worktrees-toggle"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onOpenPlanDialog}
|
||||
data-testid="plan-backlog-button"
|
||||
>
|
||||
<Wand2 className="w-4 h-4 mr-2" />
|
||||
Plan
|
||||
</Button>
|
||||
|
||||
{/* Concurrency Control - only show after mount to prevent hydration issues */}
|
||||
{isMounted && (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className={`${controlContainerClass} cursor-pointer hover:bg-accent/50 transition-colors`}
|
||||
data-testid="concurrency-slider-container"
|
||||
>
|
||||
<Bot className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Agents</span>
|
||||
<span className="text-sm text-muted-foreground" data-testid="concurrency-value">
|
||||
{runningAgentsCount}/{maxConcurrency}
|
||||
</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64" align="end">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="font-medium text-sm mb-1">Max Concurrent Agents</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Controls how many AI agents can run simultaneously. Higher values process more
|
||||
features in parallel but use more API resources.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Slider
|
||||
value={[maxConcurrency]}
|
||||
onValueChange={(value) => onConcurrencyChange(value[0])}
|
||||
min={1}
|
||||
max={10}
|
||||
step={1}
|
||||
className="flex-1"
|
||||
data-testid="concurrency-slider"
|
||||
/>
|
||||
<span className="text-sm font-medium min-w-[2ch] text-right">
|
||||
{maxConcurrency}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
{/* Auto Mode Toggle - only show after mount to prevent hydration issues */}
|
||||
{isMounted && (
|
||||
<div className={controlContainerClass} data-testid="auto-mode-toggle-container">
|
||||
<Label htmlFor="auto-mode-toggle" className="text-sm font-medium cursor-pointer">
|
||||
Auto Mode
|
||||
</Label>
|
||||
<Switch
|
||||
id="auto-mode-toggle"
|
||||
checked={isAutoModeRunning}
|
||||
onCheckedChange={onAutoModeToggle}
|
||||
data-testid="auto-mode-toggle"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowAutoModeSettings(true)}
|
||||
className="p-1 rounded hover:bg-accent/50 transition-colors"
|
||||
title="Auto Mode Settings"
|
||||
data-testid="auto-mode-settings-button"
|
||||
>
|
||||
<Settings2 className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Auto Mode Settings Dialog */}
|
||||
<AutoModeSettingsDialog
|
||||
open={showAutoModeSettings}
|
||||
onOpenChange={setShowAutoModeSettings}
|
||||
skipVerificationInAutoMode={skipVerificationInAutoMode}
|
||||
onSkipVerificationChange={setSkipVerificationInAutoMode}
|
||||
/>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onOpenPlanDialog}
|
||||
data-testid="plan-backlog-button"
|
||||
>
|
||||
<Wand2 className="w-4 h-4 mr-2" />
|
||||
Plan
|
||||
</Button>
|
||||
</div>
|
||||
<HotkeyButton
|
||||
size="sm"
|
||||
onClick={onAddFeature}
|
||||
hotkey={addFeatureShortcut}
|
||||
hotkeyActive={false}
|
||||
data-testid="add-feature-button"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Feature
|
||||
</HotkeyButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
/**
|
||||
* Board Onboarding Wizard Component
|
||||
*
|
||||
* Board-specific wrapper around the shared OnboardingWizard component.
|
||||
* Adds Quick Start functionality to generate sample tasks.
|
||||
*/
|
||||
|
||||
import { Sparkles, CheckCircle2, Trash2, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { OnboardingWizard, type OnboardingStep } from '@/components/shared/onboarding';
|
||||
|
||||
interface BoardOnboardingWizardProps {
|
||||
isVisible: boolean;
|
||||
currentStep: number;
|
||||
currentStepData: OnboardingStep | null;
|
||||
totalSteps: number;
|
||||
onNext: () => void;
|
||||
onPrevious: () => void;
|
||||
onSkip: () => void;
|
||||
onComplete: () => void;
|
||||
onQuickStart: () => void;
|
||||
hasSampleData: boolean;
|
||||
onClearSampleData: () => void;
|
||||
isQuickStartLoading?: boolean;
|
||||
steps: OnboardingStep[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick Start section component - only shown on first step
|
||||
*/
|
||||
function QuickStartSection({
|
||||
onQuickStart,
|
||||
hasSampleData,
|
||||
onClearSampleData,
|
||||
isQuickStartLoading = false,
|
||||
}: {
|
||||
onQuickStart: () => void;
|
||||
hasSampleData: boolean;
|
||||
onClearSampleData: () => void;
|
||||
isQuickStartLoading?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg bg-muted/30 border border-border/50 p-4 mb-4">
|
||||
<h4 className="text-sm font-medium text-foreground mb-2 flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4 text-primary" aria-hidden="true" />
|
||||
Quick Start
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
Want to see the board in action? We can add some sample tasks to demonstrate the workflow.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={onQuickStart}
|
||||
disabled={hasSampleData || isQuickStartLoading}
|
||||
className={cn('flex-1 min-h-[40px]', 'focus-visible:ring-2 focus-visible:ring-primary')}
|
||||
aria-busy={isQuickStartLoading}
|
||||
>
|
||||
{isQuickStartLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" aria-hidden="true" />
|
||||
<span>Adding tasks...</span>
|
||||
</>
|
||||
) : hasSampleData ? (
|
||||
<>
|
||||
<CheckCircle2 className="w-3.5 h-3.5 mr-1.5 text-green-500" aria-hidden="true" />
|
||||
<span>Sample Data Added</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="w-3.5 h-3.5 mr-1.5" aria-hidden="true" />
|
||||
<span>Add Sample Tasks</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{hasSampleData && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onClearSampleData}
|
||||
className={cn(
|
||||
'min-w-[44px] min-h-[40px] px-3',
|
||||
'focus-visible:ring-2 focus-visible:ring-destructive'
|
||||
)}
|
||||
aria-label="Remove sample tasks"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BoardOnboardingWizard({
|
||||
isVisible,
|
||||
currentStep,
|
||||
currentStepData,
|
||||
totalSteps,
|
||||
onNext,
|
||||
onPrevious,
|
||||
onSkip,
|
||||
onComplete,
|
||||
onQuickStart,
|
||||
hasSampleData,
|
||||
onClearSampleData,
|
||||
isQuickStartLoading = false,
|
||||
steps,
|
||||
}: BoardOnboardingWizardProps) {
|
||||
const isFirstStep = currentStep === 0;
|
||||
|
||||
return (
|
||||
<OnboardingWizard
|
||||
isVisible={isVisible}
|
||||
currentStep={currentStep}
|
||||
currentStepData={currentStepData}
|
||||
totalSteps={totalSteps}
|
||||
onNext={onNext}
|
||||
onPrevious={onPrevious}
|
||||
onSkip={onSkip}
|
||||
onComplete={onComplete}
|
||||
steps={steps}
|
||||
>
|
||||
{/* Board-specific Quick Start section - only on first step */}
|
||||
{isFirstStep && (
|
||||
<QuickStartSection
|
||||
onQuickStart={onQuickStart}
|
||||
hasSampleData={hasSampleData}
|
||||
onClearSampleData={onClearSampleData}
|
||||
isQuickStartLoading={isQuickStartLoading}
|
||||
/>
|
||||
)}
|
||||
</OnboardingWizard>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
export { KanbanCard } from './kanban-card/kanban-card';
|
||||
export { KanbanColumn } from './kanban-column';
|
||||
export { SelectionActionBar } from './selection-action-bar';
|
||||
export { BoardOnboardingWizard } from './board-onboarding-wizard';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @ts-nocheck
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Feature, ThinkingLevel } from '@/store/app-store';
|
||||
import { Feature, ThinkingLevel, useAppStore } from '@/store/app-store';
|
||||
import type { ReasoningEffort } from '@automaker/types';
|
||||
import { getProviderFromModel } from '@/lib/utils';
|
||||
import {
|
||||
@@ -68,9 +68,11 @@ export function AgentInfoPanel({
|
||||
summary,
|
||||
isCurrentAutoTask,
|
||||
}: AgentInfoPanelProps) {
|
||||
const { kanbanCardDetailLevel } = useAppStore();
|
||||
const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null);
|
||||
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
|
||||
const [isTodosExpanded, setIsTodosExpanded] = useState(false);
|
||||
|
||||
const showAgentInfo = kanbanCardDetailLevel === 'detailed';
|
||||
|
||||
useEffect(() => {
|
||||
const loadContext = async () => {
|
||||
@@ -121,7 +123,7 @@ export function AgentInfoPanel({
|
||||
}
|
||||
}, [feature.id, feature.status, contextContent, isCurrentAutoTask]);
|
||||
// Model/Preset Info for Backlog Cards
|
||||
if (feature.status === 'backlog') {
|
||||
if (showAgentInfo && feature.status === 'backlog') {
|
||||
const provider = getProviderFromModel(feature.model);
|
||||
const isCodex = provider === 'codex';
|
||||
const isClaude = provider === 'claude';
|
||||
@@ -158,7 +160,7 @@ export function AgentInfoPanel({
|
||||
}
|
||||
|
||||
// Agent Info Panel for non-backlog cards
|
||||
if (feature.status !== 'backlog' && agentInfo) {
|
||||
if (showAgentInfo && feature.status !== 'backlog' && agentInfo) {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-3 space-y-2 overflow-hidden">
|
||||
@@ -198,47 +200,32 @@ export function AgentInfoPanel({
|
||||
{agentInfo.todos.length} tasks
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'space-y-0.5 overflow-y-auto',
|
||||
isTodosExpanded ? 'max-h-40' : 'max-h-16'
|
||||
)}
|
||||
>
|
||||
{(isTodosExpanded ? agentInfo.todos : agentInfo.todos.slice(0, 3)).map(
|
||||
(todo, idx) => (
|
||||
<div key={idx} className="flex items-center gap-1.5 text-[10px]">
|
||||
{todo.status === 'completed' ? (
|
||||
<CheckCircle2 className="w-2.5 h-2.5 text-[var(--status-success)] shrink-0" />
|
||||
) : todo.status === 'in_progress' ? (
|
||||
<Loader2 className="w-2.5 h-2.5 text-[var(--status-warning)] animate-spin shrink-0" />
|
||||
) : (
|
||||
<Circle className="w-2.5 h-2.5 text-muted-foreground/50 shrink-0" />
|
||||
<div className="space-y-0.5 max-h-16 overflow-y-auto">
|
||||
{agentInfo.todos.slice(0, 3).map((todo, idx) => (
|
||||
<div key={idx} className="flex items-center gap-1.5 text-[10px]">
|
||||
{todo.status === 'completed' ? (
|
||||
<CheckCircle2 className="w-2.5 h-2.5 text-[var(--status-success)] shrink-0" />
|
||||
) : todo.status === 'in_progress' ? (
|
||||
<Loader2 className="w-2.5 h-2.5 text-[var(--status-warning)] animate-spin shrink-0" />
|
||||
) : (
|
||||
<Circle className="w-2.5 h-2.5 text-muted-foreground/50 shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
'break-words hyphens-auto line-clamp-2 leading-relaxed',
|
||||
todo.status === 'completed' && 'text-muted-foreground/60 line-through',
|
||||
todo.status === 'in_progress' && 'text-[var(--status-warning)]',
|
||||
todo.status === 'pending' && 'text-muted-foreground/80'
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
'break-words hyphens-auto line-clamp-2 leading-relaxed',
|
||||
todo.status === 'completed' && 'text-muted-foreground/60 line-through',
|
||||
todo.status === 'in_progress' && 'text-[var(--status-warning)]',
|
||||
todo.status === 'pending' && 'text-muted-foreground/80'
|
||||
)}
|
||||
>
|
||||
{todo.content}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
>
|
||||
{todo.content}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{agentInfo.todos.length > 3 && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsTodosExpanded(!isTodosExpanded);
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className="text-[10px] text-muted-foreground/60 pl-4 hover:text-muted-foreground transition-colors cursor-pointer"
|
||||
>
|
||||
{isTodosExpanded ? 'Show less' : `+${agentInfo.todos.length - 3} more`}
|
||||
</button>
|
||||
<p className="text-[10px] text-muted-foreground/60 pl-4">
|
||||
+{agentInfo.todos.length - 3} more
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -268,11 +255,7 @@ export function AgentInfoPanel({
|
||||
<Expand className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
className="text-[10px] text-muted-foreground/70 line-clamp-3 break-words hyphens-auto leading-relaxed overflow-hidden select-text cursor-text"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<p className="text-[10px] text-muted-foreground/70 line-clamp-3 break-words hyphens-auto leading-relaxed overflow-hidden">
|
||||
{feature.summary || summary || agentInfo.summary}
|
||||
</p>
|
||||
</div>
|
||||
@@ -309,15 +292,58 @@ export function AgentInfoPanel({
|
||||
);
|
||||
}
|
||||
|
||||
// Always render SummaryDialog (even if no agentInfo yet)
|
||||
// Show just the todo list for non-backlog features when showAgentInfo is false
|
||||
// This ensures users always see what the agent is working on
|
||||
if (!showAgentInfo && feature.status !== 'backlog' && agentInfo && agentInfo.todos.length > 0) {
|
||||
return (
|
||||
<div className="mb-3 space-y-1 overflow-hidden">
|
||||
<div className="flex items-center gap-1 text-[10px] text-muted-foreground/70">
|
||||
<ListTodo className="w-3 h-3" />
|
||||
<span>
|
||||
{agentInfo.todos.filter((t) => t.status === 'completed').length}/
|
||||
{agentInfo.todos.length} tasks
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-0.5 max-h-24 overflow-y-auto">
|
||||
{agentInfo.todos.map((todo, idx) => (
|
||||
<div key={idx} className="flex items-center gap-1.5 text-[10px]">
|
||||
{todo.status === 'completed' ? (
|
||||
<CheckCircle2 className="w-2.5 h-2.5 text-[var(--status-success)] shrink-0" />
|
||||
) : todo.status === 'in_progress' ? (
|
||||
<Loader2 className="w-2.5 h-2.5 text-[var(--status-warning)] animate-spin shrink-0" />
|
||||
) : (
|
||||
<Circle className="w-2.5 h-2.5 text-muted-foreground/50 shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
'break-words hyphens-auto line-clamp-2 leading-relaxed',
|
||||
todo.status === 'completed' && 'text-muted-foreground/60 line-through',
|
||||
todo.status === 'in_progress' && 'text-[var(--status-warning)]',
|
||||
todo.status === 'pending' && 'text-muted-foreground/80'
|
||||
)}
|
||||
>
|
||||
{todo.content}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Always render SummaryDialog if showAgentInfo is true (even if no agentInfo yet)
|
||||
// This ensures the dialog can be opened from the expand button
|
||||
return (
|
||||
<SummaryDialog
|
||||
feature={feature}
|
||||
agentInfo={agentInfo}
|
||||
summary={summary}
|
||||
isOpen={isSummaryDialogOpen}
|
||||
onOpenChange={setIsSummaryDialogOpen}
|
||||
/>
|
||||
<>
|
||||
{showAgentInfo && (
|
||||
<SummaryDialog
|
||||
feature={feature}
|
||||
agentInfo={agentInfo}
|
||||
summary={summary}
|
||||
isOpen={isSummaryDialogOpen}
|
||||
onOpenChange={setIsSummaryDialogOpen}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,11 +31,8 @@ export function SummaryDialog({
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="max-w-4xl max-h-[80vh] overflow-hidden flex flex-col select-text"
|
||||
className="max-w-4xl max-h-[80vh] overflow-hidden flex flex-col"
|
||||
data-testid={`summary-dialog-${feature.id}`}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
|
||||
@@ -10,8 +10,6 @@ interface KanbanColumnProps {
|
||||
count: number;
|
||||
children: ReactNode;
|
||||
headerAction?: ReactNode;
|
||||
/** Floating action button at the bottom of the column */
|
||||
footerAction?: ReactNode;
|
||||
opacity?: number;
|
||||
showBorder?: boolean;
|
||||
hideScrollbar?: boolean;
|
||||
@@ -26,7 +24,6 @@ export const KanbanColumn = memo(function KanbanColumn({
|
||||
count,
|
||||
children,
|
||||
headerAction,
|
||||
footerAction,
|
||||
opacity = 100,
|
||||
showBorder = true,
|
||||
hideScrollbar = false,
|
||||
@@ -50,7 +47,6 @@ export const KanbanColumn = memo(function KanbanColumn({
|
||||
)}
|
||||
style={widthStyle}
|
||||
data-testid={`kanban-column-${id}`}
|
||||
data-onboarding-target={id}
|
||||
>
|
||||
{/* Background layer with opacity */}
|
||||
<div
|
||||
@@ -83,21 +79,12 @@ export const KanbanColumn = memo(function KanbanColumn({
|
||||
hideScrollbar &&
|
||||
'[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]',
|
||||
// Smooth scrolling
|
||||
'scroll-smooth',
|
||||
// Add padding at bottom if there's a footer action
|
||||
footerAction && 'pb-14'
|
||||
'scroll-smooth'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Floating Footer Action */}
|
||||
{footerAction && (
|
||||
<div className="absolute bottom-0 left-0 right-0 z-20 p-2 bg-gradient-to-t from-card/95 via-card/80 to-transparent pt-6">
|
||||
{footerAction}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Drop zone indicator when dragging over */}
|
||||
{isOver && (
|
||||
<div className="absolute inset-0 rounded-xl bg-primary/5 pointer-events-none z-5 border-2 border-dashed border-primary/20" />
|
||||
|
||||
@@ -1,21 +1,11 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Pencil, X, CheckSquare, Trash2 } from 'lucide-react';
|
||||
import { Pencil, X, CheckSquare } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
interface SelectionActionBarProps {
|
||||
selectedCount: number;
|
||||
totalCount: number;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onClear: () => void;
|
||||
onSelectAll: () => void;
|
||||
}
|
||||
@@ -24,126 +14,65 @@ export function SelectionActionBar({
|
||||
selectedCount,
|
||||
totalCount,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onClear,
|
||||
onSelectAll,
|
||||
}: SelectionActionBarProps) {
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
|
||||
if (selectedCount === 0) return null;
|
||||
|
||||
const allSelected = selectedCount === totalCount;
|
||||
|
||||
const handleDeleteClick = () => {
|
||||
setShowDeleteDialog(true);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
setShowDeleteDialog(false);
|
||||
onDelete();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'fixed bottom-6 left-1/2 -translate-x-1/2 z-50',
|
||||
'flex items-center gap-3 px-4 py-3 rounded-xl',
|
||||
'bg-background/95 backdrop-blur-sm border border-border shadow-lg',
|
||||
'animate-in slide-in-from-bottom-4 fade-in duration-200'
|
||||
)}
|
||||
data-testid="selection-action-bar"
|
||||
>
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{selectedCount} feature{selectedCount !== 1 ? 's' : ''} selected
|
||||
</span>
|
||||
<div
|
||||
className={cn(
|
||||
'fixed bottom-6 left-1/2 -translate-x-1/2 z-50',
|
||||
'flex items-center gap-3 px-4 py-3 rounded-xl',
|
||||
'bg-background/95 backdrop-blur-sm border border-border shadow-lg',
|
||||
'animate-in slide-in-from-bottom-4 fade-in duration-200'
|
||||
)}
|
||||
data-testid="selection-action-bar"
|
||||
>
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{selectedCount} feature{selectedCount !== 1 ? 's' : ''} selected
|
||||
</span>
|
||||
|
||||
<div className="h-4 w-px bg-border" />
|
||||
<div className="h-4 w-px bg-border" />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={onEdit}
|
||||
className="h-8 bg-brand-500 hover:bg-brand-600"
|
||||
data-testid="selection-edit-button"
|
||||
>
|
||||
<Pencil className="w-4 h-4 mr-1.5" />
|
||||
Edit Selected
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={onEdit}
|
||||
className="h-8 bg-brand-500 hover:bg-brand-600"
|
||||
data-testid="selection-edit-button"
|
||||
>
|
||||
<Pencil className="w-4 h-4 mr-1.5" />
|
||||
Edit Selected
|
||||
</Button>
|
||||
|
||||
{!allSelected && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDeleteClick}
|
||||
className="h-8 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
data-testid="selection-delete-button"
|
||||
onClick={onSelectAll}
|
||||
className="h-8"
|
||||
data-testid="selection-select-all-button"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-1.5" />
|
||||
Delete
|
||||
<CheckSquare className="w-4 h-4 mr-1.5" />
|
||||
Select All ({totalCount})
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!allSelected && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onSelectAll}
|
||||
className="h-8"
|
||||
data-testid="selection-select-all-button"
|
||||
>
|
||||
<CheckSquare className="w-4 h-4 mr-1.5" />
|
||||
Select All ({totalCount})
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClear}
|
||||
className="h-8 text-muted-foreground hover:text-foreground"
|
||||
data-testid="selection-clear-button"
|
||||
>
|
||||
<X className="w-4 h-4 mr-1.5" />
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -89,117 +89,3 @@ export function getStepIdFromStatus(status: string): string | null {
|
||||
}
|
||||
return status.replace('pipeline_', '');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SAMPLE DATA FOR ONBOARDING WIZARD
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Prefix used to identify sample/demo features in the board
|
||||
* This marker persists through the database and is used for cleanup
|
||||
*/
|
||||
export const SAMPLE_FEATURE_PREFIX = '[DEMO]';
|
||||
|
||||
/**
|
||||
* Sample feature template for Quick Start onboarding
|
||||
* These demonstrate a typical workflow progression across columns
|
||||
*/
|
||||
export interface SampleFeatureTemplate {
|
||||
title: string;
|
||||
description: string;
|
||||
category: string;
|
||||
status: Feature['status'];
|
||||
priority: number;
|
||||
isSampleData: true; // Marker to identify sample data
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample features that demonstrate the workflow across all columns.
|
||||
* Each feature shows a realistic task at different stages.
|
||||
*/
|
||||
export const SAMPLE_FEATURES: SampleFeatureTemplate[] = [
|
||||
// Backlog items - awaiting work
|
||||
{
|
||||
title: '[DEMO] Add user profile page',
|
||||
description:
|
||||
'Create a user profile page where users can view and edit their account settings, change password, and manage preferences.\n\n---\n**This is sample data** - Click the trash icon in the wizard to remove all demo items.',
|
||||
category: 'Feature',
|
||||
status: 'backlog',
|
||||
priority: 1,
|
||||
isSampleData: true,
|
||||
},
|
||||
{
|
||||
title: '[DEMO] Implement dark mode toggle',
|
||||
description:
|
||||
'Add a toggle in the settings to switch between light and dark themes. Should persist the preference across sessions.\n\n---\n**This is sample data** - Click the trash icon in the wizard to remove all demo items.',
|
||||
category: 'Enhancement',
|
||||
status: 'backlog',
|
||||
priority: 2,
|
||||
isSampleData: true,
|
||||
},
|
||||
|
||||
// In Progress - currently being worked on
|
||||
{
|
||||
title: '[DEMO] Fix login timeout issue',
|
||||
description:
|
||||
'Users are being logged out after 5 minutes of inactivity. Investigate and increase the session timeout to 30 minutes.\n\n---\n**This is sample data** - Click the trash icon in the wizard to remove all demo items.',
|
||||
category: 'Bug Fix',
|
||||
status: 'in_progress',
|
||||
priority: 1,
|
||||
isSampleData: true,
|
||||
},
|
||||
|
||||
// Waiting Approval - completed and awaiting review
|
||||
{
|
||||
title: '[DEMO] Update API documentation',
|
||||
description:
|
||||
'Update the API documentation to reflect recent endpoint changes and add examples for new authentication flow.\n\n---\n**This is sample data** - Click the trash icon in the wizard to remove all demo items.',
|
||||
category: 'Documentation',
|
||||
status: 'waiting_approval',
|
||||
priority: 2,
|
||||
isSampleData: true,
|
||||
},
|
||||
|
||||
// Verified - approved and ready
|
||||
{
|
||||
title: '[DEMO] Add loading spinners',
|
||||
description:
|
||||
'Added loading spinner components to all async operations to improve user feedback during data fetching.\n\n---\n**This is sample data** - Click the trash icon in the wizard to remove all demo items.',
|
||||
category: 'Enhancement',
|
||||
status: 'verified',
|
||||
priority: 3,
|
||||
isSampleData: true,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Check if a feature is sample data
|
||||
* Uses the SAMPLE_FEATURE_PREFIX in the title as the marker for sample data
|
||||
*/
|
||||
export function isSampleFeature(feature: Partial<Feature>): boolean {
|
||||
// Check title prefix - this is the reliable marker that persists through the database
|
||||
return feature.title?.startsWith(SAMPLE_FEATURE_PREFIX) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate sample feature data with unique IDs
|
||||
* @returns Array of sample features ready to be created
|
||||
*/
|
||||
export function generateSampleFeatures(): Array<Omit<Feature, 'id' | 'createdAt' | 'updatedAt'>> {
|
||||
return SAMPLE_FEATURES.map((template) => ({
|
||||
title: template.title,
|
||||
description: template.description,
|
||||
category: template.category,
|
||||
status: template.status,
|
||||
priority: template.priority,
|
||||
images: [],
|
||||
imagePaths: [],
|
||||
skipTests: true,
|
||||
model: 'sonnet' as const,
|
||||
thinkingLevel: 'none' as const,
|
||||
planningMode: 'skip' as const,
|
||||
requirePlanApproval: false,
|
||||
// Mark as sample data in a way that persists
|
||||
// We use the title prefix [DEMO] as the marker
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1,254 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -32,17 +32,24 @@ import {
|
||||
ModelAlias,
|
||||
ThinkingLevel,
|
||||
FeatureImage,
|
||||
AIProfile,
|
||||
PlanningMode,
|
||||
Feature,
|
||||
} from '@/store/app-store';
|
||||
import type { ReasoningEffort, PhaseModelEntry } from '@automaker/types';
|
||||
import { supportsReasoningEffort, isClaudeModel } from '@automaker/types';
|
||||
import {
|
||||
supportsReasoningEffort,
|
||||
PROVIDER_PREFIXES,
|
||||
isCursorModel,
|
||||
isClaudeModel,
|
||||
} from '@automaker/types';
|
||||
import {
|
||||
TestingTabContent,
|
||||
PrioritySelector,
|
||||
WorkModeSelector,
|
||||
PlanningModeSelect,
|
||||
AncestorContextSection,
|
||||
ProfileTypeahead,
|
||||
} from '../shared';
|
||||
import type { WorkMode } from '../shared';
|
||||
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
|
||||
@@ -53,7 +60,7 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import {
|
||||
getAncestors,
|
||||
formatAncestorContextForPrompt,
|
||||
@@ -93,6 +100,8 @@ interface AddFeatureDialogProps {
|
||||
defaultBranch?: string;
|
||||
currentBranch?: string;
|
||||
isMaximized: boolean;
|
||||
showProfilesOnly: boolean;
|
||||
aiProfiles: AIProfile[];
|
||||
parentFeature?: Feature | null;
|
||||
allFeatures?: Feature[];
|
||||
}
|
||||
@@ -109,10 +118,13 @@ export function AddFeatureDialog({
|
||||
defaultBranch = 'main',
|
||||
currentBranch,
|
||||
isMaximized,
|
||||
showProfilesOnly,
|
||||
aiProfiles,
|
||||
parentFeature = null,
|
||||
allFeatures = [],
|
||||
}: AddFeatureDialogProps) {
|
||||
const isSpawnMode = !!parentFeature;
|
||||
const navigate = useNavigate();
|
||||
const [workMode, setWorkMode] = useState<WorkMode>('current');
|
||||
|
||||
// Form state
|
||||
@@ -127,6 +139,7 @@ export function AddFeatureDialog({
|
||||
const [priority, setPriority] = useState(2);
|
||||
|
||||
// Model selection state
|
||||
const [selectedProfileId, setSelectedProfileId] = useState<string | undefined>();
|
||||
const [modelEntry, setModelEntry] = useState<PhaseModelEntry>({ model: 'opus' });
|
||||
|
||||
// Check if current model supports planning mode (Claude/Anthropic only)
|
||||
@@ -141,7 +154,7 @@ export function AddFeatureDialog({
|
||||
const [descriptionError, setDescriptionError] = useState(false);
|
||||
const [isEnhancing, setIsEnhancing] = useState(false);
|
||||
const [enhancementMode, setEnhancementMode] = useState<
|
||||
'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer'
|
||||
'improve' | 'technical' | 'simplify' | 'acceptance'
|
||||
>('improve');
|
||||
const [enhanceOpen, setEnhanceOpen] = useState(false);
|
||||
|
||||
@@ -150,7 +163,7 @@ export function AddFeatureDialog({
|
||||
const [selectedAncestorIds, setSelectedAncestorIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// Get defaults from store
|
||||
const { defaultPlanningMode, defaultRequirePlanApproval } = useAppStore();
|
||||
const { defaultPlanningMode, defaultRequirePlanApproval, defaultAIProfileId } = useAppStore();
|
||||
|
||||
// Enhancement model override
|
||||
const enhancementOverride = useModelOverride({ phase: 'enhancementModel' });
|
||||
@@ -164,12 +177,24 @@ export function AddFeatureDialog({
|
||||
wasOpenRef.current = open;
|
||||
|
||||
if (justOpened) {
|
||||
const defaultProfile = defaultAIProfileId
|
||||
? aiProfiles.find((p) => p.id === defaultAIProfileId)
|
||||
: null;
|
||||
|
||||
setSkipTests(defaultSkipTests);
|
||||
setBranchName(defaultBranch || '');
|
||||
setWorkMode('current');
|
||||
setPlanningMode(defaultPlanningMode);
|
||||
setRequirePlanApproval(defaultRequirePlanApproval);
|
||||
setModelEntry({ model: 'opus' });
|
||||
|
||||
// Set model from default profile or fallback
|
||||
if (defaultProfile) {
|
||||
setSelectedProfileId(defaultProfile.id);
|
||||
applyProfileToModel(defaultProfile);
|
||||
} else {
|
||||
setSelectedProfileId(undefined);
|
||||
setModelEntry({ model: 'opus' });
|
||||
}
|
||||
|
||||
// Initialize ancestors for spawn mode
|
||||
if (parentFeature) {
|
||||
@@ -187,12 +212,41 @@ export function AddFeatureDialog({
|
||||
defaultBranch,
|
||||
defaultPlanningMode,
|
||||
defaultRequirePlanApproval,
|
||||
defaultAIProfileId,
|
||||
aiProfiles,
|
||||
parentFeature,
|
||||
allFeatures,
|
||||
]);
|
||||
|
||||
const applyProfileToModel = (profile: AIProfile) => {
|
||||
if (profile.provider === 'cursor') {
|
||||
const cursorModel = `${PROVIDER_PREFIXES.cursor}${profile.cursorModel || 'auto'}`;
|
||||
setModelEntry({ model: cursorModel as ModelAlias });
|
||||
} else if (profile.provider === 'codex') {
|
||||
setModelEntry({
|
||||
model: profile.codexModel || 'codex-gpt-5.2-codex',
|
||||
reasoningEffort: 'none',
|
||||
});
|
||||
} else if (profile.provider === 'opencode') {
|
||||
setModelEntry({ model: profile.opencodeModel || 'opencode/big-pickle' });
|
||||
} else {
|
||||
// Claude
|
||||
setModelEntry({
|
||||
model: profile.model || 'sonnet',
|
||||
thinkingLevel: profile.thinkingLevel || 'none',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleProfileSelect = (profile: AIProfile) => {
|
||||
setSelectedProfileId(profile.id);
|
||||
applyProfileToModel(profile);
|
||||
};
|
||||
|
||||
const handleModelChange = (entry: PhaseModelEntry) => {
|
||||
setModelEntry(entry);
|
||||
// Clear profile selection when manually changing model
|
||||
setSelectedProfileId(undefined);
|
||||
};
|
||||
|
||||
const buildFeatureData = (): FeatureData | null => {
|
||||
@@ -273,6 +327,7 @@ export function AddFeatureDialog({
|
||||
setSkipTests(defaultSkipTests);
|
||||
setBranchName('');
|
||||
setPriority(2);
|
||||
setSelectedProfileId(undefined);
|
||||
setModelEntry({ model: 'opus' });
|
||||
setWorkMode('current');
|
||||
setPlanningMode(defaultPlanningMode);
|
||||
@@ -431,7 +486,6 @@ export function AddFeatureDialog({
|
||||
{enhancementMode === 'technical' && 'Add Technical Details'}
|
||||
{enhancementMode === 'simplify' && 'Simplify'}
|
||||
{enhancementMode === 'acceptance' && 'Add Acceptance Criteria'}
|
||||
{enhancementMode === 'ux-reviewer' && 'User Experience'}
|
||||
<ChevronDown className="w-3 h-3 ml-1" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -448,9 +502,6 @@ export function AddFeatureDialog({
|
||||
<DropdownMenuItem onClick={() => setEnhancementMode('acceptance')}>
|
||||
Add Acceptance Criteria
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setEnhancementMode('ux-reviewer')}>
|
||||
User Experience
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -487,54 +538,50 @@ export function AddFeatureDialog({
|
||||
<span>AI & Execution</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Model</Label>
|
||||
<PhaseModelSelector
|
||||
value={modelEntry}
|
||||
onChange={handleModelChange}
|
||||
compact
|
||||
align="end"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Profile</Label>
|
||||
<ProfileTypeahead
|
||||
profiles={aiProfiles}
|
||||
selectedProfileId={selectedProfileId}
|
||||
onSelect={handleProfileSelect}
|
||||
placeholder="Select profile..."
|
||||
showManageLink
|
||||
onManageLinkClick={() => {
|
||||
onOpenChange(false);
|
||||
navigate({ to: '/profiles' });
|
||||
}}
|
||||
testIdPrefix="add-feature-profile"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Model</Label>
|
||||
<PhaseModelSelector
|
||||
value={modelEntry}
|
||||
onChange={handleModelChange}
|
||||
compact
|
||||
align="end"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
className={cn(
|
||||
'text-xs text-muted-foreground',
|
||||
!modelSupportsPlanningMode && 'opacity-50'
|
||||
)}
|
||||
>
|
||||
Planning
|
||||
</Label>
|
||||
{modelSupportsPlanningMode ? (
|
||||
<div
|
||||
className={cn(
|
||||
'grid gap-3',
|
||||
modelSupportsPlanningMode ? 'grid-cols-2' : 'grid-cols-1'
|
||||
)}
|
||||
>
|
||||
{modelSupportsPlanningMode && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Planning</Label>
|
||||
<PlanningModeSelect
|
||||
mode={planningMode}
|
||||
onModeChange={setPlanningMode}
|
||||
testIdPrefix="add-feature-planning"
|
||||
compact
|
||||
/>
|
||||
) : (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<PlanningModeSelect
|
||||
mode="skip"
|
||||
onModeChange={() => {}}
|
||||
testIdPrefix="add-feature-planning"
|
||||
compact
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Planning modes are only available for Claude Provider</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Options</Label>
|
||||
<div className="flex flex-col gap-2 pt-1">
|
||||
@@ -552,32 +599,28 @@ export function AddFeatureDialog({
|
||||
Run tests
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="add-feature-require-approval"
|
||||
checked={requirePlanApproval}
|
||||
onCheckedChange={(checked) => setRequirePlanApproval(!!checked)}
|
||||
disabled={
|
||||
!modelSupportsPlanningMode ||
|
||||
planningMode === 'skip' ||
|
||||
planningMode === 'lite'
|
||||
}
|
||||
data-testid="add-feature-require-approval-checkbox"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="add-feature-require-approval"
|
||||
className={cn(
|
||||
'text-xs font-normal',
|
||||
!modelSupportsPlanningMode ||
|
||||
planningMode === 'skip' ||
|
||||
planningMode === 'lite'
|
||||
? 'cursor-not-allowed text-muted-foreground'
|
||||
: 'cursor-pointer'
|
||||
)}
|
||||
>
|
||||
Require approval
|
||||
</Label>
|
||||
</div>
|
||||
{modelSupportsPlanningMode && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="add-feature-require-approval"
|
||||
checked={requirePlanApproval}
|
||||
onCheckedChange={(checked) => setRequirePlanApproval(!!checked)}
|
||||
disabled={planningMode === 'skip' || planningMode === 'lite'}
|
||||
data-testid="add-feature-require-approval-checkbox"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="add-feature-require-approval"
|
||||
className={cn(
|
||||
'text-xs font-normal',
|
||||
planningMode === 'skip' || planningMode === 'lite'
|
||||
? 'cursor-not-allowed text-muted-foreground'
|
||||
: 'cursor-pointer'
|
||||
)}
|
||||
>
|
||||
Require approval
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -34,13 +34,21 @@ import {
|
||||
import { toast } from 'sonner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { cn, modelSupportsThinking } from '@/lib/utils';
|
||||
import { Feature, ModelAlias, ThinkingLevel, useAppStore, PlanningMode } from '@/store/app-store';
|
||||
import {
|
||||
Feature,
|
||||
ModelAlias,
|
||||
ThinkingLevel,
|
||||
AIProfile,
|
||||
useAppStore,
|
||||
PlanningMode,
|
||||
} from '@/store/app-store';
|
||||
import type { ReasoningEffort, PhaseModelEntry, DescriptionHistoryEntry } from '@automaker/types';
|
||||
import {
|
||||
TestingTabContent,
|
||||
PrioritySelector,
|
||||
WorkModeSelector,
|
||||
PlanningModeSelect,
|
||||
ProfileTypeahead,
|
||||
} from '../shared';
|
||||
import type { WorkMode } from '../shared';
|
||||
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
|
||||
@@ -52,9 +60,14 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { DependencyTreeDialog } from './dependency-tree-dialog';
|
||||
import { isClaudeModel, supportsReasoningEffort } from '@automaker/types';
|
||||
import {
|
||||
isCursorModel,
|
||||
isClaudeModel,
|
||||
PROVIDER_PREFIXES,
|
||||
supportsReasoningEffort,
|
||||
} from '@automaker/types';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
|
||||
const logger = createLogger('EditFeatureDialog');
|
||||
|
||||
@@ -79,13 +92,15 @@ interface EditFeatureDialogProps {
|
||||
requirePlanApproval: boolean;
|
||||
},
|
||||
descriptionHistorySource?: 'enhance' | 'edit',
|
||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer'
|
||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance'
|
||||
) => void;
|
||||
categorySuggestions: string[];
|
||||
branchSuggestions: string[];
|
||||
branchCardCounts?: Record<string, number>; // Map of branch name to unarchived card count
|
||||
currentBranch?: string;
|
||||
isMaximized: boolean;
|
||||
showProfilesOnly: boolean;
|
||||
aiProfiles: AIProfile[];
|
||||
allFeatures: Feature[];
|
||||
}
|
||||
|
||||
@@ -98,8 +113,11 @@ export function EditFeatureDialog({
|
||||
branchCardCounts,
|
||||
currentBranch,
|
||||
isMaximized,
|
||||
showProfilesOnly,
|
||||
aiProfiles,
|
||||
allFeatures,
|
||||
}: EditFeatureDialogProps) {
|
||||
const navigate = useNavigate();
|
||||
const [editingFeature, setEditingFeature] = useState<Feature | null>(feature);
|
||||
// Derive initial workMode from feature's branchName
|
||||
const [workMode, setWorkMode] = useState<WorkMode>(() => {
|
||||
@@ -112,7 +130,7 @@ export function EditFeatureDialog({
|
||||
);
|
||||
const [isEnhancing, setIsEnhancing] = useState(false);
|
||||
const [enhancementMode, setEnhancementMode] = useState<
|
||||
'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer'
|
||||
'improve' | 'technical' | 'simplify' | 'acceptance'
|
||||
>('improve');
|
||||
const [enhanceOpen, setEnhanceOpen] = useState(false);
|
||||
const [showDependencyTree, setShowDependencyTree] = useState(false);
|
||||
@@ -122,6 +140,7 @@ export function EditFeatureDialog({
|
||||
);
|
||||
|
||||
// Model selection state
|
||||
const [selectedProfileId, setSelectedProfileId] = useState<string | undefined>();
|
||||
const [modelEntry, setModelEntry] = useState<PhaseModelEntry>(() => ({
|
||||
model: (feature?.model as ModelAlias) || 'opus',
|
||||
thinkingLevel: feature?.thinkingLevel || 'none',
|
||||
@@ -161,6 +180,7 @@ export function EditFeatureDialog({
|
||||
thinkingLevel: feature.thinkingLevel || 'none',
|
||||
reasoningEffort: feature.reasoningEffort || 'none',
|
||||
});
|
||||
setSelectedProfileId(undefined);
|
||||
} else {
|
||||
setEditFeaturePreviewMap(new Map());
|
||||
setDescriptionChangeSource(null);
|
||||
@@ -168,8 +188,35 @@ export function EditFeatureDialog({
|
||||
}
|
||||
}, [feature]);
|
||||
|
||||
const applyProfileToModel = (profile: AIProfile) => {
|
||||
if (profile.provider === 'cursor') {
|
||||
const cursorModel = `${PROVIDER_PREFIXES.cursor}${profile.cursorModel || 'auto'}`;
|
||||
setModelEntry({ model: cursorModel as ModelAlias });
|
||||
} else if (profile.provider === 'codex') {
|
||||
setModelEntry({
|
||||
model: profile.codexModel || 'codex-gpt-5.2-codex',
|
||||
reasoningEffort: 'none',
|
||||
});
|
||||
} else if (profile.provider === 'opencode') {
|
||||
setModelEntry({ model: profile.opencodeModel || 'opencode/big-pickle' });
|
||||
} else {
|
||||
// Claude
|
||||
setModelEntry({
|
||||
model: profile.model || 'sonnet',
|
||||
thinkingLevel: profile.thinkingLevel || 'none',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleProfileSelect = (profile: AIProfile) => {
|
||||
setSelectedProfileId(profile.id);
|
||||
applyProfileToModel(profile);
|
||||
};
|
||||
|
||||
const handleModelChange = (entry: PhaseModelEntry) => {
|
||||
setModelEntry(entry);
|
||||
// Clear profile selection when manually changing model
|
||||
setSelectedProfileId(undefined);
|
||||
};
|
||||
|
||||
const handleUpdate = () => {
|
||||
@@ -338,21 +385,11 @@ export function EditFeatureDialog({
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
const getEnhancementModeLabel = (mode?: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
improve: 'Improve Clarity',
|
||||
technical: 'Add Technical Details',
|
||||
simplify: 'Simplify',
|
||||
acceptance: 'Add Acceptance Criteria',
|
||||
'ux-reviewer': 'User Experience',
|
||||
};
|
||||
return labels[mode || 'improve'] || mode || 'improve';
|
||||
};
|
||||
const sourceLabel =
|
||||
entry.source === 'initial'
|
||||
? 'Original'
|
||||
: entry.source === 'enhance'
|
||||
? `Enhanced (${getEnhancementModeLabel(entry.enhancementMode)})`
|
||||
? `Enhanced (${entry.enhancementMode || 'improve'})`
|
||||
: 'Edited';
|
||||
|
||||
return (
|
||||
@@ -465,7 +502,6 @@ export function EditFeatureDialog({
|
||||
{enhancementMode === 'technical' && 'Add Technical Details'}
|
||||
{enhancementMode === 'simplify' && 'Simplify'}
|
||||
{enhancementMode === 'acceptance' && 'Add Acceptance Criteria'}
|
||||
{enhancementMode === 'ux-reviewer' && 'User Experience'}
|
||||
<ChevronDown className="w-3 h-3 ml-1" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -482,9 +518,6 @@ export function EditFeatureDialog({
|
||||
<DropdownMenuItem onClick={() => setEnhancementMode('acceptance')}>
|
||||
Add Acceptance Criteria
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setEnhancementMode('ux-reviewer')}>
|
||||
User Experience
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -521,54 +554,50 @@ export function EditFeatureDialog({
|
||||
<span>AI & Execution</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Model</Label>
|
||||
<PhaseModelSelector
|
||||
value={modelEntry}
|
||||
onChange={handleModelChange}
|
||||
compact
|
||||
align="end"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Profile</Label>
|
||||
<ProfileTypeahead
|
||||
profiles={aiProfiles}
|
||||
selectedProfileId={selectedProfileId}
|
||||
onSelect={handleProfileSelect}
|
||||
placeholder="Select profile..."
|
||||
showManageLink
|
||||
onManageLinkClick={() => {
|
||||
onClose();
|
||||
navigate({ to: '/profiles' });
|
||||
}}
|
||||
testIdPrefix="edit-feature-profile"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Model</Label>
|
||||
<PhaseModelSelector
|
||||
value={modelEntry}
|
||||
onChange={handleModelChange}
|
||||
compact
|
||||
align="end"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
className={cn(
|
||||
'text-xs text-muted-foreground',
|
||||
!modelSupportsPlanningMode && 'opacity-50'
|
||||
)}
|
||||
>
|
||||
Planning
|
||||
</Label>
|
||||
{modelSupportsPlanningMode ? (
|
||||
<div
|
||||
className={cn(
|
||||
'grid gap-3',
|
||||
modelSupportsPlanningMode ? 'grid-cols-2' : 'grid-cols-1'
|
||||
)}
|
||||
>
|
||||
{modelSupportsPlanningMode && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Planning</Label>
|
||||
<PlanningModeSelect
|
||||
mode={planningMode}
|
||||
onModeChange={setPlanningMode}
|
||||
testIdPrefix="edit-feature-planning"
|
||||
compact
|
||||
/>
|
||||
) : (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<PlanningModeSelect
|
||||
mode="skip"
|
||||
onModeChange={() => {}}
|
||||
testIdPrefix="edit-feature-planning"
|
||||
compact
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Planning modes are only available for Claude Provider</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Options</Label>
|
||||
<div className="flex flex-col gap-2 pt-1">
|
||||
@@ -588,32 +617,28 @@ export function EditFeatureDialog({
|
||||
Run tests
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="edit-feature-require-approval"
|
||||
checked={requirePlanApproval}
|
||||
onCheckedChange={(checked) => setRequirePlanApproval(!!checked)}
|
||||
disabled={
|
||||
!modelSupportsPlanningMode ||
|
||||
planningMode === 'skip' ||
|
||||
planningMode === 'lite'
|
||||
}
|
||||
data-testid="edit-feature-require-approval-checkbox"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="edit-feature-require-approval"
|
||||
className={cn(
|
||||
'text-xs font-normal',
|
||||
!modelSupportsPlanningMode ||
|
||||
planningMode === 'skip' ||
|
||||
planningMode === 'lite'
|
||||
? 'cursor-not-allowed text-muted-foreground'
|
||||
: 'cursor-pointer'
|
||||
)}
|
||||
>
|
||||
Require approval
|
||||
</Label>
|
||||
</div>
|
||||
{modelSupportsPlanningMode && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="edit-feature-require-approval"
|
||||
checked={requirePlanApproval}
|
||||
onCheckedChange={(checked) => setRequirePlanApproval(!!checked)}
|
||||
disabled={planningMode === 'skip' || planningMode === 'lite'}
|
||||
data-testid="edit-feature-require-approval-checkbox"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="edit-feature-require-approval"
|
||||
className={cn(
|
||||
'text-xs font-normal',
|
||||
planningMode === 'skip' || planningMode === 'lite'
|
||||
? 'cursor-not-allowed text-muted-foreground'
|
||||
: 'cursor-pointer'
|
||||
)}
|
||||
>
|
||||
Require approval
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,18 +12,19 @@ import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import { modelSupportsThinking } from '@/lib/utils';
|
||||
import { Feature, ModelAlias, ThinkingLevel, PlanningMode } from '@/store/app-store';
|
||||
import { TestingTabContent, PrioritySelect, PlanningModeSelect } from '../shared';
|
||||
import { Feature, ModelAlias, ThinkingLevel, AIProfile, PlanningMode } from '@/store/app-store';
|
||||
import { ProfileSelect, TestingTabContent, PrioritySelect, PlanningModeSelect } from '../shared';
|
||||
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
|
||||
import { isCursorModel, isClaudeModel, type PhaseModelEntry } from '@automaker/types';
|
||||
import { isCursorModel, PROVIDER_PREFIXES, type PhaseModelEntry } from '@automaker/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
|
||||
interface MassEditDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
selectedFeatures: Feature[];
|
||||
onApply: (updates: Partial<Feature>) => Promise<void>;
|
||||
showProfilesOnly: boolean;
|
||||
aiProfiles: AIProfile[];
|
||||
}
|
||||
|
||||
interface ApplyState {
|
||||
@@ -97,7 +98,14 @@ function FieldWrapper({ label, isMixed, willApply, onApplyChange, children }: Fi
|
||||
);
|
||||
}
|
||||
|
||||
export function MassEditDialog({ open, onClose, selectedFeatures, onApply }: MassEditDialogProps) {
|
||||
export function MassEditDialog({
|
||||
open,
|
||||
onClose,
|
||||
selectedFeatures,
|
||||
onApply,
|
||||
showProfilesOnly,
|
||||
aiProfiles,
|
||||
}: MassEditDialogProps) {
|
||||
const [isApplying, setIsApplying] = useState(false);
|
||||
|
||||
// Track which fields to apply
|
||||
@@ -141,6 +149,26 @@ export function MassEditDialog({ open, onClose, selectedFeatures, onApply }: Mas
|
||||
}
|
||||
}, [open, selectedFeatures]);
|
||||
|
||||
const handleModelSelect = (newModel: string) => {
|
||||
const isCursor = isCursorModel(newModel);
|
||||
setModel(newModel as ModelAlias);
|
||||
if (isCursor || !modelSupportsThinking(newModel)) {
|
||||
setThinkingLevel('none');
|
||||
}
|
||||
};
|
||||
|
||||
const handleProfileSelect = (profile: AIProfile) => {
|
||||
if (profile.provider === 'cursor') {
|
||||
const cursorModel = `${PROVIDER_PREFIXES.cursor}${profile.cursorModel || 'auto'}`;
|
||||
setModel(cursorModel as ModelAlias);
|
||||
setThinkingLevel('none');
|
||||
} else {
|
||||
setModel((profile.model || 'sonnet') as ModelAlias);
|
||||
setThinkingLevel(profile.thinkingLevel || 'none');
|
||||
}
|
||||
setApplyState((prev) => ({ ...prev, model: true, thinkingLevel: true }));
|
||||
};
|
||||
|
||||
const handleApply = async () => {
|
||||
const updates: Partial<Feature> = {};
|
||||
|
||||
@@ -168,7 +196,6 @@ export function MassEditDialog({ open, onClose, selectedFeatures, onApply }: Mas
|
||||
const hasAnyApply = Object.values(applyState).some(Boolean);
|
||||
const isCurrentModelCursor = isCursorModel(model);
|
||||
const modelAllowsThinking = !isCurrentModelCursor && modelSupportsThinking(model);
|
||||
const modelSupportsPlanningMode = isClaudeModel(model);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(open) => !open && onClose()}>
|
||||
@@ -181,11 +208,29 @@ export function MassEditDialog({ open, onClose, selectedFeatures, onApply }: Mas
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4 pr-4 space-y-4 max-h-[60vh] overflow-y-auto">
|
||||
{/* Quick Select Profile Section */}
|
||||
{aiProfiles.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Quick Select Profile</Label>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
Selecting a profile will automatically enable model settings
|
||||
</p>
|
||||
<ProfileSelect
|
||||
profiles={aiProfiles}
|
||||
selectedModel={model}
|
||||
selectedThinkingLevel={thinkingLevel}
|
||||
selectedCursorModel={isCurrentModelCursor ? model : undefined}
|
||||
onSelect={handleProfileSelect}
|
||||
testIdPrefix="mass-edit-profile"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Model Selector */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">AI Model</Label>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
Select a specific model configuration
|
||||
Or select a specific model configuration
|
||||
</p>
|
||||
<PhaseModelSelector
|
||||
value={{ model, thinkingLevel }}
|
||||
@@ -207,64 +252,30 @@ export function MassEditDialog({ open, onClose, selectedFeatures, onApply }: Mas
|
||||
<div className="border-t border-border" />
|
||||
|
||||
{/* Planning Mode */}
|
||||
{modelSupportsPlanningMode ? (
|
||||
<FieldWrapper
|
||||
label="Planning Mode"
|
||||
isMixed={mixedValues.planningMode || mixedValues.requirePlanApproval}
|
||||
willApply={applyState.planningMode || applyState.requirePlanApproval}
|
||||
onApplyChange={(apply) =>
|
||||
setApplyState((prev) => ({
|
||||
...prev,
|
||||
planningMode: apply,
|
||||
requirePlanApproval: apply,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<PlanningModeSelect
|
||||
mode={planningMode}
|
||||
onModeChange={(newMode) => {
|
||||
setPlanningMode(newMode);
|
||||
// Auto-suggest approval based on mode, but user can override
|
||||
setRequirePlanApproval(newMode === 'spec' || newMode === 'full');
|
||||
}}
|
||||
requireApproval={requirePlanApproval}
|
||||
onRequireApprovalChange={setRequirePlanApproval}
|
||||
testIdPrefix="mass-edit-planning"
|
||||
/>
|
||||
</FieldWrapper>
|
||||
) : (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
'p-3 rounded-lg border transition-colors border-border bg-muted/20 opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox checked={false} disabled className="opacity-50" />
|
||||
<Label className="text-sm font-medium text-muted-foreground">
|
||||
Planning Mode
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="opacity-50 pointer-events-none">
|
||||
<PlanningModeSelect
|
||||
mode="skip"
|
||||
onModeChange={() => {}}
|
||||
testIdPrefix="mass-edit-planning"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Planning modes are only available for Claude Provider</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<FieldWrapper
|
||||
label="Planning Mode"
|
||||
isMixed={mixedValues.planningMode || mixedValues.requirePlanApproval}
|
||||
willApply={applyState.planningMode || applyState.requirePlanApproval}
|
||||
onApplyChange={(apply) =>
|
||||
setApplyState((prev) => ({
|
||||
...prev,
|
||||
planningMode: apply,
|
||||
requirePlanApproval: apply,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<PlanningModeSelect
|
||||
mode={planningMode}
|
||||
onModeChange={(newMode) => {
|
||||
setPlanningMode(newMode);
|
||||
// Auto-suggest approval based on mode, but user can override
|
||||
setRequirePlanApproval(newMode === 'spec' || newMode === 'full');
|
||||
}}
|
||||
requireApproval={requirePlanApproval}
|
||||
onRequireApprovalChange={setRequirePlanApproval}
|
||||
testIdPrefix="mass-edit-planning"
|
||||
/>
|
||||
</FieldWrapper>
|
||||
|
||||
{/* Priority */}
|
||||
<FieldWrapper
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -8,11 +8,223 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Plus, Trash2, ChevronUp, ChevronDown, Pencil } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Plus, Trash2, ChevronUp, ChevronDown, Upload, Pencil, X, FileText } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import type { PipelineConfig, PipelineStep } from '@automaker/types';
|
||||
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 {
|
||||
open: boolean;
|
||||
@@ -22,10 +234,18 @@ interface PipelineSettingsDialogProps {
|
||||
onSave: (config: PipelineConfig) => Promise<void>;
|
||||
}
|
||||
|
||||
interface EditingStep {
|
||||
id?: string;
|
||||
name: string;
|
||||
instructions: string;
|
||||
colorClass: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export function PipelineSettingsDialog({
|
||||
open,
|
||||
onClose,
|
||||
projectPath: _projectPath,
|
||||
projectPath,
|
||||
pipelineConfig,
|
||||
onSave,
|
||||
}: PipelineSettingsDialogProps) {
|
||||
@@ -42,11 +262,9 @@ export function PipelineSettingsDialog({
|
||||
};
|
||||
|
||||
const [steps, setSteps] = useState<PipelineStep[]>(() => validateSteps(pipelineConfig?.steps));
|
||||
const [editingStep, setEditingStep] = useState<EditingStep | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Sub-dialog state
|
||||
const [addEditDialogOpen, setAddEditDialogOpen] = useState(false);
|
||||
const [editingStep, setEditingStep] = useState<PipelineStep | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Sync steps when dialog opens or pipelineConfig changes
|
||||
useEffect(() => {
|
||||
@@ -58,13 +276,22 @@ export function PipelineSettingsDialog({
|
||||
const sortedSteps = [...steps].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||
|
||||
const handleAddStep = () => {
|
||||
setEditingStep(null);
|
||||
setAddEditDialogOpen(true);
|
||||
setEditingStep({
|
||||
name: '',
|
||||
instructions: '',
|
||||
colorClass: COLOR_OPTIONS[steps.length % COLOR_OPTIONS.length].value,
|
||||
order: steps.length,
|
||||
});
|
||||
};
|
||||
|
||||
const handleEditStep = (step: PipelineStep) => {
|
||||
setEditingStep(step);
|
||||
setAddEditDialogOpen(true);
|
||||
setEditingStep({
|
||||
id: step.id,
|
||||
name: step.name,
|
||||
instructions: step.instructions,
|
||||
colorClass: step.colorClass,
|
||||
order: step.order,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteStep = (stepId: string) => {
|
||||
@@ -96,21 +323,53 @@ export function PipelineSettingsDialog({
|
||||
setSteps(newSteps);
|
||||
};
|
||||
|
||||
const handleSaveStep = (
|
||||
stepData: Omit<PipelineStep, 'id' | 'createdAt' | 'updatedAt'> & { id?: string }
|
||||
) => {
|
||||
const handleFileUpload = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileInputChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const content = await file.text();
|
||||
setEditingStep((prev) => (prev ? { ...prev, instructions: content } : null));
|
||||
toast.success('Instructions loaded from file');
|
||||
} catch (error) {
|
||||
toast.error('Failed to load file');
|
||||
}
|
||||
|
||||
// Reset the input so the same file can be selected again
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveStep = () => {
|
||||
if (!editingStep) return;
|
||||
|
||||
if (!editingStep.name.trim()) {
|
||||
toast.error('Step name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!editingStep.instructions.trim()) {
|
||||
toast.error('Step instructions are required');
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
if (stepData.id) {
|
||||
if (editingStep.id) {
|
||||
// Update existing step
|
||||
setSteps((prev) =>
|
||||
prev.map((s) =>
|
||||
s.id === stepData.id
|
||||
s.id === editingStep.id
|
||||
? {
|
||||
...s,
|
||||
name: stepData.name,
|
||||
instructions: stepData.instructions,
|
||||
colorClass: stepData.colorClass,
|
||||
name: editingStep.name,
|
||||
instructions: editingStep.instructions,
|
||||
colorClass: editingStep.colorClass,
|
||||
updatedAt: now,
|
||||
}
|
||||
: s
|
||||
@@ -120,21 +379,90 @@ export function PipelineSettingsDialog({
|
||||
// Add new step
|
||||
const newStep: PipelineStep = {
|
||||
id: `step_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 8)}`,
|
||||
name: stepData.name,
|
||||
instructions: stepData.instructions,
|
||||
colorClass: stepData.colorClass,
|
||||
name: editingStep.name,
|
||||
instructions: editingStep.instructions,
|
||||
colorClass: editingStep.colorClass,
|
||||
order: steps.length,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
setSteps((prev) => [...prev, newStep]);
|
||||
}
|
||||
|
||||
setEditingStep(null);
|
||||
};
|
||||
|
||||
const handleSaveConfig = async () => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const sortedEffectiveSteps = [...steps].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||
// If the user is currently editing a step and clicks "Save Configuration",
|
||||
// include that step in the config (common expectation) instead of silently dropping it.
|
||||
let effectiveSteps = steps;
|
||||
if (editingStep) {
|
||||
if (!editingStep.name.trim()) {
|
||||
toast.error('Step name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!editingStep.instructions.trim()) {
|
||||
toast.error('Step instructions are required');
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
if (editingStep.id) {
|
||||
// Update existing (or add if missing for some reason)
|
||||
const existingIdx = effectiveSteps.findIndex((s) => s.id === editingStep.id);
|
||||
if (existingIdx >= 0) {
|
||||
effectiveSteps = effectiveSteps.map((s) =>
|
||||
s.id === editingStep.id
|
||||
? {
|
||||
...s,
|
||||
name: editingStep.name,
|
||||
instructions: editingStep.instructions,
|
||||
colorClass: editingStep.colorClass,
|
||||
updatedAt: now,
|
||||
}
|
||||
: s
|
||||
);
|
||||
} else {
|
||||
effectiveSteps = [
|
||||
...effectiveSteps,
|
||||
{
|
||||
id: editingStep.id,
|
||||
name: editingStep.name,
|
||||
instructions: editingStep.instructions,
|
||||
colorClass: editingStep.colorClass,
|
||||
order: effectiveSteps.length,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
];
|
||||
}
|
||||
} else {
|
||||
// Add new step
|
||||
effectiveSteps = [
|
||||
...effectiveSteps,
|
||||
{
|
||||
id: `step_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 8)}`,
|
||||
name: editingStep.name,
|
||||
instructions: editingStep.instructions,
|
||||
colorClass: editingStep.colorClass,
|
||||
order: effectiveSteps.length,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Keep local UI state consistent with what we are saving.
|
||||
setSteps(effectiveSteps);
|
||||
setEditingStep(null);
|
||||
}
|
||||
|
||||
const sortedEffectiveSteps = [...effectiveSteps].sort(
|
||||
(a, b) => (a.order ?? 0) - (b.order ?? 0)
|
||||
);
|
||||
const config: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: sortedEffectiveSteps.map((s, index) => ({ ...s, order: index })),
|
||||
@@ -142,7 +470,7 @@ export function PipelineSettingsDialog({
|
||||
await onSave(config);
|
||||
toast.success('Pipeline configuration saved');
|
||||
onClose();
|
||||
} catch {
|
||||
} catch (error) {
|
||||
toast.error('Failed to save pipeline configuration');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
@@ -150,121 +478,259 @@ export function PipelineSettingsDialog({
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Pipeline Settings</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure custom pipeline steps that run after a feature completes "In Progress". Each
|
||||
step will automatically prompt the agent with its instructions.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Dialog open={open} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] overflow-hidden flex flex-col">
|
||||
{/* Hidden file input for loading instructions from .md files */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".md,.txt"
|
||||
className="hidden"
|
||||
onChange={handleFileInputChange}
|
||||
/>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Pipeline Settings</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure custom pipeline steps that run after a feature completes "In Progress". Each
|
||||
step will automatically prompt the agent with its instructions.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto py-4 space-y-4">
|
||||
{/* Steps List */}
|
||||
{sortedSteps.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{sortedSteps.map((step, index) => (
|
||||
<div
|
||||
key={step.id}
|
||||
className="flex items-center gap-2 p-3 border rounded-lg bg-muted/30"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
onClick={() => handleMoveStep(step.id, 'up')}
|
||||
disabled={index === 0}
|
||||
>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
onClick={() => handleMoveStep(step.id, 'down')}
|
||||
disabled={index === sortedSteps.length - 1}
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
className={cn(
|
||||
'w-3 h-8 rounded',
|
||||
(step.colorClass || 'bg-blue-500/20').replace('/20', '')
|
||||
)}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'w-3 h-8 rounded',
|
||||
(step.colorClass || 'bg-blue-500/20').replace('/20', '')
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">{step.name || 'Unnamed Step'}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{(step.instructions || '').substring(0, 100)}
|
||||
{(step.instructions || '').length > 100 ? '...' : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => handleEditStep(step)}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive hover:text-destructive"
|
||||
onClick={() => handleDeleteStep(step.id)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
<div 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>
|
||||
) : (
|
||||
<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 */}
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => handleEditStep(step)}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive hover:text-destructive"
|
||||
onClick={() => handleDeleteStep(step.id)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<p>No pipeline steps configured.</p>
|
||||
<p className="text-sm">
|
||||
Add steps to create a custom workflow after features complete.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Step Button */}
|
||||
{!editingStep && (
|
||||
<Button variant="outline" className="w-full" onClick={handleAddStep}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Pipeline Step
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSaveConfig} disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Saving...' : 'Save Pipeline'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{/* Edit/Add Step Form */}
|
||||
{editingStep && (
|
||||
<div className="border rounded-lg p-4 space-y-4 bg-muted/20">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium">{editingStep.id ? 'Edit Step' : 'New Step'}</h4>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setEditingStep(null)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Sub-dialog for adding/editing steps */}
|
||||
<AddEditPipelineStepDialog
|
||||
open={addEditDialogOpen}
|
||||
onClose={() => {
|
||||
setAddEditDialogOpen(false);
|
||||
setEditingStep(null);
|
||||
}}
|
||||
onSave={handleSaveStep}
|
||||
existingStep={editingStep}
|
||||
defaultOrder={steps.length}
|
||||
/>
|
||||
</>
|
||||
{/* Template Selector - only show for new steps */}
|
||||
{!editingStep.id && (
|
||||
<div className="space-y-2">
|
||||
<Label>Start from Template</Label>
|
||||
<Select
|
||||
onValueChange={(templateId) => {
|
||||
const template = STEP_TEMPLATES.find((t) => t.id === templateId);
|
||||
if (template) {
|
||||
setEditingStep((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
name: template.name,
|
||||
instructions: template.instructions,
|
||||
colorClass: template.colorClass,
|
||||
}
|
||||
: null
|
||||
);
|
||||
toast.success(`Loaded "${template.name}" template`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Choose a template (optional)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STEP_TEMPLATES.map((template) => (
|
||||
<SelectItem key={template.id} value={template.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
'w-2 h-2 rounded-full',
|
||||
template.colorClass.replace('/20', '')
|
||||
)}
|
||||
/>
|
||||
{template.name}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Select a pre-built template to populate the form, or create your own from
|
||||
scratch.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="step-name">Step Name</Label>
|
||||
<Input
|
||||
id="step-name"
|
||||
placeholder="e.g., Code Review, Testing, Documentation"
|
||||
value={editingStep.name}
|
||||
onChange={(e) =>
|
||||
setEditingStep((prev) => (prev ? { ...prev, name: e.target.value } : null))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Color</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{COLOR_OPTIONS.map((color) => (
|
||||
<button
|
||||
key={color.value}
|
||||
type="button"
|
||||
className={cn(
|
||||
'w-8 h-8 rounded-full transition-all',
|
||||
color.preview,
|
||||
editingStep.colorClass === color.value
|
||||
? 'ring-2 ring-offset-2 ring-primary'
|
||||
: 'opacity-60 hover:opacity-100'
|
||||
)}
|
||||
onClick={() =>
|
||||
setEditingStep((prev) =>
|
||||
prev ? { ...prev, colorClass: color.value } : null
|
||||
)
|
||||
}
|
||||
title={color.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="step-instructions">Agent Instructions</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={handleFileUpload}
|
||||
>
|
||||
<Upload className="h-3 w-3 mr-1" />
|
||||
Load from .md file
|
||||
</Button>
|
||||
</div>
|
||||
<Textarea
|
||||
id="step-instructions"
|
||||
placeholder="Instructions for the agent to follow during this pipeline step..."
|
||||
value={editingStep.instructions}
|
||||
onChange={(e) =>
|
||||
setEditingStep((prev) =>
|
||||
prev ? { ...prev, instructions: e.target.value } : null
|
||||
)
|
||||
}
|
||||
rows={6}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setEditingStep(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSaveStep}>
|
||||
{editingStep.id ? 'Update Step' : 'Add Step'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSaveConfig} disabled={isSubmitting}>
|
||||
{isSubmitting
|
||||
? 'Saving...'
|
||||
: editingStep
|
||||
? 'Save Step & Configuration'
|
||||
: 'Save Configuration'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
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.**`,
|
||||
};
|
||||
@@ -1,77 +0,0 @@
|
||||
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.**`,
|
||||
};
|
||||
@@ -1,28 +0,0 @@
|
||||
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';
|
||||
};
|
||||
@@ -1,103 +0,0 @@
|
||||
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.**`,
|
||||
};
|
||||
@@ -1,114 +0,0 @@
|
||||
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.**`,
|
||||
};
|
||||
@@ -1,81 +0,0 @@
|
||||
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.**`,
|
||||
};
|
||||
@@ -1,116 +0,0 @@
|
||||
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.**`,
|
||||
};
|
||||
@@ -8,4 +8,3 @@ export { useBoardBackground } from './use-board-background';
|
||||
export { useBoardPersistence } from './use-board-persistence';
|
||||
export { useFollowUpState } from './use-follow-up-state';
|
||||
export { useSelectionMode } from './use-selection-mode';
|
||||
export { useBoardOnboarding } from './use-board-onboarding';
|
||||
|
||||
@@ -30,7 +30,7 @@ interface UseBoardActionsProps {
|
||||
featureId: string,
|
||||
updates: Partial<Feature>,
|
||||
descriptionHistorySource?: 'enhance' | 'edit',
|
||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer'
|
||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance'
|
||||
) => Promise<void>;
|
||||
persistFeatureDelete: (featureId: string) => Promise<void>;
|
||||
saveCategory: (category: string) => Promise<void>;
|
||||
@@ -251,7 +251,7 @@ export function useBoardActions({
|
||||
workMode?: 'current' | 'auto' | 'custom';
|
||||
},
|
||||
descriptionHistorySource?: 'enhance' | 'edit',
|
||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer'
|
||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance'
|
||||
) => {
|
||||
const workMode = updates.workMode || 'current';
|
||||
|
||||
|
||||
@@ -70,21 +70,9 @@ export function useBoardColumnFeatures({
|
||||
// We're viewing main but branch hasn't been initialized yet
|
||||
// (worktrees disabled or haven't loaded yet).
|
||||
// Show features assigned to primary worktree's branch.
|
||||
if (projectPath) {
|
||||
const worktrees = useAppStore.getState().worktreesByProject[projectPath] ?? [];
|
||||
if (worktrees.length === 0) {
|
||||
// Worktrees not loaded yet - fallback to showing features on common default branches
|
||||
// This prevents features from disappearing during initial load
|
||||
matchesWorktree =
|
||||
featureBranch === 'main' || featureBranch === 'master' || featureBranch === 'develop';
|
||||
} else {
|
||||
matchesWorktree = useAppStore
|
||||
.getState()
|
||||
.isPrimaryWorktreeBranch(projectPath, featureBranch);
|
||||
}
|
||||
} else {
|
||||
matchesWorktree = false;
|
||||
}
|
||||
matchesWorktree = projectPath
|
||||
? useAppStore.getState().isPrimaryWorktreeBranch(projectPath, featureBranch)
|
||||
: false;
|
||||
} else {
|
||||
// Match by branch name
|
||||
matchesWorktree = featureBranch === effectiveBranch;
|
||||
|
||||
@@ -75,17 +75,6 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
|
||||
if (isProjectSwitch) {
|
||||
setPersistedCategories([]);
|
||||
}
|
||||
|
||||
// Check for interrupted features and resume them
|
||||
// This handles server restarts where features were in pipeline steps
|
||||
if (api.autoMode?.resumeInterrupted) {
|
||||
try {
|
||||
await api.autoMode.resumeInterrupted(currentProject.path);
|
||||
logger.info('Checked for interrupted features');
|
||||
} catch (resumeError) {
|
||||
logger.warn('Failed to check for interrupted features:', resumeError);
|
||||
}
|
||||
}
|
||||
} else if (!result.success && result.error) {
|
||||
logger.error('API returned error:', result.error);
|
||||
// If it's a new project or the error indicates no features found,
|
||||
|
||||
@@ -1,275 +0,0 @@
|
||||
/**
|
||||
* Board Onboarding Hook
|
||||
*
|
||||
* Board-specific wrapper around the shared onboarding wizard hook.
|
||||
* Manages board-specific features like sample data (Quick Start).
|
||||
*
|
||||
* Usage:
|
||||
* - Wizard is triggered manually via startWizard() when user clicks help button
|
||||
* - No auto-show logic - user controls when to see the wizard
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { getItem, setItem } from '@/lib/storage';
|
||||
import {
|
||||
useOnboardingWizard,
|
||||
ONBOARDING_TARGET_ATTRIBUTE,
|
||||
type OnboardingStep,
|
||||
} from '@/components/shared/onboarding';
|
||||
import { PlayCircle, Sparkles, Lightbulb, CheckCircle2, Settings2 } from 'lucide-react';
|
||||
|
||||
const logger = createLogger('BoardOnboarding');
|
||||
|
||||
// ============================================================================
|
||||
// CONSTANTS
|
||||
// ============================================================================
|
||||
|
||||
/** Storage key prefix for board-specific onboarding data */
|
||||
const BOARD_ONBOARDING_STORAGE_KEY = 'automaker:board-onboarding-data';
|
||||
|
||||
/** Maximum length for project path hash in storage key */
|
||||
const PROJECT_PATH_HASH_MAX_LENGTH = 50;
|
||||
|
||||
// Board-specific analytics events
|
||||
export const BOARD_ONBOARDING_ANALYTICS = {
|
||||
QUICK_START_USED: 'board_onboarding_quick_start_used',
|
||||
SAMPLE_DATA_CLEARED: 'board_onboarding_sample_data_cleared',
|
||||
} as const;
|
||||
|
||||
// ============================================================================
|
||||
// WIZARD STEPS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Board wizard step definitions
|
||||
* Each step targets a kanban column via data-onboarding-target
|
||||
*/
|
||||
export const BOARD_WIZARD_STEPS: OnboardingStep[] = [
|
||||
{
|
||||
id: 'backlog',
|
||||
targetId: 'backlog',
|
||||
title: 'Backlog',
|
||||
description:
|
||||
'This is where all your planned tasks live. Add new features, bug fixes, or improvements here. When you\'re ready to work on something, drag it to "In Progress" or click the play button.',
|
||||
tip: 'Press N or click the + button to quickly add a new feature.',
|
||||
icon: PlayCircle,
|
||||
},
|
||||
{
|
||||
id: 'in_progress',
|
||||
targetId: 'in_progress',
|
||||
title: 'In Progress',
|
||||
description:
|
||||
'Tasks being actively worked on appear here. AI agents automatically pick up items from the backlog and move them here when processing begins.',
|
||||
tip: 'You can run multiple tasks simultaneously using Auto Mode.',
|
||||
icon: Sparkles,
|
||||
},
|
||||
{
|
||||
id: 'waiting_approval',
|
||||
targetId: 'waiting_approval',
|
||||
title: 'Waiting Approval',
|
||||
description:
|
||||
'Completed work lands here for your review. Check the changes, run tests, and approve or send back for revisions.',
|
||||
tip: 'Click "View Output" to see what the AI agent did.',
|
||||
icon: Lightbulb,
|
||||
},
|
||||
{
|
||||
id: 'verified',
|
||||
targetId: 'verified',
|
||||
title: 'Verified',
|
||||
description:
|
||||
"Approved and verified tasks are ready for deployment! Archive them when you're done or move them back if changes are needed.",
|
||||
tip: 'Click "Complete All" to archive all verified items at once.',
|
||||
icon: CheckCircle2,
|
||||
},
|
||||
{
|
||||
id: 'custom_columns',
|
||||
targetId: 'pipeline-settings', // Highlight the pipeline settings button icon
|
||||
title: 'Custom Pipelines',
|
||||
description:
|
||||
'You can create custom columns (called pipelines) to build your own workflow! Click this settings icon to add, rename, or configure pipeline steps.',
|
||||
tip: 'Use pipelines to add code review, QA testing, or any custom stage to your workflow.',
|
||||
icon: Settings2,
|
||||
},
|
||||
];
|
||||
|
||||
// Re-export for backward compatibility
|
||||
export type { OnboardingStep as WizardStep } from '@/components/shared/onboarding';
|
||||
export { ONBOARDING_TARGET_ATTRIBUTE };
|
||||
|
||||
// ============================================================================
|
||||
// BOARD-SPECIFIC STATE
|
||||
// ============================================================================
|
||||
|
||||
interface BoardOnboardingData {
|
||||
hasSampleData: boolean;
|
||||
quickStartUsed: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_BOARD_DATA: BoardOnboardingData = {
|
||||
hasSampleData: false,
|
||||
quickStartUsed: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Sanitize project path to create a storage key
|
||||
*/
|
||||
function sanitizeProjectPath(projectPath: string): string {
|
||||
return projectPath.replace(/[^a-zA-Z0-9]/g, '_').slice(0, PROJECT_PATH_HASH_MAX_LENGTH);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get storage key for board-specific data
|
||||
*/
|
||||
function getBoardDataStorageKey(projectPath: string): string {
|
||||
const hash = sanitizeProjectPath(projectPath);
|
||||
return `${BOARD_ONBOARDING_STORAGE_KEY}:${hash}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load board-specific onboarding data from localStorage
|
||||
*/
|
||||
function loadBoardData(projectPath: string): BoardOnboardingData {
|
||||
try {
|
||||
const key = getBoardDataStorageKey(projectPath);
|
||||
const stored = getItem(key);
|
||||
if (stored) {
|
||||
return JSON.parse(stored) as BoardOnboardingData;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to load board onboarding data:', error);
|
||||
}
|
||||
return { ...DEFAULT_BOARD_DATA };
|
||||
}
|
||||
|
||||
/**
|
||||
* Save board-specific onboarding data to localStorage
|
||||
*/
|
||||
function saveBoardData(projectPath: string, data: BoardOnboardingData): void {
|
||||
try {
|
||||
const key = getBoardDataStorageKey(projectPath);
|
||||
setItem(key, JSON.stringify(data));
|
||||
} catch (error) {
|
||||
logger.error('Failed to save board onboarding data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track analytics event (placeholder)
|
||||
*/
|
||||
function trackAnalytics(event: string, data?: Record<string, unknown>): void {
|
||||
logger.debug(`[Analytics] ${event}`, data);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HOOK
|
||||
// ============================================================================
|
||||
|
||||
export interface UseBoardOnboardingOptions {
|
||||
projectPath: string | null;
|
||||
}
|
||||
|
||||
export interface UseBoardOnboardingResult {
|
||||
// From shared wizard hook
|
||||
isWizardVisible: boolean;
|
||||
currentStep: number;
|
||||
currentStepData: OnboardingStep | null;
|
||||
totalSteps: number;
|
||||
goToNextStep: () => void;
|
||||
goToPreviousStep: () => void;
|
||||
goToStep: (step: number) => void;
|
||||
startWizard: () => void;
|
||||
completeWizard: () => void;
|
||||
skipWizard: () => void;
|
||||
isCompleted: boolean;
|
||||
isSkipped: boolean;
|
||||
|
||||
// Board-specific
|
||||
hasSampleData: boolean;
|
||||
setHasSampleData: (has: boolean) => void;
|
||||
markQuickStartUsed: () => void;
|
||||
|
||||
// Steps data for component
|
||||
steps: OnboardingStep[];
|
||||
}
|
||||
|
||||
export function useBoardOnboarding({
|
||||
projectPath,
|
||||
}: UseBoardOnboardingOptions): UseBoardOnboardingResult {
|
||||
// Board-specific state for sample data
|
||||
const [boardData, setBoardData] = useState<BoardOnboardingData>(DEFAULT_BOARD_DATA);
|
||||
|
||||
// Create storage key from project path
|
||||
const storageKey = projectPath ? `board:${sanitizeProjectPath(projectPath)}` : 'board:default';
|
||||
|
||||
// Use the shared onboarding wizard hook
|
||||
const wizard = useOnboardingWizard({
|
||||
storageKey,
|
||||
steps: BOARD_WIZARD_STEPS,
|
||||
});
|
||||
|
||||
// Load board-specific data when project changes
|
||||
useEffect(() => {
|
||||
if (!projectPath) {
|
||||
setBoardData(DEFAULT_BOARD_DATA);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = loadBoardData(projectPath);
|
||||
setBoardData(data);
|
||||
}, [projectPath]);
|
||||
|
||||
// Update board data helper
|
||||
const updateBoardData = useCallback(
|
||||
(updates: Partial<BoardOnboardingData>) => {
|
||||
if (!projectPath) return;
|
||||
|
||||
setBoardData((prev) => {
|
||||
const newData = { ...prev, ...updates };
|
||||
saveBoardData(projectPath, newData);
|
||||
return newData;
|
||||
});
|
||||
},
|
||||
[projectPath]
|
||||
);
|
||||
|
||||
// Sample data handlers
|
||||
const setHasSampleData = useCallback(
|
||||
(has: boolean) => {
|
||||
updateBoardData({ hasSampleData: has });
|
||||
if (!has) {
|
||||
trackAnalytics(BOARD_ONBOARDING_ANALYTICS.SAMPLE_DATA_CLEARED, { projectPath });
|
||||
}
|
||||
},
|
||||
[projectPath, updateBoardData]
|
||||
);
|
||||
|
||||
const markQuickStartUsed = useCallback(() => {
|
||||
updateBoardData({ quickStartUsed: true, hasSampleData: true });
|
||||
trackAnalytics(BOARD_ONBOARDING_ANALYTICS.QUICK_START_USED, { projectPath });
|
||||
}, [projectPath, updateBoardData]);
|
||||
|
||||
return {
|
||||
// Spread shared wizard state and actions
|
||||
isWizardVisible: wizard.isVisible,
|
||||
currentStep: wizard.currentStep,
|
||||
currentStepData: wizard.currentStepData,
|
||||
totalSteps: wizard.totalSteps,
|
||||
goToNextStep: wizard.goToNextStep,
|
||||
goToPreviousStep: wizard.goToPreviousStep,
|
||||
goToStep: wizard.goToStep,
|
||||
startWizard: wizard.startWizard,
|
||||
completeWizard: wizard.completeWizard,
|
||||
skipWizard: wizard.skipWizard,
|
||||
isCompleted: wizard.isCompleted,
|
||||
isSkipped: wizard.isSkipped,
|
||||
|
||||
// Board-specific
|
||||
hasSampleData: boardData.hasSampleData,
|
||||
setHasSampleData,
|
||||
markQuickStartUsed,
|
||||
|
||||
// Steps data
|
||||
steps: BOARD_WIZARD_STEPS,
|
||||
};
|
||||
}
|
||||
@@ -19,7 +19,7 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
|
||||
featureId: string,
|
||||
updates: Partial<Feature>,
|
||||
descriptionHistorySource?: 'enhance' | 'edit',
|
||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer'
|
||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance'
|
||||
) => {
|
||||
if (!currentProject) return;
|
||||
|
||||
|
||||
@@ -3,11 +3,12 @@ import { DndContext, DragOverlay } from '@dnd-kit/core';
|
||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { KanbanColumn, KanbanCard } from './components';
|
||||
import { Feature, useAppStore, formatShortcut } from '@/store/app-store';
|
||||
import { Feature } from '@/store/app-store';
|
||||
import { Archive, Settings2, CheckSquare, GripVertical, Plus } from 'lucide-react';
|
||||
import { useResponsiveKanban } from '@/hooks/use-responsive-kanban';
|
||||
import { getColumnsWithPipeline, type ColumnId } from './constants';
|
||||
import type { PipelineConfig } from '@automaker/types';
|
||||
import { useDockState } from '@/components/layout/bottom-dock/bottom-dock';
|
||||
|
||||
interface KanbanBoardProps {
|
||||
sensors: any;
|
||||
@@ -43,7 +44,6 @@ interface KanbanBoardProps {
|
||||
featuresWithContext: Set<string>;
|
||||
runningAutoTasks: string[];
|
||||
onArchiveAllVerified: () => void;
|
||||
onAddFeature: () => void;
|
||||
pipelineConfig: PipelineConfig | null;
|
||||
onOpenPipelineSettings?: () => void;
|
||||
// Selection mode props
|
||||
@@ -51,6 +51,8 @@ interface KanbanBoardProps {
|
||||
selectedFeatureIds?: Set<string>;
|
||||
onToggleFeatureSelection?: (featureId: string) => void;
|
||||
onToggleSelectionMode?: () => void;
|
||||
// Add feature action
|
||||
onAddFeature?: () => void;
|
||||
}
|
||||
|
||||
export function KanbanBoard({
|
||||
@@ -79,34 +81,55 @@ export function KanbanBoard({
|
||||
featuresWithContext,
|
||||
runningAutoTasks,
|
||||
onArchiveAllVerified,
|
||||
onAddFeature,
|
||||
pipelineConfig,
|
||||
onOpenPipelineSettings,
|
||||
isSelectionMode = false,
|
||||
selectedFeatureIds = new Set(),
|
||||
onToggleFeatureSelection,
|
||||
onToggleSelectionMode,
|
||||
onAddFeature,
|
||||
}: KanbanBoardProps) {
|
||||
// Generate columns including pipeline steps
|
||||
const columns = useMemo(() => getColumnsWithPipeline(pipelineConfig), [pipelineConfig]);
|
||||
|
||||
// Get the keyboard shortcut for adding features
|
||||
const { keyboardShortcuts } = useAppStore();
|
||||
const addFeatureShortcut = keyboardShortcuts.addFeature || 'N';
|
||||
|
||||
// Use responsive column widths based on window size
|
||||
// containerStyle handles centering and ensures columns fit without horizontal scroll in Electron
|
||||
const { columnWidth, containerStyle } = useResponsiveKanban(columns.length);
|
||||
|
||||
// Get dock state to add padding when dock is expanded on the side
|
||||
const {
|
||||
position: dockPosition,
|
||||
isExpanded: dockExpanded,
|
||||
isMaximized: dockMaximized,
|
||||
} = useDockState();
|
||||
|
||||
// Calculate padding based on dock state
|
||||
// Dock widths: collapsed=w-10 (2.5rem), expanded=w-96 (24rem), maximized=w-[50vw]
|
||||
const getSideDockPadding = () => {
|
||||
if (!dockExpanded) return undefined;
|
||||
if (dockMaximized) return '50vw';
|
||||
return '25rem'; // 24rem dock width + 1rem breathing room
|
||||
};
|
||||
|
||||
const sideDockPadding = getSideDockPadding();
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-x-auto px-5 pt-4 pb-4 relative" style={backgroundImageStyle}>
|
||||
<div
|
||||
className="flex-1 overflow-x-auto px-5 pb-4 relative transition-[padding] duration-300"
|
||||
style={{
|
||||
...backgroundImageStyle,
|
||||
// Add padding when dock is expanded on the side so content can scroll past the overlay
|
||||
paddingRight: dockPosition === 'right' ? sideDockPadding : undefined,
|
||||
paddingLeft: dockPosition === 'left' ? sideDockPadding : undefined,
|
||||
}}
|
||||
>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={collisionDetectionStrategy}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
<div className="h-full py-1" style={containerStyle}>
|
||||
<div className="h-full pt-4 pb-1" style={containerStyle}>
|
||||
{columns.map((column) => {
|
||||
const columnFeatures = getColumnFeatures(column.id as ColumnId);
|
||||
return (
|
||||
@@ -135,31 +158,29 @@ export function KanbanBoard({
|
||||
) : column.id === 'backlog' ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
className="h-6 px-2 text-xs gap-1"
|
||||
onClick={onAddFeature}
|
||||
title="Add Feature"
|
||||
data-testid="add-feature-button"
|
||||
data-testid="backlog-add-feature-button"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<Plus className="w-3 h-3" />
|
||||
Add
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`h-6 px-2 text-xs ${isSelectionMode ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground'}`}
|
||||
className={`h-6 px-2 text-xs gap-1 ${isSelectionMode ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground'}`}
|
||||
onClick={onToggleSelectionMode}
|
||||
title={isSelectionMode ? 'Switch to Drag Mode' : 'Select Multiple'}
|
||||
data-testid="selection-mode-button"
|
||||
>
|
||||
{isSelectionMode ? (
|
||||
<>
|
||||
<GripVertical className="w-3.5 h-3.5 mr-1" />
|
||||
<GripVertical className="w-3 h-3" />
|
||||
Drag
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckSquare className="w-3.5 h-3.5 mr-1" />
|
||||
<CheckSquare className="w-3 h-3" />
|
||||
Select
|
||||
</>
|
||||
)}
|
||||
@@ -173,7 +194,6 @@ export function KanbanBoard({
|
||||
onClick={onOpenPipelineSettings}
|
||||
title="Pipeline Settings"
|
||||
data-testid="pipeline-settings-button"
|
||||
data-onboarding-target="pipeline-settings"
|
||||
>
|
||||
<Settings2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
@@ -190,23 +210,6 @@ export function KanbanBoard({
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
footerAction={
|
||||
column.id === 'backlog' ? (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="w-full h-9 text-sm"
|
||||
onClick={onAddFeature}
|
||||
data-testid="add-feature-floating-button"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Feature
|
||||
<span className="ml-auto pl-2 text-[10px] font-mono opacity-70 bg-black/20 px-1.5 py-0.5 rounded">
|
||||
{formatShortcut(addFeatureShortcut, true)}
|
||||
</span>
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<SortableContext
|
||||
items={columnFeatures.map((f) => f.id)}
|
||||
|
||||
@@ -2,6 +2,9 @@ export * from './model-constants';
|
||||
export * from './model-selector';
|
||||
export * from './thinking-level-selector';
|
||||
export * from './reasoning-effort-selector';
|
||||
export * from './profile-quick-select';
|
||||
export * from './profile-select';
|
||||
export * from './profile-typeahead';
|
||||
export * from './testing-tab-content';
|
||||
export * from './priority-selector';
|
||||
export * from './priority-select';
|
||||
|
||||
@@ -110,7 +110,7 @@ export const OPENCODE_MODELS: ModelOption[] = OPENCODE_MODEL_CONFIGS.map((config
|
||||
label: config.label,
|
||||
description: config.description,
|
||||
badge: config.tier === 'free' ? 'Free' : config.tier === 'premium' ? 'Premium' : undefined,
|
||||
provider: config.provider as ModelProvider,
|
||||
provider: 'opencode' as ModelProvider,
|
||||
}));
|
||||
|
||||
/**
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user