mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 06:42:03 +00:00
Compare commits
45 Commits
v0.13.0
...
claude/add
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3a4e13c4e | ||
|
|
7941deffd7 | ||
|
|
01859f3a9a | ||
|
|
afb6e14811 | ||
|
|
c65f931326 | ||
|
|
f480386905 | ||
|
|
7773db559d | ||
|
|
655f254538 | ||
|
|
b4be3c11e2 | ||
|
|
57ce198ae9 | ||
|
|
733ca15e15 | ||
|
|
e110c058a2 | ||
|
|
0fdda11b09 | ||
|
|
0155da0be5 | ||
|
|
41b127ebf3 | ||
|
|
e7e83a30d9 | ||
|
|
40950b5fce | ||
|
|
3f05735be1 | ||
|
|
05f0ceceb6 | ||
|
|
28d50aa017 | ||
|
|
103c6bc8a0 | ||
|
|
6c47068f71 | ||
|
|
a9616ff309 | ||
|
|
4fa0923ff8 | ||
|
|
c3cecc18f2 | ||
|
|
3fcda8abfc | ||
|
|
a45ee59b7d | ||
|
|
662f854203 | ||
|
|
f2860d9366 | ||
|
|
6eb7acb6d4 | ||
|
|
4ab927a5fb | ||
|
|
02de3df3df | ||
|
|
b73885e04a | ||
|
|
afa93dde0d | ||
|
|
aac59c2b3a | ||
|
|
c3e7e57968 | ||
|
|
7bb97953a7 | ||
|
|
2214c2700b | ||
|
|
7bee54717c | ||
|
|
5ab53afd7f | ||
|
|
3ebd67f35f | ||
|
|
641bbde877 | ||
|
|
7c80249bbf | ||
|
|
a73a57b9a4 | ||
|
|
db71dc9aa5 |
21
.github/workflows/release.yml
vendored
21
.github/workflows/release.yml
vendored
@@ -62,7 +62,9 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: macos-builds
|
||||
path: apps/ui/release/*.{dmg,zip}
|
||||
path: |
|
||||
apps/ui/release/*.dmg
|
||||
apps/ui/release/*.zip
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload Windows artifacts
|
||||
@@ -78,7 +80,10 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-builds
|
||||
path: apps/ui/release/*.{AppImage,deb,rpm}
|
||||
path: |
|
||||
apps/ui/release/*.AppImage
|
||||
apps/ui/release/*.deb
|
||||
apps/ui/release/*.rpm
|
||||
retention-days: 30
|
||||
|
||||
upload:
|
||||
@@ -109,8 +114,14 @@ jobs:
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
artifacts/macos-builds/*.{dmg,zip,blockmap}
|
||||
artifacts/windows-builds/*.{exe,blockmap}
|
||||
artifacts/linux-builds/*.{AppImage,deb,rpm,blockmap}
|
||||
artifacts/macos-builds/*.dmg
|
||||
artifacts/macos-builds/*.zip
|
||||
artifacts/macos-builds/*.blockmap
|
||||
artifacts/windows-builds/*.exe
|
||||
artifacts/windows-builds/*.blockmap
|
||||
artifacts/linux-builds/*.AppImage
|
||||
artifacts/linux-builds/*.deb
|
||||
artifacts/linux-builds/*.rpm
|
||||
artifacts/linux-builds/*.blockmap
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -28,6 +28,7 @@ COPY libs/platform/package*.json ./libs/platform/
|
||||
COPY libs/model-resolver/package*.json ./libs/model-resolver/
|
||||
COPY libs/dependency-resolver/package*.json ./libs/dependency-resolver/
|
||||
COPY libs/git-utils/package*.json ./libs/git-utils/
|
||||
COPY libs/spec-parser/package*.json ./libs/spec-parser/
|
||||
|
||||
# Copy scripts (needed by npm workspace)
|
||||
COPY scripts ./scripts
|
||||
|
||||
@@ -40,7 +40,8 @@
|
||||
"express": "5.2.1",
|
||||
"morgan": "1.10.1",
|
||||
"node-pty": "1.1.0-beta41",
|
||||
"ws": "8.18.3"
|
||||
"ws": "8.18.3",
|
||||
"yaml": "2.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cookie": "0.6.0",
|
||||
|
||||
@@ -43,7 +43,6 @@ import { createEnhancePromptRoutes } from './routes/enhance-prompt/index.js';
|
||||
import { createWorktreeRoutes } from './routes/worktree/index.js';
|
||||
import { createGitRoutes } from './routes/git/index.js';
|
||||
import { createSetupRoutes } from './routes/setup/index.js';
|
||||
import { createSuggestionsRoutes } from './routes/suggestions/index.js';
|
||||
import { createModelsRoutes } from './routes/models/index.js';
|
||||
import { createRunningAgentsRoutes } from './routes/running-agents/index.js';
|
||||
import { createWorkspaceRoutes } from './routes/workspace/index.js';
|
||||
@@ -83,6 +82,9 @@ import { createNotificationsRoutes } from './routes/notifications/index.js';
|
||||
import { getNotificationService } from './services/notification-service.js';
|
||||
import { createEventHistoryRoutes } from './routes/event-history/index.js';
|
||||
import { getEventHistoryService } from './services/event-history-service.js';
|
||||
import { getTestRunnerService } from './services/test-runner-service.js';
|
||||
import { createProviderUsageRoutes } from './routes/provider-usage/index.js';
|
||||
import { ProviderUsageTracker } from './services/provider-usage-tracker.js';
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
@@ -236,6 +238,7 @@ const codexModelCacheService = new CodexModelCacheService(DATA_DIR, codexAppServ
|
||||
const codexUsageService = new CodexUsageService(codexAppServerService);
|
||||
const mcpTestService = new MCPTestService(settingsService);
|
||||
const ideationService = new IdeationService(events, settingsService, featureLoader);
|
||||
const providerUsageTracker = new ProviderUsageTracker(codexUsageService);
|
||||
|
||||
// Initialize DevServerService with event emitter for real-time log streaming
|
||||
const devServerService = getDevServerService();
|
||||
@@ -248,6 +251,10 @@ notificationService.setEventEmitter(events);
|
||||
// Initialize Event History Service
|
||||
const eventHistoryService = getEventHistoryService();
|
||||
|
||||
// Initialize Test Runner Service with event emitter for real-time test output streaming
|
||||
const testRunnerService = getTestRunnerService();
|
||||
testRunnerService.setEventEmitter(events);
|
||||
|
||||
// Initialize Event Hook Service for custom event triggers (with history storage)
|
||||
eventHookService.initialize(events, settingsService, eventHistoryService, featureLoader);
|
||||
|
||||
@@ -326,7 +333,6 @@ app.use('/api/auto-mode', createAutoModeRoutes(autoModeService));
|
||||
app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService));
|
||||
app.use('/api/worktree', createWorktreeRoutes(events, settingsService));
|
||||
app.use('/api/git', createGitRoutes());
|
||||
app.use('/api/suggestions', createSuggestionsRoutes(events, settingsService));
|
||||
app.use('/api/models', createModelsRoutes());
|
||||
app.use('/api/spec-regeneration', createSpecRegenerationRoutes(events, settingsService));
|
||||
app.use('/api/running-agents', createRunningAgentsRoutes(autoModeService));
|
||||
@@ -344,6 +350,7 @@ app.use('/api/pipeline', createPipelineRoutes(pipelineService));
|
||||
app.use('/api/ideation', createIdeationRoutes(events, ideationService, featureLoader));
|
||||
app.use('/api/notifications', createNotificationsRoutes(notificationService));
|
||||
app.use('/api/event-history', createEventHistoryRoutes(eventHistoryService, settingsService));
|
||||
app.use('/api/provider-usage', createProviderUsageRoutes(providerUsageTracker));
|
||||
|
||||
// Create HTTP server
|
||||
const server = createServer(app);
|
||||
|
||||
@@ -337,10 +337,11 @@ export class CursorProvider extends CliProvider {
|
||||
'--stream-partial-output' // Real-time streaming
|
||||
);
|
||||
|
||||
// Only add --force if NOT in read-only mode
|
||||
// Without --force, Cursor CLI suggests changes but doesn't apply them
|
||||
// With --force, Cursor CLI can actually edit files
|
||||
if (!options.readOnly) {
|
||||
// In read-only mode, use --mode ask for Q&A style (no tools)
|
||||
// Otherwise, add --force to allow file edits
|
||||
if (options.readOnly) {
|
||||
cliArgs.push('--mode', 'ask');
|
||||
} else {
|
||||
cliArgs.push('--force');
|
||||
}
|
||||
|
||||
@@ -672,10 +673,13 @@ export class CursorProvider extends CliProvider {
|
||||
);
|
||||
}
|
||||
|
||||
// Extract prompt text to pass via stdin (avoids shell escaping issues)
|
||||
const promptText = this.extractPromptText(options);
|
||||
// Embed system prompt into user prompt (Cursor CLI doesn't support separate system messages)
|
||||
const effectiveOptions = this.embedSystemPromptIntoPrompt(options);
|
||||
|
||||
const cliArgs = this.buildCliArgs(options);
|
||||
// Extract prompt text to pass via stdin (avoids shell escaping issues)
|
||||
const promptText = this.extractPromptText(effectiveOptions);
|
||||
|
||||
const cliArgs = this.buildCliArgs(effectiveOptions);
|
||||
const subprocessOptions = this.buildSubprocessOptions(options, cliArgs);
|
||||
|
||||
// Pass prompt via stdin to avoid shell interpretation of special characters
|
||||
|
||||
815
apps/server/src/providers/gemini-provider.ts
Normal file
815
apps/server/src/providers/gemini-provider.ts
Normal file
@@ -0,0 +1,815 @@
|
||||
/**
|
||||
* Gemini Provider - Executes queries using the Gemini CLI
|
||||
*
|
||||
* Extends CliProvider with Gemini-specific:
|
||||
* - Event normalization for Gemini's JSONL streaming format
|
||||
* - Google account and API key authentication support
|
||||
* - Thinking level configuration
|
||||
*
|
||||
* Based on https://github.com/google-gemini/gemini-cli
|
||||
*/
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { CliProvider, type CliSpawnConfig, type CliErrorInfo } from './cli-provider.js';
|
||||
import type {
|
||||
ProviderConfig,
|
||||
ExecuteOptions,
|
||||
ProviderMessage,
|
||||
InstallationStatus,
|
||||
ModelDefinition,
|
||||
ContentBlock,
|
||||
} from './types.js';
|
||||
import { validateBareModelId } from '@automaker/types';
|
||||
import { GEMINI_MODEL_MAP, type GeminiAuthStatus } from '@automaker/types';
|
||||
import { createLogger, isAbortError } from '@automaker/utils';
|
||||
import { spawnJSONLProcess } from '@automaker/platform';
|
||||
|
||||
// Create logger for this module
|
||||
const logger = createLogger('GeminiProvider');
|
||||
|
||||
// =============================================================================
|
||||
// Gemini Stream Event Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Base event structure from Gemini CLI --output-format stream-json
|
||||
*
|
||||
* Actual CLI output format:
|
||||
* {"type":"init","timestamp":"...","session_id":"...","model":"..."}
|
||||
* {"type":"message","timestamp":"...","role":"user","content":"..."}
|
||||
* {"type":"message","timestamp":"...","role":"assistant","content":"...","delta":true}
|
||||
* {"type":"tool_use","timestamp":"...","tool_name":"...","tool_id":"...","parameters":{...}}
|
||||
* {"type":"tool_result","timestamp":"...","tool_id":"...","status":"success","output":"..."}
|
||||
* {"type":"result","timestamp":"...","status":"success","stats":{...}}
|
||||
*/
|
||||
interface GeminiStreamEvent {
|
||||
type: 'init' | 'message' | 'tool_use' | 'tool_result' | 'result' | 'error';
|
||||
timestamp?: string;
|
||||
session_id?: string;
|
||||
}
|
||||
|
||||
interface GeminiInitEvent extends GeminiStreamEvent {
|
||||
type: 'init';
|
||||
session_id: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
interface GeminiMessageEvent extends GeminiStreamEvent {
|
||||
type: 'message';
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
delta?: boolean;
|
||||
session_id?: string;
|
||||
}
|
||||
|
||||
interface GeminiToolUseEvent extends GeminiStreamEvent {
|
||||
type: 'tool_use';
|
||||
tool_id: string;
|
||||
tool_name: string;
|
||||
parameters: Record<string, unknown>;
|
||||
session_id?: string;
|
||||
}
|
||||
|
||||
interface GeminiToolResultEvent extends GeminiStreamEvent {
|
||||
type: 'tool_result';
|
||||
tool_id: string;
|
||||
status: 'success' | 'error';
|
||||
output: string;
|
||||
session_id?: string;
|
||||
}
|
||||
|
||||
interface GeminiResultEvent extends GeminiStreamEvent {
|
||||
type: 'result';
|
||||
status: 'success' | 'error';
|
||||
stats?: {
|
||||
total_tokens?: number;
|
||||
input_tokens?: number;
|
||||
output_tokens?: number;
|
||||
cached?: number;
|
||||
input?: number;
|
||||
duration_ms?: number;
|
||||
tool_calls?: number;
|
||||
};
|
||||
error?: string;
|
||||
session_id?: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Error Codes
|
||||
// =============================================================================
|
||||
|
||||
export enum GeminiErrorCode {
|
||||
NOT_INSTALLED = 'GEMINI_NOT_INSTALLED',
|
||||
NOT_AUTHENTICATED = 'GEMINI_NOT_AUTHENTICATED',
|
||||
RATE_LIMITED = 'GEMINI_RATE_LIMITED',
|
||||
MODEL_UNAVAILABLE = 'GEMINI_MODEL_UNAVAILABLE',
|
||||
NETWORK_ERROR = 'GEMINI_NETWORK_ERROR',
|
||||
PROCESS_CRASHED = 'GEMINI_PROCESS_CRASHED',
|
||||
TIMEOUT = 'GEMINI_TIMEOUT',
|
||||
UNKNOWN = 'GEMINI_UNKNOWN_ERROR',
|
||||
}
|
||||
|
||||
export interface GeminiError extends Error {
|
||||
code: GeminiErrorCode;
|
||||
recoverable: boolean;
|
||||
suggestion?: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tool Name Normalization
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Gemini CLI tool name to standard tool name mapping
|
||||
* This allows the UI to properly categorize and display Gemini tool calls
|
||||
*/
|
||||
const GEMINI_TOOL_NAME_MAP: Record<string, string> = {
|
||||
write_todos: 'TodoWrite',
|
||||
read_file: 'Read',
|
||||
read_many_files: 'Read',
|
||||
replace: 'Edit',
|
||||
write_file: 'Write',
|
||||
run_shell_command: 'Bash',
|
||||
search_file_content: 'Grep',
|
||||
glob: 'Glob',
|
||||
list_directory: 'Ls',
|
||||
web_fetch: 'WebFetch',
|
||||
google_web_search: 'WebSearch',
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize Gemini tool names to standard tool names
|
||||
*/
|
||||
function normalizeGeminiToolName(geminiToolName: string): string {
|
||||
return GEMINI_TOOL_NAME_MAP[geminiToolName] || geminiToolName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize Gemini tool input parameters to standard format
|
||||
*
|
||||
* Gemini `write_todos` format:
|
||||
* {"todos": [{"description": "Task text", "status": "pending|in_progress|completed|cancelled"}]}
|
||||
*
|
||||
* Claude `TodoWrite` format:
|
||||
* {"todos": [{"content": "Task text", "status": "pending|in_progress|completed", "activeForm": "..."}]}
|
||||
*/
|
||||
function normalizeGeminiToolInput(
|
||||
toolName: string,
|
||||
input: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
// Normalize write_todos: map 'description' to 'content', handle 'cancelled' status
|
||||
if (toolName === 'write_todos' && Array.isArray(input.todos)) {
|
||||
return {
|
||||
todos: input.todos.map((todo: { description?: string; status?: string }) => ({
|
||||
content: todo.description || '',
|
||||
// Map 'cancelled' to 'completed' since Claude doesn't have cancelled status
|
||||
status: todo.status === 'cancelled' ? 'completed' : todo.status,
|
||||
// Use description as activeForm since Gemini doesn't have it
|
||||
activeForm: todo.description || '',
|
||||
})),
|
||||
};
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
/**
|
||||
* GeminiProvider - Integrates Gemini CLI as an AI provider
|
||||
*
|
||||
* Features:
|
||||
* - Google account OAuth login support
|
||||
* - API key authentication (GEMINI_API_KEY)
|
||||
* - Vertex AI support
|
||||
* - Thinking level configuration
|
||||
* - Streaming JSON output
|
||||
*/
|
||||
export class GeminiProvider extends CliProvider {
|
||||
constructor(config: ProviderConfig = {}) {
|
||||
super(config);
|
||||
// Trigger CLI detection on construction
|
||||
this.ensureCliDetected();
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// CliProvider Abstract Method Implementations
|
||||
// ==========================================================================
|
||||
|
||||
getName(): string {
|
||||
return 'gemini';
|
||||
}
|
||||
|
||||
getCliName(): string {
|
||||
return 'gemini';
|
||||
}
|
||||
|
||||
getSpawnConfig(): CliSpawnConfig {
|
||||
return {
|
||||
windowsStrategy: 'npx', // Gemini CLI can be run via npx
|
||||
npxPackage: '@google/gemini-cli', // Official Google Gemini CLI package
|
||||
commonPaths: {
|
||||
linux: [
|
||||
path.join(os.homedir(), '.local/bin/gemini'),
|
||||
'/usr/local/bin/gemini',
|
||||
path.join(os.homedir(), '.npm-global/bin/gemini'),
|
||||
],
|
||||
darwin: [
|
||||
path.join(os.homedir(), '.local/bin/gemini'),
|
||||
'/usr/local/bin/gemini',
|
||||
'/opt/homebrew/bin/gemini',
|
||||
path.join(os.homedir(), '.npm-global/bin/gemini'),
|
||||
],
|
||||
win32: [
|
||||
path.join(os.homedir(), 'AppData', 'Roaming', 'npm', 'gemini.cmd'),
|
||||
path.join(os.homedir(), '.npm-global', 'gemini.cmd'),
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract prompt text from ExecuteOptions
|
||||
*/
|
||||
private extractPromptText(options: ExecuteOptions): string {
|
||||
if (typeof options.prompt === 'string') {
|
||||
return options.prompt;
|
||||
} else if (Array.isArray(options.prompt)) {
|
||||
return options.prompt
|
||||
.filter((p) => p.type === 'text' && p.text)
|
||||
.map((p) => p.text)
|
||||
.join('\n');
|
||||
} else {
|
||||
throw new Error('Invalid prompt format');
|
||||
}
|
||||
}
|
||||
|
||||
buildCliArgs(options: ExecuteOptions): string[] {
|
||||
// Model comes in stripped of provider prefix (e.g., '2.5-flash' from 'gemini-2.5-flash')
|
||||
// We need to add 'gemini-' back since it's part of the actual CLI model name
|
||||
const bareModel = options.model || '2.5-flash';
|
||||
const cliArgs: string[] = [];
|
||||
|
||||
// Streaming JSON output format for real-time updates
|
||||
cliArgs.push('--output-format', 'stream-json');
|
||||
|
||||
// Model selection - Gemini CLI expects full model names like "gemini-2.5-flash"
|
||||
// Unlike Cursor CLI where 'cursor-' is just a routing prefix, for Gemini CLI
|
||||
// the 'gemini-' is part of the actual model name Google expects
|
||||
if (bareModel && bareModel !== 'auto') {
|
||||
// Add gemini- prefix if not already present (handles edge cases)
|
||||
const cliModel = bareModel.startsWith('gemini-') ? bareModel : `gemini-${bareModel}`;
|
||||
cliArgs.push('--model', cliModel);
|
||||
}
|
||||
|
||||
// Disable sandbox mode for faster execution (sandbox adds overhead)
|
||||
cliArgs.push('--sandbox', 'false');
|
||||
|
||||
// YOLO mode for automatic approval (required for non-interactive use)
|
||||
// Use explicit approval-mode for clearer semantics
|
||||
cliArgs.push('--approval-mode', 'yolo');
|
||||
|
||||
// Explicitly include the working directory in allowed workspace directories
|
||||
// This ensures Gemini CLI allows file operations in the project directory,
|
||||
// even if it has a different workspace cached from a previous session
|
||||
if (options.cwd) {
|
||||
cliArgs.push('--include-directories', options.cwd);
|
||||
}
|
||||
|
||||
// Note: Gemini CLI doesn't have a --thinking-level flag.
|
||||
// Thinking capabilities are determined by the model selection (e.g., gemini-2.5-pro).
|
||||
// The model handles thinking internally based on the task complexity.
|
||||
|
||||
// The prompt will be passed as the last positional argument
|
||||
// We'll append it in executeQuery after extracting the text
|
||||
|
||||
return cliArgs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Gemini event to AutoMaker ProviderMessage format
|
||||
*/
|
||||
normalizeEvent(event: unknown): ProviderMessage | null {
|
||||
const geminiEvent = event as GeminiStreamEvent;
|
||||
|
||||
switch (geminiEvent.type) {
|
||||
case 'init': {
|
||||
// Init event - capture session but don't yield a message
|
||||
const initEvent = geminiEvent as GeminiInitEvent;
|
||||
logger.debug(
|
||||
`Gemini init event: session=${initEvent.session_id}, model=${initEvent.model}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'message': {
|
||||
const messageEvent = geminiEvent as GeminiMessageEvent;
|
||||
|
||||
// Skip user messages - already handled by caller
|
||||
if (messageEvent.role === 'user') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle assistant messages
|
||||
if (messageEvent.role === 'assistant') {
|
||||
return {
|
||||
type: 'assistant',
|
||||
session_id: messageEvent.session_id,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: messageEvent.content }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'tool_use': {
|
||||
const toolEvent = geminiEvent as GeminiToolUseEvent;
|
||||
const normalizedName = normalizeGeminiToolName(toolEvent.tool_name);
|
||||
const normalizedInput = normalizeGeminiToolInput(
|
||||
toolEvent.tool_name,
|
||||
toolEvent.parameters as Record<string, unknown>
|
||||
);
|
||||
|
||||
return {
|
||||
type: 'assistant',
|
||||
session_id: toolEvent.session_id,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
name: normalizedName,
|
||||
tool_use_id: toolEvent.tool_id,
|
||||
input: normalizedInput,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'tool_result': {
|
||||
const toolResultEvent = geminiEvent as GeminiToolResultEvent;
|
||||
// If tool result is an error, prefix with error indicator
|
||||
const content =
|
||||
toolResultEvent.status === 'error'
|
||||
? `[ERROR] ${toolResultEvent.output}`
|
||||
: toolResultEvent.output;
|
||||
return {
|
||||
type: 'assistant',
|
||||
session_id: toolResultEvent.session_id,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: toolResultEvent.tool_id,
|
||||
content,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'result': {
|
||||
const resultEvent = geminiEvent as GeminiResultEvent;
|
||||
|
||||
if (resultEvent.status === 'error') {
|
||||
return {
|
||||
type: 'error',
|
||||
session_id: resultEvent.session_id,
|
||||
error: resultEvent.error || 'Unknown error',
|
||||
};
|
||||
}
|
||||
|
||||
// Success result - include stats for logging
|
||||
logger.debug(
|
||||
`Gemini result: status=${resultEvent.status}, tokens=${resultEvent.stats?.total_tokens}`
|
||||
);
|
||||
return {
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
session_id: resultEvent.session_id,
|
||||
};
|
||||
}
|
||||
|
||||
case 'error': {
|
||||
const errorEvent = geminiEvent as GeminiResultEvent;
|
||||
return {
|
||||
type: 'error',
|
||||
session_id: errorEvent.session_id,
|
||||
error: errorEvent.error || 'Unknown error',
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
logger.debug(`Unknown Gemini event type: ${geminiEvent.type}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// CliProvider Overrides
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Override error mapping for Gemini-specific error codes
|
||||
*/
|
||||
protected mapError(stderr: string, exitCode: number | null): CliErrorInfo {
|
||||
const lower = stderr.toLowerCase();
|
||||
|
||||
if (
|
||||
lower.includes('not authenticated') ||
|
||||
lower.includes('please log in') ||
|
||||
lower.includes('unauthorized') ||
|
||||
lower.includes('login required') ||
|
||||
lower.includes('error authenticating') ||
|
||||
lower.includes('loadcodeassist') ||
|
||||
(lower.includes('econnrefused') && lower.includes('8888'))
|
||||
) {
|
||||
return {
|
||||
code: GeminiErrorCode.NOT_AUTHENTICATED,
|
||||
message: 'Gemini CLI is not authenticated',
|
||||
recoverable: true,
|
||||
suggestion:
|
||||
'Run "gemini" interactively to log in, or set GEMINI_API_KEY environment variable',
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
lower.includes('rate limit') ||
|
||||
lower.includes('too many requests') ||
|
||||
lower.includes('429') ||
|
||||
lower.includes('quota exceeded')
|
||||
) {
|
||||
return {
|
||||
code: GeminiErrorCode.RATE_LIMITED,
|
||||
message: 'Gemini API rate limit exceeded',
|
||||
recoverable: true,
|
||||
suggestion: 'Wait a few minutes and try again. Free tier: 60 req/min, 1000 req/day',
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
lower.includes('model not available') ||
|
||||
lower.includes('invalid model') ||
|
||||
lower.includes('unknown model') ||
|
||||
lower.includes('modelnotfounderror') ||
|
||||
lower.includes('model not found') ||
|
||||
(lower.includes('not found') && lower.includes('404'))
|
||||
) {
|
||||
return {
|
||||
code: GeminiErrorCode.MODEL_UNAVAILABLE,
|
||||
message: 'Requested model is not available',
|
||||
recoverable: true,
|
||||
suggestion: 'Try using "gemini-2.5-flash" or select a different model',
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
lower.includes('network') ||
|
||||
lower.includes('connection') ||
|
||||
lower.includes('econnrefused') ||
|
||||
lower.includes('timeout')
|
||||
) {
|
||||
return {
|
||||
code: GeminiErrorCode.NETWORK_ERROR,
|
||||
message: 'Network connection error',
|
||||
recoverable: true,
|
||||
suggestion: 'Check your internet connection and try again',
|
||||
};
|
||||
}
|
||||
|
||||
if (exitCode === 137 || lower.includes('killed') || lower.includes('sigterm')) {
|
||||
return {
|
||||
code: GeminiErrorCode.PROCESS_CRASHED,
|
||||
message: 'Gemini CLI process was terminated',
|
||||
recoverable: true,
|
||||
suggestion: 'The process may have run out of memory. Try a simpler task.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
code: GeminiErrorCode.UNKNOWN,
|
||||
message: stderr || `Gemini CLI exited with code ${exitCode}`,
|
||||
recoverable: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Override install instructions for Gemini-specific guidance
|
||||
*/
|
||||
protected getInstallInstructions(): string {
|
||||
return 'Install with: npm install -g @google/gemini-cli (or visit https://github.com/google-gemini/gemini-cli)';
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a prompt using Gemini CLI with streaming
|
||||
*/
|
||||
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
|
||||
this.ensureCliDetected();
|
||||
|
||||
// Validate that model doesn't have a provider prefix
|
||||
validateBareModelId(options.model, 'GeminiProvider');
|
||||
|
||||
if (!this.cliPath) {
|
||||
throw this.createError(
|
||||
GeminiErrorCode.NOT_INSTALLED,
|
||||
'Gemini CLI is not installed',
|
||||
true,
|
||||
this.getInstallInstructions()
|
||||
);
|
||||
}
|
||||
|
||||
// Extract prompt text to pass as positional argument
|
||||
const promptText = this.extractPromptText(options);
|
||||
|
||||
// Build CLI args and append the prompt as the last positional argument
|
||||
const cliArgs = this.buildCliArgs(options);
|
||||
cliArgs.push(promptText); // Gemini CLI uses positional args for the prompt
|
||||
|
||||
const subprocessOptions = this.buildSubprocessOptions(options, cliArgs);
|
||||
|
||||
let sessionId: string | undefined;
|
||||
|
||||
logger.debug(`GeminiProvider.executeQuery called with model: "${options.model}"`);
|
||||
|
||||
try {
|
||||
for await (const rawEvent of spawnJSONLProcess(subprocessOptions)) {
|
||||
const event = rawEvent as GeminiStreamEvent;
|
||||
|
||||
// Capture session ID from init event
|
||||
if (event.type === 'init') {
|
||||
const initEvent = event as GeminiInitEvent;
|
||||
sessionId = initEvent.session_id;
|
||||
logger.debug(`Session started: ${sessionId}, model: ${initEvent.model}`);
|
||||
}
|
||||
|
||||
// Normalize and yield the event
|
||||
const normalized = this.normalizeEvent(event);
|
||||
if (normalized) {
|
||||
if (!normalized.session_id && sessionId) {
|
||||
normalized.session_id = sessionId;
|
||||
}
|
||||
yield normalized;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) {
|
||||
logger.debug('Query aborted');
|
||||
return;
|
||||
}
|
||||
|
||||
// Map CLI errors to GeminiError
|
||||
if (error instanceof Error && 'stderr' in error) {
|
||||
const errorInfo = this.mapError(
|
||||
(error as { stderr?: string }).stderr || error.message,
|
||||
(error as { exitCode?: number | null }).exitCode ?? null
|
||||
);
|
||||
throw this.createError(
|
||||
errorInfo.code as GeminiErrorCode,
|
||||
errorInfo.message,
|
||||
errorInfo.recoverable,
|
||||
errorInfo.suggestion
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Gemini-Specific Methods
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Create a GeminiError with details
|
||||
*/
|
||||
private createError(
|
||||
code: GeminiErrorCode,
|
||||
message: string,
|
||||
recoverable: boolean = false,
|
||||
suggestion?: string
|
||||
): GeminiError {
|
||||
const error = new Error(message) as GeminiError;
|
||||
error.code = code;
|
||||
error.recoverable = recoverable;
|
||||
error.suggestion = suggestion;
|
||||
error.name = 'GeminiError';
|
||||
return error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Gemini CLI version
|
||||
*/
|
||||
async getVersion(): Promise<string | null> {
|
||||
this.ensureCliDetected();
|
||||
if (!this.cliPath) return null;
|
||||
|
||||
try {
|
||||
const result = execSync(`"${this.cliPath}" --version`, {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
stdio: 'pipe',
|
||||
}).trim();
|
||||
return result;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check authentication status
|
||||
*
|
||||
* Uses a fast credential check approach:
|
||||
* 1. Check for GEMINI_API_KEY environment variable
|
||||
* 2. Check for Google Cloud credentials
|
||||
* 3. Check for Gemini settings file with stored credentials
|
||||
* 4. Quick CLI auth test with --help (fast, doesn't make API calls)
|
||||
*/
|
||||
async checkAuth(): Promise<GeminiAuthStatus> {
|
||||
this.ensureCliDetected();
|
||||
if (!this.cliPath) {
|
||||
logger.debug('checkAuth: CLI not found');
|
||||
return { authenticated: false, method: 'none' };
|
||||
}
|
||||
|
||||
logger.debug('checkAuth: Starting credential check');
|
||||
|
||||
// Determine the likely auth method based on environment
|
||||
const hasApiKey = !!process.env.GEMINI_API_KEY;
|
||||
const hasEnvApiKey = hasApiKey;
|
||||
const hasVertexAi = !!(
|
||||
process.env.GOOGLE_APPLICATION_CREDENTIALS || process.env.GOOGLE_CLOUD_PROJECT
|
||||
);
|
||||
|
||||
logger.debug(`checkAuth: hasApiKey=${hasApiKey}, hasVertexAi=${hasVertexAi}`);
|
||||
|
||||
// Check for Gemini credentials file (~/.gemini/settings.json)
|
||||
const geminiConfigDir = path.join(os.homedir(), '.gemini');
|
||||
const settingsPath = path.join(geminiConfigDir, 'settings.json');
|
||||
let hasCredentialsFile = false;
|
||||
let authType: string | null = null;
|
||||
|
||||
try {
|
||||
await fs.access(settingsPath);
|
||||
logger.debug(`checkAuth: Found settings file at ${settingsPath}`);
|
||||
try {
|
||||
const content = await fs.readFile(settingsPath, 'utf8');
|
||||
const settings = JSON.parse(content);
|
||||
|
||||
// Auth config is at security.auth.selectedType (e.g., "oauth-personal", "oauth-adc", "api-key")
|
||||
const selectedType = settings?.security?.auth?.selectedType;
|
||||
if (selectedType) {
|
||||
hasCredentialsFile = true;
|
||||
authType = selectedType;
|
||||
logger.debug(`checkAuth: Settings file has auth config, selectedType=${selectedType}`);
|
||||
} else {
|
||||
logger.debug(`checkAuth: Settings file found but no auth type configured`);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.debug(`checkAuth: Failed to parse settings file: ${e}`);
|
||||
}
|
||||
} catch {
|
||||
logger.debug('checkAuth: No settings file found');
|
||||
}
|
||||
|
||||
// If we have an API key, we're authenticated
|
||||
if (hasApiKey) {
|
||||
logger.debug('checkAuth: Using API key authentication');
|
||||
return {
|
||||
authenticated: true,
|
||||
method: 'api_key',
|
||||
hasApiKey,
|
||||
hasEnvApiKey,
|
||||
hasCredentialsFile,
|
||||
};
|
||||
}
|
||||
|
||||
// If we have Vertex AI credentials, we're authenticated
|
||||
if (hasVertexAi) {
|
||||
logger.debug('checkAuth: Using Vertex AI authentication');
|
||||
return {
|
||||
authenticated: true,
|
||||
method: 'vertex_ai',
|
||||
hasApiKey,
|
||||
hasEnvApiKey,
|
||||
hasCredentialsFile,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if settings file indicates configured authentication
|
||||
if (hasCredentialsFile && authType) {
|
||||
// OAuth types: "oauth-personal", "oauth-adc"
|
||||
// API key type: "api-key"
|
||||
// Code assist: "code-assist" (requires IDE integration)
|
||||
if (authType.startsWith('oauth')) {
|
||||
logger.debug(`checkAuth: OAuth authentication configured (${authType})`);
|
||||
return {
|
||||
authenticated: true,
|
||||
method: 'google_login',
|
||||
hasApiKey,
|
||||
hasEnvApiKey,
|
||||
hasCredentialsFile,
|
||||
};
|
||||
}
|
||||
|
||||
if (authType === 'api-key') {
|
||||
logger.debug('checkAuth: API key authentication configured in settings');
|
||||
return {
|
||||
authenticated: true,
|
||||
method: 'api_key',
|
||||
hasApiKey,
|
||||
hasEnvApiKey,
|
||||
hasCredentialsFile,
|
||||
};
|
||||
}
|
||||
|
||||
if (authType === 'code-assist' || authType === 'codeassist') {
|
||||
logger.debug('checkAuth: Code Assist auth configured but requires local server');
|
||||
return {
|
||||
authenticated: false,
|
||||
method: 'google_login',
|
||||
hasApiKey,
|
||||
hasEnvApiKey,
|
||||
hasCredentialsFile,
|
||||
error:
|
||||
'Code Assist authentication requires IDE integration. Please use "gemini" CLI to log in with a different method, or set GEMINI_API_KEY.',
|
||||
};
|
||||
}
|
||||
|
||||
// Unknown auth type but something is configured
|
||||
logger.debug(`checkAuth: Unknown auth type configured: ${authType}`);
|
||||
return {
|
||||
authenticated: true,
|
||||
method: 'google_login',
|
||||
hasApiKey,
|
||||
hasEnvApiKey,
|
||||
hasCredentialsFile,
|
||||
};
|
||||
}
|
||||
|
||||
// No credentials found
|
||||
logger.debug('checkAuth: No valid credentials found');
|
||||
return {
|
||||
authenticated: false,
|
||||
method: 'none',
|
||||
hasApiKey,
|
||||
hasEnvApiKey,
|
||||
hasCredentialsFile,
|
||||
error:
|
||||
'No authentication configured. Run "gemini" interactively to log in, or set GEMINI_API_KEY.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect installation status (required by BaseProvider)
|
||||
*/
|
||||
async detectInstallation(): Promise<InstallationStatus> {
|
||||
const installed = await this.isInstalled();
|
||||
const version = installed ? await this.getVersion() : undefined;
|
||||
const auth = await this.checkAuth();
|
||||
|
||||
return {
|
||||
installed,
|
||||
version: version || undefined,
|
||||
path: this.cliPath || undefined,
|
||||
method: 'cli',
|
||||
hasApiKey: !!process.env.GEMINI_API_KEY,
|
||||
authenticated: auth.authenticated,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the detected CLI path (public accessor for status endpoints)
|
||||
*/
|
||||
getCliPath(): string | null {
|
||||
this.ensureCliDetected();
|
||||
return this.cliPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available Gemini models
|
||||
*/
|
||||
getAvailableModels(): ModelDefinition[] {
|
||||
return Object.entries(GEMINI_MODEL_MAP).map(([id, config]) => ({
|
||||
id, // Full model ID with gemini- prefix (e.g., 'gemini-2.5-flash')
|
||||
name: config.label,
|
||||
modelString: id, // Same as id - CLI uses the full model name
|
||||
provider: 'gemini',
|
||||
description: config.description,
|
||||
supportsTools: true,
|
||||
supportsVision: config.supportsVision,
|
||||
contextWindow: config.contextWindow,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a feature is supported
|
||||
*/
|
||||
supportsFeature(feature: string): boolean {
|
||||
const supported = ['tools', 'text', 'streaming', 'vision', 'thinking'];
|
||||
return supported.includes(feature);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,16 @@ export type {
|
||||
ProviderMessage,
|
||||
InstallationStatus,
|
||||
ModelDefinition,
|
||||
AgentDefinition,
|
||||
ReasoningEffort,
|
||||
SystemPromptPreset,
|
||||
ConversationMessage,
|
||||
ContentBlock,
|
||||
ValidationResult,
|
||||
McpServerConfig,
|
||||
McpStdioServerConfig,
|
||||
McpSSEServerConfig,
|
||||
McpHttpServerConfig,
|
||||
} from './types.js';
|
||||
|
||||
// Claude provider
|
||||
|
||||
@@ -7,7 +7,13 @@
|
||||
|
||||
import { BaseProvider } from './base-provider.js';
|
||||
import type { InstallationStatus, ModelDefinition } from './types.js';
|
||||
import { isCursorModel, isCodexModel, isOpencodeModel, type ModelProvider } from '@automaker/types';
|
||||
import {
|
||||
isCursorModel,
|
||||
isCodexModel,
|
||||
isOpencodeModel,
|
||||
isGeminiModel,
|
||||
type ModelProvider,
|
||||
} from '@automaker/types';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
@@ -16,6 +22,7 @@ const DISCONNECTED_MARKERS: Record<string, string> = {
|
||||
codex: '.codex-disconnected',
|
||||
cursor: '.cursor-disconnected',
|
||||
opencode: '.opencode-disconnected',
|
||||
gemini: '.gemini-disconnected',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -239,8 +246,8 @@ export class ProviderFactory {
|
||||
model.modelString === modelId ||
|
||||
model.id.endsWith(`-${modelId}`) ||
|
||||
model.modelString.endsWith(`-${modelId}`) ||
|
||||
model.modelString === modelId.replace(/^(claude|cursor|codex)-/, '') ||
|
||||
model.modelString === modelId.replace(/-(claude|cursor|codex)$/, '')
|
||||
model.modelString === modelId.replace(/^(claude|cursor|codex|gemini)-/, '') ||
|
||||
model.modelString === modelId.replace(/-(claude|cursor|codex|gemini)$/, '')
|
||||
) {
|
||||
return model.supportsVision ?? true;
|
||||
}
|
||||
@@ -267,6 +274,7 @@ import { ClaudeProvider } from './claude-provider.js';
|
||||
import { CursorProvider } from './cursor-provider.js';
|
||||
import { CodexProvider } from './codex-provider.js';
|
||||
import { OpencodeProvider } from './opencode-provider.js';
|
||||
import { GeminiProvider } from './gemini-provider.js';
|
||||
|
||||
// Register Claude provider
|
||||
registerProvider('claude', {
|
||||
@@ -301,3 +309,11 @@ registerProvider('opencode', {
|
||||
canHandleModel: (model: string) => isOpencodeModel(model),
|
||||
priority: 3, // Between codex (5) and claude (0)
|
||||
});
|
||||
|
||||
// Register Gemini provider
|
||||
registerProvider('gemini', {
|
||||
factory: () => new GeminiProvider(),
|
||||
aliases: ['google'],
|
||||
canHandleModel: (model: string) => isGeminiModel(model),
|
||||
priority: 4, // Between opencode (3) and codex (5)
|
||||
});
|
||||
|
||||
@@ -19,4 +19,7 @@ export type {
|
||||
InstallationStatus,
|
||||
ValidationResult,
|
||||
ModelDefinition,
|
||||
AgentDefinition,
|
||||
ReasoningEffort,
|
||||
SystemPromptPreset,
|
||||
} from '@automaker/types';
|
||||
|
||||
@@ -128,7 +128,10 @@ export async function generateBacklogPlan(
|
||||
let credentials: import('@automaker/types').Credentials | undefined;
|
||||
|
||||
if (effectiveModel) {
|
||||
// Use explicit override - just get credentials
|
||||
// Use explicit override - resolve model alias and get credentials
|
||||
const resolved = resolvePhaseModel({ model: effectiveModel });
|
||||
effectiveModel = resolved.model;
|
||||
thinkingLevel = resolved.thinkingLevel;
|
||||
credentials = await settingsService?.getCredentials();
|
||||
} else if (settingsService) {
|
||||
// Use settings-based model with provider info
|
||||
|
||||
@@ -16,6 +16,8 @@ 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';
|
||||
import { createExportHandler } from './routes/export.js';
|
||||
import { createImportHandler, createConflictCheckHandler } from './routes/import.js';
|
||||
|
||||
export function createFeaturesRoutes(
|
||||
featureLoader: FeatureLoader,
|
||||
@@ -46,6 +48,13 @@ export function createFeaturesRoutes(
|
||||
router.post('/agent-output', createAgentOutputHandler(featureLoader));
|
||||
router.post('/raw-output', createRawOutputHandler(featureLoader));
|
||||
router.post('/generate-title', createGenerateTitleHandler(settingsService));
|
||||
router.post('/export', validatePathParams('projectPath'), createExportHandler(featureLoader));
|
||||
router.post('/import', validatePathParams('projectPath'), createImportHandler(featureLoader));
|
||||
router.post(
|
||||
'/check-conflicts',
|
||||
validatePathParams('projectPath'),
|
||||
createConflictCheckHandler(featureLoader)
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
96
apps/server/src/routes/features/routes/export.ts
Normal file
96
apps/server/src/routes/features/routes/export.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* POST /export endpoint - Export features to JSON or YAML format
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { FeatureLoader } from '../../../services/feature-loader.js';
|
||||
import {
|
||||
getFeatureExportService,
|
||||
type ExportFormat,
|
||||
type BulkExportOptions,
|
||||
} from '../../../services/feature-export-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
interface ExportRequest {
|
||||
projectPath: string;
|
||||
/** Feature IDs to export. If empty/undefined, exports all features */
|
||||
featureIds?: string[];
|
||||
/** Export format: 'json' or 'yaml' */
|
||||
format?: ExportFormat;
|
||||
/** Whether to include description history */
|
||||
includeHistory?: boolean;
|
||||
/** Whether to include plan spec */
|
||||
includePlanSpec?: boolean;
|
||||
/** Filter by category */
|
||||
category?: string;
|
||||
/** Filter by status */
|
||||
status?: string;
|
||||
/** Pretty print output */
|
||||
prettyPrint?: boolean;
|
||||
/** Optional metadata to include */
|
||||
metadata?: {
|
||||
projectName?: string;
|
||||
projectPath?: string;
|
||||
branch?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export function createExportHandler(featureLoader: FeatureLoader) {
|
||||
const exportService = getFeatureExportService();
|
||||
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const {
|
||||
projectPath,
|
||||
featureIds,
|
||||
format = 'json',
|
||||
includeHistory = true,
|
||||
includePlanSpec = true,
|
||||
category,
|
||||
status,
|
||||
prettyPrint = true,
|
||||
metadata,
|
||||
} = req.body as ExportRequest;
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate format
|
||||
if (format !== 'json' && format !== 'yaml') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'format must be "json" or "yaml"',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const options: BulkExportOptions = {
|
||||
format,
|
||||
includeHistory,
|
||||
includePlanSpec,
|
||||
category,
|
||||
status,
|
||||
featureIds,
|
||||
prettyPrint,
|
||||
metadata,
|
||||
};
|
||||
|
||||
const exportData = await exportService.exportFeatures(projectPath, options);
|
||||
|
||||
// Return the export data as a string in the response
|
||||
res.json({
|
||||
success: true,
|
||||
data: exportData,
|
||||
format,
|
||||
contentType: format === 'json' ? 'application/json' : 'application/x-yaml',
|
||||
filename: `features-export.${format === 'json' ? 'json' : 'yaml'}`,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Export features failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
210
apps/server/src/routes/features/routes/import.ts
Normal file
210
apps/server/src/routes/features/routes/import.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* POST /import endpoint - Import features from JSON or YAML format
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { FeatureLoader } from '../../../services/feature-loader.js';
|
||||
import type { FeatureImportResult, Feature, FeatureExport } from '@automaker/types';
|
||||
import { getFeatureExportService } from '../../../services/feature-export-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
interface ImportRequest {
|
||||
projectPath: string;
|
||||
/** Raw JSON or YAML string containing feature data */
|
||||
data: string;
|
||||
/** Whether to overwrite existing features with same ID */
|
||||
overwrite?: boolean;
|
||||
/** Whether to preserve branch info from imported features */
|
||||
preserveBranchInfo?: boolean;
|
||||
/** Optional category to assign to all imported features */
|
||||
targetCategory?: string;
|
||||
}
|
||||
|
||||
interface ConflictCheckRequest {
|
||||
projectPath: string;
|
||||
/** Raw JSON or YAML string containing feature data */
|
||||
data: string;
|
||||
}
|
||||
|
||||
interface ConflictInfo {
|
||||
featureId: string;
|
||||
title?: string;
|
||||
existingTitle?: string;
|
||||
hasConflict: boolean;
|
||||
}
|
||||
|
||||
export function createImportHandler(featureLoader: FeatureLoader) {
|
||||
const exportService = getFeatureExportService();
|
||||
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const {
|
||||
projectPath,
|
||||
data,
|
||||
overwrite = false,
|
||||
preserveBranchInfo = false,
|
||||
targetCategory,
|
||||
} = req.body as ImportRequest;
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
res.status(400).json({ success: false, error: 'data is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Detect format and parse the data
|
||||
const format = exportService.detectFormat(data);
|
||||
if (!format) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid data format. Expected valid JSON or YAML.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = exportService.parseImportData(data);
|
||||
if (!parsed) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Failed to parse import data. Ensure it is valid JSON or YAML.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine if this is a single feature or bulk import
|
||||
const isBulkImport =
|
||||
'features' in parsed && Array.isArray((parsed as { features: unknown }).features);
|
||||
|
||||
let results: FeatureImportResult[];
|
||||
|
||||
if (isBulkImport) {
|
||||
// Bulk import
|
||||
results = await exportService.importFeatures(projectPath, data, {
|
||||
overwrite,
|
||||
preserveBranchInfo,
|
||||
targetCategory,
|
||||
});
|
||||
} else {
|
||||
// Single feature import - we know it's not a bulk export at this point
|
||||
// It must be either a Feature or FeatureExport
|
||||
const singleData = parsed as Feature | FeatureExport;
|
||||
|
||||
const result = await exportService.importFeature(projectPath, {
|
||||
data: singleData,
|
||||
overwrite,
|
||||
preserveBranchInfo,
|
||||
targetCategory,
|
||||
});
|
||||
results = [result];
|
||||
}
|
||||
|
||||
const successCount = results.filter((r) => r.success).length;
|
||||
const failureCount = results.filter((r) => !r.success).length;
|
||||
const allSuccessful = failureCount === 0;
|
||||
|
||||
res.json({
|
||||
success: allSuccessful,
|
||||
importedCount: successCount,
|
||||
failedCount: failureCount,
|
||||
results,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Import features failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create handler for checking conflicts before import
|
||||
*/
|
||||
export function createConflictCheckHandler(featureLoader: FeatureLoader) {
|
||||
const exportService = getFeatureExportService();
|
||||
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, data } = req.body as ConflictCheckRequest;
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
res.status(400).json({ success: false, error: 'data is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse the import data
|
||||
const format = exportService.detectFormat(data);
|
||||
if (!format) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid data format. Expected valid JSON or YAML.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = exportService.parseImportData(data);
|
||||
if (!parsed) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Failed to parse import data.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract features from the data using type guards
|
||||
let featuresToCheck: Array<{ id: string; title?: string }> = [];
|
||||
|
||||
if (exportService.isBulkExport(parsed)) {
|
||||
// Bulk export format
|
||||
featuresToCheck = parsed.features.map((f) => ({
|
||||
id: f.feature.id,
|
||||
title: f.feature.title,
|
||||
}));
|
||||
} else if (exportService.isFeatureExport(parsed)) {
|
||||
// Single FeatureExport format
|
||||
featuresToCheck = [
|
||||
{
|
||||
id: parsed.feature.id,
|
||||
title: parsed.feature.title,
|
||||
},
|
||||
];
|
||||
} else if (exportService.isRawFeature(parsed)) {
|
||||
// Raw Feature format
|
||||
featuresToCheck = [{ id: parsed.id, title: parsed.title }];
|
||||
}
|
||||
|
||||
// Check each feature for conflicts in parallel
|
||||
const conflicts: ConflictInfo[] = await Promise.all(
|
||||
featuresToCheck.map(async (feature) => {
|
||||
const existing = await featureLoader.get(projectPath, feature.id);
|
||||
return {
|
||||
featureId: feature.id,
|
||||
title: feature.title,
|
||||
existingTitle: existing?.title,
|
||||
hasConflict: !!existing,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const hasConflicts = conflicts.some((c) => c.hasConflict);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
hasConflicts,
|
||||
conflicts,
|
||||
totalFeatures: featuresToCheck.length,
|
||||
conflictCount: conflicts.filter((c) => c.hasConflict).length,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Conflict check failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
143
apps/server/src/routes/provider-usage/index.ts
Normal file
143
apps/server/src/routes/provider-usage/index.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Provider Usage Routes
|
||||
*
|
||||
* API endpoints for fetching usage data from all AI providers.
|
||||
*
|
||||
* Endpoints:
|
||||
* - GET /api/provider-usage - Get usage for all enabled providers
|
||||
* - GET /api/provider-usage/:providerId - Get usage for a specific provider
|
||||
* - GET /api/provider-usage/availability - Check availability of all providers
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { UsageProviderId } from '@automaker/types';
|
||||
import { ProviderUsageTracker } from '../../services/provider-usage-tracker.js';
|
||||
|
||||
const logger = createLogger('ProviderUsageRoutes');
|
||||
|
||||
// Valid provider IDs
|
||||
const VALID_PROVIDER_IDS: UsageProviderId[] = [
|
||||
'claude',
|
||||
'codex',
|
||||
'cursor',
|
||||
'gemini',
|
||||
'copilot',
|
||||
'opencode',
|
||||
'minimax',
|
||||
'glm',
|
||||
];
|
||||
|
||||
export function createProviderUsageRoutes(tracker: ProviderUsageTracker): Router {
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* GET /api/provider-usage
|
||||
* Fetch usage for all enabled providers
|
||||
*/
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const forceRefresh = req.query.refresh === 'true';
|
||||
const usage = await tracker.fetchAllUsage(forceRefresh);
|
||||
res.json({
|
||||
success: true,
|
||||
data: usage,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('Error fetching all provider usage:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/provider-usage/availability
|
||||
* Check which providers are available
|
||||
*/
|
||||
router.get('/availability', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const availability = await tracker.checkAvailability();
|
||||
res.json({
|
||||
success: true,
|
||||
data: availability,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('Error checking provider availability:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/provider-usage/:providerId
|
||||
* Fetch usage for a specific provider
|
||||
*/
|
||||
router.get('/:providerId', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const providerId = req.params.providerId as UsageProviderId;
|
||||
|
||||
// Validate provider ID
|
||||
if (!VALID_PROVIDER_IDS.includes(providerId)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `Invalid provider ID: ${providerId}. Valid providers: ${VALID_PROVIDER_IDS.join(', ')}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if provider is enabled
|
||||
if (!tracker.isProviderEnabled(providerId)) {
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
providerId,
|
||||
providerName: providerId,
|
||||
available: false,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
error: 'Provider is disabled',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const forceRefresh = req.query.refresh === 'true';
|
||||
const usage = await tracker.fetchProviderUsage(providerId, forceRefresh);
|
||||
|
||||
if (!usage) {
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
providerId,
|
||||
providerName: providerId,
|
||||
available: false,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
error: 'Failed to fetch usage data',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: usage,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error(`Error fetching usage for ${req.params.providerId}:`, error);
|
||||
|
||||
// Return 200 with error in data to avoid triggering logout
|
||||
res.status(200).json({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -24,6 +24,9 @@ import { createDeauthCursorHandler } from './routes/deauth-cursor.js';
|
||||
import { createAuthOpencodeHandler } from './routes/auth-opencode.js';
|
||||
import { createDeauthOpencodeHandler } from './routes/deauth-opencode.js';
|
||||
import { createOpencodeStatusHandler } from './routes/opencode-status.js';
|
||||
import { createGeminiStatusHandler } from './routes/gemini-status.js';
|
||||
import { createAuthGeminiHandler } from './routes/auth-gemini.js';
|
||||
import { createDeauthGeminiHandler } from './routes/deauth-gemini.js';
|
||||
import {
|
||||
createGetOpencodeModelsHandler,
|
||||
createRefreshOpencodeModelsHandler,
|
||||
@@ -72,6 +75,11 @@ export function createSetupRoutes(): Router {
|
||||
router.post('/auth-opencode', createAuthOpencodeHandler());
|
||||
router.post('/deauth-opencode', createDeauthOpencodeHandler());
|
||||
|
||||
// Gemini CLI routes
|
||||
router.get('/gemini-status', createGeminiStatusHandler());
|
||||
router.post('/auth-gemini', createAuthGeminiHandler());
|
||||
router.post('/deauth-gemini', createDeauthGeminiHandler());
|
||||
|
||||
// OpenCode Dynamic Model Discovery routes
|
||||
router.get('/opencode/models', createGetOpencodeModelsHandler());
|
||||
router.post('/opencode/models/refresh', createRefreshOpencodeModelsHandler());
|
||||
|
||||
42
apps/server/src/routes/setup/routes/auth-gemini.ts
Normal file
42
apps/server/src/routes/setup/routes/auth-gemini.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* POST /auth-gemini endpoint - Connect Gemini CLI to the app
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
const DISCONNECTED_MARKER_FILE = '.gemini-disconnected';
|
||||
|
||||
/**
|
||||
* Creates handler for POST /api/setup/auth-gemini
|
||||
* Removes the disconnection marker to allow Gemini CLI to be used
|
||||
*/
|
||||
export function createAuthGeminiHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const projectRoot = process.cwd();
|
||||
const automakerDir = path.join(projectRoot, '.automaker');
|
||||
const markerPath = path.join(automakerDir, DISCONNECTED_MARKER_FILE);
|
||||
|
||||
// Remove the disconnection marker if it exists
|
||||
try {
|
||||
await fs.unlink(markerPath);
|
||||
} catch {
|
||||
// File doesn't exist, nothing to remove
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Gemini CLI connected to app',
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Auth Gemini failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
42
apps/server/src/routes/setup/routes/deauth-gemini.ts
Normal file
42
apps/server/src/routes/setup/routes/deauth-gemini.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* POST /deauth-gemini endpoint - Disconnect Gemini CLI from the app
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
const DISCONNECTED_MARKER_FILE = '.gemini-disconnected';
|
||||
|
||||
/**
|
||||
* Creates handler for POST /api/setup/deauth-gemini
|
||||
* Creates a marker file to disconnect Gemini CLI from the app
|
||||
*/
|
||||
export function createDeauthGeminiHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const projectRoot = process.cwd();
|
||||
const automakerDir = path.join(projectRoot, '.automaker');
|
||||
|
||||
// Ensure .automaker directory exists
|
||||
await fs.mkdir(automakerDir, { recursive: true });
|
||||
|
||||
const markerPath = path.join(automakerDir, DISCONNECTED_MARKER_FILE);
|
||||
|
||||
// Create the disconnection marker
|
||||
await fs.writeFile(markerPath, 'Gemini CLI disconnected from app');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Gemini CLI disconnected from app',
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Deauth Gemini failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
79
apps/server/src/routes/setup/routes/gemini-status.ts
Normal file
79
apps/server/src/routes/setup/routes/gemini-status.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* GET /gemini-status endpoint - Get Gemini CLI installation and auth status
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { GeminiProvider } from '../../../providers/gemini-provider.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
const DISCONNECTED_MARKER_FILE = '.gemini-disconnected';
|
||||
|
||||
async function isGeminiDisconnectedFromApp(): Promise<boolean> {
|
||||
try {
|
||||
const projectRoot = process.cwd();
|
||||
const markerPath = path.join(projectRoot, '.automaker', DISCONNECTED_MARKER_FILE);
|
||||
await fs.access(markerPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates handler for GET /api/setup/gemini-status
|
||||
* Returns Gemini CLI installation and authentication status
|
||||
*/
|
||||
export function createGeminiStatusHandler() {
|
||||
const installCommand = 'npm install -g @google/gemini-cli';
|
||||
const loginCommand = 'gemini';
|
||||
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// Check if user has manually disconnected from the app
|
||||
if (await isGeminiDisconnectedFromApp()) {
|
||||
res.json({
|
||||
success: true,
|
||||
installed: true,
|
||||
version: null,
|
||||
path: null,
|
||||
auth: {
|
||||
authenticated: false,
|
||||
method: 'none',
|
||||
hasApiKey: false,
|
||||
},
|
||||
installCommand,
|
||||
loginCommand,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const provider = new GeminiProvider();
|
||||
const status = await provider.detectInstallation();
|
||||
const auth = await provider.checkAuth();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
installed: status.installed,
|
||||
version: status.version || null,
|
||||
path: status.path || null,
|
||||
auth: {
|
||||
authenticated: auth.authenticated,
|
||||
method: auth.method,
|
||||
hasApiKey: auth.hasApiKey || false,
|
||||
hasEnvApiKey: auth.hasEnvApiKey || false,
|
||||
error: auth.error,
|
||||
},
|
||||
installCommand,
|
||||
loginCommand,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Get Gemini status failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* Common utilities and state for suggestions routes
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
|
||||
|
||||
const logger = createLogger('Suggestions');
|
||||
|
||||
// Shared state for tracking generation status - private
|
||||
let isRunning = false;
|
||||
let currentAbortController: AbortController | null = null;
|
||||
|
||||
/**
|
||||
* Get the current running state
|
||||
*/
|
||||
export function getSuggestionsStatus(): {
|
||||
isRunning: boolean;
|
||||
currentAbortController: AbortController | null;
|
||||
} {
|
||||
return { isRunning, currentAbortController };
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the running state and abort controller
|
||||
*/
|
||||
export function setRunningState(running: boolean, controller: AbortController | null = null): void {
|
||||
isRunning = running;
|
||||
currentAbortController = controller;
|
||||
}
|
||||
|
||||
// Re-export shared utilities
|
||||
export { getErrorMessageShared as getErrorMessage };
|
||||
export const logError = createLogError(logger);
|
||||
@@ -1,335 +0,0 @@
|
||||
/**
|
||||
* Business logic for generating suggestions
|
||||
*
|
||||
* Model is configurable via phaseModels.suggestionsModel in settings
|
||||
* (AI Suggestions in the UI). Supports both Claude and Cursor models.
|
||||
*/
|
||||
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { DEFAULT_PHASE_MODELS, isCursorModel, type ThinkingLevel } from '@automaker/types';
|
||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||
import { extractJsonWithArray } from '../../lib/json-extractor.js';
|
||||
import { streamingQuery } from '../../providers/simple-query-service.js';
|
||||
import { FeatureLoader } from '../../services/feature-loader.js';
|
||||
import { getAppSpecPath } from '@automaker/platform';
|
||||
import * as secureFs from '../../lib/secure-fs.js';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
import {
|
||||
getAutoLoadClaudeMdSetting,
|
||||
getPromptCustomization,
|
||||
getPhaseModelWithOverrides,
|
||||
getProviderByModelId,
|
||||
} from '../../lib/settings-helpers.js';
|
||||
|
||||
const logger = createLogger('Suggestions');
|
||||
|
||||
/**
|
||||
* Extract implemented features from app_spec.txt XML content
|
||||
*
|
||||
* Note: This uses regex-based parsing which is sufficient for our controlled
|
||||
* XML structure. If more complex XML parsing is needed in the future, consider
|
||||
* using a library like 'fast-xml-parser' or 'xml2js'.
|
||||
*/
|
||||
function extractImplementedFeatures(specContent: string): string[] {
|
||||
const features: string[] = [];
|
||||
|
||||
// Match <implemented_features>...</implemented_features> section
|
||||
const implementedMatch = specContent.match(
|
||||
/<implemented_features>([\s\S]*?)<\/implemented_features>/
|
||||
);
|
||||
|
||||
if (implementedMatch) {
|
||||
const implementedSection = implementedMatch[1];
|
||||
|
||||
// Extract feature names from <name>...</name> tags using matchAll
|
||||
const nameRegex = /<name>(.*?)<\/name>/g;
|
||||
const matches = implementedSection.matchAll(nameRegex);
|
||||
|
||||
for (const match of matches) {
|
||||
features.push(match[1].trim());
|
||||
}
|
||||
}
|
||||
|
||||
return features;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load existing context (app spec and backlog features) to avoid duplicates
|
||||
*/
|
||||
async function loadExistingContext(projectPath: string): Promise<string> {
|
||||
let context = '';
|
||||
|
||||
// 1. Read app_spec.txt for implemented features
|
||||
try {
|
||||
const appSpecPath = getAppSpecPath(projectPath);
|
||||
const specContent = (await secureFs.readFile(appSpecPath, 'utf-8')) as string;
|
||||
|
||||
if (specContent && specContent.trim().length > 0) {
|
||||
const implementedFeatures = extractImplementedFeatures(specContent);
|
||||
|
||||
if (implementedFeatures.length > 0) {
|
||||
context += '\n\n=== ALREADY IMPLEMENTED FEATURES ===\n';
|
||||
context += 'These features are already implemented in the codebase:\n';
|
||||
context += implementedFeatures.map((feature) => `- ${feature}`).join('\n') + '\n';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// app_spec.txt doesn't exist or can't be read - that's okay
|
||||
logger.debug('No app_spec.txt found or error reading it:', error);
|
||||
}
|
||||
|
||||
// 2. Load existing features from backlog
|
||||
try {
|
||||
const featureLoader = new FeatureLoader();
|
||||
const features = await featureLoader.getAll(projectPath);
|
||||
|
||||
if (features.length > 0) {
|
||||
context += '\n\n=== EXISTING FEATURES IN BACKLOG ===\n';
|
||||
context += 'These features are already planned or in progress:\n';
|
||||
context +=
|
||||
features
|
||||
.map((feature) => {
|
||||
const status = feature.status || 'pending';
|
||||
const title = feature.title || feature.description?.substring(0, 50) || 'Untitled';
|
||||
return `- ${title} (${status})`;
|
||||
})
|
||||
.join('\n') + '\n';
|
||||
}
|
||||
} catch (error) {
|
||||
// Features directory doesn't exist or can't be read - that's okay
|
||||
logger.debug('No features found or error loading them:', error);
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON Schema for suggestions output
|
||||
*/
|
||||
const suggestionsSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
suggestions: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
category: { type: 'string' },
|
||||
description: { type: 'string' },
|
||||
priority: {
|
||||
type: 'number',
|
||||
minimum: 1,
|
||||
maximum: 3,
|
||||
},
|
||||
reasoning: { type: 'string' },
|
||||
},
|
||||
required: ['category', 'description', 'priority', 'reasoning'],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['suggestions'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
export async function generateSuggestions(
|
||||
projectPath: string,
|
||||
suggestionType: string,
|
||||
events: EventEmitter,
|
||||
abortController: AbortController,
|
||||
settingsService?: SettingsService,
|
||||
modelOverride?: string,
|
||||
thinkingLevelOverride?: ThinkingLevel
|
||||
): Promise<void> {
|
||||
// Get customized prompts from settings
|
||||
const prompts = await getPromptCustomization(settingsService, '[Suggestions]');
|
||||
|
||||
// Map suggestion types to their prompts
|
||||
const typePrompts: Record<string, string> = {
|
||||
features: prompts.suggestions.featuresPrompt,
|
||||
refactoring: prompts.suggestions.refactoringPrompt,
|
||||
security: prompts.suggestions.securityPrompt,
|
||||
performance: prompts.suggestions.performancePrompt,
|
||||
};
|
||||
|
||||
// Load existing context to avoid duplicates
|
||||
const existingContext = await loadExistingContext(projectPath);
|
||||
|
||||
const prompt = `${typePrompts[suggestionType] || typePrompts.features}
|
||||
${existingContext}
|
||||
|
||||
${existingContext ? '\nIMPORTANT: Do NOT suggest features that are already implemented or already in the backlog above. Focus on NEW ideas that complement what already exists.\n' : ''}
|
||||
${prompts.suggestions.baseTemplate}`;
|
||||
|
||||
// Don't send initial message - let the agent output speak for itself
|
||||
// The first agent message will be captured as an info entry
|
||||
|
||||
// Load autoLoadClaudeMd setting
|
||||
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
|
||||
projectPath,
|
||||
settingsService,
|
||||
'[Suggestions]'
|
||||
);
|
||||
|
||||
// Get model from phase settings with provider info (AI Suggestions = suggestionsModel)
|
||||
// Use override if provided, otherwise fall back to settings
|
||||
let model: string;
|
||||
let thinkingLevel: ThinkingLevel | undefined;
|
||||
let provider: import('@automaker/types').ClaudeCompatibleProvider | undefined;
|
||||
let credentials: import('@automaker/types').Credentials | undefined;
|
||||
|
||||
if (modelOverride) {
|
||||
// Use explicit override - resolve the model string
|
||||
const resolved = resolvePhaseModel({
|
||||
model: modelOverride,
|
||||
thinkingLevel: thinkingLevelOverride,
|
||||
});
|
||||
model = resolved.model;
|
||||
thinkingLevel = resolved.thinkingLevel;
|
||||
|
||||
// Try to find a provider for this model (e.g., GLM, MiniMax models)
|
||||
if (settingsService) {
|
||||
const providerResult = await getProviderByModelId(
|
||||
modelOverride,
|
||||
settingsService,
|
||||
'[Suggestions]'
|
||||
);
|
||||
provider = providerResult.provider;
|
||||
// Use resolved model from provider if available (maps to Claude model)
|
||||
if (providerResult.resolvedModel) {
|
||||
model = providerResult.resolvedModel;
|
||||
}
|
||||
credentials = providerResult.credentials ?? (await settingsService.getCredentials());
|
||||
}
|
||||
// If no settingsService, credentials remains undefined (initialized above)
|
||||
} else if (settingsService) {
|
||||
// Use settings-based model with provider info
|
||||
const phaseResult = await getPhaseModelWithOverrides(
|
||||
'suggestionsModel',
|
||||
settingsService,
|
||||
projectPath,
|
||||
'[Suggestions]'
|
||||
);
|
||||
const resolved = resolvePhaseModel(phaseResult.phaseModel);
|
||||
model = resolved.model;
|
||||
thinkingLevel = resolved.thinkingLevel;
|
||||
provider = phaseResult.provider;
|
||||
credentials = phaseResult.credentials;
|
||||
} else {
|
||||
// Fallback to defaults
|
||||
const resolved = resolvePhaseModel(DEFAULT_PHASE_MODELS.suggestionsModel);
|
||||
model = resolved.model;
|
||||
thinkingLevel = resolved.thinkingLevel;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
'[Suggestions] Using model:',
|
||||
model,
|
||||
provider ? `via provider: ${provider.name}` : 'direct API'
|
||||
);
|
||||
|
||||
let responseText = '';
|
||||
|
||||
// Determine if we should use structured output (Claude supports it, Cursor doesn't)
|
||||
const useStructuredOutput = !isCursorModel(model);
|
||||
|
||||
// Build the final prompt - for Cursor, include JSON schema instructions
|
||||
let finalPrompt = prompt;
|
||||
if (!useStructuredOutput) {
|
||||
finalPrompt = `${prompt}
|
||||
|
||||
CRITICAL INSTRUCTIONS:
|
||||
1. DO NOT write any files. Return the JSON in your response only.
|
||||
2. After analyzing the project, respond with ONLY a JSON object - no explanations, no markdown, just raw JSON.
|
||||
3. The JSON must match this exact schema:
|
||||
|
||||
${JSON.stringify(suggestionsSchema, null, 2)}
|
||||
|
||||
Your entire response should be valid JSON starting with { and ending with }. No text before or after.`;
|
||||
}
|
||||
|
||||
// Use streamingQuery with event callbacks
|
||||
const result = await streamingQuery({
|
||||
prompt: finalPrompt,
|
||||
model,
|
||||
cwd: projectPath,
|
||||
maxTurns: 250,
|
||||
allowedTools: ['Read', 'Glob', 'Grep'],
|
||||
abortController,
|
||||
thinkingLevel,
|
||||
readOnly: true, // Suggestions only reads code, doesn't write
|
||||
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
|
||||
claudeCompatibleProvider: provider, // Pass provider for alternative endpoint configuration
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
outputFormat: useStructuredOutput
|
||||
? {
|
||||
type: 'json_schema',
|
||||
schema: suggestionsSchema,
|
||||
}
|
||||
: undefined,
|
||||
onText: (text) => {
|
||||
responseText += text;
|
||||
events.emit('suggestions:event', {
|
||||
type: 'suggestions_progress',
|
||||
content: text,
|
||||
});
|
||||
},
|
||||
onToolUse: (tool, input) => {
|
||||
events.emit('suggestions:event', {
|
||||
type: 'suggestions_tool',
|
||||
tool,
|
||||
input,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Use structured output if available, otherwise fall back to parsing text
|
||||
try {
|
||||
let structuredOutput: { suggestions: Array<Record<string, unknown>> } | null = null;
|
||||
|
||||
if (result.structured_output) {
|
||||
structuredOutput = result.structured_output as {
|
||||
suggestions: Array<Record<string, unknown>>;
|
||||
};
|
||||
logger.debug('Received structured output:', structuredOutput);
|
||||
} else if (responseText) {
|
||||
// Fallback: try to parse from text using shared extraction utility
|
||||
logger.warn('No structured output received, attempting to parse from text');
|
||||
structuredOutput = extractJsonWithArray<{ suggestions: Array<Record<string, unknown>> }>(
|
||||
responseText,
|
||||
'suggestions',
|
||||
{ logger }
|
||||
);
|
||||
}
|
||||
|
||||
if (structuredOutput && structuredOutput.suggestions) {
|
||||
// Use structured output directly
|
||||
events.emit('suggestions:event', {
|
||||
type: 'suggestions_complete',
|
||||
suggestions: structuredOutput.suggestions.map((s: Record<string, unknown>, i: number) => ({
|
||||
...s,
|
||||
id: s.id || `suggestion-${Date.now()}-${i}`,
|
||||
})),
|
||||
});
|
||||
} else {
|
||||
throw new Error('No valid JSON found in response');
|
||||
}
|
||||
} catch (error) {
|
||||
// Log the parsing error for debugging
|
||||
logger.error('Failed to parse suggestions JSON from AI response:', error);
|
||||
// Return generic suggestions if parsing fails
|
||||
events.emit('suggestions:event', {
|
||||
type: 'suggestions_complete',
|
||||
suggestions: [
|
||||
{
|
||||
id: `suggestion-${Date.now()}-0`,
|
||||
category: 'Analysis',
|
||||
description: 'Review the AI analysis output for insights',
|
||||
priority: 1,
|
||||
reasoning: 'The AI provided analysis but suggestions need manual review',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
/**
|
||||
* Suggestions routes - HTTP API for AI-powered feature suggestions
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
import { validatePathParams } from '../../middleware/validate-paths.js';
|
||||
import { createGenerateHandler } from './routes/generate.js';
|
||||
import { createStopHandler } from './routes/stop.js';
|
||||
import { createStatusHandler } from './routes/status.js';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
|
||||
export function createSuggestionsRoutes(
|
||||
events: EventEmitter,
|
||||
settingsService?: SettingsService
|
||||
): Router {
|
||||
const router = Router();
|
||||
|
||||
router.post(
|
||||
'/generate',
|
||||
validatePathParams('projectPath'),
|
||||
createGenerateHandler(events, settingsService)
|
||||
);
|
||||
router.post('/stop', createStopHandler());
|
||||
router.get('/status', createStatusHandler());
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
/**
|
||||
* POST /generate endpoint - Generate suggestions
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { EventEmitter } from '../../../lib/events.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { ThinkingLevel } from '@automaker/types';
|
||||
import { getSuggestionsStatus, setRunningState, getErrorMessage, logError } from '../common.js';
|
||||
import { generateSuggestions } from '../generate-suggestions.js';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
|
||||
const logger = createLogger('Suggestions');
|
||||
|
||||
export function createGenerateHandler(events: EventEmitter, settingsService?: SettingsService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const {
|
||||
projectPath,
|
||||
suggestionType = 'features',
|
||||
model,
|
||||
thinkingLevel,
|
||||
} = req.body as {
|
||||
projectPath: string;
|
||||
suggestionType?: string;
|
||||
model?: string;
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { isRunning } = getSuggestionsStatus();
|
||||
if (isRunning) {
|
||||
res.json({
|
||||
success: false,
|
||||
error: 'Suggestions generation is already running',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setRunningState(true);
|
||||
const abortController = new AbortController();
|
||||
setRunningState(true, abortController);
|
||||
|
||||
// Start generation in background
|
||||
generateSuggestions(
|
||||
projectPath,
|
||||
suggestionType,
|
||||
events,
|
||||
abortController,
|
||||
settingsService,
|
||||
model,
|
||||
thinkingLevel
|
||||
)
|
||||
.catch((error) => {
|
||||
logError(error, 'Generate suggestions failed (background)');
|
||||
events.emit('suggestions:event', {
|
||||
type: 'suggestions_error',
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setRunningState(false, null);
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logError(error, 'Generate suggestions failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
/**
|
||||
* GET /status endpoint - Get status
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { getSuggestionsStatus, getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createStatusHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { isRunning } = getSuggestionsStatus();
|
||||
res.json({ success: true, isRunning });
|
||||
} catch (error) {
|
||||
logError(error, 'Get status failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
/**
|
||||
* POST /stop endpoint - Stop suggestions generation
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { getSuggestionsStatus, setRunningState, getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createStopHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { currentAbortController } = getSuggestionsStatus();
|
||||
if (currentAbortController) {
|
||||
currentAbortController.abort();
|
||||
}
|
||||
setRunningState(false, null);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logError(error, 'Stop suggestions failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -42,6 +42,9 @@ import { createStartDevHandler } from './routes/start-dev.js';
|
||||
import { createStopDevHandler } from './routes/stop-dev.js';
|
||||
import { createListDevServersHandler } from './routes/list-dev-servers.js';
|
||||
import { createGetDevServerLogsHandler } from './routes/dev-server-logs.js';
|
||||
import { createStartTestsHandler } from './routes/start-tests.js';
|
||||
import { createStopTestsHandler } from './routes/stop-tests.js';
|
||||
import { createGetTestLogsHandler } from './routes/test-logs.js';
|
||||
import {
|
||||
createGetInitScriptHandler,
|
||||
createPutInitScriptHandler,
|
||||
@@ -50,6 +53,7 @@ import {
|
||||
} from './routes/init-script.js';
|
||||
import { createDiscardChangesHandler } from './routes/discard-changes.js';
|
||||
import { createListRemotesHandler } from './routes/list-remotes.js';
|
||||
import { createAddRemoteHandler } from './routes/add-remote.js';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
|
||||
export function createWorktreeRoutes(
|
||||
@@ -130,7 +134,7 @@ export function createWorktreeRoutes(
|
||||
router.post(
|
||||
'/start-dev',
|
||||
validatePathParams('projectPath', 'worktreePath'),
|
||||
createStartDevHandler()
|
||||
createStartDevHandler(settingsService)
|
||||
);
|
||||
router.post('/stop-dev', createStopDevHandler());
|
||||
router.post('/list-dev-servers', createListDevServersHandler());
|
||||
@@ -140,6 +144,15 @@ export function createWorktreeRoutes(
|
||||
createGetDevServerLogsHandler()
|
||||
);
|
||||
|
||||
// Test runner routes
|
||||
router.post(
|
||||
'/start-tests',
|
||||
validatePathParams('worktreePath', 'projectPath?'),
|
||||
createStartTestsHandler(settingsService)
|
||||
);
|
||||
router.post('/stop-tests', createStopTestsHandler());
|
||||
router.get('/test-logs', validatePathParams('worktreePath?'), createGetTestLogsHandler());
|
||||
|
||||
// Init script routes
|
||||
router.get('/init-script', createGetInitScriptHandler());
|
||||
router.put('/init-script', validatePathParams('projectPath'), createPutInitScriptHandler());
|
||||
@@ -166,5 +179,13 @@ export function createWorktreeRoutes(
|
||||
createListRemotesHandler()
|
||||
);
|
||||
|
||||
// Add remote route
|
||||
router.post(
|
||||
'/add-remote',
|
||||
validatePathParams('worktreePath'),
|
||||
requireGitRepoOnly,
|
||||
createAddRemoteHandler()
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
166
apps/server/src/routes/worktree/routes/add-remote.ts
Normal file
166
apps/server/src/routes/worktree/routes/add-remote.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* POST /add-remote endpoint - Add a new remote to a git repository
|
||||
*
|
||||
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
|
||||
* the requireValidWorktree middleware in index.ts
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { getErrorMessage, logWorktreeError } from '../common.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/** Maximum allowed length for remote names */
|
||||
const MAX_REMOTE_NAME_LENGTH = 250;
|
||||
|
||||
/** Maximum allowed length for remote URLs */
|
||||
const MAX_REMOTE_URL_LENGTH = 2048;
|
||||
|
||||
/** Timeout for git fetch operations (30 seconds) */
|
||||
const FETCH_TIMEOUT_MS = 30000;
|
||||
|
||||
/**
|
||||
* Validate remote name - must be alphanumeric with dashes/underscores
|
||||
* Git remote names have similar restrictions to branch names
|
||||
*/
|
||||
function isValidRemoteName(name: string): boolean {
|
||||
// Remote names should be alphanumeric, may contain dashes, underscores, periods
|
||||
// Cannot start with a dash or period, cannot be empty
|
||||
if (!name || name.length === 0 || name.length > MAX_REMOTE_NAME_LENGTH) {
|
||||
return false;
|
||||
}
|
||||
return /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate remote URL - basic validation for git remote URLs
|
||||
* Supports HTTPS, SSH, and git:// protocols
|
||||
*/
|
||||
function isValidRemoteUrl(url: string): boolean {
|
||||
if (!url || url.length === 0 || url.length > MAX_REMOTE_URL_LENGTH) {
|
||||
return false;
|
||||
}
|
||||
// Support common git URL formats:
|
||||
// - https://github.com/user/repo.git
|
||||
// - git@github.com:user/repo.git
|
||||
// - git://github.com/user/repo.git
|
||||
// - ssh://git@github.com/user/repo.git
|
||||
const httpsPattern = /^https?:\/\/.+/;
|
||||
const sshPattern = /^[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+:.+/;
|
||||
const gitProtocolPattern = /^git:\/\/.+/;
|
||||
const sshProtocolPattern = /^ssh:\/\/.+/;
|
||||
|
||||
return (
|
||||
httpsPattern.test(url) ||
|
||||
sshPattern.test(url) ||
|
||||
gitProtocolPattern.test(url) ||
|
||||
sshProtocolPattern.test(url)
|
||||
);
|
||||
}
|
||||
|
||||
export function createAddRemoteHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { worktreePath, remoteName, remoteUrl } = req.body as {
|
||||
worktreePath: string;
|
||||
remoteName: string;
|
||||
remoteUrl: string;
|
||||
};
|
||||
|
||||
// Validate required fields
|
||||
const requiredFields = { worktreePath, remoteName, remoteUrl };
|
||||
for (const [key, value] of Object.entries(requiredFields)) {
|
||||
if (!value) {
|
||||
res.status(400).json({ success: false, error: `${key} required` });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate remote name
|
||||
if (!isValidRemoteName(remoteName)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error:
|
||||
'Invalid remote name. Must start with alphanumeric character and contain only letters, numbers, dashes, underscores, or periods.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate remote URL
|
||||
if (!isValidRemoteUrl(remoteUrl)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid remote URL. Must be a valid git URL (HTTPS, SSH, or git:// protocol).',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if remote already exists
|
||||
try {
|
||||
const { stdout: existingRemotes } = await execFileAsync('git', ['remote'], {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
const remoteNames = existingRemotes
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((r) => r.trim());
|
||||
if (remoteNames.includes(remoteName)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `Remote '${remoteName}' already exists`,
|
||||
code: 'REMOTE_EXISTS',
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
// If git remote fails, continue with adding the remote. Log for debugging.
|
||||
logWorktreeError(
|
||||
error,
|
||||
'Checking for existing remotes failed, proceeding to add.',
|
||||
worktreePath
|
||||
);
|
||||
}
|
||||
|
||||
// Add the remote using execFile with array arguments to prevent command injection
|
||||
await execFileAsync('git', ['remote', 'add', remoteName, remoteUrl], {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
|
||||
// Optionally fetch from the new remote to get its branches
|
||||
let fetchSucceeded = false;
|
||||
try {
|
||||
await execFileAsync('git', ['fetch', remoteName, '--quiet'], {
|
||||
cwd: worktreePath,
|
||||
timeout: FETCH_TIMEOUT_MS,
|
||||
});
|
||||
fetchSucceeded = true;
|
||||
} catch (fetchError) {
|
||||
// Fetch failed (maybe offline or invalid URL), but remote was added successfully
|
||||
logWorktreeError(
|
||||
fetchError,
|
||||
`Fetch from new remote '${remoteName}' failed (remote added successfully)`,
|
||||
worktreePath
|
||||
);
|
||||
fetchSucceeded = false;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
remoteName,
|
||||
remoteUrl,
|
||||
fetched: fetchSucceeded,
|
||||
message: fetchSucceeded
|
||||
? `Successfully added remote '${remoteName}' and fetched its branches`
|
||||
: `Successfully added remote '${remoteName}' (fetch failed - you may need to fetch manually)`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const worktreePath = req.body?.worktreePath;
|
||||
logWorktreeError(error, 'Add remote failed', worktreePath);
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -110,6 +110,18 @@ export function createListBranchesHandler() {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if any remotes are configured for this repository
|
||||
let hasAnyRemotes = false;
|
||||
try {
|
||||
const { stdout: remotesOutput } = await execAsync('git remote', {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
hasAnyRemotes = remotesOutput.trim().length > 0;
|
||||
} catch {
|
||||
// If git remote fails, assume no remotes
|
||||
hasAnyRemotes = false;
|
||||
}
|
||||
|
||||
// Get ahead/behind count for current branch and check if remote branch exists
|
||||
let aheadCount = 0;
|
||||
let behindCount = 0;
|
||||
@@ -154,6 +166,7 @@ export function createListBranchesHandler() {
|
||||
aheadCount,
|
||||
behindCount,
|
||||
hasRemoteBranch,
|
||||
hasAnyRemotes,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
/**
|
||||
* POST /start-dev endpoint - Start a dev server for a worktree
|
||||
*
|
||||
* Spins up a development server (npm run dev) in the worktree directory
|
||||
* on a unique port, allowing preview of the worktree's changes without
|
||||
* affecting the main dev server.
|
||||
* Spins up a development server in the worktree directory on a unique port,
|
||||
* allowing preview of the worktree's changes without affecting the main dev server.
|
||||
*
|
||||
* If a custom devCommand is configured in project settings, it will be used.
|
||||
* Otherwise, auto-detection based on package manager (npm/yarn/pnpm/bun run dev) is used.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
import { getDevServerService } from '../../../services/dev-server-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
export function createStartDevHandler() {
|
||||
const logger = createLogger('start-dev');
|
||||
|
||||
export function createStartDevHandler(settingsService?: SettingsService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, worktreePath } = req.body as {
|
||||
@@ -34,8 +40,25 @@ export function createStartDevHandler() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get custom dev command from project settings (if configured)
|
||||
let customCommand: string | undefined;
|
||||
if (settingsService) {
|
||||
const projectSettings = await settingsService.getProjectSettings(projectPath);
|
||||
const devCommand = projectSettings?.devCommand?.trim();
|
||||
if (devCommand) {
|
||||
customCommand = devCommand;
|
||||
logger.debug(`Using custom dev command from project settings: ${customCommand}`);
|
||||
} else {
|
||||
logger.debug('No custom dev command configured, using auto-detection');
|
||||
}
|
||||
}
|
||||
|
||||
const devServerService = getDevServerService();
|
||||
const result = await devServerService.startDevServer(projectPath, worktreePath);
|
||||
const result = await devServerService.startDevServer(
|
||||
projectPath,
|
||||
worktreePath,
|
||||
customCommand
|
||||
);
|
||||
|
||||
if (result.success && result.result) {
|
||||
res.json({
|
||||
|
||||
92
apps/server/src/routes/worktree/routes/start-tests.ts
Normal file
92
apps/server/src/routes/worktree/routes/start-tests.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* POST /start-tests endpoint - Start tests for a worktree
|
||||
*
|
||||
* Runs the test command configured in project settings.
|
||||
* If no testCommand is configured, returns an error.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
import { getTestRunnerService } from '../../../services/test-runner-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createStartTestsHandler(settingsService?: SettingsService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const body = req.body;
|
||||
|
||||
// Validate request body
|
||||
if (!body || typeof body !== 'object') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Request body must be an object',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const worktreePath = typeof body.worktreePath === 'string' ? body.worktreePath : undefined;
|
||||
const projectPath = typeof body.projectPath === 'string' ? body.projectPath : undefined;
|
||||
const testFile = typeof body.testFile === 'string' ? body.testFile : undefined;
|
||||
|
||||
if (!worktreePath) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'worktreePath is required and must be a string',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get project settings to find the test command
|
||||
// Use projectPath if provided, otherwise use worktreePath
|
||||
const settingsPath = projectPath || worktreePath;
|
||||
|
||||
if (!settingsService) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Settings service not available',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const projectSettings = await settingsService.getProjectSettings(settingsPath);
|
||||
const testCommand = projectSettings?.testCommand;
|
||||
|
||||
if (!testCommand) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error:
|
||||
'No test command configured. Please configure a test command in Project Settings > Testing Configuration.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const testRunnerService = getTestRunnerService();
|
||||
const result = await testRunnerService.startTests(worktreePath, {
|
||||
command: testCommand,
|
||||
testFile,
|
||||
});
|
||||
|
||||
if (result.success && result.result) {
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
sessionId: result.result.sessionId,
|
||||
worktreePath: result.result.worktreePath,
|
||||
command: result.result.command,
|
||||
status: result.result.status,
|
||||
testFile: result.result.testFile,
|
||||
message: result.result.message,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: result.error || 'Failed to start tests',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logError(error, 'Start tests failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
58
apps/server/src/routes/worktree/routes/stop-tests.ts
Normal file
58
apps/server/src/routes/worktree/routes/stop-tests.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* POST /stop-tests endpoint - Stop a running test session
|
||||
*
|
||||
* Stops the test runner process for a specific session,
|
||||
* cancelling any ongoing tests and freeing up resources.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { getTestRunnerService } from '../../../services/test-runner-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createStopTestsHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const body = req.body;
|
||||
|
||||
// Validate request body
|
||||
if (!body || typeof body !== 'object') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Request body must be an object',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = typeof body.sessionId === 'string' ? body.sessionId : undefined;
|
||||
|
||||
if (!sessionId) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'sessionId is required and must be a string',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const testRunnerService = getTestRunnerService();
|
||||
const result = await testRunnerService.stopTests(sessionId);
|
||||
|
||||
if (result.success && result.result) {
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
sessionId: result.result.sessionId,
|
||||
message: result.result.message,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: result.error || 'Failed to stop tests',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logError(error, 'Stop tests failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
160
apps/server/src/routes/worktree/routes/test-logs.ts
Normal file
160
apps/server/src/routes/worktree/routes/test-logs.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* GET /test-logs endpoint - Get buffered logs for a test runner session
|
||||
*
|
||||
* Returns the scrollback buffer containing historical log output for a test run.
|
||||
* Used by clients to populate the log panel on initial connection
|
||||
* before subscribing to real-time updates via WebSocket.
|
||||
*
|
||||
* Query parameters:
|
||||
* - worktreePath: Path to the worktree (optional if sessionId provided)
|
||||
* - sessionId: Specific test session ID (optional, uses active session if not provided)
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { getTestRunnerService } from '../../../services/test-runner-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
interface SessionInfo {
|
||||
sessionId: string;
|
||||
worktreePath?: string;
|
||||
command?: string;
|
||||
testFile?: string;
|
||||
exitCode?: number | null;
|
||||
}
|
||||
|
||||
interface OutputResult {
|
||||
sessionId: string;
|
||||
status: string;
|
||||
output: string;
|
||||
startedAt: string;
|
||||
finishedAt?: string | null;
|
||||
}
|
||||
|
||||
function buildLogsResponse(session: SessionInfo, output: OutputResult) {
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
sessionId: session.sessionId,
|
||||
worktreePath: session.worktreePath,
|
||||
command: session.command,
|
||||
status: output.status,
|
||||
testFile: session.testFile,
|
||||
logs: output.output,
|
||||
startedAt: output.startedAt,
|
||||
finishedAt: output.finishedAt,
|
||||
exitCode: session.exitCode ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createGetTestLogsHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { worktreePath, sessionId } = req.query as {
|
||||
worktreePath?: string;
|
||||
sessionId?: string;
|
||||
};
|
||||
|
||||
const testRunnerService = getTestRunnerService();
|
||||
|
||||
// If sessionId is provided, get logs for that specific session
|
||||
if (sessionId) {
|
||||
const result = testRunnerService.getSessionOutput(sessionId);
|
||||
|
||||
if (result.success && result.result) {
|
||||
const session = testRunnerService.getSession(sessionId);
|
||||
res.json(
|
||||
buildLogsResponse(
|
||||
{
|
||||
sessionId: result.result.sessionId,
|
||||
worktreePath: session?.worktreePath,
|
||||
command: session?.command,
|
||||
testFile: session?.testFile,
|
||||
exitCode: session?.exitCode,
|
||||
},
|
||||
result.result
|
||||
)
|
||||
);
|
||||
} else {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: result.error || 'Failed to get test logs',
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// If worktreePath is provided, get logs for the active session
|
||||
if (worktreePath) {
|
||||
const activeSession = testRunnerService.getActiveSession(worktreePath);
|
||||
|
||||
if (activeSession) {
|
||||
const result = testRunnerService.getSessionOutput(activeSession.id);
|
||||
|
||||
if (result.success && result.result) {
|
||||
res.json(
|
||||
buildLogsResponse(
|
||||
{
|
||||
sessionId: activeSession.id,
|
||||
worktreePath: activeSession.worktreePath,
|
||||
command: activeSession.command,
|
||||
testFile: activeSession.testFile,
|
||||
exitCode: activeSession.exitCode,
|
||||
},
|
||||
result.result
|
||||
)
|
||||
);
|
||||
} else {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: result.error || 'Failed to get test logs',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// No active session - check for most recent session for this worktree
|
||||
const sessions = testRunnerService.listSessions(worktreePath);
|
||||
if (sessions.result.sessions.length > 0) {
|
||||
// Get the most recent session (list is not sorted, so find it)
|
||||
const mostRecent = sessions.result.sessions.reduce((latest, current) => {
|
||||
const latestTime = new Date(latest.startedAt).getTime();
|
||||
const currentTime = new Date(current.startedAt).getTime();
|
||||
return currentTime > latestTime ? current : latest;
|
||||
});
|
||||
|
||||
const result = testRunnerService.getSessionOutput(mostRecent.sessionId);
|
||||
if (result.success && result.result) {
|
||||
res.json(
|
||||
buildLogsResponse(
|
||||
{
|
||||
sessionId: mostRecent.sessionId,
|
||||
worktreePath: mostRecent.worktreePath,
|
||||
command: mostRecent.command,
|
||||
testFile: mostRecent.testFile,
|
||||
exitCode: mostRecent.exitCode,
|
||||
},
|
||||
result.result
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'No test sessions found for this worktree',
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Neither sessionId nor worktreePath provided
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Either worktreePath or sessionId query parameter is required',
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Get test logs failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -534,7 +534,11 @@ export class AutoModeService {
|
||||
const autoModeByWorktree = settings.autoModeByWorktree;
|
||||
|
||||
if (projectId && autoModeByWorktree && typeof autoModeByWorktree === 'object') {
|
||||
const key = `${projectId}::${branchName ?? '__main__'}`;
|
||||
// Normalize branch name to match UI convention:
|
||||
// - null or "main" -> "__main__" (UI treats "main" as the main worktree)
|
||||
// This ensures consistency with how the UI stores worktree settings
|
||||
const normalizedBranch = branchName === 'main' ? null : branchName;
|
||||
const key = `${projectId}::${normalizedBranch ?? '__main__'}`;
|
||||
const entry = autoModeByWorktree[key];
|
||||
if (entry && typeof entry.maxConcurrency === 'number') {
|
||||
return entry.maxConcurrency;
|
||||
@@ -1039,7 +1043,9 @@ export class AutoModeService {
|
||||
}> {
|
||||
// Load feature to get branchName
|
||||
const feature = await this.loadFeature(projectPath, featureId);
|
||||
const branchName = feature?.branchName ?? null;
|
||||
const rawBranchName = feature?.branchName ?? null;
|
||||
// Normalize "main" to null to match UI convention for main worktree
|
||||
const branchName = rawBranchName === 'main' ? null : rawBranchName;
|
||||
|
||||
// Get per-worktree limit
|
||||
const maxAgents = await this.resolveMaxConcurrency(projectPath, branchName);
|
||||
@@ -1281,7 +1287,11 @@ export class AutoModeService {
|
||||
|
||||
// Check for pipeline steps and execute them
|
||||
const pipelineConfig = await pipelineService.getPipelineConfig(projectPath);
|
||||
const sortedSteps = [...(pipelineConfig?.steps || [])].sort((a, b) => a.order - b.order);
|
||||
// Filter out excluded pipeline steps and sort by order
|
||||
const excludedStepIds = new Set(feature.excludedPipelineSteps || []);
|
||||
const sortedSteps = [...(pipelineConfig?.steps || [])]
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.filter((step) => !excludedStepIds.has(step.id));
|
||||
|
||||
if (sortedSteps.length > 0) {
|
||||
// Execute pipeline steps sequentially
|
||||
@@ -1743,15 +1753,76 @@ Complete the pipeline step instructions above. Review the previous work and appl
|
||||
): Promise<void> {
|
||||
const featureId = feature.id;
|
||||
|
||||
const sortedSteps = [...pipelineConfig.steps].sort((a, b) => a.order - b.order);
|
||||
// Sort all steps first
|
||||
const allSortedSteps = [...pipelineConfig.steps].sort((a, b) => a.order - b.order);
|
||||
|
||||
// Validate step index
|
||||
if (startFromStepIndex < 0 || startFromStepIndex >= sortedSteps.length) {
|
||||
// Get the current step we're resuming from (using the index from unfiltered list)
|
||||
if (startFromStepIndex < 0 || startFromStepIndex >= allSortedSteps.length) {
|
||||
throw new Error(`Invalid step index: ${startFromStepIndex}`);
|
||||
}
|
||||
const currentStep = allSortedSteps[startFromStepIndex];
|
||||
|
||||
// Get steps to execute (from startFromStepIndex onwards)
|
||||
const stepsToExecute = sortedSteps.slice(startFromStepIndex);
|
||||
// Filter out excluded pipeline steps
|
||||
const excludedStepIds = new Set(feature.excludedPipelineSteps || []);
|
||||
|
||||
// Check if the current step is excluded
|
||||
// If so, use getNextStatus to find the appropriate next step
|
||||
if (excludedStepIds.has(currentStep.id)) {
|
||||
console.log(
|
||||
`[AutoMode] Current step ${currentStep.id} is excluded for feature ${featureId}, finding next valid step`
|
||||
);
|
||||
const nextStatus = pipelineService.getNextStatus(
|
||||
`pipeline_${currentStep.id}`,
|
||||
pipelineConfig,
|
||||
feature.skipTests ?? false,
|
||||
feature.excludedPipelineSteps
|
||||
);
|
||||
|
||||
// If next status is not a pipeline step, feature is done
|
||||
if (!pipelineService.isPipelineStatus(nextStatus)) {
|
||||
await this.updateFeatureStatus(projectPath, featureId, nextStatus);
|
||||
this.emitAutoModeEvent('auto_mode_feature_complete', {
|
||||
featureId,
|
||||
featureName: feature.title,
|
||||
branchName: feature.branchName ?? null,
|
||||
passes: true,
|
||||
message: 'Pipeline completed (remaining steps excluded)',
|
||||
projectPath,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the next step and update the start index
|
||||
const nextStepId = pipelineService.getStepIdFromStatus(nextStatus);
|
||||
const nextStepIndex = allSortedSteps.findIndex((s) => s.id === nextStepId);
|
||||
if (nextStepIndex === -1) {
|
||||
throw new Error(`Next step ${nextStepId} not found in pipeline config`);
|
||||
}
|
||||
startFromStepIndex = nextStepIndex;
|
||||
}
|
||||
|
||||
// Get steps to execute (from startFromStepIndex onwards, excluding excluded steps)
|
||||
const stepsToExecute = allSortedSteps
|
||||
.slice(startFromStepIndex)
|
||||
.filter((step) => !excludedStepIds.has(step.id));
|
||||
|
||||
// If no steps left to execute, complete the feature
|
||||
if (stepsToExecute.length === 0) {
|
||||
const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified';
|
||||
await this.updateFeatureStatus(projectPath, featureId, finalStatus);
|
||||
this.emitAutoModeEvent('auto_mode_feature_complete', {
|
||||
featureId,
|
||||
featureName: feature.title,
|
||||
branchName: feature.branchName ?? null,
|
||||
passes: true,
|
||||
message: 'Pipeline completed (all remaining steps excluded)',
|
||||
projectPath,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the filtered steps for counting
|
||||
const sortedSteps = allSortedSteps.filter((step) => !excludedStepIds.has(step.id));
|
||||
|
||||
console.log(
|
||||
`[AutoMode] Resuming pipeline for feature ${featureId} from step ${startFromStepIndex + 1}/${sortedSteps.length}`
|
||||
|
||||
288
apps/server/src/services/copilot-usage-service.ts
Normal file
288
apps/server/src/services/copilot-usage-service.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* GitHub Copilot Usage Service
|
||||
*
|
||||
* Fetches usage data from GitHub's Copilot API using GitHub OAuth.
|
||||
* Based on CodexBar reference implementation.
|
||||
*
|
||||
* Authentication methods:
|
||||
* 1. GitHub CLI token (~/.config/gh/hosts.yml)
|
||||
* 2. GitHub OAuth device flow (stored in config)
|
||||
*
|
||||
* API Endpoints:
|
||||
* - GET https://api.github.com/copilot_internal/user - Quota and plan info
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { execSync } from 'child_process';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { CopilotProviderUsage, UsageWindow } from '@automaker/types';
|
||||
|
||||
const logger = createLogger('CopilotUsage');
|
||||
|
||||
// GitHub API endpoint for Copilot
|
||||
const COPILOT_USER_ENDPOINT = 'https://api.github.com/copilot_internal/user';
|
||||
|
||||
interface CopilotQuotaSnapshot {
|
||||
percentageUsed?: number;
|
||||
percentageRemaining?: number;
|
||||
limit?: number;
|
||||
used?: number;
|
||||
}
|
||||
|
||||
interface CopilotUserResponse {
|
||||
copilotPlan?: string;
|
||||
copilot_plan?: string;
|
||||
quotaSnapshots?: {
|
||||
premiumInteractions?: CopilotQuotaSnapshot;
|
||||
chat?: CopilotQuotaSnapshot;
|
||||
};
|
||||
plan?: string;
|
||||
}
|
||||
|
||||
export class CopilotUsageService {
|
||||
private cachedToken: string | null = null;
|
||||
|
||||
/**
|
||||
* Check if GitHub Copilot credentials are available
|
||||
*/
|
||||
async isAvailable(): Promise<boolean> {
|
||||
const token = await this.getGitHubToken();
|
||||
return !!token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get GitHub token from various sources
|
||||
*/
|
||||
private async getGitHubToken(): Promise<string | null> {
|
||||
if (this.cachedToken) {
|
||||
return this.cachedToken;
|
||||
}
|
||||
|
||||
// 1. Check environment variable
|
||||
if (process.env.GITHUB_TOKEN) {
|
||||
this.cachedToken = process.env.GITHUB_TOKEN;
|
||||
return this.cachedToken;
|
||||
}
|
||||
|
||||
// 2. Check GH_TOKEN (GitHub CLI uses this)
|
||||
if (process.env.GH_TOKEN) {
|
||||
this.cachedToken = process.env.GH_TOKEN;
|
||||
return this.cachedToken;
|
||||
}
|
||||
|
||||
// 3. Try to get token from GitHub CLI
|
||||
try {
|
||||
const token = execSync('gh auth token', {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
|
||||
if (token) {
|
||||
this.cachedToken = token;
|
||||
return this.cachedToken;
|
||||
}
|
||||
} catch {
|
||||
logger.debug('Failed to get token from gh CLI');
|
||||
}
|
||||
|
||||
// 4. Check GitHub CLI hosts.yml file
|
||||
const ghHostsPath = path.join(os.homedir(), '.config', 'gh', 'hosts.yml');
|
||||
if (fs.existsSync(ghHostsPath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(ghHostsPath, 'utf8');
|
||||
// Simple YAML parsing for oauth_token
|
||||
const match = content.match(/oauth_token:\s*(.+)/);
|
||||
if (match) {
|
||||
this.cachedToken = match[1].trim();
|
||||
return this.cachedToken;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug('Failed to read gh hosts.yml:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Check CodexBar config (for users who also use CodexBar)
|
||||
const codexbarConfigPath = path.join(os.homedir(), '.codexbar', 'config.json');
|
||||
if (fs.existsSync(codexbarConfigPath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(codexbarConfigPath, 'utf8');
|
||||
const config = JSON.parse(content);
|
||||
if (config.github?.oauth_token) {
|
||||
this.cachedToken = config.github.oauth_token;
|
||||
return this.cachedToken;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug('Failed to read CodexBar config:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an authenticated request to GitHub Copilot API
|
||||
*/
|
||||
private async makeRequest<T>(url: string): Promise<T | null> {
|
||||
const token = await this.getGitHubToken();
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `token ${token}`,
|
||||
Accept: 'application/json',
|
||||
'User-Agent': 'automaker/1.0',
|
||||
// Copilot-specific headers (from CodexBar reference)
|
||||
'Editor-Version': 'vscode/1.96.2',
|
||||
'Editor-Plugin-Version': 'copilot-chat/0.26.7',
|
||||
'X-Github-Api-Version': '2025-04-01',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
// Clear cached token on auth failure
|
||||
this.cachedToken = null;
|
||||
logger.warn('GitHub Copilot API authentication failed');
|
||||
return null;
|
||||
}
|
||||
if (response.status === 404) {
|
||||
// User may not have Copilot access
|
||||
logger.info('GitHub Copilot not available for this user');
|
||||
return null;
|
||||
}
|
||||
logger.error(`GitHub Copilot API error: ${response.status} ${response.statusText}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch from GitHub Copilot API:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch usage data from GitHub Copilot
|
||||
*/
|
||||
async fetchUsageData(): Promise<CopilotProviderUsage> {
|
||||
logger.info('[fetchUsageData] Starting GitHub Copilot usage fetch...');
|
||||
|
||||
const baseUsage: CopilotProviderUsage = {
|
||||
providerId: 'copilot',
|
||||
providerName: 'GitHub Copilot',
|
||||
available: false,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Check if token is available
|
||||
const hasToken = await this.getGitHubToken();
|
||||
if (!hasToken) {
|
||||
baseUsage.error = 'GitHub authentication not available';
|
||||
return baseUsage;
|
||||
}
|
||||
|
||||
// Fetch Copilot user data
|
||||
const userResponse = await this.makeRequest<CopilotUserResponse>(COPILOT_USER_ENDPOINT);
|
||||
if (!userResponse) {
|
||||
baseUsage.error = 'Failed to fetch GitHub Copilot usage data';
|
||||
return baseUsage;
|
||||
}
|
||||
|
||||
baseUsage.available = true;
|
||||
|
||||
// Parse quota snapshots
|
||||
const quotas = userResponse.quotaSnapshots;
|
||||
if (quotas) {
|
||||
// Premium interactions quota
|
||||
if (quotas.premiumInteractions) {
|
||||
const premium = quotas.premiumInteractions;
|
||||
const usedPercent =
|
||||
premium.percentageUsed !== undefined
|
||||
? premium.percentageUsed
|
||||
: premium.percentageRemaining !== undefined
|
||||
? 100 - premium.percentageRemaining
|
||||
: 0;
|
||||
|
||||
const premiumWindow: UsageWindow = {
|
||||
name: 'Premium Interactions',
|
||||
usedPercent,
|
||||
resetsAt: '', // GitHub doesn't provide reset time
|
||||
resetText: 'Resets monthly',
|
||||
limit: premium.limit,
|
||||
used: premium.used,
|
||||
};
|
||||
|
||||
baseUsage.primary = premiumWindow;
|
||||
baseUsage.premiumInteractions = premiumWindow;
|
||||
}
|
||||
|
||||
// Chat quota
|
||||
if (quotas.chat) {
|
||||
const chat = quotas.chat;
|
||||
const usedPercent =
|
||||
chat.percentageUsed !== undefined
|
||||
? chat.percentageUsed
|
||||
: chat.percentageRemaining !== undefined
|
||||
? 100 - chat.percentageRemaining
|
||||
: 0;
|
||||
|
||||
const chatWindow: UsageWindow = {
|
||||
name: 'Chat',
|
||||
usedPercent,
|
||||
resetsAt: '',
|
||||
resetText: 'Resets monthly',
|
||||
limit: chat.limit,
|
||||
used: chat.used,
|
||||
};
|
||||
|
||||
baseUsage.secondary = chatWindow;
|
||||
baseUsage.chatQuota = chatWindow;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse plan type
|
||||
const planType = userResponse.copilotPlan || userResponse.copilot_plan || userResponse.plan;
|
||||
if (planType) {
|
||||
baseUsage.copilotPlan = planType;
|
||||
baseUsage.plan = {
|
||||
type: planType,
|
||||
displayName: this.formatPlanName(planType),
|
||||
isPaid: planType.toLowerCase() !== 'free',
|
||||
};
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[fetchUsageData] ✓ GitHub Copilot usage: Premium=${baseUsage.premiumInteractions?.usedPercent || 0}%, ` +
|
||||
`Chat=${baseUsage.chatQuota?.usedPercent || 0}%, Plan=${planType || 'unknown'}`
|
||||
);
|
||||
|
||||
return baseUsage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format plan name for display
|
||||
*/
|
||||
private formatPlanName(plan: string): string {
|
||||
const planMap: Record<string, string> = {
|
||||
free: 'Free',
|
||||
individual: 'Individual',
|
||||
business: 'Business',
|
||||
enterprise: 'Enterprise',
|
||||
};
|
||||
return planMap[plan.toLowerCase()] || plan;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cached token
|
||||
*/
|
||||
clearCache(): void {
|
||||
this.cachedToken = null;
|
||||
}
|
||||
}
|
||||
331
apps/server/src/services/cursor-usage-service.ts
Normal file
331
apps/server/src/services/cursor-usage-service.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* Cursor Usage Service
|
||||
*
|
||||
* Fetches usage data from Cursor's API using session cookies or access token.
|
||||
* Based on CodexBar reference implementation.
|
||||
*
|
||||
* Authentication methods (in priority order):
|
||||
* 1. Cached session cookie from browser import
|
||||
* 2. Access token from credentials file
|
||||
*
|
||||
* API Endpoints:
|
||||
* - GET https://cursor.com/api/usage-summary - Plan usage, on-demand, billing dates
|
||||
* - GET https://cursor.com/api/auth/me - User email and name
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { CursorProviderUsage, UsageWindow } from '@automaker/types';
|
||||
|
||||
const logger = createLogger('CursorUsage');
|
||||
|
||||
// Cursor API endpoints
|
||||
const CURSOR_API_BASE = 'https://cursor.com/api';
|
||||
const USAGE_SUMMARY_ENDPOINT = `${CURSOR_API_BASE}/usage-summary`;
|
||||
const AUTH_ME_ENDPOINT = `${CURSOR_API_BASE}/auth/me`;
|
||||
|
||||
// Session cookie names used by Cursor
|
||||
const SESSION_COOKIE_NAMES = [
|
||||
'WorkosCursorSessionToken',
|
||||
'__Secure-next-auth.session-token',
|
||||
'next-auth.session-token',
|
||||
];
|
||||
|
||||
interface CursorUsageSummary {
|
||||
planUsage?: {
|
||||
percent: number;
|
||||
resetAt?: string;
|
||||
};
|
||||
onDemandUsage?: {
|
||||
percent: number;
|
||||
costUsd?: number;
|
||||
};
|
||||
billingCycleEnd?: string;
|
||||
plan?: string;
|
||||
}
|
||||
|
||||
interface CursorAuthMe {
|
||||
email?: string;
|
||||
name?: string;
|
||||
plan?: string;
|
||||
}
|
||||
|
||||
export class CursorUsageService {
|
||||
private cachedSessionCookie: string | null = null;
|
||||
private cachedAccessToken: string | null = null;
|
||||
|
||||
/**
|
||||
* Check if Cursor credentials are available
|
||||
*/
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return await this.hasValidCredentials();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we have valid Cursor credentials
|
||||
*/
|
||||
private async hasValidCredentials(): Promise<boolean> {
|
||||
const token = await this.getAccessToken();
|
||||
return !!token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get access token from credentials file
|
||||
*/
|
||||
private async getAccessToken(): Promise<string | null> {
|
||||
if (this.cachedAccessToken) {
|
||||
return this.cachedAccessToken;
|
||||
}
|
||||
|
||||
// Check environment variable first
|
||||
if (process.env.CURSOR_ACCESS_TOKEN) {
|
||||
this.cachedAccessToken = process.env.CURSOR_ACCESS_TOKEN;
|
||||
return this.cachedAccessToken;
|
||||
}
|
||||
|
||||
// Check credentials files
|
||||
const credentialPaths = [
|
||||
path.join(os.homedir(), '.cursor', 'credentials.json'),
|
||||
path.join(os.homedir(), '.config', 'cursor', 'credentials.json'),
|
||||
];
|
||||
|
||||
for (const credPath of credentialPaths) {
|
||||
try {
|
||||
if (fs.existsSync(credPath)) {
|
||||
const content = fs.readFileSync(credPath, 'utf8');
|
||||
const creds = JSON.parse(content);
|
||||
if (creds.accessToken) {
|
||||
this.cachedAccessToken = creds.accessToken;
|
||||
return this.cachedAccessToken;
|
||||
}
|
||||
if (creds.token) {
|
||||
this.cachedAccessToken = creds.token;
|
||||
return this.cachedAccessToken;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(`Failed to read credentials from ${credPath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session cookie for API calls
|
||||
* Returns a cookie string like "WorkosCursorSessionToken=xxx"
|
||||
*/
|
||||
private async getSessionCookie(): Promise<string | null> {
|
||||
if (this.cachedSessionCookie) {
|
||||
return this.cachedSessionCookie;
|
||||
}
|
||||
|
||||
// Check for cookie in environment
|
||||
if (process.env.CURSOR_SESSION_COOKIE) {
|
||||
this.cachedSessionCookie = process.env.CURSOR_SESSION_COOKIE;
|
||||
return this.cachedSessionCookie;
|
||||
}
|
||||
|
||||
// Check for saved session file
|
||||
const sessionPath = path.join(os.homedir(), '.cursor', 'session.json');
|
||||
try {
|
||||
if (fs.existsSync(sessionPath)) {
|
||||
const content = fs.readFileSync(sessionPath, 'utf8');
|
||||
const session = JSON.parse(content);
|
||||
for (const cookieName of SESSION_COOKIE_NAMES) {
|
||||
if (session[cookieName]) {
|
||||
this.cachedSessionCookie = `${cookieName}=${session[cookieName]}`;
|
||||
return this.cachedSessionCookie;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug('Failed to read session file:', error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an authenticated request to Cursor API
|
||||
*/
|
||||
private async makeRequest<T>(url: string): Promise<T | null> {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/json',
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
|
||||
};
|
||||
|
||||
// Try access token first
|
||||
const accessToken = await this.getAccessToken();
|
||||
if (accessToken) {
|
||||
headers['Authorization'] = `Bearer ${accessToken}`;
|
||||
}
|
||||
|
||||
// Try session cookie as fallback
|
||||
const sessionCookie = await this.getSessionCookie();
|
||||
if (sessionCookie) {
|
||||
headers['Cookie'] = sessionCookie;
|
||||
}
|
||||
|
||||
if (!accessToken && !sessionCookie) {
|
||||
logger.warn('No Cursor credentials available for API request');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
// Clear cached credentials on auth failure
|
||||
this.cachedAccessToken = null;
|
||||
this.cachedSessionCookie = null;
|
||||
logger.warn('Cursor API authentication failed');
|
||||
return null;
|
||||
}
|
||||
logger.error(`Cursor API error: ${response.status} ${response.statusText}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch from Cursor API:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch usage data from Cursor
|
||||
*/
|
||||
async fetchUsageData(): Promise<CursorProviderUsage> {
|
||||
logger.info('[fetchUsageData] Starting Cursor usage fetch...');
|
||||
|
||||
const baseUsage: CursorProviderUsage = {
|
||||
providerId: 'cursor',
|
||||
providerName: 'Cursor',
|
||||
available: false,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Check if credentials are available
|
||||
const hasCredentials = await this.hasValidCredentials();
|
||||
if (!hasCredentials) {
|
||||
baseUsage.error = 'Cursor credentials not available';
|
||||
return baseUsage;
|
||||
}
|
||||
|
||||
// Fetch usage summary
|
||||
const usageSummary = await this.makeRequest<CursorUsageSummary>(USAGE_SUMMARY_ENDPOINT);
|
||||
if (!usageSummary) {
|
||||
baseUsage.error = 'Failed to fetch Cursor usage data';
|
||||
return baseUsage;
|
||||
}
|
||||
|
||||
baseUsage.available = true;
|
||||
|
||||
// Parse plan usage
|
||||
if (usageSummary.planUsage) {
|
||||
const planWindow: UsageWindow = {
|
||||
name: 'Plan Usage',
|
||||
usedPercent: usageSummary.planUsage.percent || 0,
|
||||
resetsAt: usageSummary.planUsage.resetAt || '',
|
||||
resetText: usageSummary.planUsage.resetAt
|
||||
? this.formatResetTime(usageSummary.planUsage.resetAt)
|
||||
: '',
|
||||
};
|
||||
baseUsage.primary = planWindow;
|
||||
baseUsage.planUsage = planWindow;
|
||||
}
|
||||
|
||||
// Parse on-demand usage
|
||||
if (usageSummary.onDemandUsage) {
|
||||
const onDemandWindow: UsageWindow = {
|
||||
name: 'On-Demand Usage',
|
||||
usedPercent: usageSummary.onDemandUsage.percent || 0,
|
||||
resetsAt: usageSummary.billingCycleEnd || '',
|
||||
resetText: usageSummary.billingCycleEnd
|
||||
? this.formatResetTime(usageSummary.billingCycleEnd)
|
||||
: '',
|
||||
};
|
||||
baseUsage.secondary = onDemandWindow;
|
||||
baseUsage.onDemandUsage = onDemandWindow;
|
||||
|
||||
if (usageSummary.onDemandUsage.costUsd !== undefined) {
|
||||
baseUsage.onDemandCostUsd = usageSummary.onDemandUsage.costUsd;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse billing cycle end
|
||||
if (usageSummary.billingCycleEnd) {
|
||||
baseUsage.billingCycleEnd = usageSummary.billingCycleEnd;
|
||||
}
|
||||
|
||||
// Parse plan type
|
||||
if (usageSummary.plan) {
|
||||
baseUsage.plan = {
|
||||
type: usageSummary.plan,
|
||||
displayName: this.formatPlanName(usageSummary.plan),
|
||||
isPaid: usageSummary.plan.toLowerCase() !== 'free',
|
||||
};
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[fetchUsageData] ✓ Cursor usage: Plan=${baseUsage.planUsage?.usedPercent || 0}%, ` +
|
||||
`OnDemand=${baseUsage.onDemandUsage?.usedPercent || 0}%`
|
||||
);
|
||||
|
||||
return baseUsage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format reset time as human-readable string
|
||||
*/
|
||||
private formatResetTime(resetAt: string): string {
|
||||
try {
|
||||
const date = new Date(resetAt);
|
||||
const now = new Date();
|
||||
const diff = date.getTime() - now.getTime();
|
||||
|
||||
if (diff < 0) return 'Expired';
|
||||
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (days > 0) {
|
||||
return `Resets in ${days}d`;
|
||||
}
|
||||
if (hours > 0) {
|
||||
return `Resets in ${hours}h`;
|
||||
}
|
||||
return 'Resets soon';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format plan name for display
|
||||
*/
|
||||
private formatPlanName(plan: string): string {
|
||||
const planMap: Record<string, string> = {
|
||||
free: 'Free',
|
||||
pro: 'Pro',
|
||||
business: 'Business',
|
||||
enterprise: 'Enterprise',
|
||||
};
|
||||
return planMap[plan.toLowerCase()] || plan;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cached credentials (useful for logout)
|
||||
*/
|
||||
clearCache(): void {
|
||||
this.cachedAccessToken = null;
|
||||
this.cachedSessionCookie = null;
|
||||
}
|
||||
}
|
||||
@@ -273,12 +273,56 @@ class DevServerService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a custom command string into cmd and args
|
||||
* Handles quoted strings with spaces (e.g., "my command" arg1 arg2)
|
||||
*/
|
||||
private parseCustomCommand(command: string): { cmd: string; args: string[] } {
|
||||
const tokens: string[] = [];
|
||||
let current = '';
|
||||
let inQuote = false;
|
||||
let quoteChar = '';
|
||||
|
||||
for (let i = 0; i < command.length; i++) {
|
||||
const char = command[i];
|
||||
|
||||
if (inQuote) {
|
||||
if (char === quoteChar) {
|
||||
inQuote = false;
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
} else if (char === '"' || char === "'") {
|
||||
inQuote = true;
|
||||
quoteChar = char;
|
||||
} else if (char === ' ') {
|
||||
if (current) {
|
||||
tokens.push(current);
|
||||
current = '';
|
||||
}
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
|
||||
if (current) {
|
||||
tokens.push(current);
|
||||
}
|
||||
|
||||
const [cmd, ...args] = tokens;
|
||||
return { cmd: cmd || '', args };
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a dev server for a worktree
|
||||
* @param projectPath - The project root path
|
||||
* @param worktreePath - The worktree directory path
|
||||
* @param customCommand - Optional custom command to run instead of auto-detected dev command
|
||||
*/
|
||||
async startDevServer(
|
||||
projectPath: string,
|
||||
worktreePath: string
|
||||
worktreePath: string,
|
||||
customCommand?: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
result?: {
|
||||
@@ -311,22 +355,41 @@ class DevServerService {
|
||||
};
|
||||
}
|
||||
|
||||
// Check for package.json
|
||||
const packageJsonPath = path.join(worktreePath, 'package.json');
|
||||
if (!(await this.fileExists(packageJsonPath))) {
|
||||
return {
|
||||
success: false,
|
||||
error: `No package.json found in: ${worktreePath}`,
|
||||
};
|
||||
}
|
||||
// Determine the dev command to use
|
||||
let devCommand: { cmd: string; args: string[] };
|
||||
|
||||
// Get dev command
|
||||
const devCommand = await this.getDevCommand(worktreePath);
|
||||
if (!devCommand) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Could not determine dev command for: ${worktreePath}`,
|
||||
};
|
||||
// Normalize custom command: trim whitespace and treat empty strings as undefined
|
||||
const normalizedCustomCommand = customCommand?.trim();
|
||||
|
||||
if (normalizedCustomCommand) {
|
||||
// Use the provided custom command
|
||||
devCommand = this.parseCustomCommand(normalizedCustomCommand);
|
||||
if (!devCommand.cmd) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid custom command: command cannot be empty',
|
||||
};
|
||||
}
|
||||
logger.debug(`Using custom command: ${normalizedCustomCommand}`);
|
||||
} else {
|
||||
// Check for package.json when auto-detecting
|
||||
const packageJsonPath = path.join(worktreePath, 'package.json');
|
||||
if (!(await this.fileExists(packageJsonPath))) {
|
||||
return {
|
||||
success: false,
|
||||
error: `No package.json found in: ${worktreePath}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Get dev command from package manager detection
|
||||
const detectedCommand = await this.getDevCommand(worktreePath);
|
||||
if (!detectedCommand) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Could not determine dev command for: ${worktreePath}`,
|
||||
};
|
||||
}
|
||||
devCommand = detectedCommand;
|
||||
}
|
||||
|
||||
// Find available port
|
||||
|
||||
540
apps/server/src/services/feature-export-service.ts
Normal file
540
apps/server/src/services/feature-export-service.ts
Normal file
@@ -0,0 +1,540 @@
|
||||
/**
|
||||
* Feature Export Service - Handles exporting and importing features in JSON/YAML formats
|
||||
*
|
||||
* Provides functionality to:
|
||||
* - Export single features to JSON or YAML format
|
||||
* - Export multiple features (bulk export)
|
||||
* - Import features from JSON or YAML data
|
||||
* - Validate import data for compatibility
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { stringify as yamlStringify, parse as yamlParse } from 'yaml';
|
||||
import type { Feature, FeatureExport, FeatureImport, FeatureImportResult } from '@automaker/types';
|
||||
import { FeatureLoader } from './feature-loader.js';
|
||||
|
||||
const logger = createLogger('FeatureExportService');
|
||||
|
||||
/** Current export format version */
|
||||
export const FEATURE_EXPORT_VERSION = '1.0.0';
|
||||
|
||||
/** Supported export formats */
|
||||
export type ExportFormat = 'json' | 'yaml';
|
||||
|
||||
/** Options for exporting features */
|
||||
export interface ExportOptions {
|
||||
/** Format to export in (default: 'json') */
|
||||
format?: ExportFormat;
|
||||
/** Whether to include description history (default: true) */
|
||||
includeHistory?: boolean;
|
||||
/** Whether to include plan spec (default: true) */
|
||||
includePlanSpec?: boolean;
|
||||
/** Optional metadata to include */
|
||||
metadata?: {
|
||||
projectName?: string;
|
||||
projectPath?: string;
|
||||
branch?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
/** Who/what is performing the export */
|
||||
exportedBy?: string;
|
||||
/** Pretty print output (default: true) */
|
||||
prettyPrint?: boolean;
|
||||
}
|
||||
|
||||
/** Options for bulk export */
|
||||
export interface BulkExportOptions extends ExportOptions {
|
||||
/** Filter by category */
|
||||
category?: string;
|
||||
/** Filter by status */
|
||||
status?: string;
|
||||
/** Feature IDs to include (if not specified, exports all) */
|
||||
featureIds?: string[];
|
||||
}
|
||||
|
||||
/** Result of a bulk export */
|
||||
export interface BulkExportResult {
|
||||
/** Export format version */
|
||||
version: string;
|
||||
/** ISO date string when the export was created */
|
||||
exportedAt: string;
|
||||
/** Number of features exported */
|
||||
count: number;
|
||||
/** The exported features */
|
||||
features: FeatureExport[];
|
||||
/** Export metadata */
|
||||
metadata?: {
|
||||
projectName?: string;
|
||||
projectPath?: string;
|
||||
branch?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* FeatureExportService - Manages feature export and import operations
|
||||
*/
|
||||
export class FeatureExportService {
|
||||
private featureLoader: FeatureLoader;
|
||||
|
||||
constructor(featureLoader?: FeatureLoader) {
|
||||
this.featureLoader = featureLoader || new FeatureLoader();
|
||||
}
|
||||
|
||||
/**
|
||||
* Export a single feature to the specified format
|
||||
*
|
||||
* @param projectPath - Path to the project
|
||||
* @param featureId - ID of the feature to export
|
||||
* @param options - Export options
|
||||
* @returns Promise resolving to the exported feature string
|
||||
*/
|
||||
async exportFeature(
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
options: ExportOptions = {}
|
||||
): Promise<string> {
|
||||
const feature = await this.featureLoader.get(projectPath, featureId);
|
||||
if (!feature) {
|
||||
throw new Error(`Feature ${featureId} not found`);
|
||||
}
|
||||
|
||||
return this.exportFeatureData(feature, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export feature data to the specified format (without fetching from disk)
|
||||
*
|
||||
* @param feature - The feature to export
|
||||
* @param options - Export options
|
||||
* @returns The exported feature string
|
||||
*/
|
||||
exportFeatureData(feature: Feature, options: ExportOptions = {}): string {
|
||||
const {
|
||||
format = 'json',
|
||||
includeHistory = true,
|
||||
includePlanSpec = true,
|
||||
metadata,
|
||||
exportedBy,
|
||||
prettyPrint = true,
|
||||
} = options;
|
||||
|
||||
// Prepare feature data, optionally excluding some fields
|
||||
const featureData = this.prepareFeatureForExport(feature, {
|
||||
includeHistory,
|
||||
includePlanSpec,
|
||||
});
|
||||
|
||||
const exportData: FeatureExport = {
|
||||
version: FEATURE_EXPORT_VERSION,
|
||||
feature: featureData,
|
||||
exportedAt: new Date().toISOString(),
|
||||
...(exportedBy ? { exportedBy } : {}),
|
||||
...(metadata ? { metadata } : {}),
|
||||
};
|
||||
|
||||
return this.serialize(exportData, format, prettyPrint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export multiple features to the specified format
|
||||
*
|
||||
* @param projectPath - Path to the project
|
||||
* @param options - Bulk export options
|
||||
* @returns Promise resolving to the exported features string
|
||||
*/
|
||||
async exportFeatures(projectPath: string, options: BulkExportOptions = {}): Promise<string> {
|
||||
const {
|
||||
format = 'json',
|
||||
category,
|
||||
status,
|
||||
featureIds,
|
||||
includeHistory = true,
|
||||
includePlanSpec = true,
|
||||
metadata,
|
||||
prettyPrint = true,
|
||||
} = options;
|
||||
|
||||
// Get all features
|
||||
let features = await this.featureLoader.getAll(projectPath);
|
||||
|
||||
// Apply filters
|
||||
if (featureIds && featureIds.length > 0) {
|
||||
const idSet = new Set(featureIds);
|
||||
features = features.filter((f) => idSet.has(f.id));
|
||||
}
|
||||
if (category) {
|
||||
features = features.filter((f) => f.category === category);
|
||||
}
|
||||
if (status) {
|
||||
features = features.filter((f) => f.status === status);
|
||||
}
|
||||
|
||||
// Generate timestamp once for consistent export time across all features
|
||||
const exportedAt = new Date().toISOString();
|
||||
|
||||
// Prepare feature exports
|
||||
const featureExports: FeatureExport[] = features.map((feature) => ({
|
||||
version: FEATURE_EXPORT_VERSION,
|
||||
feature: this.prepareFeatureForExport(feature, { includeHistory, includePlanSpec }),
|
||||
exportedAt,
|
||||
}));
|
||||
|
||||
const bulkExport: BulkExportResult = {
|
||||
version: FEATURE_EXPORT_VERSION,
|
||||
exportedAt,
|
||||
count: featureExports.length,
|
||||
features: featureExports,
|
||||
...(metadata ? { metadata } : {}),
|
||||
};
|
||||
|
||||
logger.info(`Exported ${featureExports.length} features from ${projectPath}`);
|
||||
|
||||
return this.serialize(bulkExport, format, prettyPrint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a feature from JSON or YAML data
|
||||
*
|
||||
* @param projectPath - Path to the project
|
||||
* @param importData - Import configuration
|
||||
* @returns Promise resolving to the import result
|
||||
*/
|
||||
async importFeature(
|
||||
projectPath: string,
|
||||
importData: FeatureImport
|
||||
): Promise<FeatureImportResult> {
|
||||
const warnings: string[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
try {
|
||||
// Extract feature from data (handle both raw Feature and wrapped FeatureExport)
|
||||
const feature = this.extractFeatureFromImport(importData.data);
|
||||
if (!feature) {
|
||||
return {
|
||||
success: false,
|
||||
importedAt: new Date().toISOString(),
|
||||
errors: ['Invalid import data: could not extract feature'],
|
||||
};
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
const validationErrors = this.validateFeature(feature);
|
||||
if (validationErrors.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
importedAt: new Date().toISOString(),
|
||||
errors: validationErrors,
|
||||
};
|
||||
}
|
||||
|
||||
// Determine the feature ID to use
|
||||
const featureId = importData.newId || feature.id || this.featureLoader.generateFeatureId();
|
||||
|
||||
// Check for existing feature
|
||||
const existingFeature = await this.featureLoader.get(projectPath, featureId);
|
||||
if (existingFeature && !importData.overwrite) {
|
||||
return {
|
||||
success: false,
|
||||
importedAt: new Date().toISOString(),
|
||||
errors: [`Feature with ID ${featureId} already exists. Set overwrite: true to replace.`],
|
||||
};
|
||||
}
|
||||
|
||||
// Prepare feature for import
|
||||
const featureToImport: Feature = {
|
||||
...feature,
|
||||
id: featureId,
|
||||
// Optionally override category
|
||||
...(importData.targetCategory ? { category: importData.targetCategory } : {}),
|
||||
// Clear branch info if not preserving
|
||||
...(importData.preserveBranchInfo ? {} : { branchName: undefined }),
|
||||
};
|
||||
|
||||
// Clear runtime-specific fields that shouldn't be imported
|
||||
delete featureToImport.titleGenerating;
|
||||
delete featureToImport.error;
|
||||
|
||||
// Handle image paths - they won't be valid after import
|
||||
if (featureToImport.imagePaths && featureToImport.imagePaths.length > 0) {
|
||||
warnings.push(
|
||||
`Feature had ${featureToImport.imagePaths.length} image path(s) that were cleared during import. Images must be re-attached.`
|
||||
);
|
||||
featureToImport.imagePaths = [];
|
||||
}
|
||||
|
||||
// Handle text file paths - they won't be valid after import
|
||||
if (featureToImport.textFilePaths && featureToImport.textFilePaths.length > 0) {
|
||||
warnings.push(
|
||||
`Feature had ${featureToImport.textFilePaths.length} text file path(s) that were cleared during import. Files must be re-attached.`
|
||||
);
|
||||
featureToImport.textFilePaths = [];
|
||||
}
|
||||
|
||||
// Create or update the feature
|
||||
if (existingFeature) {
|
||||
await this.featureLoader.update(projectPath, featureId, featureToImport);
|
||||
logger.info(`Updated feature ${featureId} via import`);
|
||||
} else {
|
||||
await this.featureLoader.create(projectPath, featureToImport);
|
||||
logger.info(`Created feature ${featureId} via import`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
featureId,
|
||||
importedAt: new Date().toISOString(),
|
||||
warnings: warnings.length > 0 ? warnings : undefined,
|
||||
wasOverwritten: !!existingFeature,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to import feature:', error);
|
||||
return {
|
||||
success: false,
|
||||
importedAt: new Date().toISOString(),
|
||||
errors: [`Import failed: ${error instanceof Error ? error.message : String(error)}`],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import multiple features from JSON or YAML data
|
||||
*
|
||||
* @param projectPath - Path to the project
|
||||
* @param data - Raw JSON or YAML string, or parsed data
|
||||
* @param options - Import options applied to all features
|
||||
* @returns Promise resolving to array of import results
|
||||
*/
|
||||
async importFeatures(
|
||||
projectPath: string,
|
||||
data: string | BulkExportResult,
|
||||
options: Omit<FeatureImport, 'data'> = {}
|
||||
): Promise<FeatureImportResult[]> {
|
||||
let bulkData: BulkExportResult;
|
||||
|
||||
// Parse if string
|
||||
if (typeof data === 'string') {
|
||||
const parsed = this.parseImportData(data);
|
||||
if (!parsed || !this.isBulkExport(parsed)) {
|
||||
return [
|
||||
{
|
||||
success: false,
|
||||
importedAt: new Date().toISOString(),
|
||||
errors: ['Invalid bulk import data: expected BulkExportResult format'],
|
||||
},
|
||||
];
|
||||
}
|
||||
bulkData = parsed as BulkExportResult;
|
||||
} else {
|
||||
bulkData = data;
|
||||
}
|
||||
|
||||
// Import each feature
|
||||
const results: FeatureImportResult[] = [];
|
||||
for (const featureExport of bulkData.features) {
|
||||
const result = await this.importFeature(projectPath, {
|
||||
data: featureExport,
|
||||
...options,
|
||||
});
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
const successCount = results.filter((r) => r.success).length;
|
||||
logger.info(`Bulk import complete: ${successCount}/${results.length} features imported`);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse import data from JSON or YAML string
|
||||
*
|
||||
* @param data - Raw JSON or YAML string
|
||||
* @returns Parsed data or null if parsing fails
|
||||
*/
|
||||
parseImportData(data: string): Feature | FeatureExport | BulkExportResult | null {
|
||||
const trimmed = data.trim();
|
||||
|
||||
// Try JSON first
|
||||
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
// Fall through to YAML
|
||||
}
|
||||
}
|
||||
|
||||
// Try YAML
|
||||
try {
|
||||
return yamlParse(trimmed);
|
||||
} catch (error) {
|
||||
logger.error('Failed to parse import data:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the format of import data
|
||||
*
|
||||
* @param data - Raw string data
|
||||
* @returns Detected format or null if unknown
|
||||
*/
|
||||
detectFormat(data: string): ExportFormat | null {
|
||||
const trimmed = data.trim();
|
||||
|
||||
// JSON detection
|
||||
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
||||
try {
|
||||
JSON.parse(trimmed);
|
||||
return 'json';
|
||||
} catch {
|
||||
// Not valid JSON
|
||||
}
|
||||
}
|
||||
|
||||
// YAML detection (if it parses and wasn't JSON)
|
||||
try {
|
||||
yamlParse(trimmed);
|
||||
return 'yaml';
|
||||
} catch {
|
||||
// Not valid YAML either
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare a feature for export by optionally removing fields
|
||||
*/
|
||||
private prepareFeatureForExport(
|
||||
feature: Feature,
|
||||
options: { includeHistory?: boolean; includePlanSpec?: boolean }
|
||||
): Feature {
|
||||
const { includeHistory = true, includePlanSpec = true } = options;
|
||||
|
||||
// Clone to avoid modifying original
|
||||
const exported: Feature = { ...feature };
|
||||
|
||||
// Remove transient fields that shouldn't be exported
|
||||
delete exported.titleGenerating;
|
||||
delete exported.error;
|
||||
|
||||
// Optionally exclude history
|
||||
if (!includeHistory) {
|
||||
delete exported.descriptionHistory;
|
||||
}
|
||||
|
||||
// Optionally exclude plan spec
|
||||
if (!includePlanSpec) {
|
||||
delete exported.planSpec;
|
||||
}
|
||||
|
||||
return exported;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a Feature from import data (handles both raw and wrapped formats)
|
||||
*/
|
||||
private extractFeatureFromImport(data: Feature | FeatureExport): Feature | null {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if it's a FeatureExport wrapper
|
||||
if ('version' in data && 'feature' in data && 'exportedAt' in data) {
|
||||
const exportData = data as FeatureExport;
|
||||
return exportData.feature;
|
||||
}
|
||||
|
||||
// Assume it's a raw Feature
|
||||
return data as Feature;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if parsed data is a bulk export
|
||||
*/
|
||||
isBulkExport(data: unknown): data is BulkExportResult {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const obj = data as Record<string, unknown>;
|
||||
return 'version' in obj && 'features' in obj && Array.isArray(obj.features);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if parsed data is a single FeatureExport
|
||||
*/
|
||||
isFeatureExport(data: unknown): data is FeatureExport {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const obj = data as Record<string, unknown>;
|
||||
return (
|
||||
'version' in obj &&
|
||||
'feature' in obj &&
|
||||
'exportedAt' in obj &&
|
||||
typeof obj.feature === 'object' &&
|
||||
obj.feature !== null &&
|
||||
'id' in (obj.feature as Record<string, unknown>)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if parsed data is a raw Feature
|
||||
*/
|
||||
isRawFeature(data: unknown): data is Feature {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const obj = data as Record<string, unknown>;
|
||||
// A raw feature has 'id' but not the 'version' + 'feature' wrapper of FeatureExport
|
||||
return 'id' in obj && !('feature' in obj && 'version' in obj);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a feature has required fields
|
||||
*/
|
||||
private validateFeature(feature: Feature): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!feature.description && !feature.title) {
|
||||
errors.push('Feature must have at least a title or description');
|
||||
}
|
||||
|
||||
if (!feature.category) {
|
||||
errors.push('Feature must have a category');
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize export data to string (handles both single feature and bulk exports)
|
||||
*/
|
||||
private serialize<T extends FeatureExport | BulkExportResult>(
|
||||
data: T,
|
||||
format: ExportFormat,
|
||||
prettyPrint: boolean
|
||||
): string {
|
||||
if (format === 'yaml') {
|
||||
return yamlStringify(data, {
|
||||
indent: 2,
|
||||
lineWidth: 120,
|
||||
});
|
||||
}
|
||||
|
||||
return prettyPrint ? JSON.stringify(data, null, 2) : JSON.stringify(data);
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let featureExportServiceInstance: FeatureExportService | null = null;
|
||||
|
||||
/**
|
||||
* Get the singleton feature export service instance
|
||||
*/
|
||||
export function getFeatureExportService(): FeatureExportService {
|
||||
if (!featureExportServiceInstance) {
|
||||
featureExportServiceInstance = new FeatureExportService();
|
||||
}
|
||||
return featureExportServiceInstance;
|
||||
}
|
||||
362
apps/server/src/services/gemini-usage-service.ts
Normal file
362
apps/server/src/services/gemini-usage-service.ts
Normal file
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* Gemini Usage Service
|
||||
*
|
||||
* Fetches usage data from Google's Gemini/Cloud Code API using OAuth credentials.
|
||||
* Based on CodexBar reference implementation.
|
||||
*
|
||||
* Authentication methods:
|
||||
* 1. OAuth credentials from ~/.gemini/oauth_creds.json
|
||||
* 2. API key (limited - only supports API calls, not quota info)
|
||||
*
|
||||
* API Endpoints:
|
||||
* - POST https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota - Quota info
|
||||
* - POST https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist - Tier detection
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { GeminiProviderUsage, UsageWindow } from '@automaker/types';
|
||||
|
||||
const logger = createLogger('GeminiUsage');
|
||||
|
||||
// Gemini API endpoints
|
||||
const QUOTA_ENDPOINT = 'https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota';
|
||||
const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist';
|
||||
const TOKEN_REFRESH_ENDPOINT = 'https://oauth2.googleapis.com/token';
|
||||
|
||||
// Gemini CLI client credentials (from Gemini CLI installation)
|
||||
// These are embedded in the Gemini CLI and are public
|
||||
const GEMINI_CLIENT_ID =
|
||||
'764086051850-6qr4p6gpi6hn506pt8ejuq83di341hur.apps.googleusercontent.com';
|
||||
const GEMINI_CLIENT_SECRET = 'd-FL95Q19q7MQmFpd7hHD0Ty';
|
||||
|
||||
interface GeminiOAuthCreds {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
id_token?: string;
|
||||
expiry_date: number;
|
||||
}
|
||||
|
||||
interface GeminiQuotaResponse {
|
||||
quotas?: Array<{
|
||||
remainingFraction: number;
|
||||
resetTime: string;
|
||||
modelId?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface GeminiCodeAssistResponse {
|
||||
tier?: string;
|
||||
claims?: {
|
||||
hd?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class GeminiUsageService {
|
||||
private cachedCreds: GeminiOAuthCreds | null = null;
|
||||
private settingsPath = path.join(os.homedir(), '.gemini', 'settings.json');
|
||||
private credsPath = path.join(os.homedir(), '.gemini', 'oauth_creds.json');
|
||||
|
||||
/**
|
||||
* Check if Gemini credentials are available
|
||||
*/
|
||||
async isAvailable(): Promise<boolean> {
|
||||
const creds = await this.getOAuthCreds();
|
||||
return !!creds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get authentication type from settings
|
||||
*/
|
||||
private getAuthType(): string | null {
|
||||
try {
|
||||
if (fs.existsSync(this.settingsPath)) {
|
||||
const content = fs.readFileSync(this.settingsPath, 'utf8');
|
||||
const settings = JSON.parse(content);
|
||||
return settings.auth_type || settings.authType || null;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug('Failed to read Gemini settings:', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get OAuth credentials from file
|
||||
*/
|
||||
private async getOAuthCreds(): Promise<GeminiOAuthCreds | null> {
|
||||
// Check auth type - only oauth-personal supports quota API
|
||||
const authType = this.getAuthType();
|
||||
if (authType && authType !== 'oauth-personal') {
|
||||
logger.debug(`Gemini auth type is ${authType}, not oauth-personal - quota API not available`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check cached credentials
|
||||
if (this.cachedCreds) {
|
||||
// Check if expired
|
||||
if (this.cachedCreds.expiry_date > Date.now()) {
|
||||
return this.cachedCreds;
|
||||
}
|
||||
// Try to refresh
|
||||
const refreshed = await this.refreshToken(this.cachedCreds.refresh_token);
|
||||
if (refreshed) {
|
||||
this.cachedCreds = refreshed;
|
||||
return this.cachedCreds;
|
||||
}
|
||||
}
|
||||
|
||||
// Load from file
|
||||
try {
|
||||
if (fs.existsSync(this.credsPath)) {
|
||||
const content = fs.readFileSync(this.credsPath, 'utf8');
|
||||
const creds = JSON.parse(content) as GeminiOAuthCreds;
|
||||
|
||||
// Check if expired
|
||||
if (creds.expiry_date && creds.expiry_date <= Date.now()) {
|
||||
// Try to refresh
|
||||
if (creds.refresh_token) {
|
||||
const refreshed = await this.refreshToken(creds.refresh_token);
|
||||
if (refreshed) {
|
||||
this.cachedCreds = refreshed;
|
||||
// Save refreshed credentials
|
||||
this.saveCreds(refreshed);
|
||||
return this.cachedCreds;
|
||||
}
|
||||
}
|
||||
logger.warn('Gemini OAuth token expired and refresh failed');
|
||||
return null;
|
||||
}
|
||||
|
||||
this.cachedCreds = creds;
|
||||
return this.cachedCreds;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug('Failed to read Gemini OAuth credentials:', error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh OAuth token
|
||||
*/
|
||||
private async refreshToken(refreshToken: string): Promise<GeminiOAuthCreds | null> {
|
||||
try {
|
||||
const response = await fetch(TOKEN_REFRESH_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: GEMINI_CLIENT_ID,
|
||||
client_secret: GEMINI_CLIENT_SECRET,
|
||||
refresh_token: refreshToken,
|
||||
grant_type: 'refresh_token',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
logger.error(`Token refresh failed: ${response.status}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
access_token: string;
|
||||
expires_in: number;
|
||||
id_token?: string;
|
||||
};
|
||||
|
||||
return {
|
||||
access_token: data.access_token,
|
||||
refresh_token: refreshToken,
|
||||
id_token: data.id_token,
|
||||
expiry_date: Date.now() + data.expires_in * 1000,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to refresh Gemini token:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save credentials to file
|
||||
*/
|
||||
private saveCreds(creds: GeminiOAuthCreds): void {
|
||||
try {
|
||||
const dir = path.dirname(this.credsPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(this.credsPath, JSON.stringify(creds, null, 2));
|
||||
} catch (error) {
|
||||
logger.warn('Failed to save Gemini credentials:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an authenticated request to Gemini API
|
||||
*/
|
||||
private async makeRequest<T>(url: string, body?: unknown): Promise<T | null> {
|
||||
const creds = await this.getOAuthCreds();
|
||||
if (!creds) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${creds.access_token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
// Clear cached credentials on auth failure
|
||||
this.cachedCreds = null;
|
||||
logger.warn('Gemini API authentication failed');
|
||||
return null;
|
||||
}
|
||||
logger.error(`Gemini API error: ${response.status} ${response.statusText}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch from Gemini API:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch usage data from Gemini
|
||||
*/
|
||||
async fetchUsageData(): Promise<GeminiProviderUsage> {
|
||||
logger.info('[fetchUsageData] Starting Gemini usage fetch...');
|
||||
|
||||
const baseUsage: GeminiProviderUsage = {
|
||||
providerId: 'gemini',
|
||||
providerName: 'Gemini',
|
||||
available: false,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Check if credentials are available
|
||||
const creds = await this.getOAuthCreds();
|
||||
if (!creds) {
|
||||
baseUsage.error = 'Gemini OAuth credentials not available';
|
||||
return baseUsage;
|
||||
}
|
||||
|
||||
// Fetch quota information
|
||||
const quotaResponse = await this.makeRequest<GeminiQuotaResponse>(QUOTA_ENDPOINT, {
|
||||
projectId: '-', // Use default project
|
||||
});
|
||||
|
||||
if (quotaResponse?.quotas && quotaResponse.quotas.length > 0) {
|
||||
baseUsage.available = true;
|
||||
|
||||
const primaryQuota = quotaResponse.quotas[0];
|
||||
|
||||
// Convert remaining fraction to used percent
|
||||
const usedPercent = Math.round((1 - (primaryQuota.remainingFraction || 0)) * 100);
|
||||
|
||||
const quotaWindow: UsageWindow = {
|
||||
name: 'Quota',
|
||||
usedPercent,
|
||||
resetsAt: primaryQuota.resetTime || '',
|
||||
resetText: primaryQuota.resetTime ? this.formatResetTime(primaryQuota.resetTime) : '',
|
||||
};
|
||||
|
||||
baseUsage.primary = quotaWindow;
|
||||
baseUsage.remainingFraction = primaryQuota.remainingFraction;
|
||||
baseUsage.modelId = primaryQuota.modelId;
|
||||
}
|
||||
|
||||
// Fetch tier information
|
||||
const codeAssistResponse = await this.makeRequest<GeminiCodeAssistResponse>(
|
||||
CODE_ASSIST_ENDPOINT,
|
||||
{
|
||||
metadata: {
|
||||
ide: 'automaker',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (codeAssistResponse?.tier) {
|
||||
baseUsage.tierType = codeAssistResponse.tier;
|
||||
|
||||
// Determine plan info from tier
|
||||
const tierMap: Record<string, { type: string; displayName: string; isPaid: boolean }> = {
|
||||
'standard-tier': { type: 'paid', displayName: 'Paid', isPaid: true },
|
||||
'free-tier': {
|
||||
type: codeAssistResponse.claims?.hd ? 'workspace' : 'free',
|
||||
displayName: codeAssistResponse.claims?.hd ? 'Workspace' : 'Free',
|
||||
isPaid: false,
|
||||
},
|
||||
'legacy-tier': { type: 'legacy', displayName: 'Legacy', isPaid: false },
|
||||
};
|
||||
|
||||
const tierInfo = tierMap[codeAssistResponse.tier] || {
|
||||
type: codeAssistResponse.tier,
|
||||
displayName: codeAssistResponse.tier,
|
||||
isPaid: false,
|
||||
};
|
||||
|
||||
baseUsage.plan = tierInfo;
|
||||
}
|
||||
|
||||
if (baseUsage.available) {
|
||||
logger.info(
|
||||
`[fetchUsageData] ✓ Gemini usage: ${baseUsage.primary?.usedPercent || 0}% used, ` +
|
||||
`tier=${baseUsage.tierType || 'unknown'}`
|
||||
);
|
||||
} else {
|
||||
baseUsage.error = 'Failed to fetch Gemini quota data';
|
||||
}
|
||||
|
||||
return baseUsage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format reset time as human-readable string
|
||||
*/
|
||||
private formatResetTime(resetAt: string): string {
|
||||
try {
|
||||
const date = new Date(resetAt);
|
||||
const now = new Date();
|
||||
const diff = date.getTime() - now.getTime();
|
||||
|
||||
if (diff < 0) return 'Expired';
|
||||
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (days > 0) {
|
||||
return `Resets in ${days}d ${hours % 24}h`;
|
||||
}
|
||||
if (hours > 0) {
|
||||
return `Resets in ${hours}h ${minutes % 60}m`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `Resets in ${minutes}m`;
|
||||
}
|
||||
return 'Resets soon';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cached credentials
|
||||
*/
|
||||
clearCache(): void {
|
||||
this.cachedCreds = null;
|
||||
}
|
||||
}
|
||||
140
apps/server/src/services/glm-usage-service.ts
Normal file
140
apps/server/src/services/glm-usage-service.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* GLM (z.AI) Usage Service
|
||||
*
|
||||
* Fetches usage data from z.AI's API.
|
||||
* GLM is a Claude-compatible provider offered by z.AI.
|
||||
*
|
||||
* Authentication:
|
||||
* - API Token from provider config or GLM_API_KEY environment variable
|
||||
*
|
||||
* Note: z.AI's API may not expose a dedicated usage endpoint.
|
||||
* This service checks for API availability and reports basic status.
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { GLMProviderUsage, ClaudeCompatibleProvider } from '@automaker/types';
|
||||
|
||||
const logger = createLogger('GLMUsage');
|
||||
|
||||
// GLM API base (z.AI)
|
||||
const GLM_API_BASE = 'https://api.z.ai';
|
||||
|
||||
export class GLMUsageService {
|
||||
private providerConfig: ClaudeCompatibleProvider | null = null;
|
||||
private cachedApiKey: string | null = null;
|
||||
|
||||
/**
|
||||
* Set the provider config (called from settings)
|
||||
*/
|
||||
setProviderConfig(config: ClaudeCompatibleProvider | null): void {
|
||||
this.providerConfig = config;
|
||||
this.cachedApiKey = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if GLM is available
|
||||
*/
|
||||
async isAvailable(): Promise<boolean> {
|
||||
const apiKey = this.getApiKey();
|
||||
return !!apiKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get API key from various sources
|
||||
*/
|
||||
private getApiKey(): string | null {
|
||||
if (this.cachedApiKey) {
|
||||
return this.cachedApiKey;
|
||||
}
|
||||
|
||||
// 1. Check environment variable
|
||||
if (process.env.GLM_API_KEY) {
|
||||
this.cachedApiKey = process.env.GLM_API_KEY;
|
||||
return this.cachedApiKey;
|
||||
}
|
||||
|
||||
// 2. Check provider config
|
||||
if (this.providerConfig?.apiKey) {
|
||||
this.cachedApiKey = this.providerConfig.apiKey;
|
||||
return this.cachedApiKey;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch usage data from GLM
|
||||
*
|
||||
* Note: z.AI may not have a public usage API.
|
||||
* This returns basic availability status.
|
||||
*/
|
||||
async fetchUsageData(): Promise<GLMProviderUsage> {
|
||||
logger.info('[fetchUsageData] Starting GLM usage fetch...');
|
||||
|
||||
const baseUsage: GLMProviderUsage = {
|
||||
providerId: 'glm',
|
||||
providerName: 'z.AI GLM',
|
||||
available: false,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const apiKey = this.getApiKey();
|
||||
if (!apiKey) {
|
||||
baseUsage.error = 'GLM API key not available';
|
||||
return baseUsage;
|
||||
}
|
||||
|
||||
// GLM/z.AI is available if we have an API key
|
||||
// z.AI doesn't appear to have a public usage endpoint
|
||||
baseUsage.available = true;
|
||||
|
||||
// Check if API key is valid by making a simple request
|
||||
try {
|
||||
const baseUrl = this.providerConfig?.baseUrl || GLM_API_BASE;
|
||||
const response = await fetch(`${baseUrl}/api/anthropic/v1/messages`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'GLM-4.7',
|
||||
max_tokens: 1,
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
});
|
||||
|
||||
// We just want to check if auth works, not actually make a request
|
||||
// A 400 with invalid request is fine - it means auth worked
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
baseUsage.available = false;
|
||||
baseUsage.error = 'GLM API authentication failed';
|
||||
}
|
||||
} catch (error) {
|
||||
// Network error or other issue - still mark as available since we have the key
|
||||
logger.debug('GLM API check failed (may be fine):', error);
|
||||
}
|
||||
|
||||
// Note: z.AI doesn't appear to expose usage metrics via API
|
||||
// Users should check their z.AI dashboard for detailed usage
|
||||
if (baseUsage.available) {
|
||||
baseUsage.plan = {
|
||||
type: 'api',
|
||||
displayName: 'API Access',
|
||||
isPaid: true,
|
||||
};
|
||||
}
|
||||
|
||||
logger.info(`[fetchUsageData] GLM available: ${baseUsage.available}`);
|
||||
|
||||
return baseUsage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cached credentials
|
||||
*/
|
||||
clearCache(): void {
|
||||
this.cachedApiKey = null;
|
||||
}
|
||||
}
|
||||
@@ -39,9 +39,13 @@ import { ProviderFactory } from '../providers/provider-factory.js';
|
||||
import type { SettingsService } from './settings-service.js';
|
||||
import type { FeatureLoader } from './feature-loader.js';
|
||||
import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js';
|
||||
import { resolveModelString } from '@automaker/model-resolver';
|
||||
import { resolveModelString, resolvePhaseModel } from '@automaker/model-resolver';
|
||||
import { stripProviderPrefix } from '@automaker/types';
|
||||
import { getPromptCustomization, getProviderByModelId } from '../lib/settings-helpers.js';
|
||||
import {
|
||||
getPromptCustomization,
|
||||
getProviderByModelId,
|
||||
getPhaseModelWithOverrides,
|
||||
} from '../lib/settings-helpers.js';
|
||||
|
||||
const logger = createLogger('IdeationService');
|
||||
|
||||
@@ -684,8 +688,24 @@ export class IdeationService {
|
||||
existingWorkContext
|
||||
);
|
||||
|
||||
// Resolve model alias to canonical identifier (with prefix)
|
||||
const modelId = resolveModelString('sonnet');
|
||||
// Get model from phase settings with provider info (ideationModel)
|
||||
const phaseResult = await getPhaseModelWithOverrides(
|
||||
'ideationModel',
|
||||
this.settingsService,
|
||||
projectPath,
|
||||
'[IdeationService]'
|
||||
);
|
||||
const resolved = resolvePhaseModel(phaseResult.phaseModel);
|
||||
// resolvePhaseModel already resolves model aliases internally - no need to call resolveModelString again
|
||||
const modelId = resolved.model;
|
||||
const claudeCompatibleProvider = phaseResult.provider;
|
||||
const credentials = phaseResult.credentials;
|
||||
|
||||
logger.info(
|
||||
'generateSuggestions using model:',
|
||||
modelId,
|
||||
claudeCompatibleProvider ? `via provider: ${claudeCompatibleProvider.name}` : 'direct API'
|
||||
);
|
||||
|
||||
// Create SDK options
|
||||
const sdkOptions = createChatOptions({
|
||||
@@ -700,9 +720,6 @@ export class IdeationService {
|
||||
// Strip provider prefix - providers need bare model IDs
|
||||
const bareModel = stripProviderPrefix(modelId);
|
||||
|
||||
// Get credentials for API calls (uses hardcoded model, no phase setting)
|
||||
const credentials = await this.settingsService?.getCredentials();
|
||||
|
||||
const executeOptions: ExecuteOptions = {
|
||||
prompt: prompt.prompt,
|
||||
model: bareModel,
|
||||
@@ -713,6 +730,8 @@ export class IdeationService {
|
||||
// Disable all tools - we just want text generation, not codebase analysis
|
||||
allowedTools: [],
|
||||
abortController: new AbortController(),
|
||||
readOnly: true, // Suggestions only need to return JSON, never write files
|
||||
claudeCompatibleProvider, // Pass provider for alternative endpoint configuration
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
};
|
||||
|
||||
|
||||
260
apps/server/src/services/minimax-usage-service.ts
Normal file
260
apps/server/src/services/minimax-usage-service.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* MiniMax Usage Service
|
||||
*
|
||||
* Fetches usage data from MiniMax's coding plan API.
|
||||
* Based on CodexBar reference implementation.
|
||||
*
|
||||
* Authentication methods:
|
||||
* 1. API Token (MINIMAX_API_KEY environment variable or provider config)
|
||||
* 2. Cookie-based authentication (from platform login)
|
||||
*
|
||||
* API Endpoints:
|
||||
* - GET https://api.minimax.io/v1/coding_plan/remains - Token-based usage
|
||||
* - GET https://platform.minimax.io/v1/api/openplatform/coding_plan/remains - Fallback
|
||||
*
|
||||
* For China mainland: platform.minimaxi.com
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { MiniMaxProviderUsage, UsageWindow, ClaudeCompatibleProvider } from '@automaker/types';
|
||||
|
||||
const logger = createLogger('MiniMaxUsage');
|
||||
|
||||
// MiniMax API endpoints
|
||||
const MINIMAX_API_BASE = 'https://api.minimax.io';
|
||||
const MINIMAX_PLATFORM_BASE = 'https://platform.minimax.io';
|
||||
const MINIMAX_CHINA_BASE = 'https://platform.minimaxi.com';
|
||||
|
||||
const CODING_PLAN_ENDPOINT = '/v1/coding_plan/remains';
|
||||
const PLATFORM_CODING_PLAN_ENDPOINT = '/v1/api/openplatform/coding_plan/remains';
|
||||
|
||||
interface MiniMaxCodingPlanResponse {
|
||||
base_resp?: {
|
||||
status_code?: number;
|
||||
status_msg?: string;
|
||||
};
|
||||
model_remains?: Array<{
|
||||
model: string;
|
||||
used: number;
|
||||
total: number;
|
||||
}>;
|
||||
remains_time?: number; // Seconds until reset
|
||||
start_time?: string;
|
||||
end_time?: string;
|
||||
}
|
||||
|
||||
export class MiniMaxUsageService {
|
||||
private providerConfig: ClaudeCompatibleProvider | null = null;
|
||||
private cachedApiKey: string | null = null;
|
||||
|
||||
/**
|
||||
* Set the provider config (called from settings)
|
||||
*/
|
||||
setProviderConfig(config: ClaudeCompatibleProvider | null): void {
|
||||
this.providerConfig = config;
|
||||
this.cachedApiKey = null; // Clear cache when config changes
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if MiniMax is available
|
||||
*/
|
||||
async isAvailable(): Promise<boolean> {
|
||||
const apiKey = this.getApiKey();
|
||||
return !!apiKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get API key from various sources
|
||||
*/
|
||||
private getApiKey(): string | null {
|
||||
if (this.cachedApiKey) {
|
||||
return this.cachedApiKey;
|
||||
}
|
||||
|
||||
// 1. Check environment variable
|
||||
if (process.env.MINIMAX_API_KEY) {
|
||||
this.cachedApiKey = process.env.MINIMAX_API_KEY;
|
||||
return this.cachedApiKey;
|
||||
}
|
||||
|
||||
// 2. Check provider config
|
||||
if (this.providerConfig?.apiKey) {
|
||||
this.cachedApiKey = this.providerConfig.apiKey;
|
||||
return this.cachedApiKey;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if we should use China endpoint
|
||||
*/
|
||||
private isChina(): boolean {
|
||||
if (this.providerConfig?.baseUrl) {
|
||||
return this.providerConfig.baseUrl.includes('minimaxi.com');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an authenticated request to MiniMax API
|
||||
*/
|
||||
private async makeRequest<T>(url: string): Promise<T | null> {
|
||||
const apiKey = this.getApiKey();
|
||||
if (!apiKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
this.cachedApiKey = null;
|
||||
logger.warn('MiniMax API authentication failed');
|
||||
return null;
|
||||
}
|
||||
logger.error(`MiniMax API error: ${response.status} ${response.statusText}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch from MiniMax API:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch usage data from MiniMax
|
||||
*/
|
||||
async fetchUsageData(): Promise<MiniMaxProviderUsage> {
|
||||
logger.info('[fetchUsageData] Starting MiniMax usage fetch...');
|
||||
|
||||
const baseUsage: MiniMaxProviderUsage = {
|
||||
providerId: 'minimax',
|
||||
providerName: 'MiniMax',
|
||||
available: false,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const apiKey = this.getApiKey();
|
||||
if (!apiKey) {
|
||||
baseUsage.error = 'MiniMax API key not available';
|
||||
return baseUsage;
|
||||
}
|
||||
|
||||
// Determine the correct endpoint
|
||||
const isChina = this.isChina();
|
||||
const baseUrl = isChina ? MINIMAX_CHINA_BASE : MINIMAX_API_BASE;
|
||||
const endpoint = `${baseUrl}${CODING_PLAN_ENDPOINT}`;
|
||||
|
||||
// Fetch coding plan data
|
||||
let codingPlan = await this.makeRequest<MiniMaxCodingPlanResponse>(endpoint);
|
||||
|
||||
// Try fallback endpoint if primary fails
|
||||
if (!codingPlan) {
|
||||
const platformBase = isChina ? MINIMAX_CHINA_BASE : MINIMAX_PLATFORM_BASE;
|
||||
const fallbackEndpoint = `${platformBase}${PLATFORM_CODING_PLAN_ENDPOINT}`;
|
||||
codingPlan = await this.makeRequest<MiniMaxCodingPlanResponse>(fallbackEndpoint);
|
||||
}
|
||||
|
||||
if (!codingPlan) {
|
||||
baseUsage.error = 'Failed to fetch MiniMax usage data';
|
||||
return baseUsage;
|
||||
}
|
||||
|
||||
// Check for error response
|
||||
if (codingPlan.base_resp?.status_code && codingPlan.base_resp.status_code !== 0) {
|
||||
baseUsage.error = codingPlan.base_resp.status_msg || 'MiniMax API error';
|
||||
return baseUsage;
|
||||
}
|
||||
|
||||
baseUsage.available = true;
|
||||
|
||||
// Parse model remains
|
||||
if (codingPlan.model_remains && codingPlan.model_remains.length > 0) {
|
||||
let totalUsed = 0;
|
||||
let totalLimit = 0;
|
||||
|
||||
for (const model of codingPlan.model_remains) {
|
||||
totalUsed += model.used;
|
||||
totalLimit += model.total;
|
||||
}
|
||||
|
||||
const usedPercent = totalLimit > 0 ? Math.round((totalUsed / totalLimit) * 100) : 0;
|
||||
|
||||
// Calculate reset time
|
||||
const resetsAt = codingPlan.remains_time
|
||||
? new Date(Date.now() + codingPlan.remains_time * 1000).toISOString()
|
||||
: codingPlan.end_time || '';
|
||||
|
||||
const usageWindow: UsageWindow = {
|
||||
name: 'Coding Plan',
|
||||
usedPercent,
|
||||
resetsAt,
|
||||
resetText: resetsAt ? this.formatResetTime(resetsAt) : '',
|
||||
used: totalUsed,
|
||||
limit: totalLimit,
|
||||
};
|
||||
|
||||
baseUsage.primary = usageWindow;
|
||||
baseUsage.tokenRemains = totalLimit - totalUsed;
|
||||
baseUsage.totalTokens = totalLimit;
|
||||
}
|
||||
|
||||
// Parse plan times
|
||||
if (codingPlan.start_time) {
|
||||
baseUsage.planStartTime = codingPlan.start_time;
|
||||
}
|
||||
if (codingPlan.end_time) {
|
||||
baseUsage.planEndTime = codingPlan.end_time;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[fetchUsageData] ✓ MiniMax usage: ${baseUsage.primary?.usedPercent || 0}% used, ` +
|
||||
`${baseUsage.tokenRemains || 0} tokens remaining`
|
||||
);
|
||||
|
||||
return baseUsage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format reset time as human-readable string
|
||||
*/
|
||||
private formatResetTime(resetAt: string): string {
|
||||
try {
|
||||
const date = new Date(resetAt);
|
||||
const now = new Date();
|
||||
const diff = date.getTime() - now.getTime();
|
||||
|
||||
if (diff < 0) return 'Expired';
|
||||
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (days > 0) {
|
||||
return `Resets in ${days}d`;
|
||||
}
|
||||
if (hours > 0) {
|
||||
return `Resets in ${hours}h`;
|
||||
}
|
||||
return 'Resets soon';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cached credentials
|
||||
*/
|
||||
clearCache(): void {
|
||||
this.cachedApiKey = null;
|
||||
}
|
||||
}
|
||||
144
apps/server/src/services/opencode-usage-service.ts
Normal file
144
apps/server/src/services/opencode-usage-service.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* OpenCode Usage Service
|
||||
*
|
||||
* Fetches usage data from OpenCode's server API.
|
||||
* Based on CodexBar reference implementation.
|
||||
*
|
||||
* Note: OpenCode usage tracking is limited as they use a proprietary
|
||||
* server function API that requires browser cookies for authentication.
|
||||
* This service provides basic status checking based on local config.
|
||||
*
|
||||
* API Endpoints (require browser cookies):
|
||||
* - POST https://opencode.ai/_server - Server functions
|
||||
* - workspaces: Get workspace info
|
||||
* - subscription.get: Get usage data
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { OpenCodeProviderUsage, UsageWindow } from '@automaker/types';
|
||||
|
||||
const logger = createLogger('OpenCodeUsage');
|
||||
|
||||
// OpenCode config locations
|
||||
const OPENCODE_CONFIG_PATHS = [
|
||||
path.join(os.homedir(), '.opencode', 'config.json'),
|
||||
path.join(os.homedir(), '.config', 'opencode', 'config.json'),
|
||||
];
|
||||
|
||||
interface OpenCodeConfig {
|
||||
workspaceId?: string;
|
||||
email?: string;
|
||||
authenticated?: boolean;
|
||||
}
|
||||
|
||||
interface OpenCodeUsageData {
|
||||
rollingUsage?: {
|
||||
usagePercent: number;
|
||||
resetInSec: number;
|
||||
};
|
||||
weeklyUsage?: {
|
||||
usagePercent: number;
|
||||
resetInSec: number;
|
||||
};
|
||||
}
|
||||
|
||||
export class OpenCodeUsageService {
|
||||
private cachedConfig: OpenCodeConfig | null = null;
|
||||
|
||||
/**
|
||||
* Check if OpenCode is available
|
||||
*/
|
||||
async isAvailable(): Promise<boolean> {
|
||||
const config = this.getConfig();
|
||||
return !!config?.authenticated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get OpenCode config from disk
|
||||
*/
|
||||
private getConfig(): OpenCodeConfig | null {
|
||||
if (this.cachedConfig) {
|
||||
return this.cachedConfig;
|
||||
}
|
||||
|
||||
// Check environment variable for workspace ID
|
||||
if (process.env.OPENCODE_WORKSPACE_ID) {
|
||||
this.cachedConfig = {
|
||||
workspaceId: process.env.OPENCODE_WORKSPACE_ID,
|
||||
authenticated: true,
|
||||
};
|
||||
return this.cachedConfig;
|
||||
}
|
||||
|
||||
// Check config files
|
||||
for (const configPath of OPENCODE_CONFIG_PATHS) {
|
||||
try {
|
||||
if (fs.existsSync(configPath)) {
|
||||
const content = fs.readFileSync(configPath, 'utf8');
|
||||
const config = JSON.parse(content) as OpenCodeConfig;
|
||||
this.cachedConfig = config;
|
||||
return this.cachedConfig;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(`Failed to read OpenCode config from ${configPath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch usage data from OpenCode
|
||||
*
|
||||
* Note: OpenCode's usage API requires browser cookies which we don't have access to.
|
||||
* This implementation returns basic availability status.
|
||||
* For full usage tracking, users should check the OpenCode dashboard.
|
||||
*/
|
||||
async fetchUsageData(): Promise<OpenCodeProviderUsage> {
|
||||
logger.info('[fetchUsageData] Starting OpenCode usage fetch...');
|
||||
|
||||
const baseUsage: OpenCodeProviderUsage = {
|
||||
providerId: 'opencode',
|
||||
providerName: 'OpenCode',
|
||||
available: false,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const config = this.getConfig();
|
||||
if (!config) {
|
||||
baseUsage.error = 'OpenCode not configured';
|
||||
return baseUsage;
|
||||
}
|
||||
|
||||
if (!config.authenticated) {
|
||||
baseUsage.error = 'OpenCode not authenticated';
|
||||
return baseUsage;
|
||||
}
|
||||
|
||||
// OpenCode is available but we can't get detailed usage without browser cookies
|
||||
baseUsage.available = true;
|
||||
baseUsage.workspaceId = config.workspaceId;
|
||||
|
||||
// Note: Full usage tracking requires browser cookie authentication
|
||||
// which is not available in a server-side context.
|
||||
// Users should check the OpenCode dashboard for detailed usage.
|
||||
baseUsage.error =
|
||||
'Usage details require browser authentication. Check opencode.ai for details.';
|
||||
|
||||
logger.info(
|
||||
`[fetchUsageData] OpenCode available, workspace: ${config.workspaceId || 'unknown'}`
|
||||
);
|
||||
|
||||
return baseUsage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cached config
|
||||
*/
|
||||
clearCache(): void {
|
||||
this.cachedConfig = null;
|
||||
}
|
||||
}
|
||||
@@ -234,51 +234,75 @@ export class PipelineService {
|
||||
*
|
||||
* Determines what status a feature should transition to based on current status.
|
||||
* Flow: in_progress -> pipeline_step_0 -> pipeline_step_1 -> ... -> final status
|
||||
* Steps in the excludedStepIds array will be skipped.
|
||||
*
|
||||
* @param currentStatus - Current feature status
|
||||
* @param config - Pipeline configuration (or null if no pipeline)
|
||||
* @param skipTests - Whether to skip tests (affects final status)
|
||||
* @param excludedStepIds - Optional array of step IDs to skip
|
||||
* @returns The next status in the pipeline flow
|
||||
*/
|
||||
getNextStatus(
|
||||
currentStatus: FeatureStatusWithPipeline,
|
||||
config: PipelineConfig | null,
|
||||
skipTests: boolean
|
||||
skipTests: boolean,
|
||||
excludedStepIds?: string[]
|
||||
): FeatureStatusWithPipeline {
|
||||
const steps = config?.steps || [];
|
||||
const exclusions = new Set(excludedStepIds || []);
|
||||
|
||||
// Sort steps by order
|
||||
const sortedSteps = [...steps].sort((a, b) => a.order - b.order);
|
||||
// Sort steps by order and filter out excluded steps
|
||||
const sortedSteps = [...steps]
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.filter((step) => !exclusions.has(step.id));
|
||||
|
||||
// If no pipeline steps, use original logic
|
||||
// If no pipeline steps (or all excluded), use original logic
|
||||
if (sortedSteps.length === 0) {
|
||||
if (currentStatus === 'in_progress') {
|
||||
// If coming from in_progress or already in a pipeline step, go to final status
|
||||
if (currentStatus === 'in_progress' || currentStatus.startsWith('pipeline_')) {
|
||||
return skipTests ? 'waiting_approval' : 'verified';
|
||||
}
|
||||
return currentStatus;
|
||||
}
|
||||
|
||||
// Coming from in_progress -> go to first pipeline step
|
||||
// Coming from in_progress -> go to first non-excluded pipeline step
|
||||
if (currentStatus === 'in_progress') {
|
||||
return `pipeline_${sortedSteps[0].id}`;
|
||||
}
|
||||
|
||||
// Coming from a pipeline step -> go to next step or final status
|
||||
// Coming from a pipeline step -> go to next non-excluded step or final status
|
||||
if (currentStatus.startsWith('pipeline_')) {
|
||||
const currentStepId = currentStatus.replace('pipeline_', '');
|
||||
const currentIndex = sortedSteps.findIndex((s) => s.id === currentStepId);
|
||||
|
||||
if (currentIndex === -1) {
|
||||
// Step not found, go to final status
|
||||
// Current step not found in filtered list (might be excluded or invalid)
|
||||
// Find next valid step after this one from the original sorted list
|
||||
const allSortedSteps = [...steps].sort((a, b) => a.order - b.order);
|
||||
const originalIndex = allSortedSteps.findIndex((s) => s.id === currentStepId);
|
||||
|
||||
if (originalIndex === -1) {
|
||||
// Step truly doesn't exist, go to final status
|
||||
return skipTests ? 'waiting_approval' : 'verified';
|
||||
}
|
||||
|
||||
// Find the next non-excluded step after the current one
|
||||
for (let i = originalIndex + 1; i < allSortedSteps.length; i++) {
|
||||
if (!exclusions.has(allSortedSteps[i].id)) {
|
||||
return `pipeline_${allSortedSteps[i].id}`;
|
||||
}
|
||||
}
|
||||
|
||||
// No more non-excluded steps, go to final status
|
||||
return skipTests ? 'waiting_approval' : 'verified';
|
||||
}
|
||||
|
||||
if (currentIndex < sortedSteps.length - 1) {
|
||||
// Go to next step
|
||||
// Go to next non-excluded step
|
||||
return `pipeline_${sortedSteps[currentIndex + 1].id}`;
|
||||
}
|
||||
|
||||
// Last step completed, go to final status
|
||||
// Last non-excluded step completed, go to final status
|
||||
return skipTests ? 'waiting_approval' : 'verified';
|
||||
}
|
||||
|
||||
|
||||
447
apps/server/src/services/provider-usage-tracker.ts
Normal file
447
apps/server/src/services/provider-usage-tracker.ts
Normal file
@@ -0,0 +1,447 @@
|
||||
/**
|
||||
* Provider Usage Tracker
|
||||
*
|
||||
* Unified service that aggregates usage data from all supported AI providers.
|
||||
* Manages caching, polling, and coordination of individual usage services.
|
||||
*
|
||||
* Supported providers:
|
||||
* - Claude (via ClaudeUsageService)
|
||||
* - Codex (via CodexUsageService)
|
||||
* - Cursor (via CursorUsageService)
|
||||
* - Gemini (via GeminiUsageService)
|
||||
* - GitHub Copilot (via CopilotUsageService)
|
||||
* - OpenCode (via OpenCodeUsageService)
|
||||
* - MiniMax (via MiniMaxUsageService)
|
||||
* - GLM (via GLMUsageService)
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type {
|
||||
UsageProviderId,
|
||||
ProviderUsage,
|
||||
AllProvidersUsage,
|
||||
ClaudeProviderUsage,
|
||||
CodexProviderUsage,
|
||||
ClaudeCompatibleProvider,
|
||||
} from '@automaker/types';
|
||||
import { ClaudeUsageService } from './claude-usage-service.js';
|
||||
import { CodexUsageService, type CodexUsageData } from './codex-usage-service.js';
|
||||
import { CursorUsageService } from './cursor-usage-service.js';
|
||||
import { GeminiUsageService } from './gemini-usage-service.js';
|
||||
import { CopilotUsageService } from './copilot-usage-service.js';
|
||||
import { OpenCodeUsageService } from './opencode-usage-service.js';
|
||||
import { MiniMaxUsageService } from './minimax-usage-service.js';
|
||||
import { GLMUsageService } from './glm-usage-service.js';
|
||||
import type { ClaudeUsage } from '../routes/claude/types.js';
|
||||
|
||||
const logger = createLogger('ProviderUsageTracker');
|
||||
|
||||
// Cache TTL in milliseconds (1 minute)
|
||||
const CACHE_TTL_MS = 60 * 1000;
|
||||
|
||||
interface CachedUsage {
|
||||
data: ProviderUsage;
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
export class ProviderUsageTracker {
|
||||
private claudeService: ClaudeUsageService;
|
||||
private codexService: CodexUsageService;
|
||||
private cursorService: CursorUsageService;
|
||||
private geminiService: GeminiUsageService;
|
||||
private copilotService: CopilotUsageService;
|
||||
private opencodeService: OpenCodeUsageService;
|
||||
private minimaxService: MiniMaxUsageService;
|
||||
private glmService: GLMUsageService;
|
||||
|
||||
private cache: Map<UsageProviderId, CachedUsage> = new Map();
|
||||
private enabledProviders: Set<UsageProviderId> = new Set([
|
||||
'claude',
|
||||
'codex',
|
||||
'cursor',
|
||||
'gemini',
|
||||
'copilot',
|
||||
'opencode',
|
||||
'minimax',
|
||||
'glm',
|
||||
]);
|
||||
|
||||
constructor(codexService?: CodexUsageService) {
|
||||
this.claudeService = new ClaudeUsageService();
|
||||
this.codexService = codexService || new CodexUsageService();
|
||||
this.cursorService = new CursorUsageService();
|
||||
this.geminiService = new GeminiUsageService();
|
||||
this.copilotService = new CopilotUsageService();
|
||||
this.opencodeService = new OpenCodeUsageService();
|
||||
this.minimaxService = new MiniMaxUsageService();
|
||||
this.glmService = new GLMUsageService();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set enabled providers (called when settings change)
|
||||
*/
|
||||
setEnabledProviders(providers: UsageProviderId[]): void {
|
||||
this.enabledProviders = new Set(providers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update custom provider configs (MiniMax, GLM)
|
||||
*/
|
||||
updateCustomProviderConfigs(providers: ClaudeCompatibleProvider[]): void {
|
||||
const minimaxConfig = providers.find(
|
||||
(p) => p.providerType === 'minimax' && p.enabled !== false
|
||||
);
|
||||
const glmConfig = providers.find((p) => p.providerType === 'glm' && p.enabled !== false);
|
||||
|
||||
this.minimaxService.setProviderConfig(minimaxConfig || null);
|
||||
this.glmService.setProviderConfig(glmConfig || null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a provider is enabled
|
||||
*/
|
||||
isProviderEnabled(providerId: UsageProviderId): boolean {
|
||||
return this.enabledProviders.has(providerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if cached data is still fresh
|
||||
*/
|
||||
private isCacheFresh(providerId: UsageProviderId): boolean {
|
||||
const cached = this.cache.get(providerId);
|
||||
if (!cached) return false;
|
||||
return Date.now() - cached.fetchedAt < CACHE_TTL_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached data for a provider
|
||||
*/
|
||||
private getCached(providerId: UsageProviderId): ProviderUsage | null {
|
||||
const cached = this.cache.get(providerId);
|
||||
return cached?.data || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set cached data for a provider
|
||||
*/
|
||||
private setCached(providerId: UsageProviderId, data: ProviderUsage): void {
|
||||
this.cache.set(providerId, {
|
||||
data,
|
||||
fetchedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Claude usage to unified format
|
||||
*/
|
||||
private convertClaudeUsage(usage: ClaudeUsage): ClaudeProviderUsage {
|
||||
return {
|
||||
providerId: 'claude',
|
||||
providerName: 'Claude',
|
||||
available: true,
|
||||
lastUpdated: usage.lastUpdated,
|
||||
userTimezone: usage.userTimezone,
|
||||
primary: {
|
||||
name: 'Session (5-hour)',
|
||||
usedPercent: usage.sessionPercentage,
|
||||
resetsAt: usage.sessionResetTime,
|
||||
resetText: usage.sessionResetText,
|
||||
},
|
||||
secondary: {
|
||||
name: 'Weekly (All Models)',
|
||||
usedPercent: usage.weeklyPercentage,
|
||||
resetsAt: usage.weeklyResetTime,
|
||||
resetText: usage.weeklyResetText,
|
||||
},
|
||||
sessionWindow: {
|
||||
name: 'Session (5-hour)',
|
||||
usedPercent: usage.sessionPercentage,
|
||||
resetsAt: usage.sessionResetTime,
|
||||
resetText: usage.sessionResetText,
|
||||
},
|
||||
weeklyWindow: {
|
||||
name: 'Weekly (All Models)',
|
||||
usedPercent: usage.weeklyPercentage,
|
||||
resetsAt: usage.weeklyResetTime,
|
||||
resetText: usage.weeklyResetText,
|
||||
},
|
||||
sonnetWindow: {
|
||||
name: 'Weekly (Sonnet)',
|
||||
usedPercent: usage.sonnetWeeklyPercentage,
|
||||
resetsAt: usage.weeklyResetTime,
|
||||
resetText: usage.sonnetResetText,
|
||||
},
|
||||
cost:
|
||||
usage.costUsed !== null
|
||||
? {
|
||||
used: usage.costUsed,
|
||||
limit: usage.costLimit,
|
||||
currency: usage.costCurrency || 'USD',
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Codex usage to unified format
|
||||
*/
|
||||
private convertCodexUsage(usage: CodexUsageData): CodexProviderUsage {
|
||||
const result: CodexProviderUsage = {
|
||||
providerId: 'codex',
|
||||
providerName: 'Codex',
|
||||
available: true,
|
||||
lastUpdated: usage.lastUpdated,
|
||||
planType: usage.rateLimits?.planType,
|
||||
};
|
||||
|
||||
if (usage.rateLimits?.primary) {
|
||||
result.primary = {
|
||||
name: `${usage.rateLimits.primary.windowDurationMins}min Window`,
|
||||
usedPercent: usage.rateLimits.primary.usedPercent,
|
||||
resetsAt: new Date(usage.rateLimits.primary.resetsAt * 1000).toISOString(),
|
||||
resetText: this.formatResetTime(usage.rateLimits.primary.resetsAt * 1000),
|
||||
windowDurationMins: usage.rateLimits.primary.windowDurationMins,
|
||||
};
|
||||
}
|
||||
|
||||
if (usage.rateLimits?.secondary) {
|
||||
result.secondary = {
|
||||
name: `${usage.rateLimits.secondary.windowDurationMins}min Window`,
|
||||
usedPercent: usage.rateLimits.secondary.usedPercent,
|
||||
resetsAt: new Date(usage.rateLimits.secondary.resetsAt * 1000).toISOString(),
|
||||
resetText: this.formatResetTime(usage.rateLimits.secondary.resetsAt * 1000),
|
||||
windowDurationMins: usage.rateLimits.secondary.windowDurationMins,
|
||||
};
|
||||
}
|
||||
|
||||
if (usage.rateLimits?.planType) {
|
||||
result.plan = {
|
||||
type: usage.rateLimits.planType,
|
||||
displayName:
|
||||
usage.rateLimits.planType.charAt(0).toUpperCase() + usage.rateLimits.planType.slice(1),
|
||||
isPaid: usage.rateLimits.planType !== 'free',
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format reset time as human-readable string
|
||||
*/
|
||||
private formatResetTime(resetAtMs: number): string {
|
||||
const diff = resetAtMs - Date.now();
|
||||
if (diff < 0) return 'Expired';
|
||||
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (days > 0) return `Resets in ${days}d ${hours % 24}h`;
|
||||
if (hours > 0) return `Resets in ${hours}h ${minutes % 60}m`;
|
||||
if (minutes > 0) return `Resets in ${minutes}m`;
|
||||
return 'Resets soon';
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch usage for a specific provider
|
||||
*/
|
||||
async fetchProviderUsage(
|
||||
providerId: UsageProviderId,
|
||||
forceRefresh = false
|
||||
): Promise<ProviderUsage | null> {
|
||||
// Check cache first
|
||||
if (!forceRefresh && this.isCacheFresh(providerId)) {
|
||||
return this.getCached(providerId);
|
||||
}
|
||||
|
||||
try {
|
||||
let usage: ProviderUsage | null = null;
|
||||
|
||||
switch (providerId) {
|
||||
case 'claude': {
|
||||
if (await this.claudeService.isAvailable()) {
|
||||
const claudeUsage = await this.claudeService.fetchUsageData();
|
||||
usage = this.convertClaudeUsage(claudeUsage);
|
||||
} else {
|
||||
usage = {
|
||||
providerId: 'claude',
|
||||
providerName: 'Claude',
|
||||
available: false,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
error: 'Claude CLI not available',
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'codex': {
|
||||
if (await this.codexService.isAvailable()) {
|
||||
const codexUsage = await this.codexService.fetchUsageData();
|
||||
usage = this.convertCodexUsage(codexUsage);
|
||||
} else {
|
||||
usage = {
|
||||
providerId: 'codex',
|
||||
providerName: 'Codex',
|
||||
available: false,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
error: 'Codex CLI not available',
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'cursor': {
|
||||
usage = await this.cursorService.fetchUsageData();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'gemini': {
|
||||
usage = await this.geminiService.fetchUsageData();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'copilot': {
|
||||
usage = await this.copilotService.fetchUsageData();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'opencode': {
|
||||
usage = await this.opencodeService.fetchUsageData();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'minimax': {
|
||||
usage = await this.minimaxService.fetchUsageData();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'glm': {
|
||||
usage = await this.glmService.fetchUsageData();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (usage) {
|
||||
this.setCached(providerId, usage);
|
||||
}
|
||||
|
||||
return usage;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to fetch usage for ${providerId}:`, error);
|
||||
return {
|
||||
providerId,
|
||||
providerName: this.getProviderName(providerId),
|
||||
available: false,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
} as ProviderUsage;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provider display name
|
||||
*/
|
||||
private getProviderName(providerId: UsageProviderId): string {
|
||||
const names: Record<UsageProviderId, string> = {
|
||||
claude: 'Claude',
|
||||
codex: 'Codex',
|
||||
cursor: 'Cursor',
|
||||
gemini: 'Gemini',
|
||||
copilot: 'GitHub Copilot',
|
||||
opencode: 'OpenCode',
|
||||
minimax: 'MiniMax',
|
||||
glm: 'z.AI GLM',
|
||||
};
|
||||
return names[providerId] || providerId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch usage for all enabled providers
|
||||
*/
|
||||
async fetchAllUsage(forceRefresh = false): Promise<AllProvidersUsage> {
|
||||
const providers: Partial<Record<UsageProviderId, ProviderUsage>> = {};
|
||||
const errors: Array<{ providerId: UsageProviderId; message: string }> = [];
|
||||
|
||||
// Fetch all enabled providers in parallel
|
||||
const enabledList = Array.from(this.enabledProviders);
|
||||
const results = await Promise.allSettled(
|
||||
enabledList.map((providerId) => this.fetchProviderUsage(providerId, forceRefresh))
|
||||
);
|
||||
|
||||
results.forEach((result, index) => {
|
||||
const providerId = enabledList[index];
|
||||
|
||||
if (result.status === 'fulfilled' && result.value) {
|
||||
providers[providerId] = result.value;
|
||||
if (result.value.error) {
|
||||
errors.push({
|
||||
providerId,
|
||||
message: result.value.error,
|
||||
});
|
||||
}
|
||||
} else if (result.status === 'rejected') {
|
||||
errors.push({
|
||||
providerId,
|
||||
message: result.reason?.message || 'Unknown error',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
providers,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check availability for all providers
|
||||
*/
|
||||
async checkAvailability(): Promise<Record<UsageProviderId, boolean>> {
|
||||
const availability: Record<string, boolean> = {};
|
||||
|
||||
const checks = await Promise.allSettled([
|
||||
this.claudeService.isAvailable(),
|
||||
this.codexService.isAvailable(),
|
||||
this.cursorService.isAvailable(),
|
||||
this.geminiService.isAvailable(),
|
||||
this.copilotService.isAvailable(),
|
||||
this.opencodeService.isAvailable(),
|
||||
this.minimaxService.isAvailable(),
|
||||
this.glmService.isAvailable(),
|
||||
]);
|
||||
|
||||
const providerIds: UsageProviderId[] = [
|
||||
'claude',
|
||||
'codex',
|
||||
'cursor',
|
||||
'gemini',
|
||||
'copilot',
|
||||
'opencode',
|
||||
'minimax',
|
||||
'glm',
|
||||
];
|
||||
|
||||
checks.forEach((result, index) => {
|
||||
availability[providerIds[index]] =
|
||||
result.status === 'fulfilled' ? result.value : false;
|
||||
});
|
||||
|
||||
return availability as Record<UsageProviderId, boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all caches
|
||||
*/
|
||||
clearCache(): void {
|
||||
this.cache.clear();
|
||||
this.claudeService = new ClaudeUsageService(); // Reset Claude service
|
||||
this.cursorService.clearCache();
|
||||
this.geminiService.clearCache();
|
||||
this.copilotService.clearCache();
|
||||
this.opencodeService.clearCache();
|
||||
this.minimaxService.clearCache();
|
||||
this.glmService.clearCache();
|
||||
}
|
||||
}
|
||||
@@ -621,6 +621,21 @@ export class SettingsService {
|
||||
};
|
||||
}
|
||||
|
||||
// Deep merge autoModeByWorktree if provided (preserves other worktree entries)
|
||||
if (sanitizedUpdates.autoModeByWorktree) {
|
||||
type WorktreeEntry = { maxConcurrency: number; branchName: string | null };
|
||||
const mergedAutoModeByWorktree: Record<string, WorktreeEntry> = {
|
||||
...current.autoModeByWorktree,
|
||||
};
|
||||
for (const [key, value] of Object.entries(sanitizedUpdates.autoModeByWorktree)) {
|
||||
mergedAutoModeByWorktree[key] = {
|
||||
...mergedAutoModeByWorktree[key],
|
||||
...value,
|
||||
};
|
||||
}
|
||||
updated.autoModeByWorktree = mergedAutoModeByWorktree;
|
||||
}
|
||||
|
||||
await writeSettingsJson(settingsPath, updated);
|
||||
logger.info('Global settings updated');
|
||||
|
||||
@@ -827,6 +842,30 @@ export class SettingsService {
|
||||
delete updated.phaseModelOverrides;
|
||||
}
|
||||
|
||||
// Handle defaultFeatureModel special cases:
|
||||
// - "__CLEAR__" marker means delete the key (use global setting)
|
||||
// - object means project-specific override
|
||||
if (
|
||||
'defaultFeatureModel' in updates &&
|
||||
(updates as Record<string, unknown>).defaultFeatureModel === '__CLEAR__'
|
||||
) {
|
||||
delete updated.defaultFeatureModel;
|
||||
}
|
||||
|
||||
// Handle devCommand special cases:
|
||||
// - null means delete the key (use auto-detection)
|
||||
// - string means custom command
|
||||
if ('devCommand' in updates && updates.devCommand === null) {
|
||||
delete updated.devCommand;
|
||||
}
|
||||
|
||||
// Handle testCommand special cases:
|
||||
// - null means delete the key (use auto-detection)
|
||||
// - string means custom command
|
||||
if ('testCommand' in updates && updates.testCommand === null) {
|
||||
delete updated.testCommand;
|
||||
}
|
||||
|
||||
await writeSettingsJson(settingsPath, updated);
|
||||
logger.info(`Project settings updated for ${projectPath}`);
|
||||
|
||||
|
||||
682
apps/server/src/services/test-runner-service.ts
Normal file
682
apps/server/src/services/test-runner-service.ts
Normal file
@@ -0,0 +1,682 @@
|
||||
/**
|
||||
* Test Runner Service
|
||||
*
|
||||
* Manages test execution processes for git worktrees.
|
||||
* Runs user-configured test commands with output streaming.
|
||||
*
|
||||
* Features:
|
||||
* - Process management with graceful shutdown
|
||||
* - Output buffering and throttling for WebSocket streaming
|
||||
* - Support for running all tests or specific files
|
||||
* - Cross-platform process cleanup (Windows/Unix)
|
||||
*/
|
||||
|
||||
import { spawn, execSync, type ChildProcess } from 'child_process';
|
||||
import * as secureFs from '../lib/secure-fs.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { EventEmitter } from '../lib/events.js';
|
||||
|
||||
const logger = createLogger('TestRunnerService');
|
||||
|
||||
// Maximum scrollback buffer size (characters)
|
||||
const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per test run
|
||||
|
||||
// Throttle output to prevent overwhelming WebSocket under heavy load
|
||||
// Note: Too aggressive throttling (< 50ms) can cause memory issues and UI crashes
|
||||
// due to rapid React state updates and string concatenation overhead
|
||||
const OUTPUT_THROTTLE_MS = 100; // ~10fps - balances responsiveness with stability
|
||||
const OUTPUT_BATCH_SIZE = 8192; // Larger batch size to reduce event frequency
|
||||
|
||||
/**
|
||||
* Status of a test run
|
||||
*/
|
||||
export type TestRunStatus = 'pending' | 'running' | 'passed' | 'failed' | 'cancelled' | 'error';
|
||||
|
||||
/**
|
||||
* Information about an active test run session
|
||||
*/
|
||||
export interface TestRunSession {
|
||||
/** Unique identifier for this test run */
|
||||
id: string;
|
||||
/** Path to the worktree where tests are running */
|
||||
worktreePath: string;
|
||||
/** The command being run */
|
||||
command: string;
|
||||
/** The spawned child process */
|
||||
process: ChildProcess | null;
|
||||
/** When the test run started */
|
||||
startedAt: Date;
|
||||
/** When the test run finished (if completed) */
|
||||
finishedAt: Date | null;
|
||||
/** Current status of the test run */
|
||||
status: TestRunStatus;
|
||||
/** Exit code from the process (if completed) */
|
||||
exitCode: number | null;
|
||||
/** Specific test file being run (optional) */
|
||||
testFile?: string;
|
||||
/** Scrollback buffer for log history (replay on reconnect) */
|
||||
scrollbackBuffer: string;
|
||||
/** Pending output to be flushed to subscribers */
|
||||
outputBuffer: string;
|
||||
/** Throttle timer for batching output */
|
||||
flushTimeout: NodeJS.Timeout | null;
|
||||
/** Flag to indicate session is stopping (prevents output after stop) */
|
||||
stopping: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a test run operation
|
||||
*/
|
||||
export interface TestRunResult {
|
||||
success: boolean;
|
||||
result?: {
|
||||
sessionId: string;
|
||||
worktreePath: string;
|
||||
command: string;
|
||||
status: TestRunStatus;
|
||||
testFile?: string;
|
||||
message: string;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test Runner Service class
|
||||
* Manages test execution processes across worktrees
|
||||
*/
|
||||
class TestRunnerService {
|
||||
private sessions: Map<string, TestRunSession> = new Map();
|
||||
private emitter: EventEmitter | null = null;
|
||||
|
||||
/**
|
||||
* Set the event emitter for streaming log events
|
||||
* Called during service initialization with the global event emitter
|
||||
*/
|
||||
setEventEmitter(emitter: EventEmitter): void {
|
||||
this.emitter = emitter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to check if a file exists using secureFs
|
||||
*/
|
||||
private async fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await secureFs.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append data to scrollback buffer with size limit enforcement
|
||||
* Evicts oldest data when buffer exceeds MAX_SCROLLBACK_SIZE
|
||||
*/
|
||||
private appendToScrollback(session: TestRunSession, data: string): void {
|
||||
session.scrollbackBuffer += data;
|
||||
if (session.scrollbackBuffer.length > MAX_SCROLLBACK_SIZE) {
|
||||
session.scrollbackBuffer = session.scrollbackBuffer.slice(-MAX_SCROLLBACK_SIZE);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush buffered output to WebSocket subscribers
|
||||
* Sends batched output to prevent overwhelming clients under heavy load
|
||||
*/
|
||||
private flushOutput(session: TestRunSession): void {
|
||||
// Skip flush if session is stopping or buffer is empty
|
||||
if (session.stopping || session.outputBuffer.length === 0) {
|
||||
session.flushTimeout = null;
|
||||
return;
|
||||
}
|
||||
|
||||
let dataToSend = session.outputBuffer;
|
||||
if (dataToSend.length > OUTPUT_BATCH_SIZE) {
|
||||
// Send in batches if buffer is large
|
||||
dataToSend = session.outputBuffer.slice(0, OUTPUT_BATCH_SIZE);
|
||||
session.outputBuffer = session.outputBuffer.slice(OUTPUT_BATCH_SIZE);
|
||||
// Schedule another flush for remaining data
|
||||
session.flushTimeout = setTimeout(() => this.flushOutput(session), OUTPUT_THROTTLE_MS);
|
||||
} else {
|
||||
session.outputBuffer = '';
|
||||
session.flushTimeout = null;
|
||||
}
|
||||
|
||||
// Emit output event for WebSocket streaming
|
||||
if (this.emitter) {
|
||||
this.emitter.emit('test-runner:output', {
|
||||
sessionId: session.id,
|
||||
worktreePath: session.worktreePath,
|
||||
content: dataToSend,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming stdout/stderr data from test process
|
||||
* Buffers data for scrollback replay and schedules throttled emission
|
||||
*/
|
||||
private handleProcessOutput(session: TestRunSession, data: Buffer): void {
|
||||
// Skip output if session is stopping
|
||||
if (session.stopping) {
|
||||
return;
|
||||
}
|
||||
|
||||
const content = data.toString();
|
||||
|
||||
// Append to scrollback buffer for replay on reconnect
|
||||
this.appendToScrollback(session, content);
|
||||
|
||||
// Buffer output for throttled live delivery
|
||||
session.outputBuffer += content;
|
||||
|
||||
// Schedule flush if not already scheduled
|
||||
if (!session.flushTimeout) {
|
||||
session.flushTimeout = setTimeout(() => this.flushOutput(session), OUTPUT_THROTTLE_MS);
|
||||
}
|
||||
|
||||
// Also log for debugging (existing behavior)
|
||||
logger.debug(`[${session.id}] ${content.trim()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill any process running (platform-specific cleanup)
|
||||
*/
|
||||
private killProcessTree(pid: number): void {
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
// Windows: use taskkill to kill process tree
|
||||
execSync(`taskkill /F /T /PID ${pid}`, { stdio: 'ignore' });
|
||||
} else {
|
||||
// Unix: kill the process group
|
||||
try {
|
||||
process.kill(-pid, 'SIGTERM');
|
||||
} catch {
|
||||
// Fallback to killing just the process
|
||||
process.kill(pid, 'SIGTERM');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(`Error killing process ${pid}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique session ID
|
||||
*/
|
||||
private generateSessionId(): string {
|
||||
return `test-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a test file path to prevent command injection
|
||||
* Allows only safe characters for file paths
|
||||
*/
|
||||
private sanitizeTestFile(testFile: string): string {
|
||||
// Remove any shell metacharacters and normalize path
|
||||
// Allow only alphanumeric, dots, slashes, hyphens, underscores, colons (for Windows paths)
|
||||
return testFile.replace(/[^a-zA-Z0-9.\\/_\-:]/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Start tests in a worktree using the provided command
|
||||
*
|
||||
* @param worktreePath - Path to the worktree where tests should run
|
||||
* @param options - Configuration for the test run
|
||||
* @returns TestRunResult with session info or error
|
||||
*/
|
||||
async startTests(
|
||||
worktreePath: string,
|
||||
options: {
|
||||
command: string;
|
||||
testFile?: string;
|
||||
}
|
||||
): Promise<TestRunResult> {
|
||||
const { command, testFile } = options;
|
||||
|
||||
// Check if already running
|
||||
const existingSession = this.getActiveSession(worktreePath);
|
||||
if (existingSession) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Tests are already running for this worktree (session: ${existingSession.id})`,
|
||||
};
|
||||
}
|
||||
|
||||
// Verify the worktree exists
|
||||
if (!(await this.fileExists(worktreePath))) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Worktree path does not exist: ${worktreePath}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!command) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'No test command provided',
|
||||
};
|
||||
}
|
||||
|
||||
// Build the final command (append test file if specified)
|
||||
let finalCommand = command;
|
||||
if (testFile) {
|
||||
// Sanitize test file path to prevent command injection
|
||||
const sanitizedFile = this.sanitizeTestFile(testFile);
|
||||
// Append the test file to the command
|
||||
// Most test runners support: command -- file or command file
|
||||
finalCommand = `${command} -- ${sanitizedFile}`;
|
||||
}
|
||||
|
||||
// Parse command into cmd and args (shell execution)
|
||||
// We use shell: true to support complex commands like "npm run test:server"
|
||||
logger.info(`Starting tests in ${worktreePath}`);
|
||||
logger.info(`Command: ${finalCommand}`);
|
||||
|
||||
// Create session
|
||||
const sessionId = this.generateSessionId();
|
||||
const session: TestRunSession = {
|
||||
id: sessionId,
|
||||
worktreePath,
|
||||
command: finalCommand,
|
||||
process: null,
|
||||
startedAt: new Date(),
|
||||
finishedAt: null,
|
||||
status: 'pending',
|
||||
exitCode: null,
|
||||
testFile,
|
||||
scrollbackBuffer: '',
|
||||
outputBuffer: '',
|
||||
flushTimeout: null,
|
||||
stopping: false,
|
||||
};
|
||||
|
||||
// Spawn the test process using shell
|
||||
const env = {
|
||||
...process.env,
|
||||
FORCE_COLOR: '1',
|
||||
COLORTERM: 'truecolor',
|
||||
TERM: 'xterm-256color',
|
||||
CI: 'true', // Helps some test runners format output better
|
||||
};
|
||||
|
||||
const testProcess = spawn(finalCommand, [], {
|
||||
cwd: worktreePath,
|
||||
env,
|
||||
shell: true,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: process.platform !== 'win32', // Use process groups on Unix for cleanup
|
||||
});
|
||||
|
||||
session.process = testProcess;
|
||||
session.status = 'running';
|
||||
|
||||
// Track if process failed early
|
||||
const status = { error: null as string | null, exited: false };
|
||||
|
||||
// Helper to clean up resources and emit events
|
||||
const cleanupAndFinish = (
|
||||
exitCode: number | null,
|
||||
finalStatus: TestRunStatus,
|
||||
errorMessage?: string
|
||||
) => {
|
||||
session.finishedAt = new Date();
|
||||
session.exitCode = exitCode;
|
||||
session.status = finalStatus;
|
||||
|
||||
if (session.flushTimeout) {
|
||||
clearTimeout(session.flushTimeout);
|
||||
session.flushTimeout = null;
|
||||
}
|
||||
|
||||
// Flush any remaining output
|
||||
if (session.outputBuffer.length > 0 && this.emitter && !session.stopping) {
|
||||
this.emitter.emit('test-runner:output', {
|
||||
sessionId: session.id,
|
||||
worktreePath: session.worktreePath,
|
||||
content: session.outputBuffer,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
session.outputBuffer = '';
|
||||
}
|
||||
|
||||
// Emit completed event
|
||||
if (this.emitter && !session.stopping) {
|
||||
this.emitter.emit('test-runner:completed', {
|
||||
sessionId: session.id,
|
||||
worktreePath: session.worktreePath,
|
||||
command: session.command,
|
||||
status: finalStatus,
|
||||
exitCode,
|
||||
error: errorMessage,
|
||||
duration: session.finishedAt.getTime() - session.startedAt.getTime(),
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Capture stdout
|
||||
if (testProcess.stdout) {
|
||||
testProcess.stdout.on('data', (data: Buffer) => {
|
||||
this.handleProcessOutput(session, data);
|
||||
});
|
||||
}
|
||||
|
||||
// Capture stderr
|
||||
if (testProcess.stderr) {
|
||||
testProcess.stderr.on('data', (data: Buffer) => {
|
||||
this.handleProcessOutput(session, data);
|
||||
});
|
||||
}
|
||||
|
||||
testProcess.on('error', (error) => {
|
||||
logger.error(`Process error for ${sessionId}:`, error);
|
||||
status.error = error.message;
|
||||
cleanupAndFinish(null, 'error', error.message);
|
||||
});
|
||||
|
||||
testProcess.on('exit', (code) => {
|
||||
logger.info(`Test process for ${worktreePath} exited with code ${code}`);
|
||||
status.exited = true;
|
||||
|
||||
// Determine final status based on exit code
|
||||
let finalStatus: TestRunStatus;
|
||||
if (session.stopping) {
|
||||
finalStatus = 'cancelled';
|
||||
} else if (code === 0) {
|
||||
finalStatus = 'passed';
|
||||
} else {
|
||||
finalStatus = 'failed';
|
||||
}
|
||||
|
||||
cleanupAndFinish(code, finalStatus);
|
||||
});
|
||||
|
||||
// Store session
|
||||
this.sessions.set(sessionId, session);
|
||||
|
||||
// Wait a moment to see if the process fails immediately
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
if (status.error) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to start tests: ${status.error}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (status.exited) {
|
||||
// Process already exited - check if it was immediate failure
|
||||
const exitedSession = this.sessions.get(sessionId);
|
||||
if (exitedSession && exitedSession.status === 'error') {
|
||||
return {
|
||||
success: false,
|
||||
error: `Test process exited immediately. Check output for details.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Emit started event
|
||||
if (this.emitter) {
|
||||
this.emitter.emit('test-runner:started', {
|
||||
sessionId,
|
||||
worktreePath,
|
||||
command: finalCommand,
|
||||
testFile,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
sessionId,
|
||||
worktreePath,
|
||||
command: finalCommand,
|
||||
status: 'running',
|
||||
testFile,
|
||||
message: `Tests started: ${finalCommand}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a running test session
|
||||
*
|
||||
* @param sessionId - The ID of the test session to stop
|
||||
* @returns Result with success status and message
|
||||
*/
|
||||
async stopTests(sessionId: string): Promise<{
|
||||
success: boolean;
|
||||
result?: { sessionId: string; message: string };
|
||||
error?: string;
|
||||
}> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
|
||||
if (!session) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Test session not found: ${sessionId}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (session.status !== 'running') {
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
sessionId,
|
||||
message: `Tests already finished (status: ${session.status})`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
logger.info(`Cancelling test session ${sessionId}`);
|
||||
|
||||
// Mark as stopping to prevent further output events
|
||||
session.stopping = true;
|
||||
|
||||
// Clean up flush timeout
|
||||
if (session.flushTimeout) {
|
||||
clearTimeout(session.flushTimeout);
|
||||
session.flushTimeout = null;
|
||||
}
|
||||
|
||||
// Kill the process
|
||||
if (session.process && !session.process.killed && session.process.pid) {
|
||||
this.killProcessTree(session.process.pid);
|
||||
}
|
||||
|
||||
session.status = 'cancelled';
|
||||
session.finishedAt = new Date();
|
||||
|
||||
// Emit cancelled event
|
||||
if (this.emitter) {
|
||||
this.emitter.emit('test-runner:completed', {
|
||||
sessionId,
|
||||
worktreePath: session.worktreePath,
|
||||
command: session.command,
|
||||
status: 'cancelled',
|
||||
exitCode: null,
|
||||
duration: session.finishedAt.getTime() - session.startedAt.getTime(),
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
sessionId,
|
||||
message: 'Test run cancelled',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the active test session for a worktree
|
||||
*/
|
||||
getActiveSession(worktreePath: string): TestRunSession | undefined {
|
||||
for (const session of this.sessions.values()) {
|
||||
if (session.worktreePath === worktreePath && session.status === 'running') {
|
||||
return session;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a test session by ID
|
||||
*/
|
||||
getSession(sessionId: string): TestRunSession | undefined {
|
||||
return this.sessions.get(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get buffered output for a test session
|
||||
*/
|
||||
getSessionOutput(sessionId: string): {
|
||||
success: boolean;
|
||||
result?: {
|
||||
sessionId: string;
|
||||
output: string;
|
||||
status: TestRunStatus;
|
||||
startedAt: string;
|
||||
finishedAt: string | null;
|
||||
};
|
||||
error?: string;
|
||||
} {
|
||||
const session = this.sessions.get(sessionId);
|
||||
|
||||
if (!session) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Test session not found: ${sessionId}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
sessionId,
|
||||
output: session.scrollbackBuffer,
|
||||
status: session.status,
|
||||
startedAt: session.startedAt.toISOString(),
|
||||
finishedAt: session.finishedAt?.toISOString() || null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List all test sessions (optionally filter by worktree)
|
||||
*/
|
||||
listSessions(worktreePath?: string): {
|
||||
success: boolean;
|
||||
result: {
|
||||
sessions: Array<{
|
||||
sessionId: string;
|
||||
worktreePath: string;
|
||||
command: string;
|
||||
status: TestRunStatus;
|
||||
testFile?: string;
|
||||
startedAt: string;
|
||||
finishedAt: string | null;
|
||||
exitCode: number | null;
|
||||
}>;
|
||||
};
|
||||
} {
|
||||
let sessions = Array.from(this.sessions.values());
|
||||
|
||||
if (worktreePath) {
|
||||
sessions = sessions.filter((s) => s.worktreePath === worktreePath);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
sessions: sessions.map((s) => ({
|
||||
sessionId: s.id,
|
||||
worktreePath: s.worktreePath,
|
||||
command: s.command,
|
||||
status: s.status,
|
||||
testFile: s.testFile,
|
||||
startedAt: s.startedAt.toISOString(),
|
||||
finishedAt: s.finishedAt?.toISOString() || null,
|
||||
exitCode: s.exitCode,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a worktree has an active test run
|
||||
*/
|
||||
isRunning(worktreePath: string): boolean {
|
||||
return this.getActiveSession(worktreePath) !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old completed sessions (keep only recent ones)
|
||||
*/
|
||||
cleanupOldSessions(maxAgeMs: number = 30 * 60 * 1000): void {
|
||||
const now = Date.now();
|
||||
for (const [sessionId, session] of this.sessions.entries()) {
|
||||
if (session.status !== 'running' && session.finishedAt) {
|
||||
if (now - session.finishedAt.getTime() > maxAgeMs) {
|
||||
this.sessions.delete(sessionId);
|
||||
logger.debug(`Cleaned up old test session: ${sessionId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel all running test sessions (for cleanup)
|
||||
*/
|
||||
async cancelAll(): Promise<void> {
|
||||
logger.info(`Cancelling all ${this.sessions.size} test sessions`);
|
||||
|
||||
for (const session of this.sessions.values()) {
|
||||
if (session.status === 'running') {
|
||||
await this.stopTests(session.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup service resources
|
||||
*/
|
||||
async cleanup(): Promise<void> {
|
||||
await this.cancelAll();
|
||||
this.sessions.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let testRunnerServiceInstance: TestRunnerService | null = null;
|
||||
|
||||
export function getTestRunnerService(): TestRunnerService {
|
||||
if (!testRunnerServiceInstance) {
|
||||
testRunnerServiceInstance = new TestRunnerService();
|
||||
}
|
||||
return testRunnerServiceInstance;
|
||||
}
|
||||
|
||||
// Cleanup on process exit
|
||||
process.on('SIGTERM', () => {
|
||||
if (testRunnerServiceInstance) {
|
||||
testRunnerServiceInstance.cleanup().catch((err) => {
|
||||
logger.error('Cleanup failed on SIGTERM:', err);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
if (testRunnerServiceInstance) {
|
||||
testRunnerServiceInstance.cleanup().catch((err) => {
|
||||
logger.error('Cleanup failed on SIGINT:', err);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Export the class for testing purposes
|
||||
export { TestRunnerService };
|
||||
@@ -4,6 +4,7 @@ import { ClaudeProvider } from '@/providers/claude-provider.js';
|
||||
import { CursorProvider } from '@/providers/cursor-provider.js';
|
||||
import { CodexProvider } from '@/providers/codex-provider.js';
|
||||
import { OpencodeProvider } from '@/providers/opencode-provider.js';
|
||||
import { GeminiProvider } from '@/providers/gemini-provider.js';
|
||||
|
||||
describe('provider-factory.ts', () => {
|
||||
let consoleSpy: any;
|
||||
@@ -11,6 +12,7 @@ describe('provider-factory.ts', () => {
|
||||
let detectCursorSpy: any;
|
||||
let detectCodexSpy: any;
|
||||
let detectOpencodeSpy: any;
|
||||
let detectGeminiSpy: any;
|
||||
|
||||
beforeEach(() => {
|
||||
consoleSpy = {
|
||||
@@ -30,6 +32,9 @@ describe('provider-factory.ts', () => {
|
||||
detectOpencodeSpy = vi
|
||||
.spyOn(OpencodeProvider.prototype, 'detectInstallation')
|
||||
.mockResolvedValue({ installed: true });
|
||||
detectGeminiSpy = vi
|
||||
.spyOn(GeminiProvider.prototype, 'detectInstallation')
|
||||
.mockResolvedValue({ installed: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -38,6 +43,7 @@ describe('provider-factory.ts', () => {
|
||||
detectCursorSpy.mockRestore();
|
||||
detectCodexSpy.mockRestore();
|
||||
detectOpencodeSpy.mockRestore();
|
||||
detectGeminiSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('getProviderForModel', () => {
|
||||
@@ -166,9 +172,15 @@ describe('provider-factory.ts', () => {
|
||||
expect(hasClaudeProvider).toBe(true);
|
||||
});
|
||||
|
||||
it('should return exactly 4 providers', () => {
|
||||
it('should return exactly 5 providers', () => {
|
||||
const providers = ProviderFactory.getAllProviders();
|
||||
expect(providers).toHaveLength(4);
|
||||
expect(providers).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('should include GeminiProvider', () => {
|
||||
const providers = ProviderFactory.getAllProviders();
|
||||
const hasGeminiProvider = providers.some((p) => p instanceof GeminiProvider);
|
||||
expect(hasGeminiProvider).toBe(true);
|
||||
});
|
||||
|
||||
it('should include CursorProvider', () => {
|
||||
@@ -206,7 +218,8 @@ describe('provider-factory.ts', () => {
|
||||
expect(keys).toContain('cursor');
|
||||
expect(keys).toContain('codex');
|
||||
expect(keys).toContain('opencode');
|
||||
expect(keys).toHaveLength(4);
|
||||
expect(keys).toContain('gemini');
|
||||
expect(keys).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('should include cursor status', async () => {
|
||||
|
||||
565
apps/server/tests/unit/routes/worktree/add-remote.test.ts
Normal file
565
apps/server/tests/unit/routes/worktree/add-remote.test.ts
Normal file
@@ -0,0 +1,565 @@
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import type { Request, Response } from 'express';
|
||||
import { createMockExpressContext } from '../../../utils/mocks.js';
|
||||
|
||||
// Mock child_process with importOriginal to keep other exports
|
||||
vi.mock('child_process', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('child_process')>();
|
||||
return {
|
||||
...actual,
|
||||
execFile: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock util.promisify to return the function as-is so we can mock execFile
|
||||
vi.mock('util', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('util')>();
|
||||
return {
|
||||
...actual,
|
||||
promisify: (fn: unknown) => fn,
|
||||
};
|
||||
});
|
||||
|
||||
// Import handler after mocks are set up
|
||||
import { createAddRemoteHandler } from '@/routes/worktree/routes/add-remote.js';
|
||||
import { execFile } from 'child_process';
|
||||
|
||||
// Get the mocked execFile
|
||||
const mockExecFile = execFile as Mock;
|
||||
|
||||
/**
|
||||
* Helper to create a standard mock implementation for git commands
|
||||
*/
|
||||
function createGitMock(options: {
|
||||
existingRemotes?: string[];
|
||||
addRemoteFails?: boolean;
|
||||
addRemoteError?: string;
|
||||
fetchFails?: boolean;
|
||||
}): (command: string, args: string[]) => Promise<{ stdout: string; stderr: string }> {
|
||||
const {
|
||||
existingRemotes = [],
|
||||
addRemoteFails = false,
|
||||
addRemoteError = 'git remote add failed',
|
||||
fetchFails = false,
|
||||
} = options;
|
||||
|
||||
return (command: string, args: string[]) => {
|
||||
if (command === 'git' && args[0] === 'remote' && args.length === 1) {
|
||||
return Promise.resolve({ stdout: existingRemotes.join('\n'), stderr: '' });
|
||||
}
|
||||
if (command === 'git' && args[0] === 'remote' && args[1] === 'add') {
|
||||
if (addRemoteFails) {
|
||||
return Promise.reject(new Error(addRemoteError));
|
||||
}
|
||||
return Promise.resolve({ stdout: '', stderr: '' });
|
||||
}
|
||||
if (command === 'git' && args[0] === 'fetch') {
|
||||
if (fetchFails) {
|
||||
return Promise.reject(new Error('fetch failed'));
|
||||
}
|
||||
return Promise.resolve({ stdout: '', stderr: '' });
|
||||
}
|
||||
return Promise.resolve({ stdout: '', stderr: '' });
|
||||
};
|
||||
}
|
||||
|
||||
describe('add-remote route', () => {
|
||||
let req: Request;
|
||||
let res: Response;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
const context = createMockExpressContext();
|
||||
req = context.req;
|
||||
res = context.res;
|
||||
});
|
||||
|
||||
describe('input validation', () => {
|
||||
it('should return 400 if worktreePath is missing', async () => {
|
||||
req.body = { remoteName: 'origin', remoteUrl: 'https://github.com/user/repo.git' };
|
||||
|
||||
const handler = createAddRemoteHandler();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'worktreePath required',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if remoteName is missing', async () => {
|
||||
req.body = { worktreePath: '/test/path', remoteUrl: 'https://github.com/user/repo.git' };
|
||||
|
||||
const handler = createAddRemoteHandler();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'remoteName required',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if remoteUrl is missing', async () => {
|
||||
req.body = { worktreePath: '/test/path', remoteName: 'origin' };
|
||||
|
||||
const handler = createAddRemoteHandler();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'remoteUrl required',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('remote name validation', () => {
|
||||
it('should return 400 for empty remote name', async () => {
|
||||
req.body = {
|
||||
worktreePath: '/test/path',
|
||||
remoteName: '',
|
||||
remoteUrl: 'https://github.com/user/repo.git',
|
||||
};
|
||||
|
||||
const handler = createAddRemoteHandler();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'remoteName required',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 for remote name starting with dash', async () => {
|
||||
req.body = {
|
||||
worktreePath: '/test/path',
|
||||
remoteName: '-invalid',
|
||||
remoteUrl: 'https://github.com/user/repo.git',
|
||||
};
|
||||
|
||||
const handler = createAddRemoteHandler();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error:
|
||||
'Invalid remote name. Must start with alphanumeric character and contain only letters, numbers, dashes, underscores, or periods.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 for remote name starting with period', async () => {
|
||||
req.body = {
|
||||
worktreePath: '/test/path',
|
||||
remoteName: '.invalid',
|
||||
remoteUrl: 'https://github.com/user/repo.git',
|
||||
};
|
||||
|
||||
const handler = createAddRemoteHandler();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error:
|
||||
'Invalid remote name. Must start with alphanumeric character and contain only letters, numbers, dashes, underscores, or periods.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 for remote name with invalid characters', async () => {
|
||||
req.body = {
|
||||
worktreePath: '/test/path',
|
||||
remoteName: 'invalid name',
|
||||
remoteUrl: 'https://github.com/user/repo.git',
|
||||
};
|
||||
|
||||
const handler = createAddRemoteHandler();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error:
|
||||
'Invalid remote name. Must start with alphanumeric character and contain only letters, numbers, dashes, underscores, or periods.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 for remote name exceeding 250 characters', async () => {
|
||||
req.body = {
|
||||
worktreePath: '/test/path',
|
||||
remoteName: 'a'.repeat(251),
|
||||
remoteUrl: 'https://github.com/user/repo.git',
|
||||
};
|
||||
|
||||
const handler = createAddRemoteHandler();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error:
|
||||
'Invalid remote name. Must start with alphanumeric character and contain only letters, numbers, dashes, underscores, or periods.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should accept valid remote names with alphanumeric, dashes, underscores, and periods', async () => {
|
||||
req.body = {
|
||||
worktreePath: '/test/path',
|
||||
remoteName: 'my-remote_name.1',
|
||||
remoteUrl: 'https://github.com/user/repo.git',
|
||||
};
|
||||
|
||||
// Mock git remote to return empty list (no existing remotes)
|
||||
mockExecFile.mockImplementation(createGitMock({ existingRemotes: [] }));
|
||||
|
||||
const handler = createAddRemoteHandler();
|
||||
await handler(req, res);
|
||||
|
||||
// Should not return 400 for invalid name
|
||||
expect(res.status).not.toHaveBeenCalledWith(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remote URL validation', () => {
|
||||
it('should return 400 for empty remote URL', async () => {
|
||||
req.body = {
|
||||
worktreePath: '/test/path',
|
||||
remoteName: 'origin',
|
||||
remoteUrl: '',
|
||||
};
|
||||
|
||||
const handler = createAddRemoteHandler();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'remoteUrl required',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 for invalid remote URL', async () => {
|
||||
req.body = {
|
||||
worktreePath: '/test/path',
|
||||
remoteName: 'origin',
|
||||
remoteUrl: 'not-a-valid-url',
|
||||
};
|
||||
|
||||
const handler = createAddRemoteHandler();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'Invalid remote URL. Must be a valid git URL (HTTPS, SSH, or git:// protocol).',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 for URL exceeding 2048 characters', async () => {
|
||||
req.body = {
|
||||
worktreePath: '/test/path',
|
||||
remoteName: 'origin',
|
||||
remoteUrl: 'https://github.com/' + 'a'.repeat(2049) + '.git',
|
||||
};
|
||||
|
||||
const handler = createAddRemoteHandler();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'Invalid remote URL. Must be a valid git URL (HTTPS, SSH, or git:// protocol).',
|
||||
});
|
||||
});
|
||||
|
||||
it('should accept HTTPS URLs', async () => {
|
||||
req.body = {
|
||||
worktreePath: '/test/path',
|
||||
remoteName: 'origin',
|
||||
remoteUrl: 'https://github.com/user/repo.git',
|
||||
};
|
||||
|
||||
mockExecFile.mockImplementation(createGitMock({ existingRemotes: [] }));
|
||||
|
||||
const handler = createAddRemoteHandler();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).not.toHaveBeenCalledWith(400);
|
||||
});
|
||||
|
||||
it('should accept HTTP URLs', async () => {
|
||||
req.body = {
|
||||
worktreePath: '/test/path',
|
||||
remoteName: 'origin',
|
||||
remoteUrl: 'http://github.com/user/repo.git',
|
||||
};
|
||||
|
||||
mockExecFile.mockImplementation(createGitMock({ existingRemotes: [] }));
|
||||
|
||||
const handler = createAddRemoteHandler();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).not.toHaveBeenCalledWith(400);
|
||||
});
|
||||
|
||||
it('should accept SSH URLs', async () => {
|
||||
req.body = {
|
||||
worktreePath: '/test/path',
|
||||
remoteName: 'origin',
|
||||
remoteUrl: 'git@github.com:user/repo.git',
|
||||
};
|
||||
|
||||
mockExecFile.mockImplementation(createGitMock({ existingRemotes: [] }));
|
||||
|
||||
const handler = createAddRemoteHandler();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).not.toHaveBeenCalledWith(400);
|
||||
});
|
||||
|
||||
it('should accept git:// protocol URLs', async () => {
|
||||
req.body = {
|
||||
worktreePath: '/test/path',
|
||||
remoteName: 'origin',
|
||||
remoteUrl: 'git://github.com/user/repo.git',
|
||||
};
|
||||
|
||||
mockExecFile.mockImplementation(createGitMock({ existingRemotes: [] }));
|
||||
|
||||
const handler = createAddRemoteHandler();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).not.toHaveBeenCalledWith(400);
|
||||
});
|
||||
|
||||
it('should accept ssh:// protocol URLs', async () => {
|
||||
req.body = {
|
||||
worktreePath: '/test/path',
|
||||
remoteName: 'origin',
|
||||
remoteUrl: 'ssh://git@github.com/user/repo.git',
|
||||
};
|
||||
|
||||
mockExecFile.mockImplementation(createGitMock({ existingRemotes: [] }));
|
||||
|
||||
const handler = createAddRemoteHandler();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).not.toHaveBeenCalledWith(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remote already exists check', () => {
|
||||
it('should return 400 with REMOTE_EXISTS code when remote already exists', async () => {
|
||||
req.body = {
|
||||
worktreePath: '/test/path',
|
||||
remoteName: 'origin',
|
||||
remoteUrl: 'https://github.com/user/repo.git',
|
||||
};
|
||||
|
||||
mockExecFile.mockImplementation(createGitMock({ existingRemotes: ['origin', 'upstream'] }));
|
||||
|
||||
const handler = createAddRemoteHandler();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: "Remote 'origin' already exists",
|
||||
code: 'REMOTE_EXISTS',
|
||||
});
|
||||
});
|
||||
|
||||
it('should proceed if remote does not exist', async () => {
|
||||
req.body = {
|
||||
worktreePath: '/test/path',
|
||||
remoteName: 'new-remote',
|
||||
remoteUrl: 'https://github.com/user/repo.git',
|
||||
};
|
||||
|
||||
mockExecFile.mockImplementation(createGitMock({ existingRemotes: ['origin'] }));
|
||||
|
||||
const handler = createAddRemoteHandler();
|
||||
await handler(req, res);
|
||||
|
||||
// Should call git remote add with array arguments
|
||||
expect(mockExecFile).toHaveBeenCalledWith(
|
||||
'git',
|
||||
['remote', 'add', 'new-remote', 'https://github.com/user/repo.git'],
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('successful remote addition', () => {
|
||||
it('should add remote successfully with successful fetch', async () => {
|
||||
req.body = {
|
||||
worktreePath: '/test/path',
|
||||
remoteName: 'upstream',
|
||||
remoteUrl: 'https://github.com/other/repo.git',
|
||||
};
|
||||
|
||||
mockExecFile.mockImplementation(
|
||||
createGitMock({ existingRemotes: ['origin'], fetchFails: false })
|
||||
);
|
||||
|
||||
const handler = createAddRemoteHandler();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
result: {
|
||||
remoteName: 'upstream',
|
||||
remoteUrl: 'https://github.com/other/repo.git',
|
||||
fetched: true,
|
||||
message: "Successfully added remote 'upstream' and fetched its branches",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should add remote successfully even if fetch fails', async () => {
|
||||
req.body = {
|
||||
worktreePath: '/test/path',
|
||||
remoteName: 'upstream',
|
||||
remoteUrl: 'https://github.com/other/repo.git',
|
||||
};
|
||||
|
||||
mockExecFile.mockImplementation(
|
||||
createGitMock({ existingRemotes: ['origin'], fetchFails: true })
|
||||
);
|
||||
|
||||
const handler = createAddRemoteHandler();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
result: {
|
||||
remoteName: 'upstream',
|
||||
remoteUrl: 'https://github.com/other/repo.git',
|
||||
fetched: false,
|
||||
message:
|
||||
"Successfully added remote 'upstream' (fetch failed - you may need to fetch manually)",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass correct cwd option to git commands', async () => {
|
||||
req.body = {
|
||||
worktreePath: '/custom/worktree/path',
|
||||
remoteName: 'origin',
|
||||
remoteUrl: 'https://github.com/user/repo.git',
|
||||
};
|
||||
|
||||
const execCalls: { command: string; args: string[]; options: unknown }[] = [];
|
||||
mockExecFile.mockImplementation((command: string, args: string[], options: unknown) => {
|
||||
execCalls.push({ command, args, options });
|
||||
if (command === 'git' && args[0] === 'remote' && args.length === 1) {
|
||||
return Promise.resolve({ stdout: '', stderr: '' });
|
||||
}
|
||||
return Promise.resolve({ stdout: '', stderr: '' });
|
||||
});
|
||||
|
||||
const handler = createAddRemoteHandler();
|
||||
await handler(req, res);
|
||||
|
||||
// Check that git remote was called with correct cwd
|
||||
expect((execCalls[0].options as { cwd: string }).cwd).toBe('/custom/worktree/path');
|
||||
// Check that git remote add was called with correct cwd
|
||||
expect((execCalls[1].options as { cwd: string }).cwd).toBe('/custom/worktree/path');
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should return 500 when git remote add fails', async () => {
|
||||
req.body = {
|
||||
worktreePath: '/test/path',
|
||||
remoteName: 'origin',
|
||||
remoteUrl: 'https://github.com/user/repo.git',
|
||||
};
|
||||
|
||||
mockExecFile.mockImplementation(
|
||||
createGitMock({
|
||||
existingRemotes: [],
|
||||
addRemoteFails: true,
|
||||
addRemoteError: 'git remote add failed',
|
||||
})
|
||||
);
|
||||
|
||||
const handler = createAddRemoteHandler();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'git remote add failed',
|
||||
});
|
||||
});
|
||||
|
||||
it('should continue adding remote if git remote check fails', async () => {
|
||||
req.body = {
|
||||
worktreePath: '/test/path',
|
||||
remoteName: 'origin',
|
||||
remoteUrl: 'https://github.com/user/repo.git',
|
||||
};
|
||||
|
||||
mockExecFile.mockImplementation((command: string, args: string[]) => {
|
||||
if (command === 'git' && args[0] === 'remote' && args.length === 1) {
|
||||
return Promise.reject(new Error('not a git repo'));
|
||||
}
|
||||
if (command === 'git' && args[0] === 'remote' && args[1] === 'add') {
|
||||
return Promise.resolve({ stdout: '', stderr: '' });
|
||||
}
|
||||
if (command === 'git' && args[0] === 'fetch') {
|
||||
return Promise.resolve({ stdout: '', stderr: '' });
|
||||
}
|
||||
return Promise.resolve({ stdout: '', stderr: '' });
|
||||
});
|
||||
|
||||
const handler = createAddRemoteHandler();
|
||||
await handler(req, res);
|
||||
|
||||
// Should still try to add remote with array arguments
|
||||
expect(mockExecFile).toHaveBeenCalledWith(
|
||||
'git',
|
||||
['remote', 'add', 'origin', 'https://github.com/user/repo.git'],
|
||||
expect.any(Object)
|
||||
);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
result: expect.objectContaining({
|
||||
remoteName: 'origin',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle non-Error exceptions', async () => {
|
||||
req.body = {
|
||||
worktreePath: '/test/path',
|
||||
remoteName: 'origin',
|
||||
remoteUrl: 'https://github.com/user/repo.git',
|
||||
};
|
||||
|
||||
mockExecFile.mockImplementation((command: string, args: string[]) => {
|
||||
if (command === 'git' && args[0] === 'remote' && args.length === 1) {
|
||||
return Promise.resolve({ stdout: '', stderr: '' });
|
||||
}
|
||||
if (command === 'git' && args[0] === 'remote' && args[1] === 'add') {
|
||||
return Promise.reject('String error');
|
||||
}
|
||||
return Promise.resolve({ stdout: '', stderr: '' });
|
||||
});
|
||||
|
||||
const handler = createAddRemoteHandler();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
623
apps/server/tests/unit/services/feature-export-service.test.ts
Normal file
623
apps/server/tests/unit/services/feature-export-service.test.ts
Normal file
@@ -0,0 +1,623 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { FeatureExportService, FEATURE_EXPORT_VERSION } from '@/services/feature-export-service.js';
|
||||
import type { Feature, FeatureExport } from '@automaker/types';
|
||||
import type { FeatureLoader } from '@/services/feature-loader.js';
|
||||
|
||||
describe('feature-export-service.ts', () => {
|
||||
let exportService: FeatureExportService;
|
||||
let mockFeatureLoader: {
|
||||
get: ReturnType<typeof vi.fn>;
|
||||
getAll: ReturnType<typeof vi.fn>;
|
||||
create: ReturnType<typeof vi.fn>;
|
||||
update: ReturnType<typeof vi.fn>;
|
||||
generateFeatureId: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
const testProjectPath = '/test/project';
|
||||
|
||||
const sampleFeature: Feature = {
|
||||
id: 'feature-123-abc',
|
||||
title: 'Test Feature',
|
||||
category: 'UI',
|
||||
description: 'A test feature description',
|
||||
status: 'pending',
|
||||
priority: 1,
|
||||
dependencies: ['feature-456'],
|
||||
descriptionHistory: [
|
||||
{
|
||||
description: 'Initial description',
|
||||
timestamp: '2024-01-01T00:00:00.000Z',
|
||||
source: 'initial',
|
||||
},
|
||||
],
|
||||
planSpec: {
|
||||
status: 'generated',
|
||||
content: 'Plan content',
|
||||
version: 1,
|
||||
reviewedByUser: false,
|
||||
},
|
||||
imagePaths: ['/tmp/image1.png', '/tmp/image2.jpg'],
|
||||
textFilePaths: [
|
||||
{
|
||||
id: 'file-1',
|
||||
path: '/tmp/doc.txt',
|
||||
filename: 'doc.txt',
|
||||
mimeType: 'text/plain',
|
||||
content: 'Some content',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Create mock FeatureLoader instance
|
||||
mockFeatureLoader = {
|
||||
get: vi.fn(),
|
||||
getAll: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
generateFeatureId: vi.fn().mockReturnValue('feature-mock-id'),
|
||||
};
|
||||
|
||||
// Inject mock via constructor
|
||||
exportService = new FeatureExportService(mockFeatureLoader as unknown as FeatureLoader);
|
||||
});
|
||||
|
||||
describe('exportFeatureData', () => {
|
||||
it('should export feature to JSON format', () => {
|
||||
const result = exportService.exportFeatureData(sampleFeature, { format: 'json' });
|
||||
|
||||
const parsed = JSON.parse(result) as FeatureExport;
|
||||
expect(parsed.version).toBe(FEATURE_EXPORT_VERSION);
|
||||
expect(parsed.feature.id).toBe(sampleFeature.id);
|
||||
expect(parsed.feature.title).toBe(sampleFeature.title);
|
||||
expect(parsed.exportedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('should export feature to YAML format', () => {
|
||||
const result = exportService.exportFeatureData(sampleFeature, { format: 'yaml' });
|
||||
|
||||
expect(result).toContain('version:');
|
||||
expect(result).toContain('feature:');
|
||||
expect(result).toContain('Test Feature');
|
||||
expect(result).toContain('exportedAt:');
|
||||
});
|
||||
|
||||
it('should exclude description history when option is false', () => {
|
||||
const result = exportService.exportFeatureData(sampleFeature, {
|
||||
format: 'json',
|
||||
includeHistory: false,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result) as FeatureExport;
|
||||
expect(parsed.feature.descriptionHistory).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should include description history by default', () => {
|
||||
const result = exportService.exportFeatureData(sampleFeature, { format: 'json' });
|
||||
|
||||
const parsed = JSON.parse(result) as FeatureExport;
|
||||
expect(parsed.feature.descriptionHistory).toBeDefined();
|
||||
expect(parsed.feature.descriptionHistory).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should exclude plan spec when option is false', () => {
|
||||
const result = exportService.exportFeatureData(sampleFeature, {
|
||||
format: 'json',
|
||||
includePlanSpec: false,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result) as FeatureExport;
|
||||
expect(parsed.feature.planSpec).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should include plan spec by default', () => {
|
||||
const result = exportService.exportFeatureData(sampleFeature, { format: 'json' });
|
||||
|
||||
const parsed = JSON.parse(result) as FeatureExport;
|
||||
expect(parsed.feature.planSpec).toBeDefined();
|
||||
});
|
||||
|
||||
it('should include metadata when provided', () => {
|
||||
const result = exportService.exportFeatureData(sampleFeature, {
|
||||
format: 'json',
|
||||
metadata: { projectName: 'TestProject', branch: 'main' },
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result) as FeatureExport;
|
||||
expect(parsed.metadata).toEqual({ projectName: 'TestProject', branch: 'main' });
|
||||
});
|
||||
|
||||
it('should include exportedBy when provided', () => {
|
||||
const result = exportService.exportFeatureData(sampleFeature, {
|
||||
format: 'json',
|
||||
exportedBy: 'test-user',
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result) as FeatureExport;
|
||||
expect(parsed.exportedBy).toBe('test-user');
|
||||
});
|
||||
|
||||
it('should remove transient fields (titleGenerating, error)', () => {
|
||||
const featureWithTransient: Feature = {
|
||||
...sampleFeature,
|
||||
titleGenerating: true,
|
||||
error: 'Some error',
|
||||
};
|
||||
|
||||
const result = exportService.exportFeatureData(featureWithTransient, { format: 'json' });
|
||||
|
||||
const parsed = JSON.parse(result) as FeatureExport;
|
||||
expect(parsed.feature.titleGenerating).toBeUndefined();
|
||||
expect(parsed.feature.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should support compact JSON (prettyPrint: false)', () => {
|
||||
const prettyResult = exportService.exportFeatureData(sampleFeature, {
|
||||
format: 'json',
|
||||
prettyPrint: true,
|
||||
});
|
||||
const compactResult = exportService.exportFeatureData(sampleFeature, {
|
||||
format: 'json',
|
||||
prettyPrint: false,
|
||||
});
|
||||
|
||||
// Compact should have no newlines/indentation
|
||||
expect(compactResult).not.toContain('\n');
|
||||
// Pretty should have newlines
|
||||
expect(prettyResult).toContain('\n');
|
||||
});
|
||||
});
|
||||
|
||||
describe('exportFeature', () => {
|
||||
it('should fetch and export feature by ID', async () => {
|
||||
mockFeatureLoader.get.mockResolvedValue(sampleFeature);
|
||||
|
||||
const result = await exportService.exportFeature(testProjectPath, 'feature-123-abc');
|
||||
|
||||
expect(mockFeatureLoader.get).toHaveBeenCalledWith(testProjectPath, 'feature-123-abc');
|
||||
const parsed = JSON.parse(result) as FeatureExport;
|
||||
expect(parsed.feature.id).toBe(sampleFeature.id);
|
||||
});
|
||||
|
||||
it('should throw when feature not found', async () => {
|
||||
mockFeatureLoader.get.mockResolvedValue(null);
|
||||
|
||||
await expect(exportService.exportFeature(testProjectPath, 'nonexistent')).rejects.toThrow(
|
||||
'Feature nonexistent not found'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('exportFeatures', () => {
|
||||
const features: Feature[] = [
|
||||
{ ...sampleFeature, id: 'feature-1', category: 'UI' },
|
||||
{ ...sampleFeature, id: 'feature-2', category: 'Backend', status: 'completed' },
|
||||
{ ...sampleFeature, id: 'feature-3', category: 'UI', status: 'pending' },
|
||||
];
|
||||
|
||||
it('should export all features', async () => {
|
||||
mockFeatureLoader.getAll.mockResolvedValue(features);
|
||||
|
||||
const result = await exportService.exportFeatures(testProjectPath);
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.count).toBe(3);
|
||||
expect(parsed.features).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should filter by category', async () => {
|
||||
mockFeatureLoader.getAll.mockResolvedValue(features);
|
||||
|
||||
const result = await exportService.exportFeatures(testProjectPath, { category: 'UI' });
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.count).toBe(2);
|
||||
expect(parsed.features.every((f: FeatureExport) => f.feature.category === 'UI')).toBe(true);
|
||||
});
|
||||
|
||||
it('should filter by status', async () => {
|
||||
mockFeatureLoader.getAll.mockResolvedValue(features);
|
||||
|
||||
const result = await exportService.exportFeatures(testProjectPath, { status: 'completed' });
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.count).toBe(1);
|
||||
expect(parsed.features[0].feature.status).toBe('completed');
|
||||
});
|
||||
|
||||
it('should filter by feature IDs', async () => {
|
||||
mockFeatureLoader.getAll.mockResolvedValue(features);
|
||||
|
||||
const result = await exportService.exportFeatures(testProjectPath, {
|
||||
featureIds: ['feature-1', 'feature-3'],
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.count).toBe(2);
|
||||
const ids = parsed.features.map((f: FeatureExport) => f.feature.id);
|
||||
expect(ids).toContain('feature-1');
|
||||
expect(ids).toContain('feature-3');
|
||||
expect(ids).not.toContain('feature-2');
|
||||
});
|
||||
|
||||
it('should export to YAML format', async () => {
|
||||
mockFeatureLoader.getAll.mockResolvedValue(features);
|
||||
|
||||
const result = await exportService.exportFeatures(testProjectPath, { format: 'yaml' });
|
||||
|
||||
expect(result).toContain('version:');
|
||||
expect(result).toContain('count:');
|
||||
expect(result).toContain('features:');
|
||||
});
|
||||
|
||||
it('should include metadata when provided', async () => {
|
||||
mockFeatureLoader.getAll.mockResolvedValue(features);
|
||||
|
||||
const result = await exportService.exportFeatures(testProjectPath, {
|
||||
metadata: { projectName: 'TestProject' },
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.metadata).toEqual({ projectName: 'TestProject' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseImportData', () => {
|
||||
it('should parse valid JSON', () => {
|
||||
const json = JSON.stringify(sampleFeature);
|
||||
const result = exportService.parseImportData(json);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect((result as Feature).id).toBe(sampleFeature.id);
|
||||
});
|
||||
|
||||
it('should parse valid YAML', () => {
|
||||
const yaml = `
|
||||
id: feature-yaml-123
|
||||
title: YAML Feature
|
||||
category: Testing
|
||||
description: A YAML feature
|
||||
`;
|
||||
const result = exportService.parseImportData(yaml);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect((result as Feature).id).toBe('feature-yaml-123');
|
||||
expect((result as Feature).title).toBe('YAML Feature');
|
||||
});
|
||||
|
||||
it('should return null for invalid data', () => {
|
||||
const result = exportService.parseImportData('not valid {json} or yaml: [');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should parse FeatureExport wrapper', () => {
|
||||
const exportData: FeatureExport = {
|
||||
version: '1.0.0',
|
||||
feature: sampleFeature,
|
||||
exportedAt: new Date().toISOString(),
|
||||
};
|
||||
const json = JSON.stringify(exportData);
|
||||
|
||||
const result = exportService.parseImportData(json) as FeatureExport;
|
||||
|
||||
expect(result.version).toBe('1.0.0');
|
||||
expect(result.feature.id).toBe(sampleFeature.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectFormat', () => {
|
||||
it('should detect JSON format', () => {
|
||||
const json = JSON.stringify({ id: 'test' });
|
||||
expect(exportService.detectFormat(json)).toBe('json');
|
||||
});
|
||||
|
||||
it('should detect YAML format', () => {
|
||||
const yaml = `
|
||||
id: test
|
||||
title: Test
|
||||
`;
|
||||
expect(exportService.detectFormat(yaml)).toBe('yaml');
|
||||
});
|
||||
|
||||
it('should detect YAML for plain text (YAML is very permissive)', () => {
|
||||
// YAML parses any plain text as a string, so this is detected as valid YAML
|
||||
// The actual validation happens in parseImportData which checks for required fields
|
||||
expect(exportService.detectFormat('not valid {[')).toBe('yaml');
|
||||
});
|
||||
|
||||
it('should handle whitespace', () => {
|
||||
const json = ' { "id": "test" } ';
|
||||
expect(exportService.detectFormat(json)).toBe('json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('importFeature', () => {
|
||||
it('should import feature from raw Feature data', async () => {
|
||||
mockFeatureLoader.get.mockResolvedValue(null);
|
||||
mockFeatureLoader.create.mockResolvedValue(sampleFeature);
|
||||
|
||||
const result = await exportService.importFeature(testProjectPath, {
|
||||
data: sampleFeature,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.featureId).toBe(sampleFeature.id);
|
||||
expect(mockFeatureLoader.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should import feature from FeatureExport wrapper', async () => {
|
||||
mockFeatureLoader.get.mockResolvedValue(null);
|
||||
mockFeatureLoader.create.mockResolvedValue(sampleFeature);
|
||||
|
||||
const exportData: FeatureExport = {
|
||||
version: '1.0.0',
|
||||
feature: sampleFeature,
|
||||
exportedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const result = await exportService.importFeature(testProjectPath, {
|
||||
data: exportData,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.featureId).toBe(sampleFeature.id);
|
||||
});
|
||||
|
||||
it('should use custom ID when provided', async () => {
|
||||
mockFeatureLoader.get.mockResolvedValue(null);
|
||||
mockFeatureLoader.create.mockImplementation(async (_, data) => ({
|
||||
...sampleFeature,
|
||||
id: data.id!,
|
||||
}));
|
||||
|
||||
const result = await exportService.importFeature(testProjectPath, {
|
||||
data: sampleFeature,
|
||||
newId: 'custom-id-123',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.featureId).toBe('custom-id-123');
|
||||
});
|
||||
|
||||
it('should fail when feature exists and overwrite is false', async () => {
|
||||
mockFeatureLoader.get.mockResolvedValue(sampleFeature);
|
||||
|
||||
const result = await exportService.importFeature(testProjectPath, {
|
||||
data: sampleFeature,
|
||||
overwrite: false,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain(
|
||||
`Feature with ID ${sampleFeature.id} already exists. Set overwrite: true to replace.`
|
||||
);
|
||||
});
|
||||
|
||||
it('should overwrite when overwrite is true', async () => {
|
||||
mockFeatureLoader.get.mockResolvedValue(sampleFeature);
|
||||
mockFeatureLoader.update.mockResolvedValue(sampleFeature);
|
||||
|
||||
const result = await exportService.importFeature(testProjectPath, {
|
||||
data: sampleFeature,
|
||||
overwrite: true,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.wasOverwritten).toBe(true);
|
||||
expect(mockFeatureLoader.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should apply target category override', async () => {
|
||||
mockFeatureLoader.get.mockResolvedValue(null);
|
||||
mockFeatureLoader.create.mockImplementation(async (_, data) => ({
|
||||
...sampleFeature,
|
||||
...data,
|
||||
}));
|
||||
|
||||
await exportService.importFeature(testProjectPath, {
|
||||
data: sampleFeature,
|
||||
targetCategory: 'NewCategory',
|
||||
});
|
||||
|
||||
const createCall = mockFeatureLoader.create.mock.calls[0];
|
||||
expect(createCall[1].category).toBe('NewCategory');
|
||||
});
|
||||
|
||||
it('should clear branch info when preserveBranchInfo is false', async () => {
|
||||
const featureWithBranch: Feature = {
|
||||
...sampleFeature,
|
||||
branchName: 'feature/test-branch',
|
||||
};
|
||||
mockFeatureLoader.get.mockResolvedValue(null);
|
||||
mockFeatureLoader.create.mockImplementation(async (_, data) => ({
|
||||
...featureWithBranch,
|
||||
...data,
|
||||
}));
|
||||
|
||||
await exportService.importFeature(testProjectPath, {
|
||||
data: featureWithBranch,
|
||||
preserveBranchInfo: false,
|
||||
});
|
||||
|
||||
const createCall = mockFeatureLoader.create.mock.calls[0];
|
||||
expect(createCall[1].branchName).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should preserve branch info when preserveBranchInfo is true', async () => {
|
||||
const featureWithBranch: Feature = {
|
||||
...sampleFeature,
|
||||
branchName: 'feature/test-branch',
|
||||
};
|
||||
mockFeatureLoader.get.mockResolvedValue(null);
|
||||
mockFeatureLoader.create.mockImplementation(async (_, data) => ({
|
||||
...featureWithBranch,
|
||||
...data,
|
||||
}));
|
||||
|
||||
await exportService.importFeature(testProjectPath, {
|
||||
data: featureWithBranch,
|
||||
preserveBranchInfo: true,
|
||||
});
|
||||
|
||||
const createCall = mockFeatureLoader.create.mock.calls[0];
|
||||
expect(createCall[1].branchName).toBe('feature/test-branch');
|
||||
});
|
||||
|
||||
it('should warn and clear image paths', async () => {
|
||||
mockFeatureLoader.get.mockResolvedValue(null);
|
||||
mockFeatureLoader.create.mockResolvedValue(sampleFeature);
|
||||
|
||||
const result = await exportService.importFeature(testProjectPath, {
|
||||
data: sampleFeature,
|
||||
});
|
||||
|
||||
expect(result.warnings).toBeDefined();
|
||||
expect(result.warnings).toContainEqual(expect.stringContaining('image path'));
|
||||
const createCall = mockFeatureLoader.create.mock.calls[0];
|
||||
expect(createCall[1].imagePaths).toEqual([]);
|
||||
});
|
||||
|
||||
it('should warn and clear text file paths', async () => {
|
||||
mockFeatureLoader.get.mockResolvedValue(null);
|
||||
mockFeatureLoader.create.mockResolvedValue(sampleFeature);
|
||||
|
||||
const result = await exportService.importFeature(testProjectPath, {
|
||||
data: sampleFeature,
|
||||
});
|
||||
|
||||
expect(result.warnings).toBeDefined();
|
||||
expect(result.warnings).toContainEqual(expect.stringContaining('text file path'));
|
||||
const createCall = mockFeatureLoader.create.mock.calls[0];
|
||||
expect(createCall[1].textFilePaths).toEqual([]);
|
||||
});
|
||||
|
||||
it('should fail with validation error for missing required fields', async () => {
|
||||
const invalidFeature = {
|
||||
id: 'feature-invalid',
|
||||
// Missing description, title, and category
|
||||
} as Feature;
|
||||
|
||||
const result = await exportService.importFeature(testProjectPath, {
|
||||
data: invalidFeature,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toBeDefined();
|
||||
expect(result.errors!.some((e) => e.includes('title or description'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should generate ID when none provided', async () => {
|
||||
const featureWithoutId = {
|
||||
title: 'No ID Feature',
|
||||
category: 'Testing',
|
||||
description: 'Feature without ID',
|
||||
} as Feature;
|
||||
|
||||
mockFeatureLoader.get.mockResolvedValue(null);
|
||||
mockFeatureLoader.create.mockImplementation(async (_, data) => ({
|
||||
...featureWithoutId,
|
||||
id: data.id!,
|
||||
}));
|
||||
|
||||
const result = await exportService.importFeature(testProjectPath, {
|
||||
data: featureWithoutId,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.featureId).toBe('feature-mock-id');
|
||||
});
|
||||
});
|
||||
|
||||
describe('importFeatures', () => {
|
||||
const bulkExport = {
|
||||
version: '1.0.0',
|
||||
exportedAt: new Date().toISOString(),
|
||||
count: 2,
|
||||
features: [
|
||||
{
|
||||
version: '1.0.0',
|
||||
feature: { ...sampleFeature, id: 'feature-1' },
|
||||
exportedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
version: '1.0.0',
|
||||
feature: { ...sampleFeature, id: 'feature-2' },
|
||||
exportedAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it('should import multiple features from JSON string', async () => {
|
||||
mockFeatureLoader.get.mockResolvedValue(null);
|
||||
mockFeatureLoader.create.mockImplementation(async (_, data) => ({
|
||||
...sampleFeature,
|
||||
id: data.id!,
|
||||
}));
|
||||
|
||||
const results = await exportService.importFeatures(
|
||||
testProjectPath,
|
||||
JSON.stringify(bulkExport)
|
||||
);
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0].success).toBe(true);
|
||||
expect(results[1].success).toBe(true);
|
||||
});
|
||||
|
||||
it('should import multiple features from parsed data', async () => {
|
||||
mockFeatureLoader.get.mockResolvedValue(null);
|
||||
mockFeatureLoader.create.mockImplementation(async (_, data) => ({
|
||||
...sampleFeature,
|
||||
id: data.id!,
|
||||
}));
|
||||
|
||||
const results = await exportService.importFeatures(testProjectPath, bulkExport);
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results.every((r) => r.success)).toBe(true);
|
||||
});
|
||||
|
||||
it('should apply options to all features', async () => {
|
||||
mockFeatureLoader.get.mockResolvedValue(null);
|
||||
mockFeatureLoader.create.mockImplementation(async (_, data) => ({
|
||||
...sampleFeature,
|
||||
...data,
|
||||
}));
|
||||
|
||||
await exportService.importFeatures(testProjectPath, bulkExport, {
|
||||
targetCategory: 'ImportedCategory',
|
||||
});
|
||||
|
||||
const createCalls = mockFeatureLoader.create.mock.calls;
|
||||
expect(createCalls[0][1].category).toBe('ImportedCategory');
|
||||
expect(createCalls[1][1].category).toBe('ImportedCategory');
|
||||
});
|
||||
|
||||
it('should return error for invalid bulk format', async () => {
|
||||
const results = await exportService.importFeatures(testProjectPath, '{ "invalid": "data" }');
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].success).toBe(false);
|
||||
expect(results[0].errors).toContainEqual(expect.stringContaining('Invalid bulk import data'));
|
||||
});
|
||||
|
||||
it('should handle partial failures', async () => {
|
||||
mockFeatureLoader.get.mockResolvedValueOnce(null).mockResolvedValueOnce(sampleFeature); // Second feature exists
|
||||
|
||||
mockFeatureLoader.create.mockImplementation(async (_, data) => ({
|
||||
...sampleFeature,
|
||||
id: data.id!,
|
||||
}));
|
||||
|
||||
const results = await exportService.importFeatures(testProjectPath, bulkExport, {
|
||||
overwrite: false,
|
||||
});
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0].success).toBe(true);
|
||||
expect(results[1].success).toBe(false); // Exists without overwrite
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -788,6 +788,367 @@ describe('pipeline-service.ts', () => {
|
||||
const nextStatus = pipelineService.getNextStatus('in_progress', config, false);
|
||||
expect(nextStatus).toBe('pipeline_step1'); // Should use step1 (order 0), not step2
|
||||
});
|
||||
|
||||
describe('with exclusions', () => {
|
||||
it('should skip excluded step when coming from in_progress', () => {
|
||||
const config: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'step2',
|
||||
name: 'Step 2',
|
||||
order: 1,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'green',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const nextStatus = pipelineService.getNextStatus('in_progress', config, false, ['step1']);
|
||||
expect(nextStatus).toBe('pipeline_step2'); // Should skip step1 and go to step2
|
||||
});
|
||||
|
||||
it('should skip excluded step when moving between steps', () => {
|
||||
const config: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'step2',
|
||||
name: 'Step 2',
|
||||
order: 1,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'green',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'step3',
|
||||
name: 'Step 3',
|
||||
order: 2,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'red',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, false, [
|
||||
'step2',
|
||||
]);
|
||||
expect(nextStatus).toBe('pipeline_step3'); // Should skip step2 and go to step3
|
||||
});
|
||||
|
||||
it('should go to final status when all remaining steps are excluded', () => {
|
||||
const config: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'step2',
|
||||
name: 'Step 2',
|
||||
order: 1,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'green',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, false, [
|
||||
'step2',
|
||||
]);
|
||||
expect(nextStatus).toBe('verified'); // No more steps after exclusion
|
||||
});
|
||||
|
||||
it('should go to waiting_approval when all remaining steps excluded and skipTests is true', () => {
|
||||
const config: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'step2',
|
||||
name: 'Step 2',
|
||||
order: 1,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'green',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, true, ['step2']);
|
||||
expect(nextStatus).toBe('waiting_approval');
|
||||
});
|
||||
|
||||
it('should go to final status when all steps are excluded from in_progress', () => {
|
||||
const config: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'step2',
|
||||
name: 'Step 2',
|
||||
order: 1,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'green',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const nextStatus = pipelineService.getNextStatus('in_progress', config, false, [
|
||||
'step1',
|
||||
'step2',
|
||||
]);
|
||||
expect(nextStatus).toBe('verified');
|
||||
});
|
||||
|
||||
it('should handle empty exclusions array like no exclusions', () => {
|
||||
const config: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const nextStatus = pipelineService.getNextStatus('in_progress', config, false, []);
|
||||
expect(nextStatus).toBe('pipeline_step1');
|
||||
});
|
||||
|
||||
it('should handle undefined exclusions like no exclusions', () => {
|
||||
const config: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const nextStatus = pipelineService.getNextStatus('in_progress', config, false, undefined);
|
||||
expect(nextStatus).toBe('pipeline_step1');
|
||||
});
|
||||
|
||||
it('should skip multiple excluded steps in sequence', () => {
|
||||
const config: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'step2',
|
||||
name: 'Step 2',
|
||||
order: 1,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'green',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'step3',
|
||||
name: 'Step 3',
|
||||
order: 2,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'red',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'step4',
|
||||
name: 'Step 4',
|
||||
order: 3,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'yellow',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Exclude step2 and step3
|
||||
const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, false, [
|
||||
'step2',
|
||||
'step3',
|
||||
]);
|
||||
expect(nextStatus).toBe('pipeline_step4'); // Should skip step2 and step3
|
||||
});
|
||||
|
||||
it('should handle exclusion of non-existent step IDs gracefully', () => {
|
||||
const config: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'step2',
|
||||
name: 'Step 2',
|
||||
order: 1,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'green',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Exclude a non-existent step - should have no effect
|
||||
const nextStatus = pipelineService.getNextStatus('in_progress', config, false, [
|
||||
'nonexistent',
|
||||
]);
|
||||
expect(nextStatus).toBe('pipeline_step1');
|
||||
});
|
||||
|
||||
it('should find next valid step when current step becomes excluded mid-flow', () => {
|
||||
const config: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'step2',
|
||||
name: 'Step 2',
|
||||
order: 1,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'green',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'step3',
|
||||
name: 'Step 3',
|
||||
order: 2,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'red',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Feature is at step1 but step1 is now excluded - should find next valid step
|
||||
const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, false, [
|
||||
'step1',
|
||||
'step2',
|
||||
]);
|
||||
expect(nextStatus).toBe('pipeline_step3');
|
||||
});
|
||||
|
||||
it('should go to final status when current step is excluded and no steps remain', () => {
|
||||
const config: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'step2',
|
||||
name: 'Step 2',
|
||||
order: 1,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'green',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Feature is at step1 but both steps are excluded
|
||||
const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, false, [
|
||||
'step1',
|
||||
'step2',
|
||||
]);
|
||||
expect(nextStatus).toBe('verified');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStep', () => {
|
||||
|
||||
@@ -102,6 +102,8 @@
|
||||
"react-markdown": "10.1.0",
|
||||
"react-resizable-panels": "3.0.6",
|
||||
"rehype-raw": "7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "2.0.7",
|
||||
"tailwind-merge": "3.4.0",
|
||||
"usehooks-ts": "3.1.1",
|
||||
|
||||
@@ -58,7 +58,7 @@ const E2E_SETTINGS = {
|
||||
featureGenerationModel: { model: 'sonnet' },
|
||||
backlogPlanningModel: { model: 'sonnet' },
|
||||
projectAnalysisModel: { model: 'sonnet' },
|
||||
suggestionsModel: { model: 'sonnet' },
|
||||
ideationModel: { model: 'sonnet' },
|
||||
},
|
||||
enhancementModel: 'sonnet',
|
||||
validationModel: 'opus',
|
||||
|
||||
@@ -25,7 +25,7 @@ export function CollapseToggleButton({
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className={cn(
|
||||
'flex absolute top-[68px] -right-3 z-9999',
|
||||
'flex absolute top-[40px] -right-3.5 z-9999',
|
||||
'group/toggle items-center justify-center w-7 h-7 rounded-full',
|
||||
// Glass morphism button
|
||||
'bg-card/95 backdrop-blur-sm border border-border/80',
|
||||
|
||||
@@ -1,13 +1,31 @@
|
||||
import { useCallback } from 'react';
|
||||
import type { NavigateOptions } from '@tanstack/react-router';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatShortcut } from '@/store/app-store';
|
||||
import { Activity, Settings } from 'lucide-react';
|
||||
import { Activity, Settings, BookOpen, MessageSquare, ExternalLink } from 'lucide-react';
|
||||
import { useOSDetection } from '@/hooks/use-os-detection';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
|
||||
function getOSAbbreviation(os: string): string {
|
||||
switch (os) {
|
||||
case 'mac':
|
||||
return 'M';
|
||||
case 'windows':
|
||||
return 'W';
|
||||
case 'linux':
|
||||
return 'L';
|
||||
default:
|
||||
return '?';
|
||||
}
|
||||
}
|
||||
|
||||
interface SidebarFooterProps {
|
||||
sidebarOpen: boolean;
|
||||
isActiveRoute: (id: string) => boolean;
|
||||
navigate: (opts: NavigateOptions) => void;
|
||||
hideRunningAgents: boolean;
|
||||
hideWiki: boolean;
|
||||
runningAgentsCount: number;
|
||||
shortcuts: {
|
||||
settings: string;
|
||||
@@ -19,86 +37,225 @@ export function SidebarFooter({
|
||||
isActiveRoute,
|
||||
navigate,
|
||||
hideRunningAgents,
|
||||
hideWiki,
|
||||
runningAgentsCount,
|
||||
shortcuts,
|
||||
}: SidebarFooterProps) {
|
||||
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0';
|
||||
const { os } = useOSDetection();
|
||||
const appMode = import.meta.env.VITE_APP_MODE || '?';
|
||||
const versionSuffix = `${getOSAbbreviation(os)}${appMode}`;
|
||||
|
||||
const handleWikiClick = useCallback(() => {
|
||||
navigate({ to: '/wiki' });
|
||||
}, [navigate]);
|
||||
|
||||
const handleFeedbackClick = useCallback(() => {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
api.openExternalLink('https://github.com/AutoMaker-Org/automaker/issues');
|
||||
} catch {
|
||||
// Fallback for non-Electron environments (SSR, web browser)
|
||||
window.open('https://github.com/AutoMaker-Org/automaker/issues', '_blank');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Collapsed state
|
||||
if (!sidebarOpen) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'shrink-0 border-t border-border/40',
|
||||
'bg-gradient-to-t from-background/10 via-sidebar/50 to-transparent'
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col items-center py-2 px-2 gap-1">
|
||||
{/* Running Agents */}
|
||||
{!hideRunningAgents && (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => navigate({ to: '/running-agents' })}
|
||||
className={cn(
|
||||
'relative flex items-center justify-center w-10 h-10 rounded-xl',
|
||||
'transition-all duration-200 ease-out titlebar-no-drag',
|
||||
isActiveRoute('running-agents')
|
||||
? [
|
||||
'bg-gradient-to-r from-brand-500/20 via-brand-500/15 to-brand-600/10',
|
||||
'text-foreground border border-brand-500/30',
|
||||
'shadow-md shadow-brand-500/10',
|
||||
]
|
||||
: [
|
||||
'text-muted-foreground hover:text-foreground',
|
||||
'hover:bg-accent/50 border border-transparent hover:border-border/40',
|
||||
]
|
||||
)}
|
||||
data-testid="running-agents-link"
|
||||
>
|
||||
<Activity
|
||||
className={cn(
|
||||
'w-[18px] h-[18px]',
|
||||
isActiveRoute('running-agents') && 'text-brand-500'
|
||||
)}
|
||||
/>
|
||||
{runningAgentsCount > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
'absolute -top-1 -right-1 flex items-center justify-center',
|
||||
'min-w-4 h-4 px-1 text-[9px] font-bold rounded-full',
|
||||
'bg-brand-500 text-white shadow-sm'
|
||||
)}
|
||||
>
|
||||
{runningAgentsCount > 99 ? '99' : runningAgentsCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
Running Agents
|
||||
{runningAgentsCount > 0 && (
|
||||
<span className="ml-2 px-1.5 py-0.5 bg-brand-500 text-white rounded-full text-[10px]">
|
||||
{runningAgentsCount}
|
||||
</span>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{/* Settings */}
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => navigate({ to: '/settings' })}
|
||||
className={cn(
|
||||
'flex items-center justify-center w-10 h-10 rounded-xl',
|
||||
'transition-all duration-200 ease-out titlebar-no-drag',
|
||||
isActiveRoute('settings')
|
||||
? [
|
||||
'bg-gradient-to-r from-brand-500/20 via-brand-500/15 to-brand-600/10',
|
||||
'text-foreground border border-brand-500/30',
|
||||
'shadow-md shadow-brand-500/10',
|
||||
]
|
||||
: [
|
||||
'text-muted-foreground hover:text-foreground',
|
||||
'hover:bg-accent/50 border border-transparent hover:border-border/40',
|
||||
]
|
||||
)}
|
||||
data-testid="settings-button"
|
||||
>
|
||||
<Settings
|
||||
className={cn(
|
||||
'w-[18px] h-[18px]',
|
||||
isActiveRoute('settings') && 'text-brand-500'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
Global Settings
|
||||
<span className="ml-2 px-1.5 py-0.5 bg-muted rounded text-[10px] font-mono text-muted-foreground">
|
||||
{formatShortcut(shortcuts.settings, true)}
|
||||
</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
{/* Documentation */}
|
||||
{!hideWiki && (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={handleWikiClick}
|
||||
className={cn(
|
||||
'flex items-center justify-center w-10 h-10 rounded-xl',
|
||||
'text-muted-foreground hover:text-foreground',
|
||||
'hover:bg-accent/50 border border-transparent hover:border-border/40',
|
||||
'transition-all duration-200 ease-out titlebar-no-drag'
|
||||
)}
|
||||
data-testid="documentation-button"
|
||||
>
|
||||
<BookOpen className="w-[18px] h-[18px]" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
Documentation
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{/* Feedback */}
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={handleFeedbackClick}
|
||||
className={cn(
|
||||
'flex items-center justify-center w-10 h-10 rounded-xl',
|
||||
'text-muted-foreground hover:text-foreground',
|
||||
'hover:bg-accent/50 border border-transparent hover:border-border/40',
|
||||
'transition-all duration-200 ease-out titlebar-no-drag'
|
||||
)}
|
||||
data-testid="feedback-button"
|
||||
>
|
||||
<MessageSquare className="w-[18px] h-[18px]" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
Feedback
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Expanded state
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'shrink-0',
|
||||
// Top border with gradient fade
|
||||
'border-t border-border/40',
|
||||
// Elevated background for visual separation
|
||||
'bg-gradient-to-t from-background/10 via-sidebar/50 to-transparent'
|
||||
)}
|
||||
>
|
||||
<div className="shrink-0">
|
||||
{/* Running Agents Link */}
|
||||
{!hideRunningAgents && (
|
||||
<div className="p-2 pb-0">
|
||||
<div className="px-3 py-0.5">
|
||||
<button
|
||||
onClick={() => navigate({ to: '/running-agents' })}
|
||||
className={cn(
|
||||
'group flex items-center w-full px-3 py-2.5 rounded-xl relative overflow-hidden titlebar-no-drag',
|
||||
'group flex items-center w-full px-3 py-2 rounded-lg relative overflow-hidden titlebar-no-drag',
|
||||
'transition-all duration-200 ease-out',
|
||||
isActiveRoute('running-agents')
|
||||
? [
|
||||
'bg-gradient-to-r from-brand-500/20 via-brand-500/15 to-brand-600/10',
|
||||
'text-foreground font-medium',
|
||||
'border border-brand-500/30',
|
||||
'shadow-md shadow-brand-500/10',
|
||||
'shadow-sm shadow-brand-500/10',
|
||||
]
|
||||
: [
|
||||
'text-muted-foreground hover:text-foreground',
|
||||
'hover:bg-accent/50',
|
||||
'border border-transparent hover:border-border/40',
|
||||
'hover:shadow-sm',
|
||||
],
|
||||
sidebarOpen ? 'justify-start' : 'justify-center',
|
||||
'hover:scale-[1.02] active:scale-[0.97]'
|
||||
]
|
||||
)}
|
||||
title={!sidebarOpen ? 'Running Agents' : undefined}
|
||||
data-testid="running-agents-link"
|
||||
>
|
||||
<div className="relative">
|
||||
<Activity
|
||||
className={cn(
|
||||
'w-[18px] h-[18px] shrink-0 transition-all duration-200',
|
||||
isActiveRoute('running-agents')
|
||||
? 'text-brand-500 drop-shadow-sm'
|
||||
: 'group-hover:text-brand-400 group-hover:scale-110'
|
||||
)}
|
||||
/>
|
||||
{/* Running agents count badge - shown in collapsed state */}
|
||||
{!sidebarOpen && runningAgentsCount > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
'absolute -top-1.5 -right-1.5 flex items-center justify-center',
|
||||
'min-w-4 h-4 px-1 text-[9px] font-bold rounded-full',
|
||||
'bg-brand-500 text-white shadow-sm',
|
||||
'animate-in fade-in zoom-in duration-200'
|
||||
)}
|
||||
data-testid="running-agents-count-collapsed"
|
||||
>
|
||||
{runningAgentsCount > 99 ? '99' : runningAgentsCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
<Activity
|
||||
className={cn(
|
||||
'ml-3 font-medium text-sm flex-1 text-left',
|
||||
sidebarOpen ? 'block' : 'hidden'
|
||||
'w-[18px] h-[18px] shrink-0 transition-all duration-200',
|
||||
isActiveRoute('running-agents')
|
||||
? 'text-brand-500 drop-shadow-sm'
|
||||
: 'group-hover:text-brand-400'
|
||||
)}
|
||||
>
|
||||
Running Agents
|
||||
</span>
|
||||
{/* Running agents count badge - shown in expanded state */}
|
||||
{sidebarOpen && runningAgentsCount > 0 && (
|
||||
/>
|
||||
<span className="ml-3 text-sm flex-1 text-left">Running Agents</span>
|
||||
{runningAgentsCount > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
'flex items-center justify-center',
|
||||
'min-w-6 h-6 px-1.5 text-xs font-semibold rounded-full',
|
||||
'min-w-5 h-5 px-1.5 text-[10px] font-bold rounded-full',
|
||||
'bg-brand-500 text-white shadow-sm',
|
||||
'animate-in fade-in zoom-in duration-200',
|
||||
isActiveRoute('running-agents') && 'bg-brand-600'
|
||||
)}
|
||||
data-testid="running-agents-count"
|
||||
@@ -106,52 +263,30 @@ export function SidebarFooter({
|
||||
{runningAgentsCount > 99 ? '99' : runningAgentsCount}
|
||||
</span>
|
||||
)}
|
||||
{!sidebarOpen && (
|
||||
<span
|
||||
className={cn(
|
||||
'absolute left-full ml-3 px-2.5 py-1.5 rounded-lg',
|
||||
'bg-popover text-popover-foreground text-xs font-medium',
|
||||
'border border-border shadow-lg',
|
||||
'opacity-0 group-hover:opacity-100',
|
||||
'transition-all duration-200 whitespace-nowrap z-50',
|
||||
'translate-x-1 group-hover:translate-x-0'
|
||||
)}
|
||||
>
|
||||
Running Agents
|
||||
{runningAgentsCount > 0 && (
|
||||
<span className="ml-2 px-1.5 py-0.5 bg-brand-500 text-white rounded-full text-[10px] font-semibold">
|
||||
{runningAgentsCount}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Settings Link */}
|
||||
<div className="p-2">
|
||||
<div className="px-3 py-0.5">
|
||||
<button
|
||||
onClick={() => navigate({ to: '/settings' })}
|
||||
className={cn(
|
||||
'group flex items-center w-full px-3 py-2.5 rounded-xl relative overflow-hidden titlebar-no-drag',
|
||||
'group flex items-center w-full px-3 py-2 rounded-lg relative overflow-hidden titlebar-no-drag',
|
||||
'transition-all duration-200 ease-out',
|
||||
isActiveRoute('settings')
|
||||
? [
|
||||
'bg-gradient-to-r from-brand-500/20 via-brand-500/15 to-brand-600/10',
|
||||
'text-foreground font-medium',
|
||||
'border border-brand-500/30',
|
||||
'shadow-md shadow-brand-500/10',
|
||||
'shadow-sm shadow-brand-500/10',
|
||||
]
|
||||
: [
|
||||
'text-muted-foreground hover:text-foreground',
|
||||
'hover:bg-accent/50',
|
||||
'border border-transparent hover:border-border/40',
|
||||
'hover:shadow-sm',
|
||||
],
|
||||
sidebarOpen ? 'justify-start' : 'justify-center',
|
||||
'hover:scale-[1.02] active:scale-[0.97]'
|
||||
]
|
||||
)}
|
||||
title={!sidebarOpen ? 'Global Settings' : undefined}
|
||||
data-testid="settings-button"
|
||||
>
|
||||
<Settings
|
||||
@@ -159,49 +294,70 @@ export function SidebarFooter({
|
||||
'w-[18px] h-[18px] shrink-0 transition-all duration-200',
|
||||
isActiveRoute('settings')
|
||||
? 'text-brand-500 drop-shadow-sm'
|
||||
: 'group-hover:text-brand-400 group-hover:rotate-90 group-hover:scale-110'
|
||||
: 'group-hover:text-brand-400'
|
||||
)}
|
||||
/>
|
||||
<span className="ml-3 text-sm flex-1 text-left">Settings</span>
|
||||
<span
|
||||
className={cn(
|
||||
'ml-3 font-medium text-sm flex-1 text-left',
|
||||
sidebarOpen ? 'block' : 'hidden'
|
||||
'flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded transition-all duration-200',
|
||||
isActiveRoute('settings')
|
||||
? 'bg-brand-500/20 text-brand-400'
|
||||
: 'bg-muted text-muted-foreground group-hover:bg-accent'
|
||||
)}
|
||||
data-testid="shortcut-settings"
|
||||
>
|
||||
Global Settings
|
||||
{formatShortcut(shortcuts.settings, true)}
|
||||
</span>
|
||||
{sidebarOpen && (
|
||||
<span
|
||||
className={cn(
|
||||
'flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200',
|
||||
isActiveRoute('settings')
|
||||
? 'bg-brand-500/20 text-brand-400'
|
||||
: 'bg-muted text-muted-foreground group-hover:bg-accent'
|
||||
)}
|
||||
data-testid="shortcut-settings"
|
||||
>
|
||||
{formatShortcut(shortcuts.settings, true)}
|
||||
</span>
|
||||
)}
|
||||
{!sidebarOpen && (
|
||||
<span
|
||||
className={cn(
|
||||
'absolute left-full ml-3 px-2.5 py-1.5 rounded-lg',
|
||||
'bg-popover text-popover-foreground text-xs font-medium',
|
||||
'border border-border shadow-lg',
|
||||
'opacity-0 group-hover:opacity-100',
|
||||
'transition-all duration-200 whitespace-nowrap z-50',
|
||||
'translate-x-1 group-hover:translate-x-0'
|
||||
)}
|
||||
>
|
||||
Global Settings
|
||||
<span className="ml-2 px-1.5 py-0.5 bg-muted rounded text-[10px] font-mono text-muted-foreground">
|
||||
{formatShortcut(shortcuts.settings, true)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="h-px bg-border/40 mx-3 my-2" />
|
||||
|
||||
{/* Documentation Link */}
|
||||
{!hideWiki && (
|
||||
<div className="px-3 py-0.5">
|
||||
<button
|
||||
onClick={handleWikiClick}
|
||||
className={cn(
|
||||
'group flex items-center w-full px-3 py-1.5 rounded-md titlebar-no-drag',
|
||||
'text-muted-foreground/70 hover:text-foreground',
|
||||
'hover:bg-accent/30',
|
||||
'transition-all duration-200 ease-out'
|
||||
)}
|
||||
data-testid="documentation-button"
|
||||
>
|
||||
<BookOpen className="w-4 h-4 shrink-0" />
|
||||
<span className="ml-2.5 text-xs">Documentation</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Feedback Link */}
|
||||
<div className="px-3 pt-0.5">
|
||||
<button
|
||||
onClick={handleFeedbackClick}
|
||||
className={cn(
|
||||
'group flex items-center w-full px-3 py-1.5 rounded-md titlebar-no-drag',
|
||||
'text-muted-foreground/70 hover:text-foreground',
|
||||
'hover:bg-accent/30',
|
||||
'transition-all duration-200 ease-out'
|
||||
)}
|
||||
data-testid="feedback-button"
|
||||
>
|
||||
<MessageSquare className="w-4 h-4 shrink-0" />
|
||||
<span className="ml-2.5 text-xs">Feedback</span>
|
||||
<ExternalLink className="w-3 h-3 ml-auto text-muted-foreground/50" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Version */}
|
||||
<div className="px-6 py-1.5 text-center">
|
||||
<span className="text-[9px] text-muted-foreground/40">
|
||||
v{appVersion} {versionSuffix}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,179 +1,411 @@
|
||||
import { useState } from 'react';
|
||||
import { Folder, LucideIcon, X, Menu, Check } from 'lucide-react';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { ChevronsUpDown, Folder, Plus, FolderOpen } from 'lucide-react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { cn, isMac } from '@/lib/utils';
|
||||
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
|
||||
import { formatShortcut } from '@/store/app-store';
|
||||
import { isElectron, type Project } from '@/lib/electron';
|
||||
import { useIsCompact } from '@/hooks/use-media-query';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
|
||||
interface SidebarHeaderProps {
|
||||
sidebarOpen: boolean;
|
||||
currentProject: Project | null;
|
||||
onClose?: () => void;
|
||||
onExpand?: () => void;
|
||||
onNewProject: () => void;
|
||||
onOpenFolder: () => void;
|
||||
onProjectContextMenu: (project: Project, event: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
export function SidebarHeader({
|
||||
sidebarOpen,
|
||||
currentProject,
|
||||
onClose,
|
||||
onExpand,
|
||||
onNewProject,
|
||||
onOpenFolder,
|
||||
onProjectContextMenu,
|
||||
}: SidebarHeaderProps) {
|
||||
const isCompact = useIsCompact();
|
||||
const [projectListOpen, setProjectListOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const { projects, setCurrentProject } = useAppStore();
|
||||
// Get the icon component from lucide-react
|
||||
const getIconComponent = (): LucideIcon => {
|
||||
if (currentProject?.icon && currentProject.icon in LucideIcons) {
|
||||
return (LucideIcons as unknown as Record<string, LucideIcon>)[currentProject.icon];
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
|
||||
const handleLogoClick = useCallback(() => {
|
||||
navigate({ to: '/dashboard' });
|
||||
}, [navigate]);
|
||||
|
||||
const handleProjectSelect = useCallback(
|
||||
(project: Project) => {
|
||||
setCurrentProject(project);
|
||||
setDropdownOpen(false);
|
||||
navigate({ to: '/board' });
|
||||
},
|
||||
[setCurrentProject, navigate]
|
||||
);
|
||||
|
||||
const getIconComponent = (project: Project): LucideIcon => {
|
||||
if (project.icon && project.icon in LucideIcons) {
|
||||
return (LucideIcons as unknown as Record<string, LucideIcon>)[project.icon];
|
||||
}
|
||||
return Folder;
|
||||
};
|
||||
|
||||
const IconComponent = getIconComponent();
|
||||
const hasCustomIcon = !!currentProject?.customIconPath;
|
||||
const renderProjectIcon = (project: Project, size: 'sm' | 'md' = 'md') => {
|
||||
const IconComponent = getIconComponent(project);
|
||||
const sizeClasses = size === 'sm' ? 'w-6 h-6' : 'w-8 h-8';
|
||||
const iconSizeClasses = size === 'sm' ? 'w-4 h-4' : 'w-5 h-5';
|
||||
|
||||
if (project.customIconPath) {
|
||||
return (
|
||||
<img
|
||||
src={getAuthenticatedImageUrl(project.customIconPath, project.path)}
|
||||
alt={project.name}
|
||||
className={cn(sizeClasses, 'rounded-lg object-cover ring-1 ring-border/50')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
sizeClasses,
|
||||
'rounded-lg bg-brand-500/10 border border-brand-500/20 flex items-center justify-center'
|
||||
)}
|
||||
>
|
||||
<IconComponent className={cn(iconSizeClasses, 'text-brand-500')} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Collapsed state - show logo only
|
||||
if (!sidebarOpen) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'shrink-0 flex flex-col items-center relative px-2 pt-3 pb-2',
|
||||
isMac && isElectron() && 'pt-[10px]'
|
||||
)}
|
||||
>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={handleLogoClick}
|
||||
className="group flex flex-col items-center"
|
||||
data-testid="logo-button"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
role="img"
|
||||
aria-label="Automaker Logo"
|
||||
className="size-8 group-hover:rotate-12 transition-transform duration-300 ease-out"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="bg-collapsed"
|
||||
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(#bg-collapsed)" />
|
||||
<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>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
Go to Dashboard
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
{/* Collapsed project icon with dropdown */}
|
||||
{currentProject && (
|
||||
<>
|
||||
<div className="w-full h-px bg-border/40 my-2" />
|
||||
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
onContextMenu={(e) => onProjectContextMenu(currentProject, e)}
|
||||
className="p-1 rounded-lg hover:bg-accent/50 transition-colors"
|
||||
data-testid="collapsed-project-button"
|
||||
>
|
||||
{renderProjectIcon(currentProject)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
{currentProject.name}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
side="right"
|
||||
sideOffset={8}
|
||||
className="w-64"
|
||||
data-testid="collapsed-project-dropdown-content"
|
||||
>
|
||||
<div className="px-2 py-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">Projects</span>
|
||||
</div>
|
||||
{projects.map((project, index) => {
|
||||
const isActive = currentProject?.id === project.id;
|
||||
const hotkeyLabel = index < 9 ? `${index + 1}` : index === 9 ? '0' : undefined;
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={project.id}
|
||||
onClick={() => handleProjectSelect(project)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDropdownOpen(false);
|
||||
onProjectContextMenu(project, e);
|
||||
}}
|
||||
className="flex items-center gap-3 cursor-pointer"
|
||||
data-testid={`collapsed-project-item-${project.id}`}
|
||||
>
|
||||
{renderProjectIcon(project, 'sm')}
|
||||
<span
|
||||
className={cn(
|
||||
'flex-1 truncate',
|
||||
isActive && 'font-semibold text-foreground'
|
||||
)}
|
||||
>
|
||||
{project.name}
|
||||
</span>
|
||||
{hotkeyLabel && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatShortcut(`Cmd+${hotkeyLabel}`, true)}
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setDropdownOpen(false);
|
||||
onNewProject();
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
data-testid="collapsed-new-project-dropdown-item"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
<span>New Project</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setDropdownOpen(false);
|
||||
onOpenFolder();
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
data-testid="collapsed-open-project-dropdown-item"
|
||||
>
|
||||
<FolderOpen className="w-4 h-4 mr-2" />
|
||||
<span>Open Project</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Expanded state - show logo + project dropdown
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'shrink-0 flex flex-col relative',
|
||||
// Add padding on macOS Electron for traffic light buttons
|
||||
'shrink-0 flex flex-col relative px-3 pt-3 pb-2',
|
||||
isMac && isElectron() && 'pt-[10px]'
|
||||
)}
|
||||
>
|
||||
{/* Mobile close button - only visible on mobile when sidebar is open */}
|
||||
{sidebarOpen && onClose && (
|
||||
{/* Header with logo and project dropdown */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Logo */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
'lg:hidden absolute top-3 right-3 z-10',
|
||||
'flex items-center justify-center w-8 h-8 rounded-lg',
|
||||
'bg-muted/50 hover:bg-muted',
|
||||
'text-muted-foreground hover:text-foreground',
|
||||
'transition-colors duration-200'
|
||||
)}
|
||||
aria-label="Close navigation"
|
||||
data-testid="sidebar-mobile-close"
|
||||
onClick={handleLogoClick}
|
||||
className="group flex items-center shrink-0 titlebar-no-drag"
|
||||
title="Go to Dashboard"
|
||||
data-testid="logo-button"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
{/* Mobile expand button - hamburger menu to expand sidebar when collapsed on mobile */}
|
||||
{!sidebarOpen && isCompact && onExpand && (
|
||||
<button
|
||||
onClick={onExpand}
|
||||
className={cn(
|
||||
'flex items-center justify-center w-10 h-10 mx-auto mt-2 rounded-lg',
|
||||
'bg-muted/50 hover:bg-muted',
|
||||
'text-muted-foreground hover:text-foreground',
|
||||
'transition-colors duration-200'
|
||||
)}
|
||||
aria-label="Expand navigation"
|
||||
data-testid="sidebar-mobile-expand"
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
{/* Project name and icon display - entire element clickable on mobile */}
|
||||
{currentProject && (
|
||||
<Popover open={projectListOpen} onOpenChange={setProjectListOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-4 pt-3 pb-1 w-full text-left',
|
||||
'rounded-lg transition-colors duration-150',
|
||||
!sidebarOpen && 'justify-center px-2',
|
||||
// Only enable click behavior on compact screens
|
||||
isCompact && 'hover:bg-accent/50 cursor-pointer',
|
||||
!isCompact && 'pointer-events-none'
|
||||
)}
|
||||
title={isCompact ? 'Switch project' : undefined}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
role="img"
|
||||
aria-label="Automaker Logo"
|
||||
className="h-8 w-8 group-hover:rotate-12 transition-transform duration-300 ease-out"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="bg-header"
|
||||
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(#bg-header)" />
|
||||
<g
|
||||
fill="none"
|
||||
stroke="#FFFFFF"
|
||||
strokeWidth="20"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
{/* Project Icon */}
|
||||
<div className="shrink-0">
|
||||
{hasCustomIcon ? (
|
||||
<img
|
||||
src={getAuthenticatedImageUrl(
|
||||
currentProject.customIconPath!,
|
||||
currentProject.path
|
||||
)}
|
||||
alt={currentProject.name}
|
||||
className="w-8 h-8 rounded-lg object-cover ring-1 ring-border/50"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-lg bg-brand-500/10 border border-brand-500/20 flex items-center justify-center">
|
||||
<IconComponent className="w-5 h-5 text-brand-500" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{/* Project Name - only show when sidebar is open */}
|
||||
{sidebarOpen && (
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-sm font-semibold text-foreground truncate">
|
||||
{currentProject.name}
|
||||
</h2>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64 p-2" align="start" side="bottom" sideOffset={8}>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium text-muted-foreground px-2 py-1">Switch Project</p>
|
||||
{projects.map((project) => {
|
||||
const ProjectIcon =
|
||||
project.icon && project.icon in LucideIcons
|
||||
? (LucideIcons as unknown as Record<string, LucideIcon>)[project.icon]
|
||||
: Folder;
|
||||
{/* Project Dropdown */}
|
||||
{currentProject ? (
|
||||
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'flex-1 flex items-center gap-2 px-2 py-1.5 rounded-lg min-w-0',
|
||||
'hover:bg-accent/50 transition-colors titlebar-no-drag',
|
||||
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1'
|
||||
)}
|
||||
onContextMenu={(e) => onProjectContextMenu(currentProject, e)}
|
||||
data-testid="project-dropdown-trigger"
|
||||
>
|
||||
{renderProjectIcon(currentProject, 'sm')}
|
||||
<span className="flex-1 text-sm font-semibold text-foreground truncate text-left">
|
||||
{currentProject.name}
|
||||
</span>
|
||||
<ChevronsUpDown className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
side="bottom"
|
||||
sideOffset={8}
|
||||
className="w-64"
|
||||
data-testid="project-dropdown-content"
|
||||
>
|
||||
<div className="px-2 py-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">Projects</span>
|
||||
</div>
|
||||
{projects.map((project, index) => {
|
||||
const isActive = currentProject?.id === project.id;
|
||||
const hotkeyLabel = index < 9 ? `${index + 1}` : index === 9 ? '0' : undefined;
|
||||
|
||||
return (
|
||||
<button
|
||||
<DropdownMenuItem
|
||||
key={project.id}
|
||||
onClick={() => {
|
||||
setCurrentProject(project);
|
||||
setProjectListOpen(false);
|
||||
onClick={() => handleProjectSelect(project)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDropdownOpen(false);
|
||||
onProjectContextMenu(project, e);
|
||||
}}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 px-2 py-2 rounded-lg text-left',
|
||||
'transition-colors duration-150',
|
||||
isActive
|
||||
? 'bg-brand-500/10 text-brand-500'
|
||||
: 'hover:bg-accent text-foreground'
|
||||
)}
|
||||
className="flex items-center gap-3 cursor-pointer"
|
||||
data-testid={`project-item-${project.id}`}
|
||||
>
|
||||
{project.customIconPath ? (
|
||||
<img
|
||||
src={getAuthenticatedImageUrl(project.customIconPath, project.path)}
|
||||
alt={project.name}
|
||||
className="w-6 h-6 rounded object-cover ring-1 ring-border/50"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
'w-6 h-6 rounded flex items-center justify-center',
|
||||
isActive ? 'bg-brand-500/20' : 'bg-muted'
|
||||
)}
|
||||
>
|
||||
<ProjectIcon
|
||||
className={cn(
|
||||
'w-4 h-4',
|
||||
isActive ? 'text-brand-500' : 'text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{renderProjectIcon(project, 'sm')}
|
||||
<span
|
||||
className={cn('flex-1 truncate', isActive && 'font-semibold text-foreground')}
|
||||
>
|
||||
{project.name}
|
||||
</span>
|
||||
{hotkeyLabel && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatShortcut(`Cmd+${hotkeyLabel}`, true)}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex-1 text-sm truncate">{project.name}</span>
|
||||
{isActive && <Check className="w-4 h-4 text-brand-500" />}
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setDropdownOpen(false);
|
||||
onNewProject();
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
data-testid="new-project-dropdown-item"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
<span>New Project</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setDropdownOpen(false);
|
||||
onOpenFolder();
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
data-testid="open-project-dropdown-item"
|
||||
>
|
||||
<FolderOpen className="w-4 h-4 mr-2" />
|
||||
<span>Open Project</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
<button
|
||||
onClick={onNewProject}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-1.5 rounded-lg',
|
||||
'text-sm text-muted-foreground hover:text-foreground',
|
||||
'hover:bg-accent/50 transition-colors titlebar-no-drag'
|
||||
)}
|
||||
data-testid="new-project-button"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>New Project</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={onOpenFolder}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-1.5 rounded-lg',
|
||||
'text-sm text-muted-foreground hover:text-foreground',
|
||||
'hover:bg-accent/50 transition-colors titlebar-no-drag'
|
||||
)}
|
||||
data-testid="open-project-button"
|
||||
>
|
||||
<FolderOpen className="w-4 h-4" />
|
||||
<span>Open</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,24 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import type { NavigateOptions } from '@tanstack/react-router';
|
||||
import { ChevronDown, Wrench, Github } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatShortcut } from '@/store/app-store';
|
||||
import type { NavSection } from '../types';
|
||||
import type { Project } from '@/lib/electron';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
|
||||
// Map section labels to icons
|
||||
const sectionIcons: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
Tools: Wrench,
|
||||
GitHub: Github,
|
||||
};
|
||||
|
||||
interface SidebarNavigationProps {
|
||||
currentProject: Project | null;
|
||||
@@ -11,6 +26,7 @@ interface SidebarNavigationProps {
|
||||
navSections: NavSection[];
|
||||
isActiveRoute: (id: string) => boolean;
|
||||
navigate: (opts: NavigateOptions) => void;
|
||||
onScrollStateChange?: (canScrollDown: boolean) => void;
|
||||
}
|
||||
|
||||
export function SidebarNavigation({
|
||||
@@ -19,174 +35,299 @@ export function SidebarNavigation({
|
||||
navSections,
|
||||
isActiveRoute,
|
||||
navigate,
|
||||
onScrollStateChange,
|
||||
}: SidebarNavigationProps) {
|
||||
const navRef = useRef<HTMLElement>(null);
|
||||
|
||||
// Track collapsed state for each collapsible section
|
||||
const [collapsedSections, setCollapsedSections] = useState<Record<string, boolean>>({});
|
||||
|
||||
// Initialize collapsed state when sections change (e.g., GitHub section appears)
|
||||
useEffect(() => {
|
||||
setCollapsedSections((prev) => {
|
||||
const updated = { ...prev };
|
||||
navSections.forEach((section) => {
|
||||
if (section.collapsible && section.label && !(section.label in updated)) {
|
||||
updated[section.label] = section.defaultCollapsed ?? false;
|
||||
}
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
}, [navSections]);
|
||||
|
||||
// Check scroll state
|
||||
const checkScrollState = useCallback(() => {
|
||||
if (!navRef.current || !onScrollStateChange) return;
|
||||
const { scrollTop, scrollHeight, clientHeight } = navRef.current;
|
||||
const canScrollDown = scrollTop + clientHeight < scrollHeight - 10;
|
||||
onScrollStateChange(canScrollDown);
|
||||
}, [onScrollStateChange]);
|
||||
|
||||
// Monitor scroll state
|
||||
useEffect(() => {
|
||||
checkScrollState();
|
||||
const nav = navRef.current;
|
||||
if (!nav) return;
|
||||
|
||||
nav.addEventListener('scroll', checkScrollState);
|
||||
const resizeObserver = new ResizeObserver(checkScrollState);
|
||||
resizeObserver.observe(nav);
|
||||
|
||||
return () => {
|
||||
nav.removeEventListener('scroll', checkScrollState);
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [checkScrollState, collapsedSections]);
|
||||
|
||||
const toggleSection = useCallback((label: string) => {
|
||||
setCollapsedSections((prev) => ({
|
||||
...prev,
|
||||
[label]: !prev[label],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// Filter sections: always show non-project sections, only show project sections when project exists
|
||||
const visibleSections = navSections.filter((section) => {
|
||||
// Always show Dashboard (first section with no label)
|
||||
if (!section.label && section.items.some((item) => item.id === 'dashboard')) {
|
||||
return true;
|
||||
}
|
||||
// Show other sections only when project is selected
|
||||
return !!currentProject;
|
||||
});
|
||||
|
||||
return (
|
||||
<nav
|
||||
className={cn(
|
||||
'flex-1 overflow-y-auto scrollbar-hide px-3 pb-2',
|
||||
sidebarOpen ? 'mt-1' : 'mt-1'
|
||||
)}
|
||||
>
|
||||
{!currentProject && sidebarOpen ? (
|
||||
// Placeholder when no project is selected (only in expanded state)
|
||||
<div className="flex items-center justify-center h-full px-4">
|
||||
<p className="text-muted-foreground text-sm text-center">
|
||||
<span className="block">Select or create a project above</span>
|
||||
</p>
|
||||
</div>
|
||||
) : currentProject ? (
|
||||
// Navigation sections when project is selected
|
||||
navSections.map((section, sectionIdx) => (
|
||||
<div key={sectionIdx} className={sectionIdx > 0 && sidebarOpen ? 'mt-6' : ''}>
|
||||
{/* Section Label */}
|
||||
<nav ref={navRef} className={cn('flex-1 overflow-y-auto scrollbar-hide px-3 pb-2 mt-1')}>
|
||||
{/* Navigation sections */}
|
||||
{visibleSections.map((section, sectionIdx) => {
|
||||
const isCollapsed = section.label ? collapsedSections[section.label] : false;
|
||||
const isCollapsible = section.collapsible && section.label && sidebarOpen;
|
||||
|
||||
const SectionIcon = section.label ? sectionIcons[section.label] : null;
|
||||
|
||||
return (
|
||||
<div key={sectionIdx} className={sectionIdx > 0 && sidebarOpen ? 'mt-4' : ''}>
|
||||
{/* Section Label - clickable if collapsible (expanded sidebar) */}
|
||||
{section.label && sidebarOpen && (
|
||||
<div className="px-3 mb-2">
|
||||
<button
|
||||
onClick={() => isCollapsible && toggleSection(section.label!)}
|
||||
className={cn(
|
||||
'flex items-center w-full px-3 mb-1.5',
|
||||
isCollapsible && 'cursor-pointer hover:text-foreground'
|
||||
)}
|
||||
disabled={!isCollapsible}
|
||||
>
|
||||
<span className="text-[10px] font-semibold text-muted-foreground/70 uppercase tracking-widest">
|
||||
{section.label}
|
||||
</span>
|
||||
</div>
|
||||
{isCollapsible && (
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'w-3 h-3 ml-auto text-muted-foreground/50 transition-transform duration-200',
|
||||
isCollapsed && '-rotate-90'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Section icon with dropdown (collapsed sidebar) */}
|
||||
{section.label && !sidebarOpen && SectionIcon && section.collapsible && isCollapsed && (
|
||||
<DropdownMenu>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'group flex items-center justify-center w-full py-2 rounded-lg',
|
||||
'text-muted-foreground hover:text-foreground',
|
||||
'hover:bg-accent/50 border border-transparent hover:border-border/40',
|
||||
'transition-all duration-200 ease-out'
|
||||
)}
|
||||
>
|
||||
<SectionIcon className="w-[18px] h-[18px]" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
{section.label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<DropdownMenuContent side="right" align="start" sideOffset={8} className="w-48">
|
||||
{section.items.map((item) => {
|
||||
const ItemIcon = item.icon;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={item.id}
|
||||
onClick={() => navigate({ to: `/${item.id}` as unknown as '/' })}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<ItemIcon className="w-4 h-4" />
|
||||
<span>{item.label}</span>
|
||||
{item.shortcut && (
|
||||
<span className="ml-auto text-[10px] font-mono text-muted-foreground">
|
||||
{formatShortcut(item.shortcut, true)}
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{/* Separator for sections without label (visual separation) */}
|
||||
{!section.label && sectionIdx > 0 && sidebarOpen && (
|
||||
<div className="h-px bg-border/40 mx-3 mb-4"></div>
|
||||
<div className="h-px bg-border/40 mx-3 mb-3"></div>
|
||||
)}
|
||||
{(section.label || sectionIdx > 0) && !sidebarOpen && (
|
||||
<div className="h-px bg-border/30 mx-2 my-1.5"></div>
|
||||
)}
|
||||
|
||||
{/* Nav Items */}
|
||||
<div className="space-y-1.5">
|
||||
{section.items.map((item) => {
|
||||
const isActive = isActiveRoute(item.id);
|
||||
const Icon = item.icon;
|
||||
{/* Nav Items - show when section is expanded, or when sidebar is collapsed and section doesn't use dropdown */}
|
||||
{!isCollapsed && (
|
||||
<div className="space-y-1">
|
||||
{section.items.map((item) => {
|
||||
const isActive = isActiveRoute(item.id);
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => {
|
||||
// Cast to the router's path type; item.id is constrained to known routes
|
||||
navigate({ to: `/${item.id}` as unknown as '/' });
|
||||
}}
|
||||
className={cn(
|
||||
'group flex items-center w-full px-3 py-2.5 rounded-xl relative overflow-hidden titlebar-no-drag',
|
||||
'transition-all duration-200 ease-out',
|
||||
isActive
|
||||
? [
|
||||
// Active: Premium gradient with glow
|
||||
'bg-gradient-to-r from-brand-500/20 via-brand-500/15 to-brand-600/10',
|
||||
'text-foreground font-medium',
|
||||
'border border-brand-500/30',
|
||||
'shadow-md shadow-brand-500/10',
|
||||
]
|
||||
: [
|
||||
// Inactive: Subtle hover state
|
||||
'text-muted-foreground hover:text-foreground',
|
||||
'hover:bg-accent/50',
|
||||
'border border-transparent hover:border-border/40',
|
||||
'hover:shadow-sm',
|
||||
],
|
||||
sidebarOpen ? 'justify-start' : 'justify-center',
|
||||
'hover:scale-[1.02] active:scale-[0.97]'
|
||||
)}
|
||||
title={!sidebarOpen ? item.label : undefined}
|
||||
data-testid={`nav-${item.id}`}
|
||||
>
|
||||
<div className="relative">
|
||||
{item.isLoading ? (
|
||||
<Spinner
|
||||
size="md"
|
||||
className={cn(
|
||||
'shrink-0',
|
||||
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'
|
||||
)}
|
||||
/>
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => {
|
||||
// Cast to the router's path type; item.id is constrained to known routes
|
||||
navigate({ to: `/${item.id}` as unknown as '/' });
|
||||
}}
|
||||
className={cn(
|
||||
'group flex items-center w-full px-3 py-2 rounded-lg relative overflow-hidden titlebar-no-drag',
|
||||
'transition-all duration-200 ease-out',
|
||||
isActive
|
||||
? [
|
||||
// Active: Premium gradient with glow
|
||||
'bg-gradient-to-r from-brand-500/20 via-brand-500/15 to-brand-600/10',
|
||||
'text-foreground font-medium',
|
||||
'border border-brand-500/30',
|
||||
'shadow-sm shadow-brand-500/10',
|
||||
]
|
||||
: [
|
||||
// Inactive: Subtle hover state
|
||||
'text-muted-foreground hover:text-foreground',
|
||||
'hover:bg-accent/50',
|
||||
'border border-transparent hover:border-border/40',
|
||||
],
|
||||
sidebarOpen ? 'justify-start' : 'justify-center'
|
||||
)}
|
||||
{/* Count badge for collapsed state */}
|
||||
{!sidebarOpen && item.count !== undefined && item.count > 0 && (
|
||||
title={!sidebarOpen ? item.label : undefined}
|
||||
data-testid={`nav-${item.id}`}
|
||||
>
|
||||
<div className="relative">
|
||||
{item.isLoading ? (
|
||||
<Spinner
|
||||
size="sm"
|
||||
className={cn(
|
||||
'shrink-0',
|
||||
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'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{/* Count badge for collapsed state */}
|
||||
{!sidebarOpen && item.count !== undefined && item.count > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
'absolute -top-1.5 -right-1.5 flex items-center justify-center',
|
||||
'min-w-4 h-4 px-0.5 text-[9px] font-bold rounded-full',
|
||||
'bg-primary text-primary-foreground shadow-sm',
|
||||
'animate-in fade-in zoom-in duration-200'
|
||||
)}
|
||||
>
|
||||
{item.count > 99 ? '99' : item.count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'ml-3 text-sm flex-1 text-left',
|
||||
sidebarOpen ? 'block' : 'hidden'
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
{/* Count badge */}
|
||||
{item.count !== undefined && item.count > 0 && sidebarOpen && (
|
||||
<span
|
||||
className={cn(
|
||||
'absolute -top-1.5 -right-1.5 flex items-center justify-center',
|
||||
'min-w-4 h-4 px-1 text-[9px] font-bold rounded-full',
|
||||
'flex items-center justify-center',
|
||||
'min-w-5 h-5 px-1.5 text-[10px] font-bold rounded-full',
|
||||
'bg-primary text-primary-foreground shadow-sm',
|
||||
'animate-in fade-in zoom-in duration-200'
|
||||
)}
|
||||
data-testid={`count-${item.id}`}
|
||||
>
|
||||
{item.count > 99 ? '99' : item.count}
|
||||
{item.count > 99 ? '99+' : item.count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'ml-3 font-medium text-sm flex-1 text-left',
|
||||
sidebarOpen ? 'block' : 'hidden'
|
||||
{item.shortcut && sidebarOpen && !item.count && (
|
||||
<span
|
||||
className={cn(
|
||||
'flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded transition-all duration-200',
|
||||
isActive
|
||||
? 'bg-brand-500/20 text-brand-400'
|
||||
: 'bg-muted text-muted-foreground group-hover:bg-accent'
|
||||
)}
|
||||
data-testid={`shortcut-${item.id}`}
|
||||
>
|
||||
{formatShortcut(item.shortcut, true)}
|
||||
</span>
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
{/* Count badge */}
|
||||
{item.count !== undefined && item.count > 0 && sidebarOpen && (
|
||||
<span
|
||||
className={cn(
|
||||
'flex items-center justify-center',
|
||||
'min-w-5 h-5 px-1.5 text-[10px] font-bold rounded-full',
|
||||
'bg-primary text-primary-foreground shadow-sm',
|
||||
'animate-in fade-in zoom-in duration-200'
|
||||
)}
|
||||
data-testid={`count-${item.id}`}
|
||||
>
|
||||
{item.count > 99 ? '99+' : item.count}
|
||||
</span>
|
||||
)}
|
||||
{item.shortcut && sidebarOpen && !item.count && (
|
||||
<span
|
||||
className={cn(
|
||||
'flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200',
|
||||
isActive
|
||||
? 'bg-brand-500/20 text-brand-400'
|
||||
: 'bg-muted text-muted-foreground group-hover:bg-accent'
|
||||
)}
|
||||
data-testid={`shortcut-${item.id}`}
|
||||
>
|
||||
{formatShortcut(item.shortcut, true)}
|
||||
</span>
|
||||
)}
|
||||
{/* Tooltip for collapsed state */}
|
||||
{!sidebarOpen && (
|
||||
<span
|
||||
className={cn(
|
||||
'absolute left-full ml-3 px-2.5 py-1.5 rounded-lg',
|
||||
'bg-popover text-popover-foreground text-xs font-medium',
|
||||
'border border-border shadow-lg',
|
||||
'opacity-0 group-hover:opacity-100',
|
||||
'transition-all duration-200 whitespace-nowrap z-50',
|
||||
'translate-x-1 group-hover:translate-x-0'
|
||||
)}
|
||||
data-testid={`sidebar-tooltip-${item.label.toLowerCase()}`}
|
||||
>
|
||||
{item.label}
|
||||
{item.shortcut && (
|
||||
<span className="ml-2 px-1.5 py-0.5 bg-muted rounded text-[10px] font-mono text-muted-foreground">
|
||||
{formatShortcut(item.shortcut, true)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* Tooltip for collapsed state */}
|
||||
{!sidebarOpen && (
|
||||
<span
|
||||
className={cn(
|
||||
'absolute left-full ml-3 px-2.5 py-1.5 rounded-md',
|
||||
'bg-popover text-popover-foreground text-sm',
|
||||
'border border-border shadow-lg',
|
||||
'opacity-0 group-hover:opacity-100',
|
||||
'transition-all duration-200 whitespace-nowrap z-50',
|
||||
'translate-x-1 group-hover:translate-x-0'
|
||||
)}
|
||||
data-testid={`sidebar-tooltip-${item.label.toLowerCase()}`}
|
||||
>
|
||||
{item.label}
|
||||
{item.shortcut && (
|
||||
<span className="ml-2 px-1.5 py-0.5 bg-muted rounded text-[10px] font-mono text-muted-foreground">
|
||||
{formatShortcut(item.shortcut, true)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : null}
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Placeholder when no project is selected */}
|
||||
{!currentProject && sidebarOpen && (
|
||||
<div className="flex items-center justify-center px-4 py-8">
|
||||
<p className="text-muted-foreground text-xs text-center">
|
||||
Select or create a project to continue
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Network,
|
||||
Bell,
|
||||
Settings,
|
||||
Home,
|
||||
} from 'lucide-react';
|
||||
import type { NavSection, NavItem } from '../types';
|
||||
import type { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts';
|
||||
@@ -174,13 +175,30 @@ export function useNavigation({
|
||||
}
|
||||
|
||||
const sections: NavSection[] = [
|
||||
// Dashboard - standalone at top
|
||||
{
|
||||
label: '',
|
||||
items: [
|
||||
{
|
||||
id: 'dashboard',
|
||||
label: 'Dashboard',
|
||||
icon: Home,
|
||||
},
|
||||
],
|
||||
},
|
||||
// Project section - expanded by default
|
||||
{
|
||||
label: 'Project',
|
||||
items: projectItems,
|
||||
collapsible: true,
|
||||
defaultCollapsed: false,
|
||||
},
|
||||
// Tools section - collapsed by default
|
||||
{
|
||||
label: 'Tools',
|
||||
items: visibleToolsItems,
|
||||
collapsible: true,
|
||||
defaultCollapsed: true,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -203,6 +221,8 @@ export function useNavigation({
|
||||
shortcut: shortcuts.githubPrs,
|
||||
},
|
||||
],
|
||||
collapsible: true,
|
||||
defaultCollapsed: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
1
apps/ui/src/components/layout/sidebar/index.ts
Normal file
1
apps/ui/src/components/layout/sidebar/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Sidebar } from './sidebar';
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { useNavigate, useLocation } from '@tanstack/react-router';
|
||||
|
||||
const logger = createLogger('Sidebar');
|
||||
import { PanelLeftClose, ChevronDown } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useNotificationsStore } from '@/store/notifications-store';
|
||||
@@ -10,22 +9,18 @@ import { useKeyboardShortcuts, useKeyboardShortcutsConfig } from '@/hooks/use-ke
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { initializeProject, hasAppSpec, hasAutomakerDir } from '@/lib/project-init';
|
||||
import { toast } from 'sonner';
|
||||
import { DeleteProjectDialog } from '@/components/views/settings-view/components/delete-project-dialog';
|
||||
import { NewProjectModal } from '@/components/dialogs/new-project-modal';
|
||||
import { CreateSpecDialog } from '@/components/views/spec-view/dialogs';
|
||||
|
||||
// Local imports from subfolder
|
||||
import {
|
||||
CollapseToggleButton,
|
||||
SidebarHeader,
|
||||
SidebarNavigation,
|
||||
SidebarFooter,
|
||||
MobileSidebarToggle,
|
||||
} from './sidebar/components';
|
||||
import { useIsCompact } from '@/hooks/use-media-query';
|
||||
import { PanelLeftClose } from 'lucide-react';
|
||||
import { TrashDialog, OnboardingDialog } from './sidebar/dialogs';
|
||||
import { SIDEBAR_FEATURE_FLAGS } from './sidebar/constants';
|
||||
import type { Project } from '@/lib/electron';
|
||||
|
||||
// Sidebar components
|
||||
import {
|
||||
SidebarNavigation,
|
||||
CollapseToggleButton,
|
||||
MobileSidebarToggle,
|
||||
SidebarHeader,
|
||||
SidebarFooter,
|
||||
} from './components';
|
||||
import { SIDEBAR_FEATURE_FLAGS } from './constants';
|
||||
import {
|
||||
useSidebarAutoCollapse,
|
||||
useRunningAgents,
|
||||
@@ -35,7 +30,19 @@ import {
|
||||
useSetupDialog,
|
||||
useTrashOperations,
|
||||
useUnviewedValidations,
|
||||
} from './sidebar/hooks';
|
||||
} from './hooks';
|
||||
import { TrashDialog, OnboardingDialog } from './dialogs';
|
||||
|
||||
// Reuse dialogs from project-switcher
|
||||
import { ProjectContextMenu } from '../project-switcher/components/project-context-menu';
|
||||
import { EditProjectDialog } from '../project-switcher/components/edit-project-dialog';
|
||||
|
||||
// Import shared dialogs
|
||||
import { DeleteProjectDialog } from '@/components/views/settings-view/components/delete-project-dialog';
|
||||
import { NewProjectModal } from '@/components/dialogs/new-project-modal';
|
||||
import { CreateSpecDialog } from '@/components/views/spec-view/dialogs';
|
||||
|
||||
const logger = createLogger('Sidebar');
|
||||
|
||||
export function Sidebar() {
|
||||
const navigate = useNavigate();
|
||||
@@ -59,12 +66,14 @@ export function Sidebar() {
|
||||
moveProjectToTrash,
|
||||
specCreatingForProject,
|
||||
setSpecCreatingForProject,
|
||||
setCurrentProject,
|
||||
} = useAppStore();
|
||||
|
||||
const isCompact = useIsCompact();
|
||||
|
||||
// Environment variable flags for hiding sidebar items
|
||||
const { hideTerminal, hideRunningAgents, hideContext, hideSpecEditor } = SIDEBAR_FEATURE_FLAGS;
|
||||
const { hideTerminal, hideRunningAgents, hideContext, hideSpecEditor, hideWiki } =
|
||||
SIDEBAR_FEATURE_FLAGS;
|
||||
|
||||
// Get customizable keyboard shortcuts
|
||||
const shortcuts = useKeyboardShortcutsConfig();
|
||||
@@ -72,6 +81,13 @@ export function Sidebar() {
|
||||
// Get unread notifications count
|
||||
const unreadNotificationsCount = useNotificationsStore((s) => s.unreadCount);
|
||||
|
||||
// State for context menu
|
||||
const [contextMenuProject, setContextMenuProject] = useState<Project | null>(null);
|
||||
const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(
|
||||
null
|
||||
);
|
||||
const [editDialogProject, setEditDialogProject] = useState<Project | null>(null);
|
||||
|
||||
// State for delete project confirmation dialog
|
||||
const [showDeleteProjectDialog, setShowDeleteProjectDialog] = useState(false);
|
||||
|
||||
@@ -129,7 +145,7 @@ export function Sidebar() {
|
||||
const isCurrentProjectGeneratingSpec =
|
||||
specCreatingForProject !== null && specCreatingForProject === currentProject?.path;
|
||||
|
||||
// Auto-collapse sidebar on small screens and update Electron window minWidth
|
||||
// Auto-collapse sidebar on small screens
|
||||
useSidebarAutoCollapse({ sidebarOpen, toggleSidebar });
|
||||
|
||||
// Running agents count
|
||||
@@ -163,9 +179,28 @@ export function Sidebar() {
|
||||
setNewProjectPath,
|
||||
});
|
||||
|
||||
// Context menu handlers
|
||||
const handleContextMenu = useCallback((project: Project, event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
setContextMenuProject(project);
|
||||
setContextMenuPosition({ x: event.clientX, y: event.clientY });
|
||||
}, []);
|
||||
|
||||
const handleCloseContextMenu = useCallback(() => {
|
||||
setContextMenuProject(null);
|
||||
setContextMenuPosition(null);
|
||||
}, []);
|
||||
|
||||
const handleEditProject = useCallback(
|
||||
(project: Project) => {
|
||||
setEditDialogProject(project);
|
||||
handleCloseContextMenu();
|
||||
},
|
||||
[handleCloseContextMenu]
|
||||
);
|
||||
|
||||
/**
|
||||
* Opens the system folder selection dialog and initializes the selected project.
|
||||
* Used by both the 'O' keyboard shortcut and the folder icon button.
|
||||
*/
|
||||
const handleOpenFolder = useCallback(async () => {
|
||||
const api = getElectronAPI();
|
||||
@@ -173,14 +208,10 @@ export function Sidebar() {
|
||||
|
||||
if (!result.canceled && result.filePaths[0]) {
|
||||
const path = result.filePaths[0];
|
||||
// Extract folder name from path (works on both Windows and Mac/Linux)
|
||||
const name = path.split(/[/\\]/).filter(Boolean).pop() || 'Untitled Project';
|
||||
|
||||
try {
|
||||
// Check if this is a brand new project (no .automaker directory)
|
||||
const hadAutomakerDir = await hasAutomakerDir(path);
|
||||
|
||||
// Initialize the .automaker directory structure
|
||||
const initResult = await initializeProject(path);
|
||||
|
||||
if (!initResult.success) {
|
||||
@@ -190,15 +221,10 @@ export function Sidebar() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Upsert project and set as current (handles both create and update cases)
|
||||
// Theme handling (trashed project recovery or undefined for global) is done by the store
|
||||
upsertAndSetCurrentProject(path, name);
|
||||
|
||||
// Check if app_spec.txt exists
|
||||
const specExists = await hasAppSpec(path);
|
||||
|
||||
if (!hadAutomakerDir && !specExists) {
|
||||
// This is a brand new project - show setup dialog
|
||||
setSetupProjectPath(path);
|
||||
setShowSetupDialog(true);
|
||||
toast.success('Project opened', {
|
||||
@@ -213,6 +239,8 @@ export function Sidebar() {
|
||||
description: `Opened ${name}`,
|
||||
});
|
||||
}
|
||||
|
||||
navigate({ to: '/board' });
|
||||
} catch (error) {
|
||||
logger.error('Failed to open project:', error);
|
||||
toast.error('Failed to open project', {
|
||||
@@ -220,9 +248,13 @@ export function Sidebar() {
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [upsertAndSetCurrentProject]);
|
||||
}, [upsertAndSetCurrentProject, navigate, setSetupProjectPath, setShowSetupDialog]);
|
||||
|
||||
// Navigation sections and keyboard shortcuts (defined after handlers)
|
||||
const handleNewProject = useCallback(() => {
|
||||
setShowNewProjectModal(true);
|
||||
}, [setShowNewProjectModal]);
|
||||
|
||||
// Navigation sections and keyboard shortcuts
|
||||
const { navSections, navigationShortcuts } = useNavigation({
|
||||
shortcuts,
|
||||
hideSpecEditor,
|
||||
@@ -244,12 +276,48 @@ export function Sidebar() {
|
||||
// Register keyboard shortcuts
|
||||
useKeyboardShortcuts(navigationShortcuts);
|
||||
|
||||
// Keyboard shortcuts for project switching (1-9, 0)
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.ctrlKey || event.metaKey || event.altKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = event.key;
|
||||
let projectIndex: number | null = null;
|
||||
|
||||
if (key >= '1' && key <= '9') {
|
||||
projectIndex = parseInt(key, 10) - 1;
|
||||
} else if (key === '0') {
|
||||
projectIndex = 9;
|
||||
}
|
||||
|
||||
if (projectIndex !== null && projectIndex < projects.length) {
|
||||
const targetProject = projects[projectIndex];
|
||||
if (targetProject && targetProject.id !== currentProject?.id) {
|
||||
setCurrentProject(targetProject);
|
||||
navigate({ to: '/board' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [projects, currentProject, setCurrentProject, navigate]);
|
||||
|
||||
const isActiveRoute = (id: string) => {
|
||||
// Map view IDs to route paths
|
||||
const routePath = id === 'welcome' ? '/' : `/${id}`;
|
||||
return location.pathname === routePath;
|
||||
};
|
||||
|
||||
// Track if nav can scroll down
|
||||
const [canScrollDown, setCanScrollDown] = useState(false);
|
||||
|
||||
// Check if sidebar should be completely hidden on mobile
|
||||
const shouldHideSidebar = isCompact && mobileSidebarHidden;
|
||||
|
||||
@@ -266,6 +334,7 @@ export function Sidebar() {
|
||||
data-testid="sidebar-backdrop"
|
||||
/>
|
||||
)}
|
||||
|
||||
<aside
|
||||
className={cn(
|
||||
'flex-shrink-0 flex flex-col z-30',
|
||||
@@ -277,9 +346,11 @@ export function Sidebar() {
|
||||
'transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]',
|
||||
// Mobile: completely hidden when mobileSidebarHidden is true
|
||||
shouldHideSidebar && 'hidden',
|
||||
// Mobile: overlay when open, collapsed when closed
|
||||
// Width based on state
|
||||
!shouldHideSidebar &&
|
||||
(sidebarOpen ? 'fixed inset-y-0 left-0 w-72 lg:relative lg:w-72' : 'relative w-16')
|
||||
(sidebarOpen
|
||||
? 'fixed inset-y-0 left-0 w-[17rem] lg:relative lg:w-[17rem]'
|
||||
: 'relative w-14')
|
||||
)}
|
||||
data-testid="sidebar"
|
||||
>
|
||||
@@ -313,8 +384,9 @@ export function Sidebar() {
|
||||
<SidebarHeader
|
||||
sidebarOpen={sidebarOpen}
|
||||
currentProject={currentProject}
|
||||
onClose={toggleSidebar}
|
||||
onExpand={toggleSidebar}
|
||||
onNewProject={handleNewProject}
|
||||
onOpenFolder={handleOpenFolder}
|
||||
onProjectContextMenu={handleContextMenu}
|
||||
/>
|
||||
|
||||
<SidebarNavigation
|
||||
@@ -323,17 +395,27 @@ export function Sidebar() {
|
||||
navSections={navSections}
|
||||
isActiveRoute={isActiveRoute}
|
||||
navigate={navigate}
|
||||
onScrollStateChange={setCanScrollDown}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Scroll indicator - shows there's more content below */}
|
||||
{canScrollDown && sidebarOpen && (
|
||||
<div className="flex justify-center py-1 border-t border-border/30">
|
||||
<ChevronDown className="w-4 h-4 text-muted-foreground/50 animate-bounce" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SidebarFooter
|
||||
sidebarOpen={sidebarOpen}
|
||||
isActiveRoute={isActiveRoute}
|
||||
navigate={navigate}
|
||||
hideRunningAgents={hideRunningAgents}
|
||||
hideWiki={hideWiki}
|
||||
runningAgentsCount={runningAgentsCount}
|
||||
shortcuts={{ settings: shortcuts.settings }}
|
||||
/>
|
||||
|
||||
<TrashDialog
|
||||
open={showTrashDialog}
|
||||
onOpenChange={setShowTrashDialog}
|
||||
@@ -392,6 +474,25 @@ export function Sidebar() {
|
||||
isCreating={isCreatingProject}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
{/* Context Menu */}
|
||||
{contextMenuProject && contextMenuPosition && (
|
||||
<ProjectContextMenu
|
||||
project={contextMenuProject}
|
||||
position={contextMenuPosition}
|
||||
onClose={handleCloseContextMenu}
|
||||
onEdit={handleEditProject}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Edit Project Dialog */}
|
||||
{editDialogProject && (
|
||||
<EditProjectDialog
|
||||
project={editDialogProject}
|
||||
open={!!editDialogProject}
|
||||
onOpenChange={(open) => !open && setEditDialogProject(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,10 @@ import type React from 'react';
|
||||
export interface NavSection {
|
||||
label?: string;
|
||||
items: NavItem[];
|
||||
/** Whether this section can be collapsed */
|
||||
collapsible?: boolean;
|
||||
/** Whether this section should start collapsed */
|
||||
defaultCollapsed?: boolean;
|
||||
}
|
||||
|
||||
export interface NavItem {
|
||||
|
||||
389
apps/ui/src/components/provider-usage-bar.tsx
Normal file
389
apps/ui/src/components/provider-usage-bar.tsx
Normal file
@@ -0,0 +1,389 @@
|
||||
/**
|
||||
* Provider Usage Bar
|
||||
*
|
||||
* A compact usage bar that displays usage statistics for all enabled AI providers.
|
||||
* Shows a unified view with individual provider usage indicators.
|
||||
*/
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
AnthropicIcon,
|
||||
OpenAIIcon,
|
||||
CursorIcon,
|
||||
GeminiIcon,
|
||||
OpenCodeIcon,
|
||||
MiniMaxIcon,
|
||||
GlmIcon,
|
||||
} from '@/components/ui/provider-icon';
|
||||
import { useAllProvidersUsage } from '@/hooks/queries';
|
||||
import type { UsageProviderId, ProviderUsage } from '@automaker/types';
|
||||
import { getMaxUsagePercent } from '@automaker/types';
|
||||
|
||||
// GitHub icon component
|
||||
function GitHubIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={cn('inline-block', className)} fill="currentColor">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Provider icon mapping
|
||||
const PROVIDER_ICONS: Record<UsageProviderId, React.FC<{ className?: string }>> = {
|
||||
claude: AnthropicIcon,
|
||||
codex: OpenAIIcon,
|
||||
cursor: CursorIcon,
|
||||
gemini: GeminiIcon,
|
||||
copilot: GitHubIcon,
|
||||
opencode: OpenCodeIcon,
|
||||
minimax: MiniMaxIcon,
|
||||
glm: GlmIcon,
|
||||
};
|
||||
|
||||
// Provider dashboard URLs
|
||||
const PROVIDER_DASHBOARD_URLS: Record<UsageProviderId, string | undefined> = {
|
||||
claude: 'https://status.claude.com',
|
||||
codex: 'https://platform.openai.com/usage',
|
||||
cursor: 'https://cursor.com/settings',
|
||||
gemini: 'https://aistudio.google.com',
|
||||
copilot: 'https://github.com/settings/copilot',
|
||||
opencode: 'https://opencode.ai',
|
||||
minimax: 'https://platform.minimax.io/user-center/payment/coding-plan',
|
||||
glm: 'https://z.ai/account',
|
||||
};
|
||||
|
||||
// Helper to get status color based on percentage
|
||||
function getStatusInfo(percentage: number) {
|
||||
if (percentage >= 90) return { color: 'text-red-500', icon: XCircle, bg: 'bg-red-500' };
|
||||
if (percentage >= 75) return { color: 'text-orange-500', icon: AlertTriangle, bg: 'bg-orange-500' };
|
||||
if (percentage >= 50) return { color: 'text-yellow-500', icon: AlertTriangle, bg: 'bg-yellow-500' };
|
||||
return { color: 'text-green-500', icon: CheckCircle, bg: 'bg-green-500' };
|
||||
}
|
||||
|
||||
// Progress bar component
|
||||
function ProgressBar({ percentage, colorClass }: { percentage: number; colorClass: string }) {
|
||||
return (
|
||||
<div className="h-2 w-full bg-secondary/50 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn('h-full transition-all duration-500', colorClass)}
|
||||
style={{ width: `${Math.min(percentage, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Usage card component
|
||||
function UsageCard({
|
||||
title,
|
||||
subtitle,
|
||||
percentage,
|
||||
resetText,
|
||||
isPrimary = false,
|
||||
stale = false,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
percentage: number;
|
||||
resetText?: string;
|
||||
isPrimary?: boolean;
|
||||
stale?: boolean;
|
||||
}) {
|
||||
const isValidPercentage =
|
||||
typeof percentage === 'number' && !isNaN(percentage) && isFinite(percentage);
|
||||
const safePercentage = isValidPercentage ? percentage : 0;
|
||||
|
||||
const status = getStatusInfo(safePercentage);
|
||||
const StatusIcon = status.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl border bg-card/50 p-3 transition-opacity',
|
||||
isPrimary ? 'border-border/60 shadow-sm' : 'border-border/40',
|
||||
(stale || !isValidPercentage) && 'opacity-50'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div>
|
||||
<h4 className={cn('font-semibold', isPrimary ? 'text-sm' : 'text-xs')}>{title}</h4>
|
||||
<p className="text-[10px] text-muted-foreground">{subtitle}</p>
|
||||
</div>
|
||||
{isValidPercentage ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<StatusIcon className={cn('w-3.5 h-3.5', status.color)} />
|
||||
<span
|
||||
className={cn(
|
||||
'font-mono font-bold',
|
||||
status.color,
|
||||
isPrimary ? 'text-base' : 'text-sm'
|
||||
)}
|
||||
>
|
||||
{Math.round(safePercentage)}%
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">N/A</span>
|
||||
)}
|
||||
</div>
|
||||
<ProgressBar
|
||||
percentage={safePercentage}
|
||||
colorClass={isValidPercentage ? status.bg : 'bg-muted-foreground/30'}
|
||||
/>
|
||||
{resetText && (
|
||||
<div className="mt-1.5 flex justify-end">
|
||||
<p className="text-[10px] text-muted-foreground flex items-center gap-1">
|
||||
<Clock className="w-2.5 h-2.5" />
|
||||
{resetText}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Provider usage panel component
|
||||
function ProviderUsagePanel({
|
||||
providerId,
|
||||
usage,
|
||||
isStale,
|
||||
}: {
|
||||
providerId: UsageProviderId;
|
||||
usage: ProviderUsage;
|
||||
isStale: boolean;
|
||||
}) {
|
||||
const ProviderIcon = PROVIDER_ICONS[providerId];
|
||||
const dashboardUrl = PROVIDER_DASHBOARD_URLS[providerId];
|
||||
|
||||
if (!usage.available) {
|
||||
return (
|
||||
<div className="p-3 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIcon className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">{usage.providerName}</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center py-4 text-center space-y-2">
|
||||
<AlertTriangle className="w-6 h-6 text-yellow-500/80" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{usage.error || 'Not available'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIcon className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">{usage.providerName}</span>
|
||||
</div>
|
||||
{usage.plan && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 bg-secondary rounded text-muted-foreground">
|
||||
{usage.plan.displayName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{usage.primary && (
|
||||
<UsageCard
|
||||
title={usage.primary.name}
|
||||
subtitle={usage.primary.windowDurationMins ? `${usage.primary.windowDurationMins}min window` : 'Usage quota'}
|
||||
percentage={usage.primary.usedPercent}
|
||||
resetText={usage.primary.resetText}
|
||||
isPrimary={true}
|
||||
stale={isStale}
|
||||
/>
|
||||
)}
|
||||
|
||||
{usage.secondary && (
|
||||
<UsageCard
|
||||
title={usage.secondary.name}
|
||||
subtitle={usage.secondary.windowDurationMins ? `${usage.secondary.windowDurationMins}min window` : 'Usage quota'}
|
||||
percentage={usage.secondary.usedPercent}
|
||||
resetText={usage.secondary.resetText}
|
||||
stale={isStale}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!usage.primary && !usage.secondary && (
|
||||
<div className="text-xs text-muted-foreground text-center py-2">
|
||||
{dashboardUrl ? (
|
||||
<>
|
||||
Check{' '}
|
||||
<a
|
||||
href={dashboardUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline hover:text-foreground"
|
||||
>
|
||||
dashboard
|
||||
</a>{' '}
|
||||
for details
|
||||
</>
|
||||
) : (
|
||||
'No usage data available'
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProviderUsageBar() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const {
|
||||
data: allUsage,
|
||||
isLoading,
|
||||
error,
|
||||
dataUpdatedAt,
|
||||
refetch,
|
||||
} = useAllProvidersUsage(open);
|
||||
|
||||
// Calculate overall max usage percentage
|
||||
const { maxPercent, maxProviderId, availableCount } = useMemo(() => {
|
||||
if (!allUsage?.providers) {
|
||||
return { maxPercent: 0, maxProviderId: null as UsageProviderId | null, availableCount: 0 };
|
||||
}
|
||||
|
||||
let max = 0;
|
||||
let maxId: UsageProviderId | null = null;
|
||||
let count = 0;
|
||||
|
||||
for (const [id, usage] of Object.entries(allUsage.providers)) {
|
||||
if (usage?.available) {
|
||||
count++;
|
||||
const percent = getMaxUsagePercent(usage);
|
||||
if (percent > max) {
|
||||
max = percent;
|
||||
maxId = id as UsageProviderId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { maxPercent: max, maxProviderId: maxId, availableCount: count };
|
||||
}, [allUsage]);
|
||||
|
||||
// Check if data is stale (older than 2 minutes)
|
||||
const isStale = !dataUpdatedAt || Date.now() - dataUpdatedAt > 2 * 60 * 1000;
|
||||
|
||||
const getProgressBarColor = (percentage: number) => {
|
||||
if (percentage >= 90) return 'bg-red-500';
|
||||
if (percentage >= 75) return 'bg-orange-500';
|
||||
if (percentage >= 50) return 'bg-yellow-500';
|
||||
return 'bg-green-500';
|
||||
};
|
||||
|
||||
// Get the icon for the provider with highest usage
|
||||
const MaxProviderIcon = maxProviderId ? PROVIDER_ICONS[maxProviderId] : AnthropicIcon;
|
||||
const statusColor = getStatusInfo(maxPercent).color;
|
||||
|
||||
// Get list of available providers for the dropdown
|
||||
const availableProviders = useMemo(() => {
|
||||
if (!allUsage?.providers) return [];
|
||||
return Object.entries(allUsage.providers)
|
||||
.filter(([_, usage]) => usage?.available)
|
||||
.map(([id, usage]) => ({ id: id as UsageProviderId, usage: usage! }));
|
||||
}, [allUsage]);
|
||||
|
||||
const trigger = (
|
||||
<Button variant="ghost" size="sm" className="h-9 gap-2 bg-secondary border border-border px-3">
|
||||
{availableCount > 0 && <MaxProviderIcon className={cn('w-4 h-4', statusColor)} />}
|
||||
<span className="text-sm font-medium">Usage</span>
|
||||
{availableCount > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
'h-1.5 w-16 bg-muted-foreground/20 rounded-full overflow-hidden transition-opacity',
|
||||
isStale && 'opacity-60'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn('h-full transition-all duration-500', getProgressBarColor(maxPercent))}
|
||||
style={{ width: `${Math.min(maxPercent, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{availableCount > 1 && (
|
||||
<span className="text-[10px] text-muted-foreground">+{availableCount - 1}</span>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-80 p-0 overflow-hidden bg-background/95 backdrop-blur-xl border-border shadow-2xl max-h-[80vh] overflow-y-auto"
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border/50 bg-secondary/10 sticky top-0 z-10">
|
||||
<span className="text-sm font-semibold">Provider Usage</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn('h-6 w-6', isLoading && 'animate-spin')}
|
||||
onClick={() => refetch()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="divide-y divide-border/50">
|
||||
{isLoading && !allUsage ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 space-y-2">
|
||||
<Spinner size="lg" />
|
||||
<p className="text-xs text-muted-foreground">Loading usage data...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex flex-col items-center justify-center py-6 text-center space-y-3 px-4">
|
||||
<AlertTriangle className="w-8 h-8 text-yellow-500/80" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">Failed to load usage</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{error instanceof Error ? error.message : 'Unknown error'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : availableProviders.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-6 text-center space-y-3 px-4">
|
||||
<AlertTriangle className="w-8 h-8 text-muted-foreground/50" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">No providers available</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Configure providers in Settings to track usage
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
availableProviders.map(({ id, usage }) => (
|
||||
<ProviderUsagePanel
|
||||
key={id}
|
||||
providerId={id}
|
||||
usage={usage}
|
||||
isStale={isStale}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-secondary/10 border-t border-border/50 sticky bottom-0">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{availableCount} provider{availableCount !== 1 ? 's' : ''} active
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">Updates every minute</span>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,97 @@
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import ReactMarkdown, { Components } from 'react-markdown';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
import rehypeSanitize from 'rehype-sanitize';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Square, CheckSquare } from 'lucide-react';
|
||||
|
||||
interface MarkdownProps {
|
||||
children: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a tasks code block as a proper task list with checkboxes
|
||||
*/
|
||||
function TasksBlock({ content }: { content: string }) {
|
||||
const lines = content.split('\n');
|
||||
|
||||
return (
|
||||
<div className="my-4 space-y-1">
|
||||
{lines.map((line, idx) => {
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Check for phase/section headers (## Phase 1: ...)
|
||||
const headerMatch = trimmed.match(/^##\s+(.+)$/);
|
||||
if (headerMatch) {
|
||||
return (
|
||||
<div key={idx} className="text-foreground font-semibold mt-4 mb-2 text-sm">
|
||||
{headerMatch[1]}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Check for task items (- [ ] or - [x])
|
||||
const taskMatch = trimmed.match(/^-\s*\[([ xX])\]\s*(.+)$/);
|
||||
if (taskMatch) {
|
||||
const isChecked = taskMatch[1].toLowerCase() === 'x';
|
||||
const taskText = taskMatch[2];
|
||||
|
||||
return (
|
||||
<div key={idx} className="flex items-start gap-2 py-1">
|
||||
{isChecked ? (
|
||||
<CheckSquare className="w-4 h-4 text-emerald-500 mt-0.5 flex-shrink-0" />
|
||||
) : (
|
||||
<Square className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm',
|
||||
isChecked ? 'text-muted-foreground line-through' : 'text-foreground-secondary'
|
||||
)}
|
||||
>
|
||||
{taskText}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Empty lines
|
||||
if (!trimmed) {
|
||||
return <div key={idx} className="h-2" />;
|
||||
}
|
||||
|
||||
// Other content (render as-is)
|
||||
return (
|
||||
<div key={idx} className="text-sm text-foreground-secondary">
|
||||
{trimmed}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom components for ReactMarkdown
|
||||
*/
|
||||
const markdownComponents: Components = {
|
||||
// Handle code blocks - special case for 'tasks' language
|
||||
code({ className, children }) {
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
const language = match ? match[1] : '';
|
||||
const content = String(children).replace(/\n$/, '');
|
||||
|
||||
// Special handling for tasks code blocks
|
||||
if (language === 'tasks') {
|
||||
return <TasksBlock content={content} />;
|
||||
}
|
||||
|
||||
// Regular code (inline or block)
|
||||
return <code className={className}>{children}</code>;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Reusable Markdown component for rendering markdown content
|
||||
* Theme-aware styling that adapts to all predefined themes
|
||||
@@ -42,10 +126,20 @@ export function Markdown({ children, className }: MarkdownProps) {
|
||||
'[&_hr]:border-border [&_hr]:my-4',
|
||||
// Images
|
||||
'[&_img]:max-w-full [&_img]:h-auto [&_img]:rounded-lg [&_img]:my-2 [&_img]:border [&_img]:border-border',
|
||||
// Tables
|
||||
'[&_table]:w-full [&_table]:border-collapse [&_table]:my-4',
|
||||
'[&_th]:border [&_th]:border-border [&_th]:bg-muted [&_th]:px-3 [&_th]:py-2 [&_th]:text-left [&_th]:text-foreground [&_th]:font-semibold',
|
||||
'[&_td]:border [&_td]:border-border [&_td]:px-3 [&_td]:py-2 [&_td]:text-foreground-secondary',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<ReactMarkdown rehypePlugins={[rehypeRaw, rehypeSanitize]}>{children}</ReactMarkdown>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw, rehypeSanitize]}
|
||||
components={markdownComponents}
|
||||
>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -395,6 +395,7 @@ export const PROVIDER_ICON_COMPONENTS: Record<
|
||||
cursor: CursorIcon,
|
||||
codex: OpenAIIcon,
|
||||
opencode: OpenCodeIcon,
|
||||
gemini: GeminiIcon,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
426
apps/ui/src/components/ui/test-logs-panel.tsx
Normal file
426
apps/ui/src/components/ui/test-logs-panel.tsx
Normal file
@@ -0,0 +1,426 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Terminal,
|
||||
ArrowDown,
|
||||
Square,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
Clock,
|
||||
GitBranch,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
FlaskConical,
|
||||
} from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { XtermLogViewer, type XtermLogViewerRef } from '@/components/ui/xterm-log-viewer';
|
||||
import { useTestLogs } from '@/hooks/use-test-logs';
|
||||
import { useIsMobile } from '@/hooks/use-media-query';
|
||||
import type { TestRunStatus } from '@/types/electron';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export interface TestLogsPanelProps {
|
||||
/** Whether the panel is open */
|
||||
open: boolean;
|
||||
/** Callback when the panel is closed */
|
||||
onClose: () => void;
|
||||
/** Path to the worktree to show test logs for */
|
||||
worktreePath: string | null;
|
||||
/** Branch name for display */
|
||||
branch?: string;
|
||||
/** Specific session ID to fetch logs for (optional) */
|
||||
sessionId?: string;
|
||||
/** Callback to stop the running tests */
|
||||
onStopTests?: () => void;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get status indicator based on test run status
|
||||
*/
|
||||
function getStatusIndicator(status: TestRunStatus | null): {
|
||||
text: string;
|
||||
className: string;
|
||||
icon?: React.ReactNode;
|
||||
} {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return {
|
||||
text: 'Running',
|
||||
className: 'bg-blue-500/10 text-blue-500',
|
||||
icon: <span className="w-1.5 h-1.5 rounded-full bg-blue-500 animate-pulse" />,
|
||||
};
|
||||
case 'pending':
|
||||
return {
|
||||
text: 'Pending',
|
||||
className: 'bg-amber-500/10 text-amber-500',
|
||||
icon: <Clock className="w-3 h-3" />,
|
||||
};
|
||||
case 'passed':
|
||||
return {
|
||||
text: 'Passed',
|
||||
className: 'bg-green-500/10 text-green-500',
|
||||
icon: <CheckCircle2 className="w-3 h-3" />,
|
||||
};
|
||||
case 'failed':
|
||||
return {
|
||||
text: 'Failed',
|
||||
className: 'bg-red-500/10 text-red-500',
|
||||
icon: <XCircle className="w-3 h-3" />,
|
||||
};
|
||||
case 'cancelled':
|
||||
return {
|
||||
text: 'Cancelled',
|
||||
className: 'bg-yellow-500/10 text-yellow-500',
|
||||
icon: <AlertCircle className="w-3 h-3" />,
|
||||
};
|
||||
case 'error':
|
||||
return {
|
||||
text: 'Error',
|
||||
className: 'bg-red-500/10 text-red-500',
|
||||
icon: <AlertCircle className="w-3 h-3" />,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
text: 'Idle',
|
||||
className: 'bg-muted text-muted-foreground',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration in milliseconds to human-readable string
|
||||
*/
|
||||
function formatDuration(ms: number | null): string | null {
|
||||
if (ms === null) return null;
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
const minutes = Math.floor(ms / 60000);
|
||||
const seconds = ((ms % 60000) / 1000).toFixed(0);
|
||||
return `${minutes}m ${seconds}s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format timestamp to localized time string
|
||||
*/
|
||||
function formatTime(timestamp: string | null): string | null {
|
||||
if (!timestamp) return null;
|
||||
try {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleTimeString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Inner Content Component
|
||||
// ============================================================================
|
||||
|
||||
interface TestLogsPanelContentProps {
|
||||
worktreePath: string | null;
|
||||
branch?: string;
|
||||
sessionId?: string;
|
||||
onStopTests?: () => void;
|
||||
}
|
||||
|
||||
function TestLogsPanelContent({
|
||||
worktreePath,
|
||||
branch,
|
||||
sessionId,
|
||||
onStopTests,
|
||||
}: TestLogsPanelContentProps) {
|
||||
const xtermRef = useRef<XtermLogViewerRef>(null);
|
||||
const [autoScrollEnabled, setAutoScrollEnabled] = useState(true);
|
||||
const lastLogsLengthRef = useRef(0);
|
||||
const lastSessionIdRef = useRef<string | null>(null);
|
||||
|
||||
const {
|
||||
logs,
|
||||
isLoading,
|
||||
error,
|
||||
status,
|
||||
sessionId: currentSessionId,
|
||||
command,
|
||||
testFile,
|
||||
startedAt,
|
||||
exitCode,
|
||||
duration,
|
||||
isRunning,
|
||||
fetchLogs,
|
||||
} = useTestLogs({
|
||||
worktreePath,
|
||||
sessionId,
|
||||
autoSubscribe: true,
|
||||
});
|
||||
|
||||
// Write logs to xterm when they change
|
||||
useEffect(() => {
|
||||
if (!xtermRef.current || !logs) return;
|
||||
|
||||
// If session changed, reset the terminal and write all content
|
||||
if (lastSessionIdRef.current !== currentSessionId) {
|
||||
lastSessionIdRef.current = currentSessionId;
|
||||
lastLogsLengthRef.current = 0;
|
||||
xtermRef.current.write(logs);
|
||||
lastLogsLengthRef.current = logs.length;
|
||||
return;
|
||||
}
|
||||
|
||||
// If logs got shorter (e.g., cleared), rewrite all
|
||||
if (logs.length < lastLogsLengthRef.current) {
|
||||
xtermRef.current.write(logs);
|
||||
lastLogsLengthRef.current = logs.length;
|
||||
return;
|
||||
}
|
||||
|
||||
// Append only the new content
|
||||
if (logs.length > lastLogsLengthRef.current) {
|
||||
const newContent = logs.slice(lastLogsLengthRef.current);
|
||||
xtermRef.current.append(newContent);
|
||||
lastLogsLengthRef.current = logs.length;
|
||||
}
|
||||
}, [logs, currentSessionId]);
|
||||
|
||||
// Reset auto-scroll when session changes
|
||||
useEffect(() => {
|
||||
if (currentSessionId !== lastSessionIdRef.current) {
|
||||
setAutoScrollEnabled(true);
|
||||
lastLogsLengthRef.current = 0;
|
||||
}
|
||||
}, [currentSessionId]);
|
||||
|
||||
// Scroll to bottom handler
|
||||
const scrollToBottom = useCallback(() => {
|
||||
xtermRef.current?.scrollToBottom();
|
||||
setAutoScrollEnabled(true);
|
||||
}, []);
|
||||
|
||||
const statusIndicator = getStatusIndicator(status);
|
||||
const formattedStartTime = formatTime(startedAt);
|
||||
const formattedDuration = formatDuration(duration);
|
||||
const lineCount = logs ? logs.split('\n').length : 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Header */}
|
||||
<DialogHeader className="shrink-0 px-4 py-3 border-b border-border/50 pr-12">
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle className="flex items-center gap-2 text-base">
|
||||
<FlaskConical className="w-4 h-4 text-primary" />
|
||||
<span>Test Runner</span>
|
||||
{status && (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium',
|
||||
statusIndicator.className
|
||||
)}
|
||||
>
|
||||
{statusIndicator.icon}
|
||||
{statusIndicator.text}
|
||||
</span>
|
||||
)}
|
||||
{formattedDuration && !isRunning && (
|
||||
<span className="text-xs text-muted-foreground font-mono">{formattedDuration}</span>
|
||||
)}
|
||||
</DialogTitle>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isRunning && onStopTests && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2.5 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={onStopTests}
|
||||
>
|
||||
<Square className="w-3 h-3 mr-1.5 fill-current" />
|
||||
Stop
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => fetchLogs()}
|
||||
title="Refresh logs"
|
||||
>
|
||||
{isLoading ? <Spinner size="xs" /> : <RefreshCw className="w-3.5 h-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info bar */}
|
||||
<div className="flex items-center gap-3 mt-2 text-xs text-muted-foreground">
|
||||
{branch && (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<GitBranch className="w-3 h-3" />
|
||||
<span className="font-medium text-foreground/80">{branch}</span>
|
||||
</span>
|
||||
)}
|
||||
{command && (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="text-muted-foreground/60">Command</span>
|
||||
<span className="font-mono text-primary truncate max-w-[200px]">{command}</span>
|
||||
</span>
|
||||
)}
|
||||
{testFile && (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="text-muted-foreground/60">File</span>
|
||||
<span className="font-mono truncate max-w-[150px]">{testFile}</span>
|
||||
</span>
|
||||
)}
|
||||
{formattedStartTime && (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Clock className="w-3 h-3" />
|
||||
{formattedStartTime}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Error displays */}
|
||||
{error && (
|
||||
<div className="shrink-0 px-4 py-2 bg-destructive/5 border-b border-destructive/20">
|
||||
<div className="flex items-center gap-2 text-xs text-destructive">
|
||||
<AlertCircle className="w-3.5 h-3.5 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Log content area */}
|
||||
<div className="flex-1 min-h-0 overflow-hidden bg-zinc-950" data-testid="test-logs-content">
|
||||
{isLoading && !logs ? (
|
||||
<div className="flex items-center justify-center h-full min-h-[300px] text-muted-foreground">
|
||||
<Spinner size="md" className="mr-2" />
|
||||
<span className="text-sm">Loading logs...</span>
|
||||
</div>
|
||||
) : !logs && !isRunning && !status ? (
|
||||
<div className="flex flex-col items-center justify-center h-full min-h-[300px] text-muted-foreground p-8">
|
||||
<Terminal className="w-10 h-10 mb-3 opacity-20" />
|
||||
<p className="text-sm">No test run active</p>
|
||||
<p className="text-xs mt-1 opacity-60">Start a test run to see logs here</p>
|
||||
</div>
|
||||
) : isRunning && !logs ? (
|
||||
<div className="flex flex-col items-center justify-center h-full min-h-[300px] text-muted-foreground p-8">
|
||||
<Spinner size="xl" className="mb-3" />
|
||||
<p className="text-sm">Waiting for output...</p>
|
||||
<p className="text-xs mt-1 opacity-60">Logs will appear as tests generate output</p>
|
||||
</div>
|
||||
) : (
|
||||
<XtermLogViewer
|
||||
ref={xtermRef}
|
||||
className="h-full"
|
||||
minHeight={280}
|
||||
autoScroll={autoScrollEnabled}
|
||||
onScrollAwayFromBottom={() => setAutoScrollEnabled(false)}
|
||||
onScrollToBottom={() => setAutoScrollEnabled(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer status bar */}
|
||||
<div className="shrink-0 flex items-center justify-between px-4 py-2 bg-muted/30 border-t border-border/50 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-mono">{lineCount > 0 ? `${lineCount} lines` : 'No output'}</span>
|
||||
{exitCode !== null && (
|
||||
<span className={cn('font-mono', exitCode === 0 ? 'text-green-500' : 'text-red-500')}>
|
||||
Exit: {exitCode}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!autoScrollEnabled && logs && (
|
||||
<button
|
||||
onClick={scrollToBottom}
|
||||
className="inline-flex items-center gap-1.5 px-2 py-1 rounded hover:bg-muted transition-colors text-primary"
|
||||
>
|
||||
<ArrowDown className="w-3 h-3" />
|
||||
Scroll to bottom
|
||||
</button>
|
||||
)}
|
||||
{autoScrollEnabled && logs && (
|
||||
<span className="inline-flex items-center gap-1.5 opacity-60">
|
||||
<ArrowDown className="w-3 h-3" />
|
||||
Auto-scroll
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Component
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Panel component for displaying test runner logs with ANSI color rendering
|
||||
* and real-time streaming support.
|
||||
*
|
||||
* Features:
|
||||
* - Real-time log streaming via WebSocket
|
||||
* - Full ANSI color code rendering via xterm.js
|
||||
* - Auto-scroll to bottom (can be paused by scrolling up)
|
||||
* - Test status indicators (pending, running, passed, failed, etc.)
|
||||
* - Dialog on desktop, Sheet on mobile
|
||||
* - Quick actions (stop tests, refresh logs)
|
||||
*/
|
||||
export function TestLogsPanel({
|
||||
open,
|
||||
onClose,
|
||||
worktreePath,
|
||||
branch,
|
||||
sessionId,
|
||||
onStopTests,
|
||||
}: TestLogsPanelProps) {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
if (!worktreePath) return null;
|
||||
|
||||
// Mobile: use Sheet (bottom drawer)
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<SheetContent side="bottom" className="h-[80vh] p-0 flex flex-col">
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Test Logs</SheetTitle>
|
||||
</SheetHeader>
|
||||
<TestLogsPanelContent
|
||||
worktreePath={worktreePath}
|
||||
branch={branch}
|
||||
sessionId={sessionId}
|
||||
onStopTests={onStopTests}
|
||||
/>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop: use Dialog
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent
|
||||
className="w-full h-full max-w-full max-h-full sm:w-[70vw] sm:max-w-[900px] sm:max-h-[85vh] sm:h-auto sm:rounded-xl rounded-none flex flex-col gap-0 p-0 overflow-hidden"
|
||||
data-testid="test-logs-panel"
|
||||
compact
|
||||
>
|
||||
<TestLogsPanelContent
|
||||
worktreePath={worktreePath}
|
||||
branch={branch}
|
||||
sessionId={sessionId}
|
||||
onStopTests={onStopTests}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -87,6 +87,7 @@ import { usePipelineConfig } from '@/hooks/queries';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { queryKeys } from '@/lib/query-keys';
|
||||
import { useAutoModeQueryInvalidation } from '@/hooks/use-query-invalidation';
|
||||
import { useUpdateGlobalSettings } from '@/hooks/mutations/use-settings-mutations';
|
||||
|
||||
// Stable empty array to avoid infinite loop in selector
|
||||
const EMPTY_WORKTREES: ReturnType<ReturnType<typeof useAppStore.getState>['getWorktrees']> = [];
|
||||
@@ -451,6 +452,8 @@ export function BoardView() {
|
||||
const maxConcurrency = autoMode.maxConcurrency;
|
||||
// Get worktree-specific setter
|
||||
const setMaxConcurrencyForWorktree = useAppStore((state) => state.setMaxConcurrencyForWorktree);
|
||||
// Mutation to persist maxConcurrency to server settings
|
||||
const updateGlobalSettings = useUpdateGlobalSettings({ showSuccessToast: false });
|
||||
|
||||
// Get the current branch from the selected worktree (not from store which may be stale)
|
||||
const currentWorktreeBranch = selectedWorktree?.branch ?? null;
|
||||
@@ -1277,6 +1280,15 @@ export function BoardView() {
|
||||
if (currentProject && selectedWorktree) {
|
||||
const branchName = selectedWorktree.isMain ? null : selectedWorktree.branch;
|
||||
setMaxConcurrencyForWorktree(currentProject.id, branchName, newMaxConcurrency);
|
||||
|
||||
// Persist to server settings so capacity checks use the correct value
|
||||
const worktreeKey = `${currentProject.id}::${branchName ?? '__main__'}`;
|
||||
updateGlobalSettings.mutate({
|
||||
autoModeByWorktree: {
|
||||
[worktreeKey]: { maxConcurrency: newMaxConcurrency },
|
||||
},
|
||||
});
|
||||
|
||||
// Also update backend if auto mode is running
|
||||
if (autoMode.isRunning) {
|
||||
// Restart auto mode with new concurrency (backend will handle this)
|
||||
@@ -1489,6 +1501,7 @@ export function BoardView() {
|
||||
branchSuggestions={branchSuggestions}
|
||||
branchCardCounts={branchCardCounts}
|
||||
currentBranch={currentWorktreeBranch || undefined}
|
||||
projectPath={currentProject?.path}
|
||||
/>
|
||||
|
||||
{/* Board Background Modal */}
|
||||
@@ -1538,6 +1551,7 @@ export function BoardView() {
|
||||
isMaximized={isMaximized}
|
||||
parentFeature={spawnParentFeature}
|
||||
allFeatures={hookFeatures}
|
||||
projectPath={currentProject?.path}
|
||||
// When setting is enabled and a non-main worktree is selected, pass its branch to default to 'custom' work mode
|
||||
selectedNonMainWorktreeBranch={
|
||||
addFeatureUseSelectedWorktreeBranch && currentWorktreePath !== null
|
||||
@@ -1568,6 +1582,7 @@ export function BoardView() {
|
||||
currentBranch={currentWorktreeBranch || undefined}
|
||||
isMaximized={isMaximized}
|
||||
allFeatures={hookFeatures}
|
||||
projectPath={currentProject?.path}
|
||||
/>
|
||||
|
||||
{/* Agent Output Modal */}
|
||||
|
||||
@@ -10,20 +10,22 @@ interface BoardControlsProps {
|
||||
export function BoardControls({ isMounted, onShowBoardBackground }: BoardControlsProps) {
|
||||
if (!isMounted) return null;
|
||||
|
||||
const buttonClass = cn(
|
||||
'inline-flex h-8 items-center justify-center rounded-md px-2 text-sm font-medium transition-all duration-200 cursor-pointer',
|
||||
'text-muted-foreground hover:text-foreground hover:bg-accent',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
'border border-border'
|
||||
);
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Board Background Button */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={onShowBoardBackground}
|
||||
className={cn(
|
||||
'inline-flex h-8 items-center justify-center rounded-md px-2 text-sm font-medium transition-all duration-200 cursor-pointer',
|
||||
'text-muted-foreground hover:text-foreground hover:bg-accent',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
'border border-border'
|
||||
)}
|
||||
className={buttonClass}
|
||||
data-testid="board-background-button"
|
||||
>
|
||||
<ImageIcon className="w-4 h-4" />
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useState } from 'react';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Wand2, GitBranch, ClipboardCheck } from 'lucide-react';
|
||||
import { UsagePopover } from '@/components/usage-popover';
|
||||
import { ProviderUsageBar } from '@/components/provider-usage-bar';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useSetupStore } from '@/store/setup-store';
|
||||
import { useIsTablet } from '@/hooks/use-media-query';
|
||||
@@ -127,8 +127,8 @@ export function BoardHeader({
|
||||
<BoardControls isMounted={isMounted} onShowBoardBackground={onShowBoardBackground} />
|
||||
</div>
|
||||
<div className="flex gap-4 items-center">
|
||||
{/* Usage Popover - show if either provider is authenticated, only on desktop */}
|
||||
{isMounted && !isTablet && (showClaudeUsage || showCodexUsage) && <UsagePopover />}
|
||||
{/* Provider Usage Bar - shows all available providers, only on desktop */}
|
||||
{isMounted && !isTablet && <ProviderUsageBar />}
|
||||
|
||||
{/* Tablet/Mobile view: show hamburger menu with all controls */}
|
||||
{isMounted && isTablet && (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useEffect, useState, useMemo } from 'react';
|
||||
import { memo, useEffect, useState, useMemo, useRef } from 'react';
|
||||
import { Feature, ThinkingLevel, ParsedTask } from '@/store/app-store';
|
||||
import type { ReasoningEffort } from '@automaker/types';
|
||||
import { getProviderFromModel } from '@/lib/utils';
|
||||
@@ -69,21 +69,70 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
|
||||
const [taskStatusMap, setTaskStatusMap] = useState<
|
||||
Map<string, 'pending' | 'in_progress' | 'completed'>
|
||||
>(new Map());
|
||||
// Track last WebSocket event timestamp to know if we're receiving real-time updates
|
||||
const [lastWsEventTimestamp, setLastWsEventTimestamp] = useState<number | null>(null);
|
||||
|
||||
// Determine if we should poll for updates
|
||||
const shouldPoll = isCurrentAutoTask || feature.status === 'in_progress';
|
||||
const shouldFetchData = feature.status !== 'backlog';
|
||||
|
||||
// Track whether we're receiving WebSocket events (within threshold)
|
||||
// Use a state to trigger re-renders when the WebSocket connection becomes stale
|
||||
const [isReceivingWsEvents, setIsReceivingWsEvents] = useState(false);
|
||||
const wsEventTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// WebSocket activity threshold in ms - if no events within this time, consider WS inactive
|
||||
const WS_ACTIVITY_THRESHOLD = 10000;
|
||||
|
||||
// Update isReceivingWsEvents when we get new WebSocket events
|
||||
useEffect(() => {
|
||||
if (lastWsEventTimestamp !== null) {
|
||||
// We just received an event, mark as active
|
||||
setIsReceivingWsEvents(true);
|
||||
|
||||
// Clear any existing timeout
|
||||
if (wsEventTimeoutRef.current) {
|
||||
clearTimeout(wsEventTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Set a timeout to mark as inactive if no new events
|
||||
wsEventTimeoutRef.current = setTimeout(() => {
|
||||
setIsReceivingWsEvents(false);
|
||||
}, WS_ACTIVITY_THRESHOLD);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (wsEventTimeoutRef.current) {
|
||||
clearTimeout(wsEventTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [lastWsEventTimestamp]);
|
||||
|
||||
// Polling interval logic:
|
||||
// - If receiving WebSocket events: use longer interval (10s) as a fallback
|
||||
// - If not receiving WebSocket events but in_progress: use normal interval (3s)
|
||||
// - Otherwise: no polling
|
||||
const pollingInterval = useMemo((): number | false => {
|
||||
if (!(isCurrentAutoTask || feature.status === 'in_progress')) {
|
||||
return false;
|
||||
}
|
||||
// If receiving WebSocket events, use longer polling interval as fallback
|
||||
if (isReceivingWsEvents) {
|
||||
return WS_ACTIVITY_THRESHOLD;
|
||||
}
|
||||
// Default polling interval
|
||||
return 3000;
|
||||
}, [isCurrentAutoTask, feature.status, isReceivingWsEvents]);
|
||||
|
||||
// Fetch fresh feature data for planSpec (store data can be stale for task progress)
|
||||
const { data: freshFeature } = useFeature(projectPath, feature.id, {
|
||||
enabled: shouldFetchData && !contextContent,
|
||||
pollingInterval: shouldPoll ? 3000 : false,
|
||||
pollingInterval,
|
||||
});
|
||||
|
||||
// Fetch agent output for parsing
|
||||
const { data: agentOutputContent } = useAgentOutput(projectPath, feature.id, {
|
||||
enabled: shouldFetchData && !contextContent,
|
||||
pollingInterval: shouldPoll ? 3000 : false,
|
||||
pollingInterval,
|
||||
});
|
||||
|
||||
// Parse agent output into agentInfo
|
||||
@@ -174,6 +223,9 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
|
||||
// Only handle events for this feature
|
||||
if (!('featureId' in event) || event.featureId !== feature.id) return;
|
||||
|
||||
// Update timestamp for any event related to this feature
|
||||
setLastWsEventTimestamp(Date.now());
|
||||
|
||||
switch (event.type) {
|
||||
case 'auto_mode_task_started':
|
||||
if ('taskId' in event) {
|
||||
|
||||
@@ -3,9 +3,10 @@ import { memo, useEffect, useMemo, useState } from 'react';
|
||||
import { Feature, useAppStore } from '@/store/app-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { AlertCircle, Lock, Hand, Sparkles } from 'lucide-react';
|
||||
import { AlertCircle, Lock, Hand, Sparkles, SkipForward } from 'lucide-react';
|
||||
import { getBlockingDependencies } from '@automaker/dependency-resolver';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { usePipelineConfig } from '@/hooks/queries/use-pipeline';
|
||||
|
||||
/** Uniform badge style for all card badges */
|
||||
const uniformBadgeClass =
|
||||
@@ -51,9 +52,13 @@ export const CardBadges = memo(function CardBadges({ feature }: CardBadgesProps)
|
||||
|
||||
interface PriorityBadgesProps {
|
||||
feature: Feature;
|
||||
projectPath?: string;
|
||||
}
|
||||
|
||||
export const PriorityBadges = memo(function PriorityBadges({ feature }: PriorityBadgesProps) {
|
||||
export const PriorityBadges = memo(function PriorityBadges({
|
||||
feature,
|
||||
projectPath,
|
||||
}: PriorityBadgesProps) {
|
||||
const { enableDependencyBlocking, features } = useAppStore(
|
||||
useShallow((state) => ({
|
||||
enableDependencyBlocking: state.enableDependencyBlocking,
|
||||
@@ -62,6 +67,9 @@ export const PriorityBadges = memo(function PriorityBadges({ feature }: Priority
|
||||
);
|
||||
const [currentTime, setCurrentTime] = useState(() => Date.now());
|
||||
|
||||
// Fetch pipeline config to check if there are pipelines to exclude
|
||||
const { data: pipelineConfig } = usePipelineConfig(projectPath);
|
||||
|
||||
// Calculate blocking dependencies (if feature is in backlog and has incomplete dependencies)
|
||||
const blockingDependencies = useMemo(() => {
|
||||
if (!enableDependencyBlocking || feature.status !== 'backlog') {
|
||||
@@ -108,7 +116,19 @@ export const PriorityBadges = memo(function PriorityBadges({ feature }: Priority
|
||||
const showManualVerification =
|
||||
feature.skipTests && !feature.error && feature.status === 'backlog';
|
||||
|
||||
const showBadges = feature.priority || showManualVerification || isBlocked || isJustFinished;
|
||||
// Check if feature has excluded pipeline steps
|
||||
const excludedStepCount = feature.excludedPipelineSteps?.length || 0;
|
||||
const totalPipelineSteps = pipelineConfig?.steps?.length || 0;
|
||||
const hasPipelineExclusions =
|
||||
excludedStepCount > 0 && totalPipelineSteps > 0 && feature.status === 'backlog';
|
||||
const allPipelinesExcluded = hasPipelineExclusions && excludedStepCount >= totalPipelineSteps;
|
||||
|
||||
const showBadges =
|
||||
feature.priority ||
|
||||
showManualVerification ||
|
||||
isBlocked ||
|
||||
isJustFinished ||
|
||||
hasPipelineExclusions;
|
||||
|
||||
if (!showBadges) {
|
||||
return null;
|
||||
@@ -227,6 +247,39 @@ export const PriorityBadges = memo(function PriorityBadges({ feature }: Priority
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{/* Pipeline exclusion badge */}
|
||||
{hasPipelineExclusions && (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
uniformBadgeClass,
|
||||
allPipelinesExcluded
|
||||
? 'bg-violet-500/20 border-violet-500/50 text-violet-500'
|
||||
: 'bg-violet-500/10 border-violet-500/30 text-violet-400'
|
||||
)}
|
||||
data-testid={`pipeline-exclusion-badge-${feature.id}`}
|
||||
>
|
||||
<SkipForward className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs max-w-[250px]">
|
||||
<p className="font-medium mb-1">
|
||||
{allPipelinesExcluded
|
||||
? 'All pipelines skipped'
|
||||
: `${excludedStepCount} of ${totalPipelineSteps} pipeline${totalPipelineSteps !== 1 ? 's' : ''} skipped`}
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
{allPipelinesExcluded
|
||||
? 'This feature will skip all custom pipeline steps'
|
||||
: 'Some custom pipeline steps will be skipped for this feature'}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -136,8 +136,9 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
});
|
||||
|
||||
// Make the card a drop target for creating dependency links
|
||||
// Only backlog cards can be link targets (to avoid complexity with running features)
|
||||
const isDroppable = !isOverlay && feature.status === 'backlog' && !isSelectionMode;
|
||||
// All non-completed cards can be link targets to allow flexible dependency creation
|
||||
// (completed features are excluded as they're already done)
|
||||
const isDroppable = !isOverlay && feature.status !== 'completed' && !isSelectionMode;
|
||||
const { setNodeRef: setDroppableRef, isOver } = useDroppable({
|
||||
id: `card-drop-${feature.id}`,
|
||||
disabled: !isDroppable,
|
||||
@@ -236,7 +237,7 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
</div>
|
||||
|
||||
{/* Priority and Manual Verification badges */}
|
||||
<PriorityBadges feature={feature} />
|
||||
<PriorityBadges feature={feature} projectPath={currentProject?.path} />
|
||||
|
||||
{/* Card Header */}
|
||||
<CardHeaderSection
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
AncestorContextSection,
|
||||
EnhanceWithAI,
|
||||
EnhancementHistoryButton,
|
||||
PipelineExclusionControls,
|
||||
type BaseHistoryEntry,
|
||||
} from '../shared';
|
||||
import type { WorkMode } from '../shared';
|
||||
@@ -101,6 +102,7 @@ type FeatureData = {
|
||||
requirePlanApproval: boolean;
|
||||
dependencies?: string[];
|
||||
childDependencies?: string[]; // Feature IDs that should depend on this feature
|
||||
excludedPipelineSteps?: string[]; // Pipeline step IDs to skip for this feature
|
||||
workMode: WorkMode;
|
||||
};
|
||||
|
||||
@@ -118,6 +120,10 @@ interface AddFeatureDialogProps {
|
||||
isMaximized: boolean;
|
||||
parentFeature?: Feature | null;
|
||||
allFeatures?: Feature[];
|
||||
/**
|
||||
* Path to the current project for loading pipeline config.
|
||||
*/
|
||||
projectPath?: string;
|
||||
/**
|
||||
* When a non-main worktree is selected in the board header, this will be set to that worktree's branch.
|
||||
* When set, the dialog will default to 'custom' work mode with this branch pre-filled.
|
||||
@@ -151,6 +157,7 @@ export function AddFeatureDialog({
|
||||
isMaximized,
|
||||
parentFeature = null,
|
||||
allFeatures = [],
|
||||
projectPath,
|
||||
selectedNonMainWorktreeBranch,
|
||||
forceCurrentBranchMode,
|
||||
}: AddFeatureDialogProps) {
|
||||
@@ -194,9 +201,20 @@ export function AddFeatureDialog({
|
||||
const [parentDependencies, setParentDependencies] = useState<string[]>([]);
|
||||
const [childDependencies, setChildDependencies] = useState<string[]>([]);
|
||||
|
||||
// Pipeline exclusion state
|
||||
const [excludedPipelineSteps, setExcludedPipelineSteps] = useState<string[]>([]);
|
||||
|
||||
// Get defaults from store
|
||||
const { defaultPlanningMode, defaultRequirePlanApproval, useWorktrees, defaultFeatureModel } =
|
||||
useAppStore();
|
||||
const {
|
||||
defaultPlanningMode,
|
||||
defaultRequirePlanApproval,
|
||||
useWorktrees,
|
||||
defaultFeatureModel,
|
||||
currentProject,
|
||||
} = useAppStore();
|
||||
|
||||
// Use project-level default feature model if set, otherwise fall back to global
|
||||
const effectiveDefaultFeatureModel = currentProject?.defaultFeatureModel ?? defaultFeatureModel;
|
||||
|
||||
// Track previous open state to detect when dialog opens
|
||||
const wasOpenRef = useRef(false);
|
||||
@@ -216,7 +234,7 @@ export function AddFeatureDialog({
|
||||
);
|
||||
setPlanningMode(defaultPlanningMode);
|
||||
setRequirePlanApproval(defaultRequirePlanApproval);
|
||||
setModelEntry(defaultFeatureModel);
|
||||
setModelEntry(effectiveDefaultFeatureModel);
|
||||
|
||||
// Initialize description history (empty for new feature)
|
||||
setDescriptionHistory([]);
|
||||
@@ -234,6 +252,9 @@ export function AddFeatureDialog({
|
||||
// Reset dependency selections
|
||||
setParentDependencies([]);
|
||||
setChildDependencies([]);
|
||||
|
||||
// Reset pipeline exclusions (all pipelines enabled by default)
|
||||
setExcludedPipelineSteps([]);
|
||||
}
|
||||
}, [
|
||||
open,
|
||||
@@ -241,7 +262,7 @@ export function AddFeatureDialog({
|
||||
defaultBranch,
|
||||
defaultPlanningMode,
|
||||
defaultRequirePlanApproval,
|
||||
defaultFeatureModel,
|
||||
effectiveDefaultFeatureModel,
|
||||
useWorktrees,
|
||||
selectedNonMainWorktreeBranch,
|
||||
forceCurrentBranchMode,
|
||||
@@ -328,6 +349,7 @@ export function AddFeatureDialog({
|
||||
requirePlanApproval,
|
||||
dependencies: finalDependencies,
|
||||
childDependencies: childDependencies.length > 0 ? childDependencies : undefined,
|
||||
excludedPipelineSteps: excludedPipelineSteps.length > 0 ? excludedPipelineSteps : undefined,
|
||||
workMode,
|
||||
};
|
||||
};
|
||||
@@ -343,7 +365,7 @@ export function AddFeatureDialog({
|
||||
// When a non-main worktree is selected, use its branch name for custom mode
|
||||
setBranchName(selectedNonMainWorktreeBranch || '');
|
||||
setPriority(2);
|
||||
setModelEntry(defaultFeatureModel);
|
||||
setModelEntry(effectiveDefaultFeatureModel);
|
||||
setWorkMode(
|
||||
getDefaultWorkMode(useWorktrees, selectedNonMainWorktreeBranch, forceCurrentBranchMode)
|
||||
);
|
||||
@@ -354,6 +376,7 @@ export function AddFeatureDialog({
|
||||
setDescriptionHistory([]);
|
||||
setParentDependencies([]);
|
||||
setChildDependencies([]);
|
||||
setExcludedPipelineSteps([]);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
@@ -696,6 +719,16 @@ export function AddFeatureDialog({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pipeline Exclusion Controls */}
|
||||
<div className="pt-2">
|
||||
<PipelineExclusionControls
|
||||
projectPath={projectPath}
|
||||
excludedPipelineSteps={excludedPipelineSteps}
|
||||
onExcludedStepsChange={setExcludedPipelineSteps}
|
||||
testIdPrefix="add-feature-pipeline"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ import { Button } from '@/components/ui/button';
|
||||
import { ArrowDown, ArrowUp, Link2, X } from 'lucide-react';
|
||||
import type { Feature } from '@/store/app-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { StatusBadge } from '../components';
|
||||
import type { FeatureStatusWithPipeline } from '@automaker/types';
|
||||
|
||||
export type DependencyLinkType = 'parent' | 'child';
|
||||
|
||||
@@ -57,7 +59,10 @@ export function DependencyLinkDialog({
|
||||
<div className="py-4 space-y-4">
|
||||
{/* Dragged feature */}
|
||||
<div className="p-3 rounded-lg border bg-muted/30">
|
||||
<div className="text-xs text-muted-foreground mb-1">Dragged Feature</div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-xs text-muted-foreground">Dragged Feature</span>
|
||||
<StatusBadge status={draggedFeature.status as FeatureStatusWithPipeline} size="sm" />
|
||||
</div>
|
||||
<div className="text-sm font-medium line-clamp-3 break-words">
|
||||
{draggedFeature.description}
|
||||
</div>
|
||||
@@ -71,7 +76,10 @@ export function DependencyLinkDialog({
|
||||
|
||||
{/* Target feature */}
|
||||
<div className="p-3 rounded-lg border bg-muted/30">
|
||||
<div className="text-xs text-muted-foreground mb-1">Target Feature</div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-xs text-muted-foreground">Target Feature</span>
|
||||
<StatusBadge status={targetFeature.status as FeatureStatusWithPipeline} size="sm" />
|
||||
</div>
|
||||
<div className="text-sm font-medium line-clamp-3 break-words">
|
||||
{targetFeature.description}
|
||||
</div>
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
PlanningModeSelect,
|
||||
EnhanceWithAI,
|
||||
EnhancementHistoryButton,
|
||||
PipelineExclusionControls,
|
||||
type EnhancementMode,
|
||||
} from '../shared';
|
||||
import type { WorkMode } from '../shared';
|
||||
@@ -67,6 +68,7 @@ interface EditFeatureDialogProps {
|
||||
requirePlanApproval: boolean;
|
||||
dependencies?: string[];
|
||||
childDependencies?: string[]; // Feature IDs that should depend on this feature
|
||||
excludedPipelineSteps?: string[]; // Pipeline step IDs to skip for this feature
|
||||
},
|
||||
descriptionHistorySource?: 'enhance' | 'edit',
|
||||
enhancementMode?: EnhancementMode,
|
||||
@@ -78,6 +80,7 @@ interface EditFeatureDialogProps {
|
||||
currentBranch?: string;
|
||||
isMaximized: boolean;
|
||||
allFeatures: Feature[];
|
||||
projectPath?: string;
|
||||
}
|
||||
|
||||
export function EditFeatureDialog({
|
||||
@@ -90,6 +93,7 @@ export function EditFeatureDialog({
|
||||
currentBranch,
|
||||
isMaximized,
|
||||
allFeatures,
|
||||
projectPath,
|
||||
}: EditFeatureDialogProps) {
|
||||
const navigate = useNavigate();
|
||||
const [editingFeature, setEditingFeature] = useState<Feature | null>(feature);
|
||||
@@ -146,6 +150,11 @@ export function EditFeatureDialog({
|
||||
return allFeatures.filter((f) => f.dependencies?.includes(feature.id)).map((f) => f.id);
|
||||
});
|
||||
|
||||
// Pipeline exclusion state
|
||||
const [excludedPipelineSteps, setExcludedPipelineSteps] = useState<string[]>(
|
||||
feature?.excludedPipelineSteps ?? []
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setEditingFeature(feature);
|
||||
if (feature) {
|
||||
@@ -171,6 +180,8 @@ export function EditFeatureDialog({
|
||||
.map((f) => f.id);
|
||||
setChildDependencies(childDeps);
|
||||
setOriginalChildDependencies(childDeps);
|
||||
// Reset pipeline exclusion state
|
||||
setExcludedPipelineSteps(feature.excludedPipelineSteps ?? []);
|
||||
} else {
|
||||
setEditFeaturePreviewMap(new Map());
|
||||
setDescriptionChangeSource(null);
|
||||
@@ -179,6 +190,7 @@ export function EditFeatureDialog({
|
||||
setParentDependencies([]);
|
||||
setChildDependencies([]);
|
||||
setOriginalChildDependencies([]);
|
||||
setExcludedPipelineSteps([]);
|
||||
}
|
||||
}, [feature, allFeatures]);
|
||||
|
||||
@@ -232,6 +244,7 @@ export function EditFeatureDialog({
|
||||
workMode,
|
||||
dependencies: parentDependencies,
|
||||
childDependencies: childDepsChanged ? childDependencies : undefined,
|
||||
excludedPipelineSteps: excludedPipelineSteps.length > 0 ? excludedPipelineSteps : undefined,
|
||||
};
|
||||
|
||||
// Determine if description changed and what source to use
|
||||
@@ -618,6 +631,16 @@ export function EditFeatureDialog({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pipeline Exclusion Controls */}
|
||||
<div className="pt-2">
|
||||
<PipelineExclusionControls
|
||||
projectPath={projectPath}
|
||||
excludedPipelineSteps={excludedPipelineSteps}
|
||||
onExcludedStepsChange={setExcludedPipelineSteps}
|
||||
testIdPrefix="edit-feature-pipeline"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Download, FileJson, FileText } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import type { Feature } from '@/store/app-store';
|
||||
|
||||
type ExportFormat = 'json' | 'yaml';
|
||||
|
||||
interface ExportFeaturesDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
projectPath: string;
|
||||
features: Feature[];
|
||||
selectedFeatureIds?: string[];
|
||||
}
|
||||
|
||||
export function ExportFeaturesDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
projectPath,
|
||||
features,
|
||||
selectedFeatureIds,
|
||||
}: ExportFeaturesDialogProps) {
|
||||
const [format, setFormat] = useState<ExportFormat>('json');
|
||||
const [includeHistory, setIncludeHistory] = useState(true);
|
||||
const [includePlanSpec, setIncludePlanSpec] = useState(true);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
|
||||
// Determine which features to export
|
||||
const featuresToExport =
|
||||
selectedFeatureIds && selectedFeatureIds.length > 0
|
||||
? features.filter((f) => selectedFeatureIds.includes(f.id))
|
||||
: features;
|
||||
|
||||
// Reset state when dialog opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setFormat('json');
|
||||
setIncludeHistory(true);
|
||||
setIncludePlanSpec(true);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleExport = async () => {
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const result = await api.features.export(projectPath, {
|
||||
featureIds: selectedFeatureIds,
|
||||
format,
|
||||
includeHistory,
|
||||
includePlanSpec,
|
||||
prettyPrint: true,
|
||||
});
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
toast.error(result.error || 'Failed to export features');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a blob and trigger download
|
||||
const mimeType = format === 'json' ? 'application/json' : 'application/x-yaml';
|
||||
const blob = new Blob([result.data], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = result.filename || `features-export.${format}`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast.success(`Exported ${featuresToExport.length} feature(s) to ${format.toUpperCase()}`);
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to export features');
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent data-testid="export-features-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Download className="w-5 h-5" />
|
||||
Export Features
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Export {featuresToExport.length} feature(s) to a file for backup or sharing with other
|
||||
projects.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4 space-y-4">
|
||||
{/* Format Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label>Export Format</Label>
|
||||
<Select value={format} onValueChange={(v) => setFormat(v as ExportFormat)}>
|
||||
<SelectTrigger data-testid="export-format-select">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="json">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileJson className="w-4 h-4" />
|
||||
<span>JSON</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="yaml">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="w-4 h-4" />
|
||||
<span>YAML</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
<div className="space-y-3">
|
||||
<Label>Options</Label>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="include-history"
|
||||
checked={includeHistory}
|
||||
onCheckedChange={(checked) => setIncludeHistory(!!checked)}
|
||||
data-testid="export-include-history"
|
||||
/>
|
||||
<Label htmlFor="include-history" className="text-sm font-normal cursor-pointer">
|
||||
Include description history
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="include-plan-spec"
|
||||
checked={includePlanSpec}
|
||||
onCheckedChange={(checked) => setIncludePlanSpec(!!checked)}
|
||||
data-testid="export-include-plan-spec"
|
||||
/>
|
||||
<Label htmlFor="include-plan-spec" className="text-sm font-normal cursor-pointer">
|
||||
Include plan specifications
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features to Export Preview */}
|
||||
{featuresToExport.length > 0 && featuresToExport.length <= 10 && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">Features to export</Label>
|
||||
<div className="max-h-32 overflow-y-auto rounded-md border border-border/50 bg-muted/30 p-2 text-sm">
|
||||
{featuresToExport.map((f) => (
|
||||
<div key={f.id} className="py-1 px-2 truncate text-muted-foreground">
|
||||
{f.title || f.description.slice(0, 50)}...
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={isExporting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleExport} disabled={isExporting} data-testid="confirm-export">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
{isExporting ? 'Exporting...' : 'Export'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { CategoryAutocomplete } from '@/components/ui/category-autocomplete';
|
||||
import { Upload, AlertTriangle, CheckCircle2, XCircle, FileJson, FileText } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ConflictInfo {
|
||||
featureId: string;
|
||||
title?: string;
|
||||
existingTitle?: string;
|
||||
hasConflict: boolean;
|
||||
}
|
||||
|
||||
interface ImportResult {
|
||||
success: boolean;
|
||||
featureId?: string;
|
||||
importedAt: string;
|
||||
warnings?: string[];
|
||||
errors?: string[];
|
||||
wasOverwritten?: boolean;
|
||||
}
|
||||
|
||||
interface ImportFeaturesDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
projectPath: string;
|
||||
categorySuggestions: string[];
|
||||
onImportComplete?: () => void;
|
||||
}
|
||||
|
||||
type ImportStep = 'upload' | 'review' | 'result';
|
||||
|
||||
export function ImportFeaturesDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
projectPath,
|
||||
categorySuggestions,
|
||||
onImportComplete,
|
||||
}: ImportFeaturesDialogProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [step, setStep] = useState<ImportStep>('upload');
|
||||
const [fileData, setFileData] = useState<string>('');
|
||||
const [fileName, setFileName] = useState<string>('');
|
||||
const [fileFormat, setFileFormat] = useState<'json' | 'yaml' | null>(null);
|
||||
|
||||
// Options
|
||||
const [overwrite, setOverwrite] = useState(false);
|
||||
const [targetCategory, setTargetCategory] = useState('');
|
||||
|
||||
// Conflict check results
|
||||
const [conflicts, setConflicts] = useState<ConflictInfo[]>([]);
|
||||
const [isCheckingConflicts, setIsCheckingConflicts] = useState(false);
|
||||
|
||||
// Import results
|
||||
const [importResults, setImportResults] = useState<ImportResult[]>([]);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
|
||||
// Parse error
|
||||
const [parseError, setParseError] = useState<string>('');
|
||||
|
||||
// Reset state when dialog opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setStep('upload');
|
||||
setFileData('');
|
||||
setFileName('');
|
||||
setFileFormat(null);
|
||||
setOverwrite(false);
|
||||
setTargetCategory('');
|
||||
setConflicts([]);
|
||||
setImportResults([]);
|
||||
setParseError('');
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Check file extension
|
||||
const ext = file.name.split('.').pop()?.toLowerCase();
|
||||
if (ext !== 'json' && ext !== 'yaml' && ext !== 'yml') {
|
||||
setParseError('Please select a JSON or YAML file');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await file.text();
|
||||
setFileData(content);
|
||||
setFileName(file.name);
|
||||
setFileFormat(ext === 'yml' ? 'yaml' : (ext as 'json' | 'yaml'));
|
||||
setParseError('');
|
||||
|
||||
// Check for conflicts
|
||||
await checkConflicts(content);
|
||||
} catch {
|
||||
setParseError('Failed to read file');
|
||||
}
|
||||
|
||||
// Reset input
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const checkConflicts = async (data: string) => {
|
||||
setIsCheckingConflicts(true);
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const result = await api.features.checkConflicts(projectPath, data);
|
||||
|
||||
if (!result.success) {
|
||||
setParseError(result.error || 'Failed to parse import file');
|
||||
setConflicts([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setConflicts(result.conflicts || []);
|
||||
setStep('review');
|
||||
} catch (error) {
|
||||
setParseError(error instanceof Error ? error.message : 'Failed to check conflicts');
|
||||
} finally {
|
||||
setIsCheckingConflicts(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
setIsImporting(true);
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const result = await api.features.import(projectPath, fileData, {
|
||||
overwrite,
|
||||
targetCategory: targetCategory || undefined,
|
||||
});
|
||||
|
||||
if (!result.success && result.failedCount === result.results?.length) {
|
||||
toast.error(result.error || 'Failed to import features');
|
||||
return;
|
||||
}
|
||||
|
||||
setImportResults(result.results || []);
|
||||
setStep('result');
|
||||
|
||||
const successCount = result.importedCount || 0;
|
||||
const failCount = result.failedCount || 0;
|
||||
|
||||
if (failCount === 0) {
|
||||
toast.success(`Successfully imported ${successCount} feature(s)`);
|
||||
} else if (successCount > 0) {
|
||||
toast.warning(`Imported ${successCount} feature(s), ${failCount} failed`);
|
||||
} else {
|
||||
toast.error(`Failed to import features`);
|
||||
}
|
||||
|
||||
onImportComplete?.();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to import features');
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = async (event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const file = event.dataTransfer.files[0];
|
||||
if (!file) return;
|
||||
|
||||
const ext = file.name.split('.').pop()?.toLowerCase();
|
||||
if (ext !== 'json' && ext !== 'yaml' && ext !== 'yml') {
|
||||
setParseError('Please drop a JSON or YAML file');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await file.text();
|
||||
setFileData(content);
|
||||
setFileName(file.name);
|
||||
setFileFormat(ext === 'yml' ? 'yaml' : (ext as 'json' | 'yaml'));
|
||||
setParseError('');
|
||||
|
||||
await checkConflicts(content);
|
||||
} catch {
|
||||
setParseError('Failed to read file');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const conflictingFeatures = conflicts.filter((c) => c.hasConflict);
|
||||
const hasConflicts = conflictingFeatures.length > 0;
|
||||
|
||||
const renderUploadStep = () => (
|
||||
<div className="py-4 space-y-4">
|
||||
{/* Drop Zone */}
|
||||
<div
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
className={cn(
|
||||
'border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer',
|
||||
'hover:border-primary/50 hover:bg-muted/30',
|
||||
parseError ? 'border-destructive/50' : 'border-border'
|
||||
)}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
data-testid="import-drop-zone"
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json,.yaml,.yml"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Upload className="w-8 h-8 text-muted-foreground" />
|
||||
<div className="text-sm">
|
||||
<span className="text-primary font-medium">Click to upload</span>
|
||||
<span className="text-muted-foreground"> or drag and drop</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<FileJson className="w-3.5 h-3.5" />
|
||||
<span>JSON</span>
|
||||
<span>or</span>
|
||||
<FileText className="w-3.5 h-3.5" />
|
||||
<span>YAML</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{parseError && (
|
||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||
<XCircle className="w-4 h-4" />
|
||||
{parseError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCheckingConflicts && (
|
||||
<div className="text-sm text-muted-foreground text-center">Analyzing file...</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderReviewStep = () => (
|
||||
<div className="py-4 space-y-4">
|
||||
{/* File Info */}
|
||||
<div className="flex items-center gap-2 p-3 rounded-md border border-border/50 bg-muted/30">
|
||||
{fileFormat === 'json' ? (
|
||||
<FileJson className="w-5 h-5 text-muted-foreground" />
|
||||
) : (
|
||||
<FileText className="w-5 h-5 text-muted-foreground" />
|
||||
)}
|
||||
<div className="flex-1 truncate">
|
||||
<div className="text-sm font-medium">{fileName}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{conflicts.length} feature(s) to import
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conflict Warning */}
|
||||
{hasConflicts && (
|
||||
<div className="flex items-start gap-2 p-3 rounded-md border border-warning/50 bg-warning/10">
|
||||
<AlertTriangle className="w-5 h-5 text-warning shrink-0 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium text-warning">
|
||||
{conflictingFeatures.length} conflict(s) detected
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
The following features already exist in this project:
|
||||
</div>
|
||||
<ul className="text-xs text-muted-foreground list-disc list-inside max-h-24 overflow-y-auto">
|
||||
{conflictingFeatures.map((c) => (
|
||||
<li key={c.featureId} className="truncate">
|
||||
{c.existingTitle || c.featureId}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Options */}
|
||||
<div className="space-y-3">
|
||||
<Label>Import Options</Label>
|
||||
|
||||
{hasConflicts && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="overwrite"
|
||||
checked={overwrite}
|
||||
onCheckedChange={(checked) => setOverwrite(!!checked)}
|
||||
data-testid="import-overwrite"
|
||||
/>
|
||||
<Label htmlFor="overwrite" className="text-sm font-normal cursor-pointer">
|
||||
Overwrite existing features with same ID
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Target Category (optional - override imported categories)
|
||||
</Label>
|
||||
<CategoryAutocomplete
|
||||
value={targetCategory}
|
||||
onChange={setTargetCategory}
|
||||
suggestions={categorySuggestions}
|
||||
placeholder="Keep original categories"
|
||||
data-testid="import-target-category"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features Preview */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">Features to import</Label>
|
||||
<div className="max-h-40 overflow-y-auto rounded-md border border-border/50 bg-muted/30 p-2 text-sm">
|
||||
{conflicts.map((c) => (
|
||||
<div
|
||||
key={c.featureId}
|
||||
className={cn(
|
||||
'py-1 px-2 flex items-center gap-2',
|
||||
c.hasConflict && !overwrite ? 'text-warning' : 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{c.hasConflict ? (
|
||||
overwrite ? (
|
||||
<CheckCircle2 className="w-3.5 h-3.5 text-primary shrink-0" />
|
||||
) : (
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-warning shrink-0" />
|
||||
)
|
||||
) : (
|
||||
<CheckCircle2 className="w-3.5 h-3.5 text-primary shrink-0" />
|
||||
)}
|
||||
<span className="truncate">{c.title || c.featureId}</span>
|
||||
{c.hasConflict && !overwrite && (
|
||||
<span className="text-xs text-warning">(will skip)</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderResultStep = () => {
|
||||
const successResults = importResults.filter((r) => r.success);
|
||||
const failedResults = importResults.filter((r) => !r.success);
|
||||
|
||||
return (
|
||||
<div className="py-4 space-y-4">
|
||||
{/* Summary */}
|
||||
<div className="flex items-center gap-4 justify-center">
|
||||
{successResults.length > 0 && (
|
||||
<div className="flex items-center gap-2 text-primary">
|
||||
<CheckCircle2 className="w-5 h-5" />
|
||||
<span className="font-medium">{successResults.length} imported</span>
|
||||
</div>
|
||||
)}
|
||||
{failedResults.length > 0 && (
|
||||
<div className="flex items-center gap-2 text-destructive">
|
||||
<XCircle className="w-5 h-5" />
|
||||
<span className="font-medium">{failedResults.length} failed</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Results List */}
|
||||
<div className="max-h-60 overflow-y-auto rounded-md border border-border/50 bg-muted/30 p-2 text-sm space-y-1">
|
||||
{importResults.map((result, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={cn(
|
||||
'py-1.5 px-2 rounded',
|
||||
result.success ? 'text-foreground' : 'text-destructive bg-destructive/10'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{result.success ? (
|
||||
<CheckCircle2 className="w-3.5 h-3.5 text-primary shrink-0" />
|
||||
) : (
|
||||
<XCircle className="w-3.5 h-3.5 text-destructive shrink-0" />
|
||||
)}
|
||||
<span className="truncate">{result.featureId || `Feature ${idx + 1}`}</span>
|
||||
{result.wasOverwritten && (
|
||||
<span className="text-xs text-muted-foreground">(overwritten)</span>
|
||||
)}
|
||||
</div>
|
||||
{result.warnings && result.warnings.length > 0 && (
|
||||
<div className="mt-1 pl-5 text-xs text-warning">
|
||||
{result.warnings.map((w, i) => (
|
||||
<div key={i}>{w}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{result.errors && result.errors.length > 0 && (
|
||||
<div className="mt-1 pl-5 text-xs text-destructive">
|
||||
{result.errors.map((e, i) => (
|
||||
<div key={i}>{e}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent data-testid="import-features-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Upload className="w-5 h-5" />
|
||||
Import Features
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{step === 'upload' && 'Import features from a JSON or YAML export file.'}
|
||||
{step === 'review' && 'Review and configure import options.'}
|
||||
{step === 'result' && 'Import completed.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{step === 'upload' && renderUploadStep()}
|
||||
{step === 'review' && renderReviewStep()}
|
||||
{step === 'result' && renderResultStep()}
|
||||
|
||||
<DialogFooter>
|
||||
{step === 'upload' && (
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
{step === 'review' && (
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setStep('upload')}>
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={handleImport} disabled={isImporting} data-testid="confirm-import">
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
{isImporting
|
||||
? 'Importing...'
|
||||
: `Import ${hasConflicts && !overwrite ? conflicts.filter((c) => !c.hasConflict).length : conflicts.length} Feature(s)`}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{step === 'result' && (
|
||||
<Button onClick={() => onOpenChange(false)} data-testid="close-import">
|
||||
Done
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -13,3 +13,5 @@ export { MassEditDialog } from './mass-edit-dialog';
|
||||
export { PullResolveConflictsDialog } from './pull-resolve-conflicts-dialog';
|
||||
export { PushToRemoteDialog } from './push-to-remote-dialog';
|
||||
export { ViewWorktreeChangesDialog } from './view-worktree-changes-dialog';
|
||||
export { ExportFeaturesDialog } from './export-features-dialog';
|
||||
export { ImportFeaturesDialog } from './import-features-dialog';
|
||||
|
||||
@@ -13,7 +13,13 @@ 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, WorkModeSelector } from '../shared';
|
||||
import {
|
||||
TestingTabContent,
|
||||
PrioritySelect,
|
||||
PlanningModeSelect,
|
||||
WorkModeSelector,
|
||||
PipelineExclusionControls,
|
||||
} from '../shared';
|
||||
import type { WorkMode } from '../shared';
|
||||
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
|
||||
import { isCursorModel, isClaudeModel, type PhaseModelEntry } from '@automaker/types';
|
||||
@@ -28,6 +34,7 @@ interface MassEditDialogProps {
|
||||
branchSuggestions: string[];
|
||||
branchCardCounts?: Record<string, number>;
|
||||
currentBranch?: string;
|
||||
projectPath?: string;
|
||||
}
|
||||
|
||||
interface ApplyState {
|
||||
@@ -38,11 +45,13 @@ interface ApplyState {
|
||||
priority: boolean;
|
||||
skipTests: boolean;
|
||||
branchName: boolean;
|
||||
excludedPipelineSteps: boolean;
|
||||
}
|
||||
|
||||
function getMixedValues(features: Feature[]): Record<string, boolean> {
|
||||
if (features.length === 0) return {};
|
||||
const first = features[0];
|
||||
const firstExcludedSteps = JSON.stringify(first.excludedPipelineSteps || []);
|
||||
return {
|
||||
model: !features.every((f) => f.model === first.model),
|
||||
thinkingLevel: !features.every((f) => f.thinkingLevel === first.thinkingLevel),
|
||||
@@ -53,6 +62,9 @@ function getMixedValues(features: Feature[]): Record<string, boolean> {
|
||||
priority: !features.every((f) => f.priority === first.priority),
|
||||
skipTests: !features.every((f) => f.skipTests === first.skipTests),
|
||||
branchName: !features.every((f) => f.branchName === first.branchName),
|
||||
excludedPipelineSteps: !features.every(
|
||||
(f) => JSON.stringify(f.excludedPipelineSteps || []) === firstExcludedSteps
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -111,6 +123,7 @@ export function MassEditDialog({
|
||||
branchSuggestions,
|
||||
branchCardCounts,
|
||||
currentBranch,
|
||||
projectPath,
|
||||
}: MassEditDialogProps) {
|
||||
const [isApplying, setIsApplying] = useState(false);
|
||||
|
||||
@@ -123,6 +136,7 @@ export function MassEditDialog({
|
||||
priority: false,
|
||||
skipTests: false,
|
||||
branchName: false,
|
||||
excludedPipelineSteps: false,
|
||||
});
|
||||
|
||||
// Field values
|
||||
@@ -146,6 +160,11 @@ export function MassEditDialog({
|
||||
return getInitialValue(selectedFeatures, 'branchName', '') as string;
|
||||
});
|
||||
|
||||
// Pipeline exclusion state
|
||||
const [excludedPipelineSteps, setExcludedPipelineSteps] = useState<string[]>(() => {
|
||||
return getInitialValue(selectedFeatures, 'excludedPipelineSteps', []) as string[];
|
||||
});
|
||||
|
||||
// Calculate mixed values
|
||||
const mixedValues = useMemo(() => getMixedValues(selectedFeatures), [selectedFeatures]);
|
||||
|
||||
@@ -160,6 +179,7 @@ export function MassEditDialog({
|
||||
priority: false,
|
||||
skipTests: false,
|
||||
branchName: false,
|
||||
excludedPipelineSteps: false,
|
||||
});
|
||||
setModel(getInitialValue(selectedFeatures, 'model', 'claude-sonnet') as ModelAlias);
|
||||
setThinkingLevel(getInitialValue(selectedFeatures, 'thinkingLevel', 'none') as ThinkingLevel);
|
||||
@@ -172,6 +192,10 @@ export function MassEditDialog({
|
||||
const initialBranchName = getInitialValue(selectedFeatures, 'branchName', '') as string;
|
||||
setBranchName(initialBranchName);
|
||||
setWorkMode(initialBranchName ? 'custom' : 'current');
|
||||
// Reset pipeline exclusions
|
||||
setExcludedPipelineSteps(
|
||||
getInitialValue(selectedFeatures, 'excludedPipelineSteps', []) as string[]
|
||||
);
|
||||
}
|
||||
}, [open, selectedFeatures]);
|
||||
|
||||
@@ -190,6 +214,10 @@ export function MassEditDialog({
|
||||
// For 'custom' mode, use the specified branch name
|
||||
updates.branchName = workMode === 'custom' ? branchName : '';
|
||||
}
|
||||
if (applyState.excludedPipelineSteps) {
|
||||
updates.excludedPipelineSteps =
|
||||
excludedPipelineSteps.length > 0 ? excludedPipelineSteps : undefined;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
onClose();
|
||||
@@ -353,6 +381,23 @@ export function MassEditDialog({
|
||||
testIdPrefix="mass-edit-work-mode"
|
||||
/>
|
||||
</FieldWrapper>
|
||||
|
||||
{/* Pipeline Exclusion */}
|
||||
<FieldWrapper
|
||||
label="Pipeline Steps"
|
||||
isMixed={mixedValues.excludedPipelineSteps}
|
||||
willApply={applyState.excludedPipelineSteps}
|
||||
onApplyChange={(apply) =>
|
||||
setApplyState((prev) => ({ ...prev, excludedPipelineSteps: apply }))
|
||||
}
|
||||
>
|
||||
<PipelineExclusionControls
|
||||
projectPath={projectPath}
|
||||
excludedPipelineSteps={excludedPipelineSteps}
|
||||
onExcludedStepsChange={setExcludedPipelineSteps}
|
||||
testIdPrefix="mass-edit-pipeline"
|
||||
/>
|
||||
</FieldWrapper>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Markdown } from '@/components/ui/markdown';
|
||||
import { PlanContentViewer } from './plan-content-viewer';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Feature } from '@/store/app-store';
|
||||
import { Check, RefreshCw, Edit2, Eye } from 'lucide-react';
|
||||
@@ -42,6 +42,10 @@ export function PlanApprovalDialog({
|
||||
const [editedPlan, setEditedPlan] = useState(planContent);
|
||||
const [showRejectFeedback, setShowRejectFeedback] = useState(false);
|
||||
const [rejectFeedback, setRejectFeedback] = useState('');
|
||||
const [showFullDescription, setShowFullDescription] = useState(false);
|
||||
|
||||
const DESCRIPTION_LIMIT = 250;
|
||||
const TITLE_LIMIT = 50;
|
||||
|
||||
// Reset state when dialog opens or plan content changes
|
||||
useEffect(() => {
|
||||
@@ -50,6 +54,7 @@ export function PlanApprovalDialog({
|
||||
setIsEditMode(false);
|
||||
setShowRejectFeedback(false);
|
||||
setRejectFeedback('');
|
||||
setShowFullDescription(false);
|
||||
}
|
||||
}, [open, planContent]);
|
||||
|
||||
@@ -82,15 +87,31 @@ export function PlanApprovalDialog({
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-4xl" data-testid="plan-approval-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{viewOnly ? 'View Plan' : 'Review Plan'}</DialogTitle>
|
||||
<DialogTitle>
|
||||
{viewOnly ? 'View Plan' : 'Review Plan'}
|
||||
{feature?.title && feature.title.length <= TITLE_LIMIT && (
|
||||
<span className="font-normal text-muted-foreground"> - {feature.title}</span>
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{viewOnly
|
||||
? 'View the generated plan for this feature.'
|
||||
: 'Review the generated plan before implementation begins.'}
|
||||
{feature && (
|
||||
<span className="block mt-2 text-primary">
|
||||
Feature: {feature.description.slice(0, 150)}
|
||||
{feature.description.length > 150 ? '...' : ''}
|
||||
Feature:{' '}
|
||||
{showFullDescription || feature.description.length <= DESCRIPTION_LIMIT
|
||||
? feature.description
|
||||
: `${feature.description.slice(0, DESCRIPTION_LIMIT)}...`}
|
||||
{feature.description.length > DESCRIPTION_LIMIT && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowFullDescription(!showFullDescription)}
|
||||
className="ml-1 text-muted-foreground hover:text-foreground underline text-sm"
|
||||
>
|
||||
{showFullDescription ? 'show less' : 'show more'}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
@@ -135,9 +156,7 @@ export function PlanApprovalDialog({
|
||||
disabled={isLoading}
|
||||
/>
|
||||
) : (
|
||||
<div className="p-4 overflow-auto">
|
||||
<Markdown>{editedPlan || 'No plan content available.'}</Markdown>
|
||||
</div>
|
||||
<PlanContentViewer content={editedPlan || ''} className="p-4" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ChevronDown, ChevronRight, Wrench } from 'lucide-react';
|
||||
import { Markdown } from '@/components/ui/markdown';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ToolCall {
|
||||
tool: string;
|
||||
input: string;
|
||||
}
|
||||
|
||||
interface ParsedPlanContent {
|
||||
toolCalls: ToolCall[];
|
||||
planMarkdown: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses plan content to separate tool calls from the actual plan/specification markdown.
|
||||
* Tool calls appear at the beginning (exploration phase), followed by the plan markdown.
|
||||
*/
|
||||
function parsePlanContent(content: string): ParsedPlanContent {
|
||||
const lines = content.split('\n');
|
||||
const toolCalls: ToolCall[] = [];
|
||||
let planStartIndex = -1;
|
||||
|
||||
let currentTool: string | null = null;
|
||||
let currentInput: string[] = [];
|
||||
let inJsonBlock = false;
|
||||
let braceDepth = 0;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Check if this line starts the actual plan/spec (markdown heading)
|
||||
// Plans typically start with # or ## headings
|
||||
if (
|
||||
!inJsonBlock &&
|
||||
(trimmed.match(/^#{1,3}\s+\S/) || // Markdown headings (including emoji like ## ✅ Plan)
|
||||
trimmed.startsWith('---') || // Horizontal rule often used as separator
|
||||
trimmed.match(/^\*\*\S/)) // Bold text starting a section
|
||||
) {
|
||||
// Flush any active tool call before starting the plan
|
||||
if (currentTool && currentInput.length > 0) {
|
||||
toolCalls.push({
|
||||
tool: currentTool,
|
||||
input: currentInput.join('\n').trim(),
|
||||
});
|
||||
currentTool = null;
|
||||
currentInput = [];
|
||||
}
|
||||
planStartIndex = i;
|
||||
break;
|
||||
}
|
||||
|
||||
// Detect tool call start (supports tool names with dots/hyphens like web.run, file-read)
|
||||
const toolMatch = trimmed.match(/^(?:🔧\s*)?Tool:\s*([^\s]+)/i);
|
||||
if (toolMatch && !inJsonBlock) {
|
||||
// Save previous tool call if exists
|
||||
if (currentTool && currentInput.length > 0) {
|
||||
toolCalls.push({
|
||||
tool: currentTool,
|
||||
input: currentInput.join('\n').trim(),
|
||||
});
|
||||
}
|
||||
currentTool = toolMatch[1];
|
||||
currentInput = [];
|
||||
continue;
|
||||
}
|
||||
|
||||
// Detect Input: line
|
||||
if (trimmed.startsWith('Input:') && currentTool) {
|
||||
const inputContent = trimmed.replace(/^Input:\s*/, '');
|
||||
if (inputContent) {
|
||||
currentInput.push(inputContent);
|
||||
// Check if JSON starts
|
||||
if (inputContent.includes('{')) {
|
||||
braceDepth =
|
||||
(inputContent.match(/\{/g) || []).length - (inputContent.match(/\}/g) || []).length;
|
||||
inJsonBlock = braceDepth > 0;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// If we're collecting input for a tool
|
||||
if (currentTool) {
|
||||
if (inJsonBlock) {
|
||||
currentInput.push(line);
|
||||
braceDepth += (trimmed.match(/\{/g) || []).length - (trimmed.match(/\}/g) || []).length;
|
||||
if (braceDepth <= 0) {
|
||||
inJsonBlock = false;
|
||||
// Save tool call
|
||||
toolCalls.push({
|
||||
tool: currentTool,
|
||||
input: currentInput.join('\n').trim(),
|
||||
});
|
||||
currentTool = null;
|
||||
currentInput = [];
|
||||
}
|
||||
} else if (trimmed.startsWith('{')) {
|
||||
// JSON block starting
|
||||
currentInput.push(line);
|
||||
braceDepth = (trimmed.match(/\{/g) || []).length - (trimmed.match(/\}/g) || []).length;
|
||||
inJsonBlock = braceDepth > 0;
|
||||
if (!inJsonBlock) {
|
||||
// Single-line JSON
|
||||
toolCalls.push({
|
||||
tool: currentTool,
|
||||
input: currentInput.join('\n').trim(),
|
||||
});
|
||||
currentTool = null;
|
||||
currentInput = [];
|
||||
}
|
||||
} else if (trimmed === '') {
|
||||
// Empty line might end the tool call section
|
||||
if (currentInput.length > 0) {
|
||||
toolCalls.push({
|
||||
tool: currentTool,
|
||||
input: currentInput.join('\n').trim(),
|
||||
});
|
||||
currentTool = null;
|
||||
currentInput = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save any remaining tool call
|
||||
if (currentTool && currentInput.length > 0) {
|
||||
toolCalls.push({
|
||||
tool: currentTool,
|
||||
input: currentInput.join('\n').trim(),
|
||||
});
|
||||
}
|
||||
|
||||
// Extract plan markdown
|
||||
let planMarkdown = '';
|
||||
if (planStartIndex >= 0) {
|
||||
planMarkdown = lines.slice(planStartIndex).join('\n').trim();
|
||||
} else if (toolCalls.length === 0) {
|
||||
// No tool calls found, treat entire content as markdown
|
||||
planMarkdown = content.trim();
|
||||
}
|
||||
|
||||
return { toolCalls, planMarkdown };
|
||||
}
|
||||
|
||||
interface PlanContentViewerProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PlanContentViewer({ content, className }: PlanContentViewerProps) {
|
||||
const [showToolCalls, setShowToolCalls] = useState(false);
|
||||
|
||||
const { toolCalls, planMarkdown } = useMemo(() => parsePlanContent(content), [content]);
|
||||
|
||||
if (!content || !content.trim()) {
|
||||
return (
|
||||
<div className={cn('text-muted-foreground text-center py-8', className)}>
|
||||
No plan content available.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
{/* Tool Calls Section - Collapsed by default */}
|
||||
{toolCalls.length > 0 && (
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => setShowToolCalls(!showToolCalls)}
|
||||
className="w-full px-4 py-2 flex items-center gap-2 bg-muted/30 hover:bg-muted/50 transition-colors text-left"
|
||||
>
|
||||
{showToolCalls ? (
|
||||
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
<Wrench className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Exploration ({toolCalls.length} tool call{toolCalls.length !== 1 ? 's' : ''})
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{showToolCalls && (
|
||||
<div className="p-3 space-y-2 bg-muted/10 max-h-[300px] overflow-y-auto">
|
||||
{toolCalls.map((tc, idx) => (
|
||||
<div key={idx} className="text-xs font-mono">
|
||||
<div className="text-cyan-400 mb-1">Tool: {tc.tool}</div>
|
||||
<pre className="bg-muted/50 rounded p-2 overflow-x-auto text-foreground-secondary whitespace-pre-wrap">
|
||||
{tc.input}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Plan/Specification Content - Main focus */}
|
||||
{planMarkdown ? (
|
||||
<div className="min-h-[200px]">
|
||||
<Markdown>{planMarkdown}</Markdown>
|
||||
</div>
|
||||
) : toolCalls.length > 0 ? (
|
||||
<div className="text-muted-foreground text-center py-8 border border-dashed border-border rounded-lg">
|
||||
<p className="text-sm">No specification content found.</p>
|
||||
<p className="text-xs mt-1">The plan appears to only contain exploration tool calls.</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
@@ -18,8 +19,9 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import { getErrorMessage } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
import { Upload, RefreshCw, AlertTriangle, Sparkles } from 'lucide-react';
|
||||
import { Upload, RefreshCw, AlertTriangle, Sparkles, Plus, Link } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import type { WorktreeInfo } from '../worktree-panel/types';
|
||||
|
||||
@@ -49,18 +51,76 @@ export function PushToRemoteDialog({
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Add remote form state
|
||||
const [showAddRemoteForm, setShowAddRemoteForm] = useState(false);
|
||||
const [newRemoteName, setNewRemoteName] = useState('origin');
|
||||
const [newRemoteUrl, setNewRemoteUrl] = useState('');
|
||||
const [isAddingRemote, setIsAddingRemote] = useState(false);
|
||||
const [addRemoteError, setAddRemoteError] = useState<string | null>(null);
|
||||
|
||||
/**
|
||||
* Transforms API remote data to RemoteInfo format
|
||||
*/
|
||||
const transformRemoteData = useCallback(
|
||||
(remotes: Array<{ name: string; url: string }>): RemoteInfo[] => {
|
||||
return remotes.map((r) => ({
|
||||
name: r.name,
|
||||
url: r.url,
|
||||
}));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
/**
|
||||
* Updates remotes state and hides add form if remotes exist
|
||||
*/
|
||||
const updateRemotesState = useCallback((remoteInfos: RemoteInfo[]) => {
|
||||
setRemotes(remoteInfos);
|
||||
if (remoteInfos.length > 0) {
|
||||
setShowAddRemoteForm(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchRemotes = useCallback(async () => {
|
||||
if (!worktree) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const result = await api.worktree.listRemotes(worktree.path);
|
||||
|
||||
if (result.success && result.result) {
|
||||
const remoteInfos = transformRemoteData(result.result.remotes);
|
||||
updateRemotesState(remoteInfos);
|
||||
} else {
|
||||
setError(result.error || 'Failed to fetch remotes');
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Failed to fetch remotes:', err);
|
||||
setError(getErrorMessage(err));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [worktree, transformRemoteData, updateRemotesState]);
|
||||
|
||||
// Fetch remotes when dialog opens
|
||||
useEffect(() => {
|
||||
if (open && worktree) {
|
||||
fetchRemotes();
|
||||
}
|
||||
}, [open, worktree]);
|
||||
}, [open, worktree, fetchRemotes]);
|
||||
|
||||
// Reset state when dialog closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSelectedRemote('');
|
||||
setError(null);
|
||||
setShowAddRemoteForm(false);
|
||||
setNewRemoteName('origin');
|
||||
setNewRemoteUrl('');
|
||||
setAddRemoteError(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
@@ -73,36 +133,12 @@ export function PushToRemoteDialog({
|
||||
}
|
||||
}, [remotes, selectedRemote]);
|
||||
|
||||
const fetchRemotes = async () => {
|
||||
if (!worktree) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const result = await api.worktree.listRemotes(worktree.path);
|
||||
|
||||
if (result.success && result.result) {
|
||||
// Extract just the remote info (name and URL), not the branches
|
||||
const remoteInfos: RemoteInfo[] = result.result.remotes.map((r) => ({
|
||||
name: r.name,
|
||||
url: r.url,
|
||||
}));
|
||||
setRemotes(remoteInfos);
|
||||
if (remoteInfos.length === 0) {
|
||||
setError('No remotes found in this repository. Please add a remote first.');
|
||||
}
|
||||
} else {
|
||||
setError(result.error || 'Failed to fetch remotes');
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Failed to fetch remotes:', err);
|
||||
setError('Failed to fetch remotes');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
// Show add remote form when no remotes (but not when there's an error)
|
||||
useEffect(() => {
|
||||
if (!isLoading && remotes.length === 0 && !error) {
|
||||
setShowAddRemoteForm(true);
|
||||
}
|
||||
};
|
||||
}, [isLoading, remotes.length, error]);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
if (!worktree) return;
|
||||
@@ -115,47 +151,270 @@ export function PushToRemoteDialog({
|
||||
const result = await api.worktree.listRemotes(worktree.path);
|
||||
|
||||
if (result.success && result.result) {
|
||||
const remoteInfos: RemoteInfo[] = result.result.remotes.map((r) => ({
|
||||
name: r.name,
|
||||
url: r.url,
|
||||
}));
|
||||
setRemotes(remoteInfos);
|
||||
const remoteInfos = transformRemoteData(result.result.remotes);
|
||||
updateRemotesState(remoteInfos);
|
||||
toast.success('Remotes refreshed');
|
||||
} else {
|
||||
toast.error(result.error || 'Failed to refresh remotes');
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Failed to refresh remotes:', err);
|
||||
toast.error('Failed to refresh remotes');
|
||||
toast.error(getErrorMessage(err));
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddRemote = async () => {
|
||||
if (!worktree || !newRemoteName.trim() || !newRemoteUrl.trim()) return;
|
||||
|
||||
setIsAddingRemote(true);
|
||||
setAddRemoteError(null);
|
||||
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const result = await api.worktree.addRemote(
|
||||
worktree.path,
|
||||
newRemoteName.trim(),
|
||||
newRemoteUrl.trim()
|
||||
);
|
||||
|
||||
if (result.success && result.result) {
|
||||
toast.success(result.result.message);
|
||||
// Add the new remote to the list and select it
|
||||
const newRemote: RemoteInfo = {
|
||||
name: result.result.remoteName,
|
||||
url: result.result.remoteUrl,
|
||||
};
|
||||
setRemotes((prev) => [...prev, newRemote]);
|
||||
setSelectedRemote(newRemote.name);
|
||||
setShowAddRemoteForm(false);
|
||||
setNewRemoteName('origin');
|
||||
setNewRemoteUrl('');
|
||||
} else {
|
||||
setAddRemoteError(result.error || 'Failed to add remote');
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Failed to add remote:', err);
|
||||
setAddRemoteError(getErrorMessage(err));
|
||||
} finally {
|
||||
setIsAddingRemote(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!worktree || !selectedRemote) return;
|
||||
onConfirm(worktree, selectedRemote);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const renderAddRemoteForm = () => (
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="flex items-center gap-2 text-muted-foreground mb-2">
|
||||
<Link className="w-4 h-4" />
|
||||
<span className="text-sm">
|
||||
{remotes.length === 0
|
||||
? 'No remotes found. Add a remote to push your branch.'
|
||||
: 'Add a new remote'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="remote-name">Remote Name</Label>
|
||||
<Input
|
||||
id="remote-name"
|
||||
placeholder="origin"
|
||||
value={newRemoteName}
|
||||
onChange={(e) => {
|
||||
setNewRemoteName(e.target.value);
|
||||
setAddRemoteError(null);
|
||||
}}
|
||||
disabled={isAddingRemote}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="remote-url">Remote URL</Label>
|
||||
<Input
|
||||
id="remote-url"
|
||||
placeholder="https://github.com/user/repo.git"
|
||||
value={newRemoteUrl}
|
||||
onChange={(e) => {
|
||||
setNewRemoteUrl(e.target.value);
|
||||
setAddRemoteError(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
e.key === 'Enter' &&
|
||||
newRemoteName.trim() &&
|
||||
newRemoteUrl.trim() &&
|
||||
!isAddingRemote
|
||||
) {
|
||||
handleAddRemote();
|
||||
}
|
||||
}}
|
||||
disabled={isAddingRemote}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Supports HTTPS, SSH (git@github.com:user/repo.git), or git:// URLs
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{addRemoteError && (
|
||||
<div className="flex items-center gap-2 text-destructive">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
<span className="text-sm">{addRemoteError}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderRemoteSelector = () => (
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="remote-select">Select Remote</Label>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowAddRemoteForm(true)}
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
<Plus className="w-3 h-3 mr-1" />
|
||||
Add
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
{isRefreshing ? (
|
||||
<Spinner size="xs" className="mr-1" />
|
||||
) : (
|
||||
<RefreshCw className="w-3 h-3 mr-1" />
|
||||
)}
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Select value={selectedRemote} onValueChange={setSelectedRemote}>
|
||||
<SelectTrigger id="remote-select">
|
||||
<SelectValue placeholder="Select a remote" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{remotes.map((remote) => (
|
||||
<SelectItem key={remote.name} value={remote.name}>
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="font-medium">{remote.name}</span>
|
||||
<span className="text-xs text-muted-foreground truncate max-w-[300px]">
|
||||
{remote.url}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{selectedRemote && (
|
||||
<div className="mt-2 p-3 rounded-md bg-muted/50 border border-border">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This will create a new remote branch{' '}
|
||||
<span className="font-mono text-foreground">
|
||||
{selectedRemote}/{worktree?.branch}
|
||||
</span>{' '}
|
||||
and set up tracking.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderFooter = () => {
|
||||
if (showAddRemoteForm) {
|
||||
return (
|
||||
<DialogFooter>
|
||||
{remotes.length > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowAddRemoteForm(false)}
|
||||
disabled={isAddingRemote}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isAddingRemote}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAddRemote}
|
||||
disabled={!newRemoteName.trim() || !newRemoteUrl.trim() || isAddingRemote}
|
||||
>
|
||||
{isAddingRemote ? (
|
||||
<>
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Adding...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Remote
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} disabled={!selectedRemote || isLoading}>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
Push to {selectedRemote || 'Remote'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[450px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Upload className="w-5 h-5 text-primary" />
|
||||
Push New Branch to Remote
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium bg-primary/10 text-primary px-2 py-0.5 rounded-full ml-2">
|
||||
<Sparkles className="w-3 h-3" />
|
||||
new
|
||||
</span>
|
||||
{showAddRemoteForm ? (
|
||||
<>
|
||||
<Plus className="w-5 h-5 text-primary" />
|
||||
Add Remote
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="w-5 h-5 text-primary" />
|
||||
Push New Branch to Remote
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium bg-primary/10 text-primary px-2 py-0.5 rounded-full ml-2">
|
||||
<Sparkles className="w-3 h-3" />
|
||||
new
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Push{' '}
|
||||
<span className="font-mono text-foreground">
|
||||
{worktree?.branch || 'current branch'}
|
||||
</span>{' '}
|
||||
to a remote repository for the first time.
|
||||
{showAddRemoteForm ? (
|
||||
<>Add a remote repository to push your changes to.</>
|
||||
) : (
|
||||
<>
|
||||
Push{' '}
|
||||
<span className="font-mono text-foreground">
|
||||
{worktree?.branch || 'current branch'}
|
||||
</span>{' '}
|
||||
to a remote repository for the first time.
|
||||
</>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -163,7 +422,7 @@ export function PushToRemoteDialog({
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
) : error ? (
|
||||
) : error && !showAddRemoteForm ? (
|
||||
<div className="flex flex-col items-center gap-4 py-6">
|
||||
<div className="flex items-center gap-2 text-destructive">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
@@ -174,68 +433,13 @@ export function PushToRemoteDialog({
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
) : showAddRemoteForm ? (
|
||||
renderAddRemoteForm()
|
||||
) : (
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="remote-select">Select Remote</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
{isRefreshing ? (
|
||||
<Spinner size="xs" className="mr-1" />
|
||||
) : (
|
||||
<RefreshCw className="w-3 h-3 mr-1" />
|
||||
)}
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
<Select value={selectedRemote} onValueChange={setSelectedRemote}>
|
||||
<SelectTrigger id="remote-select">
|
||||
<SelectValue placeholder="Select a remote" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{remotes.map((remote) => (
|
||||
<SelectItem key={remote.name} value={remote.name}>
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="font-medium">{remote.name}</span>
|
||||
<span className="text-xs text-muted-foreground truncate max-w-[300px]">
|
||||
{remote.url}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{selectedRemote && (
|
||||
<div className="mt-2 p-3 rounded-md bg-muted/50 border border-border">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This will create a new remote branch{' '}
|
||||
<span className="font-mono text-foreground">
|
||||
{selectedRemote}/{worktree?.branch}
|
||||
</span>{' '}
|
||||
and set up tracking.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
renderRemoteSelector()
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} disabled={!selectedRemote || isLoading}>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
Push to {selectedRemote || 'Remote'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
{renderFooter()}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -123,9 +123,34 @@ export function useBoardActions({
|
||||
}) => {
|
||||
const workMode = featureData.workMode || 'current';
|
||||
|
||||
// For auto worktree mode, we need a title for the branch name.
|
||||
// If no title provided, generate one from the description first.
|
||||
let titleForBranch = featureData.title;
|
||||
let titleWasGenerated = false;
|
||||
|
||||
if (workMode === 'auto' && !featureData.title.trim() && featureData.description.trim()) {
|
||||
// Generate title first so we can use it for the branch name
|
||||
const api = getElectronAPI();
|
||||
if (api?.features?.generateTitle) {
|
||||
try {
|
||||
const result = await api.features.generateTitle(featureData.description);
|
||||
if (result.success && result.title) {
|
||||
titleForBranch = result.title;
|
||||
titleWasGenerated = true;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error generating title for branch name:', error);
|
||||
}
|
||||
}
|
||||
// If title generation failed, fall back to first part of description
|
||||
if (!titleForBranch.trim()) {
|
||||
titleForBranch = featureData.description.substring(0, 60);
|
||||
}
|
||||
}
|
||||
|
||||
// Determine final branch name based on work mode:
|
||||
// - 'current': Use current worktree's branch (or undefined if on main)
|
||||
// - 'auto': Auto-generate branch name based on current branch
|
||||
// - 'auto': Auto-generate branch name based on feature title
|
||||
// - 'custom': Use the provided branch name
|
||||
let finalBranchName: string | undefined;
|
||||
|
||||
@@ -134,13 +159,16 @@ export function useBoardActions({
|
||||
// This ensures features created on a non-main worktree are associated with that worktree
|
||||
finalBranchName = currentWorktreeBranch || undefined;
|
||||
} else if (workMode === 'auto') {
|
||||
// Auto-generate a branch name based on primary branch (main/master) and timestamp
|
||||
// Always use primary branch to avoid nested feature/feature/... paths
|
||||
const baseBranch =
|
||||
(currentProject?.path ? getPrimaryWorktreeBranch(currentProject.path) : null) || 'main';
|
||||
const timestamp = Date.now();
|
||||
// Auto-generate a branch name based on feature title and timestamp
|
||||
// Create a slug from the title: lowercase, replace non-alphanumeric with hyphens
|
||||
const titleSlug =
|
||||
titleForBranch
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-') // Replace non-alphanumeric sequences with hyphens
|
||||
.substring(0, 50) // Limit length first
|
||||
.replace(/^-|-$/g, '') || 'untitled'; // Then remove leading/trailing hyphens, with fallback
|
||||
const randomSuffix = Math.random().toString(36).substring(2, 6);
|
||||
finalBranchName = `feature/${baseBranch}-${timestamp}-${randomSuffix}`;
|
||||
finalBranchName = `feature/${titleSlug}-${randomSuffix}`;
|
||||
} else {
|
||||
// Custom mode - use provided branch name
|
||||
finalBranchName = featureData.branchName || undefined;
|
||||
@@ -183,12 +211,13 @@ export function useBoardActions({
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we need to generate a title
|
||||
const needsTitleGeneration = !featureData.title.trim() && featureData.description.trim();
|
||||
// Check if we need to generate a title (only if we didn't already generate it for the branch name)
|
||||
const needsTitleGeneration =
|
||||
!titleWasGenerated && !featureData.title.trim() && featureData.description.trim();
|
||||
|
||||
const newFeatureData = {
|
||||
...featureData,
|
||||
title: featureData.title,
|
||||
title: titleWasGenerated ? titleForBranch : featureData.title,
|
||||
titleGenerating: needsTitleGeneration,
|
||||
status: 'backlog' as const,
|
||||
branchName: finalBranchName,
|
||||
@@ -255,7 +284,6 @@ export function useBoardActions({
|
||||
projectPath,
|
||||
onWorktreeCreated,
|
||||
onWorktreeAutoSelect,
|
||||
getPrimaryWorktreeBranch,
|
||||
features,
|
||||
currentWorktreeBranch,
|
||||
]
|
||||
@@ -287,6 +315,31 @@ export function useBoardActions({
|
||||
) => {
|
||||
const workMode = updates.workMode || 'current';
|
||||
|
||||
// For auto worktree mode, we need a title for the branch name.
|
||||
// If no title provided, generate one from the description first.
|
||||
let titleForBranch = updates.title;
|
||||
let titleWasGenerated = false;
|
||||
|
||||
if (workMode === 'auto' && !updates.title.trim() && updates.description.trim()) {
|
||||
// Generate title first so we can use it for the branch name
|
||||
const api = getElectronAPI();
|
||||
if (api?.features?.generateTitle) {
|
||||
try {
|
||||
const result = await api.features.generateTitle(updates.description);
|
||||
if (result.success && result.title) {
|
||||
titleForBranch = result.title;
|
||||
titleWasGenerated = true;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error generating title for branch name:', error);
|
||||
}
|
||||
}
|
||||
// If title generation failed, fall back to first part of description
|
||||
if (!titleForBranch.trim()) {
|
||||
titleForBranch = updates.description.substring(0, 60);
|
||||
}
|
||||
}
|
||||
|
||||
// Determine final branch name based on work mode
|
||||
let finalBranchName: string | undefined;
|
||||
|
||||
@@ -295,13 +348,21 @@ export function useBoardActions({
|
||||
// This ensures features updated on a non-main worktree are associated with that worktree
|
||||
finalBranchName = currentWorktreeBranch || undefined;
|
||||
} else if (workMode === 'auto') {
|
||||
// Auto-generate a branch name based on primary branch (main/master) and timestamp
|
||||
// Always use primary branch to avoid nested feature/feature/... paths
|
||||
const baseBranch =
|
||||
(currentProject?.path ? getPrimaryWorktreeBranch(currentProject.path) : null) || 'main';
|
||||
const timestamp = Date.now();
|
||||
const randomSuffix = Math.random().toString(36).substring(2, 6);
|
||||
finalBranchName = `feature/${baseBranch}-${timestamp}-${randomSuffix}`;
|
||||
// Preserve existing branch name if one exists (avoid orphaning worktrees on edit)
|
||||
if (updates.branchName?.trim()) {
|
||||
finalBranchName = updates.branchName;
|
||||
} else {
|
||||
// Auto-generate a branch name based on feature title
|
||||
// Create a slug from the title: lowercase, replace non-alphanumeric with hyphens
|
||||
const titleSlug =
|
||||
titleForBranch
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-') // Replace non-alphanumeric sequences with hyphens
|
||||
.substring(0, 50) // Limit length first
|
||||
.replace(/^-|-$/g, '') || 'untitled'; // Then remove leading/trailing hyphens, with fallback
|
||||
const randomSuffix = Math.random().toString(36).substring(2, 6);
|
||||
finalBranchName = `feature/${titleSlug}-${randomSuffix}`;
|
||||
}
|
||||
} else {
|
||||
finalBranchName = updates.branchName || undefined;
|
||||
}
|
||||
@@ -343,7 +404,7 @@ export function useBoardActions({
|
||||
|
||||
const finalUpdates = {
|
||||
...restUpdates,
|
||||
title: updates.title,
|
||||
title: titleWasGenerated ? titleForBranch : updates.title,
|
||||
branchName: finalBranchName,
|
||||
};
|
||||
|
||||
@@ -406,7 +467,6 @@ export function useBoardActions({
|
||||
setEditingFeature,
|
||||
currentProject,
|
||||
onWorktreeCreated,
|
||||
getPrimaryWorktreeBranch,
|
||||
features,
|
||||
currentWorktreeBranch,
|
||||
]
|
||||
@@ -553,6 +613,11 @@ export function useBoardActions({
|
||||
};
|
||||
updateFeature(feature.id, rollbackUpdates);
|
||||
|
||||
// Also persist the rollback so it survives page refresh
|
||||
persistFeatureUpdate(feature.id, rollbackUpdates).catch((persistError) => {
|
||||
logger.error('Failed to persist rollback:', persistError);
|
||||
});
|
||||
|
||||
// If server is offline (connection refused), redirect to login page
|
||||
if (isConnectionError(error)) {
|
||||
handleServerOffline();
|
||||
|
||||
@@ -88,10 +88,10 @@ export function useBoardDragDrop({
|
||||
const targetFeature = features.find((f) => f.id === targetFeatureId);
|
||||
if (!targetFeature) return;
|
||||
|
||||
// Only allow linking backlog features (both must be in backlog)
|
||||
if (draggedFeature.status !== 'backlog' || targetFeature.status !== 'backlog') {
|
||||
// Don't allow linking completed features (they're already done)
|
||||
if (draggedFeature.status === 'completed' || targetFeature.status === 'completed') {
|
||||
toast.error('Cannot link features', {
|
||||
description: 'Both features must be in the backlog to create a dependency link.',
|
||||
description: 'Completed features cannot be linked.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -11,3 +11,4 @@ export * from './planning-mode-select';
|
||||
export * from './ancestor-context-section';
|
||||
export * from './work-mode-selector';
|
||||
export * from './enhancement';
|
||||
export * from './pipeline-exclusion-controls';
|
||||
|
||||
@@ -4,9 +4,16 @@ import {
|
||||
CURSOR_MODEL_MAP,
|
||||
CODEX_MODEL_MAP,
|
||||
OPENCODE_MODELS as OPENCODE_MODEL_CONFIGS,
|
||||
GEMINI_MODEL_MAP,
|
||||
} from '@automaker/types';
|
||||
import { Brain, Zap, Scale, Cpu, Rocket, Sparkles } from 'lucide-react';
|
||||
import { AnthropicIcon, CursorIcon, OpenAIIcon, OpenCodeIcon } from '@/components/ui/provider-icon';
|
||||
import {
|
||||
AnthropicIcon,
|
||||
CursorIcon,
|
||||
OpenAIIcon,
|
||||
OpenCodeIcon,
|
||||
GeminiIcon,
|
||||
} from '@/components/ui/provider-icon';
|
||||
|
||||
export type ModelOption = {
|
||||
id: string; // All model IDs use canonical prefixed format (e.g., "claude-sonnet", "cursor-auto")
|
||||
@@ -118,13 +125,29 @@ export const OPENCODE_MODELS: ModelOption[] = OPENCODE_MODEL_CONFIGS.map((config
|
||||
}));
|
||||
|
||||
/**
|
||||
* All available models (Claude + Cursor + Codex + OpenCode)
|
||||
* Gemini models derived from GEMINI_MODEL_MAP
|
||||
* Model IDs already have 'gemini-' prefix (like Cursor models)
|
||||
*/
|
||||
export const GEMINI_MODELS: ModelOption[] = Object.entries(GEMINI_MODEL_MAP).map(
|
||||
([id, config]) => ({
|
||||
id, // IDs already have gemini- prefix (e.g., 'gemini-2.5-flash')
|
||||
label: config.label,
|
||||
description: config.description,
|
||||
badge: config.supportsThinking ? 'Thinking' : 'Speed',
|
||||
provider: 'gemini' as ModelProvider,
|
||||
hasThinking: config.supportsThinking,
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* All available models (Claude + Cursor + Codex + OpenCode + Gemini)
|
||||
*/
|
||||
export const ALL_MODELS: ModelOption[] = [
|
||||
...CLAUDE_MODELS,
|
||||
...CURSOR_MODELS,
|
||||
...CODEX_MODELS,
|
||||
...OPENCODE_MODELS,
|
||||
...GEMINI_MODELS,
|
||||
];
|
||||
|
||||
export const THINKING_LEVELS: ThinkingLevel[] = ['none', 'low', 'medium', 'high', 'ultrathink'];
|
||||
@@ -171,4 +194,5 @@ export const PROFILE_ICONS: Record<string, React.ComponentType<{ className?: str
|
||||
Cursor: CursorIcon,
|
||||
Codex: OpenAIIcon,
|
||||
OpenCode: OpenCodeIcon,
|
||||
Gemini: GeminiIcon,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { GitBranch, Workflow } from 'lucide-react';
|
||||
import { usePipelineConfig } from '@/hooks/queries/use-pipeline';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface PipelineExclusionControlsProps {
|
||||
projectPath: string | undefined;
|
||||
excludedPipelineSteps: string[];
|
||||
onExcludedStepsChange: (excludedSteps: string[]) => void;
|
||||
testIdPrefix?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component for selecting which custom pipeline steps should be excluded for a feature.
|
||||
* Each pipeline step is shown as a toggleable switch, defaulting to enabled (included).
|
||||
* Disabling a step adds it to the exclusion list.
|
||||
*/
|
||||
export function PipelineExclusionControls({
|
||||
projectPath,
|
||||
excludedPipelineSteps,
|
||||
onExcludedStepsChange,
|
||||
testIdPrefix = 'pipeline-exclusion',
|
||||
disabled = false,
|
||||
}: PipelineExclusionControlsProps) {
|
||||
const { data: pipelineConfig, isLoading } = usePipelineConfig(projectPath);
|
||||
|
||||
// Sort steps by order
|
||||
const sortedSteps = [...(pipelineConfig?.steps || [])].sort((a, b) => a.order - b.order);
|
||||
|
||||
// If no pipeline steps exist or loading, don't render anything
|
||||
if (isLoading || sortedSteps.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const toggleStep = (stepId: string) => {
|
||||
const isCurrentlyExcluded = excludedPipelineSteps.includes(stepId);
|
||||
if (isCurrentlyExcluded) {
|
||||
// Remove from exclusions (enable the step)
|
||||
onExcludedStepsChange(excludedPipelineSteps.filter((id) => id !== stepId));
|
||||
} else {
|
||||
// Add to exclusions (disable the step)
|
||||
onExcludedStepsChange([...excludedPipelineSteps, stepId]);
|
||||
}
|
||||
};
|
||||
|
||||
const allExcluded = sortedSteps.every((step) => excludedPipelineSteps.includes(step.id));
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Workflow className="w-4 h-4 text-muted-foreground" />
|
||||
<Label className="text-sm font-medium">Custom Pipeline Steps</Label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{sortedSteps.map((step) => {
|
||||
const isIncluded = !excludedPipelineSteps.includes(step.id);
|
||||
return (
|
||||
<div
|
||||
key={step.id}
|
||||
className={cn(
|
||||
'flex items-center justify-between gap-3 px-3 py-2 rounded-md border',
|
||||
isIncluded
|
||||
? 'border-border/50 bg-muted/30'
|
||||
: 'border-border/30 bg-muted/10 opacity-60'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<div
|
||||
className={cn(
|
||||
'w-2 h-2 rounded-full flex-shrink-0',
|
||||
step.colorClass || 'bg-gray-400'
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: step.colorClass?.startsWith('#') ? step.colorClass : undefined,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm truncate',
|
||||
isIncluded ? 'text-foreground' : 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{step.name}
|
||||
</span>
|
||||
</div>
|
||||
<Switch
|
||||
checked={isIncluded}
|
||||
onCheckedChange={() => toggleStep(step.id)}
|
||||
disabled={disabled}
|
||||
data-testid={`${testIdPrefix}-step-${step.id}`}
|
||||
aria-label={`${isIncluded ? 'Disable' : 'Enable'} ${step.name} pipeline step`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{allExcluded && (
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1.5">
|
||||
<GitBranch className="w-3.5 h-3.5" />
|
||||
All pipeline steps disabled. Feature will skip directly to verification.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enabled steps will run after implementation. Disable steps to skip them for this feature.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -27,16 +27,17 @@ import {
|
||||
Copy,
|
||||
Eye,
|
||||
ScrollText,
|
||||
Sparkles,
|
||||
CloudOff,
|
||||
Terminal,
|
||||
SquarePlus,
|
||||
SplitSquareHorizontal,
|
||||
Undo2,
|
||||
Zap,
|
||||
FlaskConical,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { WorktreeInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types';
|
||||
import type { WorktreeInfo, DevServerInfo, PRInfo, GitRepoStatus, TestSessionInfo } from '../types';
|
||||
import { TooltipWrapper } from './tooltip-wrapper';
|
||||
import { useAvailableEditors, useEffectiveDefaultEditor } from '../hooks/use-available-editors';
|
||||
import {
|
||||
@@ -63,6 +64,14 @@ interface WorktreeActionsDropdownProps {
|
||||
standalone?: boolean;
|
||||
/** Whether auto mode is running for this worktree */
|
||||
isAutoModeRunning?: boolean;
|
||||
/** Whether a test command is configured in project settings */
|
||||
hasTestCommand?: boolean;
|
||||
/** Whether tests are being started for this worktree */
|
||||
isStartingTests?: boolean;
|
||||
/** Whether tests are currently running for this worktree */
|
||||
isTestRunning?: boolean;
|
||||
/** Active test session info for this worktree */
|
||||
testSessionInfo?: TestSessionInfo;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onPull: (worktree: WorktreeInfo) => void;
|
||||
onPush: (worktree: WorktreeInfo) => void;
|
||||
@@ -84,6 +93,12 @@ interface WorktreeActionsDropdownProps {
|
||||
onRunInitScript: (worktree: WorktreeInfo) => void;
|
||||
onToggleAutoMode?: (worktree: WorktreeInfo) => void;
|
||||
onMerge: (worktree: WorktreeInfo) => void;
|
||||
/** Start running tests for this worktree */
|
||||
onStartTests?: (worktree: WorktreeInfo) => void;
|
||||
/** Stop running tests for this worktree */
|
||||
onStopTests?: (worktree: WorktreeInfo) => void;
|
||||
/** View test logs for this worktree */
|
||||
onViewTestLogs?: (worktree: WorktreeInfo) => void;
|
||||
hasInitScript: boolean;
|
||||
}
|
||||
|
||||
@@ -101,6 +116,10 @@ export function WorktreeActionsDropdown({
|
||||
gitRepoStatus,
|
||||
standalone = false,
|
||||
isAutoModeRunning = false,
|
||||
hasTestCommand = false,
|
||||
isStartingTests = false,
|
||||
isTestRunning = false,
|
||||
testSessionInfo,
|
||||
onOpenChange,
|
||||
onPull,
|
||||
onPush,
|
||||
@@ -122,6 +141,9 @@ export function WorktreeActionsDropdown({
|
||||
onRunInitScript,
|
||||
onToggleAutoMode,
|
||||
onMerge,
|
||||
onStartTests,
|
||||
onStopTests,
|
||||
onViewTestLogs,
|
||||
hasInitScript,
|
||||
}: WorktreeActionsDropdownProps) {
|
||||
// Get available editors for the "Open In" submenu
|
||||
@@ -231,6 +253,65 @@ export function WorktreeActionsDropdown({
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
{/* Test Runner section - only show when test command is configured */}
|
||||
{hasTestCommand && onStartTests && (
|
||||
<>
|
||||
{isTestRunning ? (
|
||||
<>
|
||||
<DropdownMenuLabel className="text-xs flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-blue-500 animate-pulse" />
|
||||
Tests Running
|
||||
</DropdownMenuLabel>
|
||||
{onViewTestLogs && (
|
||||
<DropdownMenuItem onClick={() => onViewTestLogs(worktree)} className="text-xs">
|
||||
<ScrollText className="w-3.5 h-3.5 mr-2" />
|
||||
View Test Logs
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onStopTests && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => onStopTests(worktree)}
|
||||
className="text-xs text-destructive focus:text-destructive"
|
||||
>
|
||||
<Square className="w-3.5 h-3.5 mr-2" />
|
||||
Stop Tests
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onStartTests(worktree)}
|
||||
disabled={isStartingTests}
|
||||
className="text-xs"
|
||||
>
|
||||
<FlaskConical
|
||||
className={cn('w-3.5 h-3.5 mr-2', isStartingTests && 'animate-pulse')}
|
||||
/>
|
||||
{isStartingTests ? 'Starting Tests...' : 'Run Tests'}
|
||||
</DropdownMenuItem>
|
||||
{onViewTestLogs && testSessionInfo && (
|
||||
<DropdownMenuItem onClick={() => onViewTestLogs(worktree)} className="text-xs">
|
||||
<ScrollText className="w-3.5 h-3.5 mr-2" />
|
||||
View Last Test Results
|
||||
{testSessionInfo.status === 'passed' && (
|
||||
<span className="ml-auto text-[10px] bg-green-500/20 text-green-600 px-1.5 py-0.5 rounded">
|
||||
passed
|
||||
</span>
|
||||
)}
|
||||
{testSessionInfo.status === 'failed' && (
|
||||
<span className="ml-auto text-[10px] bg-red-500/20 text-red-600 px-1.5 py-0.5 rounded">
|
||||
failed
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* Auto Mode toggle */}
|
||||
{onToggleAutoMode && (
|
||||
<>
|
||||
@@ -284,9 +365,9 @@ export function WorktreeActionsDropdown({
|
||||
{isPushing ? 'Pushing...' : 'Push'}
|
||||
{!canPerformGitOps && <AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />}
|
||||
{canPerformGitOps && !hasRemoteBranch && (
|
||||
<span className="ml-auto inline-flex items-center gap-0.5 text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded">
|
||||
<Sparkles className="w-2.5 h-2.5" />
|
||||
new
|
||||
<span className="ml-auto inline-flex items-center gap-0.5 text-[10px] bg-amber-500/20 text-amber-600 dark:text-amber-400 px-1.5 py-0.5 rounded">
|
||||
<CloudOff className="w-2.5 h-2.5" />
|
||||
local only
|
||||
</span>
|
||||
)}
|
||||
{canPerformGitOps && hasRemoteBranch && aheadCount > 0 && (
|
||||
|
||||
@@ -5,7 +5,14 @@ import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
import type { WorktreeInfo, BranchInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types';
|
||||
import type {
|
||||
WorktreeInfo,
|
||||
BranchInfo,
|
||||
DevServerInfo,
|
||||
PRInfo,
|
||||
GitRepoStatus,
|
||||
TestSessionInfo,
|
||||
} from '../types';
|
||||
import { BranchSwitchDropdown } from './branch-switch-dropdown';
|
||||
import { WorktreeActionsDropdown } from './worktree-actions-dropdown';
|
||||
|
||||
@@ -33,6 +40,12 @@ interface WorktreeTabProps {
|
||||
gitRepoStatus: GitRepoStatus;
|
||||
/** Whether auto mode is running for this worktree */
|
||||
isAutoModeRunning?: boolean;
|
||||
/** Whether tests are being started for this worktree */
|
||||
isStartingTests?: boolean;
|
||||
/** Whether tests are currently running for this worktree */
|
||||
isTestRunning?: boolean;
|
||||
/** Active test session info for this worktree */
|
||||
testSessionInfo?: TestSessionInfo;
|
||||
onSelectWorktree: (worktree: WorktreeInfo) => void;
|
||||
onBranchDropdownOpenChange: (open: boolean) => void;
|
||||
onActionsDropdownOpenChange: (open: boolean) => void;
|
||||
@@ -59,7 +72,15 @@ interface WorktreeTabProps {
|
||||
onViewDevServerLogs: (worktree: WorktreeInfo) => void;
|
||||
onRunInitScript: (worktree: WorktreeInfo) => void;
|
||||
onToggleAutoMode?: (worktree: WorktreeInfo) => void;
|
||||
/** Start running tests for this worktree */
|
||||
onStartTests?: (worktree: WorktreeInfo) => void;
|
||||
/** Stop running tests for this worktree */
|
||||
onStopTests?: (worktree: WorktreeInfo) => void;
|
||||
/** View test logs for this worktree */
|
||||
onViewTestLogs?: (worktree: WorktreeInfo) => void;
|
||||
hasInitScript: boolean;
|
||||
/** Whether a test command is configured in project settings */
|
||||
hasTestCommand?: boolean;
|
||||
}
|
||||
|
||||
export function WorktreeTab({
|
||||
@@ -85,6 +106,9 @@ export function WorktreeTab({
|
||||
hasRemoteBranch,
|
||||
gitRepoStatus,
|
||||
isAutoModeRunning = false,
|
||||
isStartingTests = false,
|
||||
isTestRunning = false,
|
||||
testSessionInfo,
|
||||
onSelectWorktree,
|
||||
onBranchDropdownOpenChange,
|
||||
onActionsDropdownOpenChange,
|
||||
@@ -111,7 +135,11 @@ export function WorktreeTab({
|
||||
onViewDevServerLogs,
|
||||
onRunInitScript,
|
||||
onToggleAutoMode,
|
||||
onStartTests,
|
||||
onStopTests,
|
||||
onViewTestLogs,
|
||||
hasInitScript,
|
||||
hasTestCommand = false,
|
||||
}: WorktreeTabProps) {
|
||||
// Make the worktree tab a drop target for feature cards
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
@@ -395,6 +423,10 @@ export function WorktreeTab({
|
||||
devServerInfo={devServerInfo}
|
||||
gitRepoStatus={gitRepoStatus}
|
||||
isAutoModeRunning={isAutoModeRunning}
|
||||
hasTestCommand={hasTestCommand}
|
||||
isStartingTests={isStartingTests}
|
||||
isTestRunning={isTestRunning}
|
||||
testSessionInfo={testSessionInfo}
|
||||
onOpenChange={onActionsDropdownOpenChange}
|
||||
onPull={onPull}
|
||||
onPush={onPush}
|
||||
@@ -416,6 +448,9 @@ export function WorktreeTab({
|
||||
onViewDevServerLogs={onViewDevServerLogs}
|
||||
onRunInitScript={onRunInitScript}
|
||||
onToggleAutoMode={onToggleAutoMode}
|
||||
onStartTests={onStartTests}
|
||||
onStopTests={onStopTests}
|
||||
onViewTestLogs={onViewTestLogs}
|
||||
hasInitScript={hasInitScript}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -30,6 +30,19 @@ export interface DevServerInfo {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface TestSessionInfo {
|
||||
sessionId: string;
|
||||
worktreePath: string;
|
||||
/** The test command being run (from project settings) */
|
||||
command: string;
|
||||
status: 'pending' | 'running' | 'passed' | 'failed' | 'cancelled';
|
||||
testFile?: string;
|
||||
startedAt: string;
|
||||
finishedAt?: string;
|
||||
exitCode?: number | null;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export interface FeatureInfo {
|
||||
id: string;
|
||||
branchName?: string;
|
||||
|
||||
@@ -6,8 +6,15 @@ import { pathsEqual } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import { useIsMobile } from '@/hooks/use-media-query';
|
||||
import { useWorktreeInitScript } from '@/hooks/queries';
|
||||
import type { WorktreePanelProps, WorktreeInfo } from './types';
|
||||
import { useWorktreeInitScript, useProjectSettings } from '@/hooks/queries';
|
||||
import { useTestRunnerEvents } from '@/hooks/use-test-runners';
|
||||
import { useTestRunnersStore } from '@/store/test-runners-store';
|
||||
import type {
|
||||
TestRunnerStartedEvent,
|
||||
TestRunnerOutputEvent,
|
||||
TestRunnerCompletedEvent,
|
||||
} from '@/types/electron';
|
||||
import type { WorktreePanelProps, WorktreeInfo, TestSessionInfo } from './types';
|
||||
import {
|
||||
useWorktrees,
|
||||
useDevServers,
|
||||
@@ -25,6 +32,7 @@ import {
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { ViewWorktreeChangesDialog, PushToRemoteDialog, MergeWorktreeDialog } from '../dialogs';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { TestLogsPanel } from '@/components/ui/test-logs-panel';
|
||||
import { Undo2 } from 'lucide-react';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
|
||||
@@ -161,6 +169,194 @@ export function WorktreePanel({
|
||||
const { data: initScriptData } = useWorktreeInitScript(projectPath);
|
||||
const hasInitScript = initScriptData?.exists ?? false;
|
||||
|
||||
// Check if test command is configured in project settings
|
||||
const { data: projectSettings } = useProjectSettings(projectPath);
|
||||
const hasTestCommand = !!projectSettings?.testCommand;
|
||||
|
||||
// Test runner state management
|
||||
// Use the test runners store to get global state for all worktrees
|
||||
const testRunnersStore = useTestRunnersStore();
|
||||
const [isStartingTests, setIsStartingTests] = useState(false);
|
||||
|
||||
// Subscribe to test runner events to update store state in real-time
|
||||
// This ensures the UI updates when tests start, output is received, or tests complete
|
||||
useTestRunnerEvents(
|
||||
// onStarted - a new test run has begun
|
||||
useCallback(
|
||||
(event: TestRunnerStartedEvent) => {
|
||||
testRunnersStore.startSession({
|
||||
sessionId: event.sessionId,
|
||||
worktreePath: event.worktreePath,
|
||||
command: event.command,
|
||||
status: 'running',
|
||||
testFile: event.testFile,
|
||||
startedAt: event.timestamp,
|
||||
});
|
||||
},
|
||||
[testRunnersStore]
|
||||
),
|
||||
// onOutput - test output received
|
||||
useCallback(
|
||||
(event: TestRunnerOutputEvent) => {
|
||||
testRunnersStore.appendOutput(event.sessionId, event.content);
|
||||
},
|
||||
[testRunnersStore]
|
||||
),
|
||||
// onCompleted - test run finished
|
||||
useCallback(
|
||||
(event: TestRunnerCompletedEvent) => {
|
||||
testRunnersStore.completeSession(
|
||||
event.sessionId,
|
||||
event.status,
|
||||
event.exitCode,
|
||||
event.duration
|
||||
);
|
||||
// Show toast notification for test completion
|
||||
const statusEmoji =
|
||||
event.status === 'passed' ? '✅' : event.status === 'failed' ? '❌' : '⏹️';
|
||||
const statusText =
|
||||
event.status === 'passed' ? 'passed' : event.status === 'failed' ? 'failed' : 'stopped';
|
||||
toast(`${statusEmoji} Tests ${statusText}`, {
|
||||
description: `Exit code: ${event.exitCode ?? 'N/A'}`,
|
||||
duration: 4000,
|
||||
});
|
||||
},
|
||||
[testRunnersStore]
|
||||
)
|
||||
);
|
||||
|
||||
// Test logs panel state
|
||||
const [testLogsPanelOpen, setTestLogsPanelOpen] = useState(false);
|
||||
const [testLogsPanelWorktree, setTestLogsPanelWorktree] = useState<WorktreeInfo | null>(null);
|
||||
|
||||
// Helper to check if tests are running for a specific worktree
|
||||
const isTestRunningForWorktree = useCallback(
|
||||
(worktree: WorktreeInfo): boolean => {
|
||||
return testRunnersStore.isWorktreeRunning(worktree.path);
|
||||
},
|
||||
[testRunnersStore]
|
||||
);
|
||||
|
||||
// Helper to get test session info for a specific worktree
|
||||
const getTestSessionInfo = useCallback(
|
||||
(worktree: WorktreeInfo): TestSessionInfo | undefined => {
|
||||
const session = testRunnersStore.getActiveSession(worktree.path);
|
||||
if (!session) {
|
||||
// Check for completed sessions to show last result
|
||||
const allSessions = Object.values(testRunnersStore.sessions).filter(
|
||||
(s) => s.worktreePath === worktree.path
|
||||
);
|
||||
const lastSession = allSessions.sort(
|
||||
(a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime()
|
||||
)[0];
|
||||
if (lastSession) {
|
||||
return {
|
||||
sessionId: lastSession.sessionId,
|
||||
worktreePath: lastSession.worktreePath,
|
||||
command: lastSession.command,
|
||||
status: lastSession.status as TestSessionInfo['status'],
|
||||
testFile: lastSession.testFile,
|
||||
startedAt: lastSession.startedAt,
|
||||
finishedAt: lastSession.finishedAt,
|
||||
exitCode: lastSession.exitCode,
|
||||
duration: lastSession.duration,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
sessionId: session.sessionId,
|
||||
worktreePath: session.worktreePath,
|
||||
command: session.command,
|
||||
status: session.status as TestSessionInfo['status'],
|
||||
testFile: session.testFile,
|
||||
startedAt: session.startedAt,
|
||||
finishedAt: session.finishedAt,
|
||||
exitCode: session.exitCode,
|
||||
duration: session.duration,
|
||||
};
|
||||
},
|
||||
[testRunnersStore]
|
||||
);
|
||||
|
||||
// Handler to start tests for a worktree
|
||||
const handleStartTests = useCallback(
|
||||
async (worktree: WorktreeInfo) => {
|
||||
setIsStartingTests(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.startTests) {
|
||||
toast.error('Test runner API not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await api.worktree.startTests(worktree.path, { projectPath });
|
||||
if (result.success) {
|
||||
toast.success('Tests started', {
|
||||
description: `Running tests in ${worktree.branch}`,
|
||||
});
|
||||
} else {
|
||||
toast.error('Failed to start tests', {
|
||||
description: result.error || 'Unknown error',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to start tests', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
} finally {
|
||||
setIsStartingTests(false);
|
||||
}
|
||||
},
|
||||
[projectPath]
|
||||
);
|
||||
|
||||
// Handler to stop tests for a worktree
|
||||
const handleStopTests = useCallback(
|
||||
async (worktree: WorktreeInfo) => {
|
||||
try {
|
||||
const session = testRunnersStore.getActiveSession(worktree.path);
|
||||
if (!session) {
|
||||
toast.error('No active test session to stop');
|
||||
return;
|
||||
}
|
||||
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.stopTests) {
|
||||
toast.error('Test runner API not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await api.worktree.stopTests(session.sessionId);
|
||||
if (result.success) {
|
||||
toast.success('Tests stopped', {
|
||||
description: `Stopped tests in ${worktree.branch}`,
|
||||
});
|
||||
} else {
|
||||
toast.error('Failed to stop tests', {
|
||||
description: result.error || 'Unknown error',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to stop tests', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
},
|
||||
[testRunnersStore]
|
||||
);
|
||||
|
||||
// Handler to view test logs for a worktree
|
||||
const handleViewTestLogs = useCallback((worktree: WorktreeInfo) => {
|
||||
setTestLogsPanelWorktree(worktree);
|
||||
setTestLogsPanelOpen(true);
|
||||
}, []);
|
||||
|
||||
// Handler to close test logs panel
|
||||
const handleCloseTestLogsPanel = useCallback(() => {
|
||||
setTestLogsPanelOpen(false);
|
||||
}, []);
|
||||
|
||||
// View changes dialog state
|
||||
const [viewChangesDialogOpen, setViewChangesDialogOpen] = useState(false);
|
||||
const [viewChangesWorktree, setViewChangesWorktree] = useState<WorktreeInfo | null>(null);
|
||||
@@ -392,6 +588,10 @@ export function WorktreePanel({
|
||||
devServerInfo={getDevServerInfo(selectedWorktree)}
|
||||
gitRepoStatus={gitRepoStatus}
|
||||
isAutoModeRunning={isAutoModeRunningForWorktree(selectedWorktree)}
|
||||
hasTestCommand={hasTestCommand}
|
||||
isStartingTests={isStartingTests}
|
||||
isTestRunning={isTestRunningForWorktree(selectedWorktree)}
|
||||
testSessionInfo={getTestSessionInfo(selectedWorktree)}
|
||||
onOpenChange={handleActionsDropdownOpenChange(selectedWorktree)}
|
||||
onPull={handlePull}
|
||||
onPush={handlePush}
|
||||
@@ -413,6 +613,9 @@ export function WorktreePanel({
|
||||
onViewDevServerLogs={handleViewDevServerLogs}
|
||||
onRunInitScript={handleRunInitScript}
|
||||
onToggleAutoMode={handleToggleAutoMode}
|
||||
onStartTests={handleStartTests}
|
||||
onStopTests={handleStopTests}
|
||||
onViewTestLogs={handleViewTestLogs}
|
||||
hasInitScript={hasInitScript}
|
||||
/>
|
||||
)}
|
||||
@@ -494,6 +697,17 @@ export function WorktreePanel({
|
||||
onMerged={handleMerged}
|
||||
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
|
||||
/>
|
||||
|
||||
{/* Test Logs Panel */}
|
||||
<TestLogsPanel
|
||||
open={testLogsPanelOpen}
|
||||
onClose={handleCloseTestLogsPanel}
|
||||
worktreePath={testLogsPanelWorktree?.path ?? null}
|
||||
branch={testLogsPanelWorktree?.branch}
|
||||
onStopTests={
|
||||
testLogsPanelWorktree ? () => handleStopTests(testLogsPanelWorktree) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -530,6 +744,9 @@ export function WorktreePanel({
|
||||
hasRemoteBranch={hasRemoteBranch}
|
||||
gitRepoStatus={gitRepoStatus}
|
||||
isAutoModeRunning={isAutoModeRunningForWorktree(mainWorktree)}
|
||||
isStartingTests={isStartingTests}
|
||||
isTestRunning={isTestRunningForWorktree(mainWorktree)}
|
||||
testSessionInfo={getTestSessionInfo(mainWorktree)}
|
||||
onSelectWorktree={handleSelectWorktree}
|
||||
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(mainWorktree)}
|
||||
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(mainWorktree)}
|
||||
@@ -556,7 +773,11 @@ export function WorktreePanel({
|
||||
onViewDevServerLogs={handleViewDevServerLogs}
|
||||
onRunInitScript={handleRunInitScript}
|
||||
onToggleAutoMode={handleToggleAutoMode}
|
||||
onStartTests={handleStartTests}
|
||||
onStopTests={handleStopTests}
|
||||
onViewTestLogs={handleViewTestLogs}
|
||||
hasInitScript={hasInitScript}
|
||||
hasTestCommand={hasTestCommand}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -596,6 +817,9 @@ export function WorktreePanel({
|
||||
hasRemoteBranch={hasRemoteBranch}
|
||||
gitRepoStatus={gitRepoStatus}
|
||||
isAutoModeRunning={isAutoModeRunningForWorktree(worktree)}
|
||||
isStartingTests={isStartingTests}
|
||||
isTestRunning={isTestRunningForWorktree(worktree)}
|
||||
testSessionInfo={getTestSessionInfo(worktree)}
|
||||
onSelectWorktree={handleSelectWorktree}
|
||||
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)}
|
||||
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)}
|
||||
@@ -622,7 +846,11 @@ export function WorktreePanel({
|
||||
onViewDevServerLogs={handleViewDevServerLogs}
|
||||
onRunInitScript={handleRunInitScript}
|
||||
onToggleAutoMode={handleToggleAutoMode}
|
||||
onStartTests={handleStartTests}
|
||||
onStopTests={handleStopTests}
|
||||
onViewTestLogs={handleViewTestLogs}
|
||||
hasInitScript={hasInitScript}
|
||||
hasTestCommand={hasTestCommand}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -703,6 +931,17 @@ export function WorktreePanel({
|
||||
onMerged={handleMerged}
|
||||
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
|
||||
/>
|
||||
|
||||
{/* Test Logs Panel */}
|
||||
<TestLogsPanel
|
||||
open={testLogsPanelOpen}
|
||||
onClose={handleCloseTestLogsPanel}
|
||||
worktreePath={testLogsPanelWorktree?.path ?? null}
|
||||
branch={testLogsPanelWorktree?.branch}
|
||||
onStopTests={
|
||||
testLogsPanelWorktree ? () => handleStopTests(testLogsPanelWorktree) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -392,6 +392,7 @@ export function GraphViewPage() {
|
||||
currentBranch={currentWorktreeBranch || undefined}
|
||||
isMaximized={false}
|
||||
allFeatures={hookFeatures}
|
||||
projectPath={currentProject?.path}
|
||||
/>
|
||||
|
||||
{/* Add Feature Dialog (for spawning) */}
|
||||
@@ -414,6 +415,7 @@ export function GraphViewPage() {
|
||||
isMaximized={false}
|
||||
parentFeature={spawnParentFeature}
|
||||
allFeatures={hookFeatures}
|
||||
projectPath={currentProject?.path}
|
||||
// When setting is enabled and a non-main worktree is selected, pass its branch to default to 'custom' work mode
|
||||
selectedNonMainWorktreeBranch={
|
||||
addFeatureUseSelectedWorktreeBranch && currentWorktreePath !== null
|
||||
|
||||
@@ -11,7 +11,6 @@ import { useIdeationStore } from '@/store/ideation-store';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useGenerateIdeationSuggestions } from '@/hooks/mutations';
|
||||
import { toast } from 'sonner';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import type { IdeaCategory, IdeationPrompt } from '@automaker/types';
|
||||
|
||||
interface PromptListProps {
|
||||
@@ -24,10 +23,8 @@ export function PromptList({ category, onBack }: PromptListProps) {
|
||||
const generationJobs = useIdeationStore((s) => s.generationJobs);
|
||||
const setMode = useIdeationStore((s) => s.setMode);
|
||||
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 navigate = useNavigate();
|
||||
|
||||
// React Query mutation
|
||||
const generateMutation = useGenerateIdeationSuggestions(currentProject?.path ?? '');
|
||||
@@ -72,27 +69,13 @@ export function PromptList({ category, onBack }: PromptListProps) {
|
||||
toast.info(`Generating ideas for "${prompt.title}"...`);
|
||||
setMode('dashboard');
|
||||
|
||||
// Start mutation - onSuccess/onError are handled at the hook level to ensure
|
||||
// they fire even after this component unmounts (which happens due to setMode above)
|
||||
generateMutation.mutate(
|
||||
{ promptId: prompt.id, category },
|
||||
{ promptId: prompt.id, category, jobId, promptTitle: prompt.title },
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
updateJobStatus(jobId, 'ready', data.suggestions);
|
||||
toast.success(`Generated ${data.suggestions.length} ideas for "${prompt.title}"`, {
|
||||
duration: 10000,
|
||||
action: {
|
||||
label: 'View Ideas',
|
||||
onClick: () => {
|
||||
setMode('dashboard');
|
||||
navigate({ to: '/ideation' });
|
||||
},
|
||||
},
|
||||
});
|
||||
setLoadingPromptId(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to generate suggestions:', error);
|
||||
updateJobStatus(jobId, 'error', undefined, error.message);
|
||||
toast.error(error.message);
|
||||
// Optional: reset local loading state if component is still mounted
|
||||
onSettled: () => {
|
||||
setLoadingPromptId(null);
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
import { useState, useEffect, useCallback, type KeyboardEvent } from 'react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Terminal, Save, RotateCcw, Info, X, Play, FlaskConical } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useProjectSettings } from '@/hooks/queries';
|
||||
import { useUpdateProjectSettings } from '@/hooks/mutations';
|
||||
import type { Project } from '@/lib/electron';
|
||||
|
||||
/** Preset dev server commands for quick selection */
|
||||
const DEV_SERVER_PRESETS = [
|
||||
{ label: 'npm run dev', command: 'npm run dev' },
|
||||
{ label: 'yarn dev', command: 'yarn dev' },
|
||||
{ label: 'pnpm dev', command: 'pnpm dev' },
|
||||
{ label: 'bun dev', command: 'bun dev' },
|
||||
{ label: 'npm start', command: 'npm start' },
|
||||
{ label: 'cargo watch', command: 'cargo watch -x run' },
|
||||
{ label: 'go run', command: 'go run .' },
|
||||
] as const;
|
||||
|
||||
/** Preset test commands for quick selection */
|
||||
const TEST_PRESETS = [
|
||||
{ label: 'npm test', command: 'npm test' },
|
||||
{ label: 'yarn test', command: 'yarn test' },
|
||||
{ label: 'pnpm test', command: 'pnpm test' },
|
||||
{ label: 'bun test', command: 'bun test' },
|
||||
{ label: 'pytest', command: 'pytest' },
|
||||
{ label: 'cargo test', command: 'cargo test' },
|
||||
{ label: 'go test', command: 'go test ./...' },
|
||||
] as const;
|
||||
|
||||
interface CommandsSectionProps {
|
||||
project: Project;
|
||||
}
|
||||
|
||||
export function CommandsSection({ project }: CommandsSectionProps) {
|
||||
// Fetch project settings using TanStack Query
|
||||
const { data: projectSettings, isLoading, isError } = useProjectSettings(project.path);
|
||||
|
||||
// Mutation hook for updating project settings
|
||||
const updateSettingsMutation = useUpdateProjectSettings(project.path);
|
||||
|
||||
// Local state for the input fields
|
||||
const [devCommand, setDevCommand] = useState('');
|
||||
const [originalDevCommand, setOriginalDevCommand] = useState('');
|
||||
const [testCommand, setTestCommand] = useState('');
|
||||
const [originalTestCommand, setOriginalTestCommand] = useState('');
|
||||
|
||||
// Sync local state when project settings load or project changes
|
||||
useEffect(() => {
|
||||
// Reset local state when project changes to avoid showing stale values
|
||||
setDevCommand('');
|
||||
setOriginalDevCommand('');
|
||||
setTestCommand('');
|
||||
setOriginalTestCommand('');
|
||||
}, [project.path]);
|
||||
|
||||
useEffect(() => {
|
||||
if (projectSettings) {
|
||||
const dev = projectSettings.devCommand || '';
|
||||
const test = projectSettings.testCommand || '';
|
||||
setDevCommand(dev);
|
||||
setOriginalDevCommand(dev);
|
||||
setTestCommand(test);
|
||||
setOriginalTestCommand(test);
|
||||
}
|
||||
}, [projectSettings]);
|
||||
|
||||
// Check if there are unsaved changes
|
||||
const hasDevChanges = devCommand !== originalDevCommand;
|
||||
const hasTestChanges = testCommand !== originalTestCommand;
|
||||
const hasChanges = hasDevChanges || hasTestChanges;
|
||||
const isSaving = updateSettingsMutation.isPending;
|
||||
|
||||
// Save all commands
|
||||
const handleSave = useCallback(() => {
|
||||
const normalizedDevCommand = devCommand.trim();
|
||||
const normalizedTestCommand = testCommand.trim();
|
||||
|
||||
updateSettingsMutation.mutate(
|
||||
{
|
||||
devCommand: normalizedDevCommand || null,
|
||||
testCommand: normalizedTestCommand || null,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setDevCommand(normalizedDevCommand);
|
||||
setOriginalDevCommand(normalizedDevCommand);
|
||||
setTestCommand(normalizedTestCommand);
|
||||
setOriginalTestCommand(normalizedTestCommand);
|
||||
},
|
||||
}
|
||||
);
|
||||
}, [devCommand, testCommand, updateSettingsMutation]);
|
||||
|
||||
// Reset to original values
|
||||
const handleReset = useCallback(() => {
|
||||
setDevCommand(originalDevCommand);
|
||||
setTestCommand(originalTestCommand);
|
||||
}, [originalDevCommand, originalTestCommand]);
|
||||
|
||||
// Use a preset command
|
||||
const handleUseDevPreset = useCallback((command: string) => {
|
||||
setDevCommand(command);
|
||||
}, []);
|
||||
|
||||
const handleUseTestPreset = useCallback((command: string) => {
|
||||
setTestCommand(command);
|
||||
}, []);
|
||||
|
||||
// Clear commands
|
||||
const handleClearDev = useCallback(() => {
|
||||
setDevCommand('');
|
||||
}, []);
|
||||
|
||||
const handleClearTest = useCallback(() => {
|
||||
setTestCommand('');
|
||||
}, []);
|
||||
|
||||
// Handle keyboard shortcuts (Enter to save)
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' && hasChanges && !isSaving) {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}
|
||||
},
|
||||
[hasChanges, isSaving, handleSave]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl overflow-hidden',
|
||||
'border border-border/50',
|
||||
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
|
||||
'shadow-sm shadow-black/5'
|
||||
)}
|
||||
>
|
||||
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
|
||||
<Terminal className="w-5 h-5 text-brand-500" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">Project Commands</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||
Configure custom commands for development and testing.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-8">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Spinner size="md" />
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="flex items-center justify-center py-8 text-sm text-destructive">
|
||||
Failed to load project settings. Please try again.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Dev Server Command Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Play className="w-4 h-4 text-brand-500" />
|
||||
<h3 className="text-base font-medium text-foreground">Dev Server</h3>
|
||||
{hasDevChanges && (
|
||||
<span className="text-xs text-amber-500 font-medium">(unsaved)</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 pl-6">
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="dev-command"
|
||||
value={devCommand}
|
||||
onChange={(e) => setDevCommand(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="e.g., npm run dev, yarn dev, cargo watch"
|
||||
className="font-mono text-sm pr-8"
|
||||
data-testid="dev-command-input"
|
||||
/>
|
||||
{devCommand && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClearDev}
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Clear dev command"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground/80">
|
||||
Leave empty to auto-detect based on your package manager.
|
||||
</p>
|
||||
|
||||
{/* Dev Presets */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{DEV_SERVER_PRESETS.map((preset) => (
|
||||
<Button
|
||||
key={preset.command}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleUseDevPreset(preset.command)}
|
||||
className="text-xs font-mono h-7 px-2"
|
||||
>
|
||||
{preset.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="border-t border-border/30" />
|
||||
|
||||
{/* Test Command Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<FlaskConical className="w-4 h-4 text-brand-500" />
|
||||
<h3 className="text-base font-medium text-foreground">Test Runner</h3>
|
||||
{hasTestChanges && (
|
||||
<span className="text-xs text-amber-500 font-medium">(unsaved)</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 pl-6">
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="test-command"
|
||||
value={testCommand}
|
||||
onChange={(e) => setTestCommand(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="e.g., npm test, pytest, cargo test"
|
||||
className="font-mono text-sm pr-8"
|
||||
data-testid="test-command-input"
|
||||
/>
|
||||
{testCommand && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClearTest}
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Clear test command"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground/80">
|
||||
Leave empty to auto-detect based on your project structure.
|
||||
</p>
|
||||
|
||||
{/* Test Presets */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{TEST_PRESETS.map((preset) => (
|
||||
<Button
|
||||
key={preset.command}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleUseTestPreset(preset.command)}
|
||||
className="text-xs font-mono h-7 px-2"
|
||||
>
|
||||
{preset.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auto-detection Info */}
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg bg-accent/20 border border-border/30">
|
||||
<Info className="w-4 h-4 text-brand-500 mt-0.5 shrink-0" />
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<p className="font-medium text-foreground mb-1">Auto-detection</p>
|
||||
<p>
|
||||
When no custom command is set, the system automatically detects your package
|
||||
manager and test framework based on project files (package.json, Cargo.toml,
|
||||
go.mod, etc.).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center justify-end gap-2 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleReset}
|
||||
disabled={!hasChanges || isSaving}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || isSaving}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{isSaving ? <Spinner size="xs" /> : <Save className="w-3.5 h-3.5" />}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { User, GitBranch, Palette, AlertTriangle, Workflow } from 'lucide-react';
|
||||
import {
|
||||
User,
|
||||
GitBranch,
|
||||
Palette,
|
||||
AlertTriangle,
|
||||
Workflow,
|
||||
Database,
|
||||
Terminal,
|
||||
} from 'lucide-react';
|
||||
import type { ProjectSettingsViewId } from '../hooks/use-project-settings-view';
|
||||
|
||||
export interface ProjectNavigationItem {
|
||||
@@ -11,7 +19,9 @@ export interface ProjectNavigationItem {
|
||||
export const PROJECT_SETTINGS_NAV_ITEMS: ProjectNavigationItem[] = [
|
||||
{ id: 'identity', label: 'Identity', icon: User },
|
||||
{ id: 'worktrees', label: 'Worktrees', icon: GitBranch },
|
||||
{ id: 'commands', label: 'Commands', icon: Terminal },
|
||||
{ id: 'theme', label: 'Theme', icon: Palette },
|
||||
{ id: 'claude', label: 'Models', icon: Workflow },
|
||||
{ id: 'data', label: 'Data', icon: Database },
|
||||
{ id: 'danger', label: 'Danger Zone', icon: AlertTriangle },
|
||||
];
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Database, Download, Upload } from 'lucide-react';
|
||||
import { ExportFeaturesDialog } from '../board-view/dialogs/export-features-dialog';
|
||||
import { ImportFeaturesDialog } from '../board-view/dialogs/import-features-dialog';
|
||||
import { useBoardFeatures } from '../board-view/hooks';
|
||||
import type { Project } from '@/lib/electron';
|
||||
|
||||
interface DataManagementSectionProps {
|
||||
project: Project;
|
||||
}
|
||||
|
||||
export function DataManagementSection({ project }: DataManagementSectionProps) {
|
||||
const [showExportDialog, setShowExportDialog] = useState(false);
|
||||
const [showImportDialog, setShowImportDialog] = useState(false);
|
||||
|
||||
// Fetch features and persisted categories using the existing hook
|
||||
const { features, persistedCategories, loadFeatures } = useBoardFeatures({
|
||||
currentProject: project,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl overflow-hidden',
|
||||
'border border-border/50',
|
||||
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
|
||||
'shadow-sm shadow-black/5'
|
||||
)}
|
||||
>
|
||||
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
|
||||
<Database className="w-5 h-5 text-brand-500" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">
|
||||
Data Management
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||
Export and import features to backup your data or share with other projects.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Export Section */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-foreground">Export Features</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Download all features as a JSON or YAML file for backup or sharing.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowExportDialog(true)}
|
||||
className="gap-2"
|
||||
data-testid="export-features-button"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Export Features
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="border-t border-border/50" />
|
||||
|
||||
{/* Import Section */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-foreground">Import Features</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Import features from a previously exported JSON or YAML file.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowImportDialog(true)}
|
||||
className="gap-2"
|
||||
data-testid="import-features-button"
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
Import Features
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Export Dialog */}
|
||||
<ExportFeaturesDialog
|
||||
open={showExportDialog}
|
||||
onOpenChange={setShowExportDialog}
|
||||
projectPath={project.path}
|
||||
features={features}
|
||||
/>
|
||||
|
||||
{/* Import Dialog */}
|
||||
<ImportFeaturesDialog
|
||||
open={showImportDialog}
|
||||
onOpenChange={setShowImportDialog}
|
||||
projectPath={project.path}
|
||||
categorySuggestions={persistedCategories}
|
||||
onImportComplete={() => {
|
||||
loadFeatures();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
export type ProjectSettingsViewId = 'identity' | 'theme' | 'worktrees' | 'claude' | 'danger';
|
||||
export type ProjectSettingsViewId =
|
||||
| 'identity'
|
||||
| 'theme'
|
||||
| 'worktrees'
|
||||
| 'commands'
|
||||
| 'claude'
|
||||
| 'data'
|
||||
| 'danger';
|
||||
|
||||
interface UseProjectSettingsViewOptions {
|
||||
initialView?: ProjectSettingsViewId;
|
||||
|
||||
@@ -2,5 +2,6 @@ export { ProjectSettingsView } from './project-settings-view';
|
||||
export { ProjectIdentitySection } from './project-identity-section';
|
||||
export { ProjectThemeSection } from './project-theme-section';
|
||||
export { WorktreePreferencesSection } from './worktree-preferences-section';
|
||||
export { CommandsSection } from './commands-section';
|
||||
export { useProjectSettingsView, type ProjectSettingsViewId } from './hooks';
|
||||
export { ProjectSettingsNavigation } from './components/project-settings-navigation';
|
||||
|
||||
@@ -25,7 +25,7 @@ import type {
|
||||
ClaudeCompatibleProvider,
|
||||
ClaudeModelAlias,
|
||||
} from '@automaker/types';
|
||||
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
|
||||
import { DEFAULT_PHASE_MODELS, DEFAULT_GLOBAL_SETTINGS } from '@automaker/types';
|
||||
|
||||
interface ProjectBulkReplaceDialogProps {
|
||||
open: boolean;
|
||||
@@ -44,12 +44,16 @@ const PHASE_LABELS: Record<PhaseModelKey, string> = {
|
||||
featureGenerationModel: 'Feature Generation',
|
||||
backlogPlanningModel: 'Backlog Planning',
|
||||
projectAnalysisModel: 'Project Analysis',
|
||||
suggestionsModel: 'AI Suggestions',
|
||||
ideationModel: 'Ideation',
|
||||
memoryExtractionModel: 'Memory Extraction',
|
||||
};
|
||||
|
||||
const ALL_PHASES = Object.keys(PHASE_LABELS) as PhaseModelKey[];
|
||||
|
||||
// Special key for default feature model (not a phase but included in bulk replace)
|
||||
const DEFAULT_FEATURE_MODEL_KEY = '__defaultFeatureModel__' as const;
|
||||
type ExtendedPhaseKey = PhaseModelKey | typeof DEFAULT_FEATURE_MODEL_KEY;
|
||||
|
||||
// Claude model display names
|
||||
const CLAUDE_MODEL_DISPLAY: Record<ClaudeModelAlias, string> = {
|
||||
haiku: 'Claude Haiku',
|
||||
@@ -62,11 +66,18 @@ export function ProjectBulkReplaceDialog({
|
||||
onOpenChange,
|
||||
project,
|
||||
}: ProjectBulkReplaceDialogProps) {
|
||||
const { phaseModels, setProjectPhaseModelOverride, claudeCompatibleProviders } = useAppStore();
|
||||
const {
|
||||
phaseModels,
|
||||
setProjectPhaseModelOverride,
|
||||
claudeCompatibleProviders,
|
||||
defaultFeatureModel,
|
||||
setProjectDefaultFeatureModel,
|
||||
} = useAppStore();
|
||||
const [selectedProvider, setSelectedProvider] = useState<string>('anthropic');
|
||||
|
||||
// Get project-level overrides
|
||||
const projectOverrides = project.phaseModelOverrides || {};
|
||||
const projectDefaultFeatureModel = project.defaultFeatureModel;
|
||||
|
||||
// Get enabled providers
|
||||
const enabledProviders = useMemo(() => {
|
||||
@@ -122,11 +133,15 @@ export function ProjectBulkReplaceDialog({
|
||||
const findModelForClaudeAlias = (
|
||||
provider: ClaudeCompatibleProvider | null,
|
||||
claudeAlias: ClaudeModelAlias,
|
||||
phase: PhaseModelKey
|
||||
key: ExtendedPhaseKey
|
||||
): PhaseModelEntry => {
|
||||
if (!provider) {
|
||||
// Anthropic Direct - reset to default phase model (includes correct thinking levels)
|
||||
return DEFAULT_PHASE_MODELS[phase];
|
||||
// For default feature model, use the default from global settings
|
||||
if (key === DEFAULT_FEATURE_MODEL_KEY) {
|
||||
return DEFAULT_GLOBAL_SETTINGS.defaultFeatureModel;
|
||||
}
|
||||
return DEFAULT_PHASE_MODELS[key];
|
||||
}
|
||||
|
||||
// Find model that maps to this Claude alias
|
||||
@@ -146,60 +161,91 @@ export function ProjectBulkReplaceDialog({
|
||||
return { model: claudeAlias };
|
||||
};
|
||||
|
||||
// Helper to generate preview item for any entry
|
||||
const generatePreviewItem = (
|
||||
key: ExtendedPhaseKey,
|
||||
label: string,
|
||||
currentEntry: PhaseModelEntry
|
||||
) => {
|
||||
const claudeAlias = getClaudeModelAlias(currentEntry);
|
||||
const newEntry = findModelForClaudeAlias(selectedProviderConfig, claudeAlias, key);
|
||||
|
||||
// Get display names
|
||||
const getCurrentDisplay = (): string => {
|
||||
if (currentEntry.providerId) {
|
||||
const provider = enabledProviders.find((p) => p.id === currentEntry.providerId);
|
||||
if (provider) {
|
||||
const model = provider.models?.find((m) => m.id === currentEntry.model);
|
||||
return model?.displayName || currentEntry.model;
|
||||
}
|
||||
}
|
||||
return CLAUDE_MODEL_DISPLAY[claudeAlias] || currentEntry.model;
|
||||
};
|
||||
|
||||
const getNewDisplay = (): string => {
|
||||
if (newEntry.providerId && selectedProviderConfig) {
|
||||
const model = selectedProviderConfig.models?.find((m) => m.id === newEntry.model);
|
||||
return model?.displayName || newEntry.model;
|
||||
}
|
||||
return CLAUDE_MODEL_DISPLAY[newEntry.model as ClaudeModelAlias] || newEntry.model;
|
||||
};
|
||||
|
||||
const isChanged =
|
||||
currentEntry.model !== newEntry.model ||
|
||||
currentEntry.providerId !== newEntry.providerId ||
|
||||
currentEntry.thinkingLevel !== newEntry.thinkingLevel;
|
||||
|
||||
return {
|
||||
key,
|
||||
label,
|
||||
claudeAlias,
|
||||
currentDisplay: getCurrentDisplay(),
|
||||
newDisplay: getNewDisplay(),
|
||||
newEntry,
|
||||
isChanged,
|
||||
};
|
||||
};
|
||||
|
||||
// Generate preview of changes
|
||||
const preview = useMemo(() => {
|
||||
return ALL_PHASES.map((phase) => {
|
||||
// Current effective value (project override or global)
|
||||
// Default feature model entry (first in the list)
|
||||
const globalDefaultFeature = defaultFeatureModel ?? DEFAULT_GLOBAL_SETTINGS.defaultFeatureModel;
|
||||
const currentDefaultFeature = projectDefaultFeatureModel || globalDefaultFeature;
|
||||
const defaultFeaturePreview = generatePreviewItem(
|
||||
DEFAULT_FEATURE_MODEL_KEY,
|
||||
'Default Feature Model',
|
||||
currentDefaultFeature
|
||||
);
|
||||
|
||||
// Phase model entries
|
||||
const phasePreview = ALL_PHASES.map((phase) => {
|
||||
const globalEntry = phaseModels[phase] ?? DEFAULT_PHASE_MODELS[phase];
|
||||
const currentEntry = projectOverrides[phase] || globalEntry;
|
||||
const claudeAlias = getClaudeModelAlias(currentEntry);
|
||||
const newEntry = findModelForClaudeAlias(selectedProviderConfig, claudeAlias, phase);
|
||||
|
||||
// Get display names
|
||||
const getCurrentDisplay = (): string => {
|
||||
if (currentEntry.providerId) {
|
||||
const provider = enabledProviders.find((p) => p.id === currentEntry.providerId);
|
||||
if (provider) {
|
||||
const model = provider.models?.find((m) => m.id === currentEntry.model);
|
||||
return model?.displayName || currentEntry.model;
|
||||
}
|
||||
}
|
||||
return CLAUDE_MODEL_DISPLAY[claudeAlias] || currentEntry.model;
|
||||
};
|
||||
|
||||
const getNewDisplay = (): string => {
|
||||
if (newEntry.providerId && selectedProviderConfig) {
|
||||
const model = selectedProviderConfig.models?.find((m) => m.id === newEntry.model);
|
||||
return model?.displayName || newEntry.model;
|
||||
}
|
||||
return CLAUDE_MODEL_DISPLAY[newEntry.model as ClaudeModelAlias] || newEntry.model;
|
||||
};
|
||||
|
||||
const isChanged =
|
||||
currentEntry.model !== newEntry.model ||
|
||||
currentEntry.providerId !== newEntry.providerId ||
|
||||
currentEntry.thinkingLevel !== newEntry.thinkingLevel;
|
||||
|
||||
return {
|
||||
phase,
|
||||
label: PHASE_LABELS[phase],
|
||||
claudeAlias,
|
||||
currentDisplay: getCurrentDisplay(),
|
||||
newDisplay: getNewDisplay(),
|
||||
newEntry,
|
||||
isChanged,
|
||||
};
|
||||
return generatePreviewItem(phase, PHASE_LABELS[phase], currentEntry);
|
||||
});
|
||||
}, [phaseModels, projectOverrides, selectedProviderConfig, enabledProviders]);
|
||||
|
||||
return [defaultFeaturePreview, ...phasePreview];
|
||||
}, [
|
||||
phaseModels,
|
||||
projectOverrides,
|
||||
selectedProviderConfig,
|
||||
enabledProviders,
|
||||
defaultFeatureModel,
|
||||
projectDefaultFeatureModel,
|
||||
]);
|
||||
|
||||
// Count how many will change
|
||||
const changeCount = preview.filter((p) => p.isChanged).length;
|
||||
|
||||
// Apply the bulk replace as project overrides
|
||||
const handleApply = () => {
|
||||
preview.forEach(({ phase, newEntry, isChanged }) => {
|
||||
preview.forEach(({ key, newEntry, isChanged }) => {
|
||||
if (isChanged) {
|
||||
setProjectPhaseModelOverride(project.id, phase, newEntry);
|
||||
if (key === DEFAULT_FEATURE_MODEL_KEY) {
|
||||
setProjectDefaultFeatureModel(project.id, newEntry);
|
||||
} else {
|
||||
setProjectPhaseModelOverride(project.id, key as PhaseModelKey, newEntry);
|
||||
}
|
||||
}
|
||||
});
|
||||
onOpenChange(false);
|
||||
@@ -295,7 +341,7 @@ export function ProjectBulkReplaceDialog({
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">Preview Changes</label>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{changeCount} of {ALL_PHASES.length} will be overridden
|
||||
{changeCount} of {preview.length} will be overridden
|
||||
</span>
|
||||
</div>
|
||||
<div className="border rounded-lg overflow-hidden max-h-[300px] overflow-y-auto">
|
||||
@@ -311,15 +357,23 @@ export function ProjectBulkReplaceDialog({
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{preview.map(({ phase, label, currentDisplay, newDisplay, isChanged }) => (
|
||||
{preview.map(({ key, label, currentDisplay, newDisplay, isChanged }) => (
|
||||
<tr
|
||||
key={phase}
|
||||
key={key}
|
||||
className={cn(
|
||||
'border-t border-border/50',
|
||||
isChanged ? 'bg-brand-500/5' : 'opacity-50'
|
||||
isChanged ? 'bg-brand-500/5' : 'opacity-50',
|
||||
key === DEFAULT_FEATURE_MODEL_KEY && 'bg-accent/30'
|
||||
)}
|
||||
>
|
||||
<td className="p-2 font-medium">{label}</td>
|
||||
<td className="p-2 font-medium">
|
||||
{label}
|
||||
{key === DEFAULT_FEATURE_MODEL_KEY && (
|
||||
<span className="ml-2 text-[10px] px-1.5 py-0.5 rounded bg-brand-500/20 text-brand-500">
|
||||
Feature Default
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-2 text-muted-foreground">{currentDisplay}</td>
|
||||
<td className="p-2 text-center">
|
||||
{isChanged ? (
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useState } from 'react';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Workflow, RotateCcw, Globe, Check, Replace } from 'lucide-react';
|
||||
import { Workflow, RotateCcw, Globe, Check, Replace, Sparkles } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Project } from '@/lib/electron';
|
||||
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
|
||||
import { ProjectBulkReplaceDialog } from './project-bulk-replace-dialog';
|
||||
import type { PhaseModelKey, PhaseModelEntry } from '@automaker/types';
|
||||
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
|
||||
import { DEFAULT_PHASE_MODELS, DEFAULT_GLOBAL_SETTINGS } from '@automaker/types';
|
||||
|
||||
interface ProjectModelsSectionProps {
|
||||
project: Project;
|
||||
@@ -72,9 +72,9 @@ const GENERATION_TASKS: PhaseConfig[] = [
|
||||
description: 'Analyzes project structure for suggestions',
|
||||
},
|
||||
{
|
||||
key: 'suggestionsModel',
|
||||
label: 'AI Suggestions',
|
||||
description: 'Model for feature, refactoring, security, and performance suggestions',
|
||||
key: 'ideationModel',
|
||||
label: 'Ideation',
|
||||
description: 'Model for ideation view (generating AI suggestions)',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -88,6 +88,127 @@ const MEMORY_TASKS: PhaseConfig[] = [
|
||||
|
||||
const ALL_PHASES = [...QUICK_TASKS, ...VALIDATION_TASKS, ...GENERATION_TASKS, ...MEMORY_TASKS];
|
||||
|
||||
/**
|
||||
* Default feature model override section for per-project settings.
|
||||
*/
|
||||
function FeatureDefaultModelOverrideSection({ project }: { project: Project }) {
|
||||
const {
|
||||
defaultFeatureModel: globalDefaultFeatureModel,
|
||||
setProjectDefaultFeatureModel,
|
||||
claudeCompatibleProviders,
|
||||
} = useAppStore();
|
||||
|
||||
const globalValue: PhaseModelEntry =
|
||||
globalDefaultFeatureModel ?? DEFAULT_GLOBAL_SETTINGS.defaultFeatureModel;
|
||||
const projectOverride = project.defaultFeatureModel;
|
||||
const hasOverride = !!projectOverride;
|
||||
const effectiveValue = projectOverride || globalValue;
|
||||
|
||||
// Get display name for a model
|
||||
const getModelDisplayName = (entry: PhaseModelEntry): string => {
|
||||
if (entry.providerId) {
|
||||
const provider = (claudeCompatibleProviders || []).find((p) => p.id === entry.providerId);
|
||||
if (provider) {
|
||||
const model = provider.models?.find((m) => m.id === entry.model);
|
||||
if (model) {
|
||||
return `${model.displayName} (${provider.name})`;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Default to model ID for built-in models (both short aliases and canonical IDs)
|
||||
const modelMap: Record<string, string> = {
|
||||
haiku: 'Claude Haiku',
|
||||
sonnet: 'Claude Sonnet',
|
||||
opus: 'Claude Opus',
|
||||
'claude-haiku': 'Claude Haiku',
|
||||
'claude-sonnet': 'Claude Sonnet',
|
||||
'claude-opus': 'Claude Opus',
|
||||
};
|
||||
return modelMap[entry.model] || entry.model;
|
||||
};
|
||||
|
||||
const handleClearOverride = () => {
|
||||
setProjectDefaultFeatureModel(project.id, null);
|
||||
};
|
||||
|
||||
const handleSetOverride = (entry: PhaseModelEntry) => {
|
||||
setProjectDefaultFeatureModel(project.id, entry);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-foreground">Feature Defaults</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Default model for new feature cards in this project
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between p-4 rounded-xl',
|
||||
'bg-accent/20 border',
|
||||
hasOverride ? 'border-brand-500/30 bg-brand-500/5' : 'border-border/30',
|
||||
'hover:bg-accent/30 transition-colors'
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 pr-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-lg bg-brand-500/10 flex items-center justify-center">
|
||||
<Sparkles className="w-4 h-4 text-brand-500" />
|
||||
</div>
|
||||
<h4 className="text-sm font-medium text-foreground">Default Feature Model</h4>
|
||||
{hasOverride ? (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-brand-500/20 text-brand-500">
|
||||
Override
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded bg-muted text-muted-foreground">
|
||||
<Globe className="w-3 h-3" />
|
||||
Global
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1 ml-10">
|
||||
Model and thinking level used when creating new feature cards
|
||||
</p>
|
||||
{hasOverride && (
|
||||
<p className="text-xs text-brand-500 mt-1 ml-10">
|
||||
Using: {getModelDisplayName(effectiveValue)}
|
||||
</p>
|
||||
)}
|
||||
{!hasOverride && (
|
||||
<p className="text-xs text-muted-foreground/70 mt-1 ml-10">
|
||||
Using global: {getModelDisplayName(globalValue)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{hasOverride && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClearOverride}
|
||||
className="h-8 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5 mr-1" />
|
||||
Reset
|
||||
</Button>
|
||||
)}
|
||||
<PhaseModelSelector
|
||||
compact
|
||||
value={effectiveValue}
|
||||
onChange={handleSetOverride}
|
||||
align="end"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PhaseOverrideItem({
|
||||
phase,
|
||||
project,
|
||||
@@ -234,8 +355,10 @@ export function ProjectModelsSection({ project }: ProjectModelsSectionProps) {
|
||||
useAppStore();
|
||||
const [showBulkReplace, setShowBulkReplace] = useState(false);
|
||||
|
||||
// Count how many overrides are set
|
||||
const overrideCount = Object.keys(project.phaseModelOverrides || {}).length;
|
||||
// Count how many overrides are set (including defaultFeatureModel)
|
||||
const phaseOverrideCount = Object.keys(project.phaseModelOverrides || {}).length;
|
||||
const hasDefaultFeatureModelOverride = !!project.defaultFeatureModel;
|
||||
const overrideCount = phaseOverrideCount + (hasDefaultFeatureModelOverride ? 1 : 0);
|
||||
|
||||
// Check if Claude is available
|
||||
const isClaudeDisabled = disabledProviders.includes('claude');
|
||||
@@ -328,6 +451,9 @@ export function ProjectModelsSection({ project }: ProjectModelsSectionProps) {
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-8">
|
||||
{/* Feature Defaults */}
|
||||
<FeatureDefaultModelOverrideSection project={project} />
|
||||
|
||||
{/* Quick Tasks */}
|
||||
<PhaseGroup
|
||||
title="Quick Tasks"
|
||||
|
||||
@@ -5,7 +5,9 @@ import { Button } from '@/components/ui/button';
|
||||
import { ProjectIdentitySection } from './project-identity-section';
|
||||
import { ProjectThemeSection } from './project-theme-section';
|
||||
import { WorktreePreferencesSection } from './worktree-preferences-section';
|
||||
import { CommandsSection } from './commands-section';
|
||||
import { ProjectModelsSection } from './project-models-section';
|
||||
import { DataManagementSection } from './data-management-section';
|
||||
import { DangerZoneSection } from '../settings-view/danger-zone/danger-zone-section';
|
||||
import { DeleteProjectDialog } from '../settings-view/components/delete-project-dialog';
|
||||
import { ProjectSettingsNavigation } from './components/project-settings-navigation';
|
||||
@@ -85,8 +87,12 @@ export function ProjectSettingsView() {
|
||||
return <ProjectThemeSection project={currentProject} />;
|
||||
case 'worktrees':
|
||||
return <WorktreePreferencesSection project={currentProject} />;
|
||||
case 'commands':
|
||||
return <CommandsSection project={currentProject} />;
|
||||
case 'claude':
|
||||
return <ProjectModelsSection project={currentProject} />;
|
||||
case 'data':
|
||||
return <DataManagementSection project={currentProject} />;
|
||||
case 'danger':
|
||||
return (
|
||||
<DangerZoneSection
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
CursorSettingsTab,
|
||||
CodexSettingsTab,
|
||||
OpencodeSettingsTab,
|
||||
GeminiSettingsTab,
|
||||
} from './settings-view/providers';
|
||||
import { MCPServersSection } from './settings-view/mcp-servers';
|
||||
import { PromptCustomizationSection } from './settings-view/prompts';
|
||||
@@ -123,6 +124,8 @@ export function SettingsView() {
|
||||
return <CodexSettingsTab />;
|
||||
case 'opencode-provider':
|
||||
return <OpencodeSettingsTab />;
|
||||
case 'gemini-provider':
|
||||
return <GeminiSettingsTab />;
|
||||
case 'providers':
|
||||
case 'claude': // Backwards compatibility - redirect to claude-provider
|
||||
return <ClaudeSettingsTab />;
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { SkeletonPulse } from '@/components/ui/skeleton';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { CheckCircle2, AlertCircle, RefreshCw, Key } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { CliStatus } from '../shared/types';
|
||||
import { GeminiIcon } from '@/components/ui/provider-icon';
|
||||
|
||||
export type GeminiAuthMethod =
|
||||
| 'api_key' // API key authentication
|
||||
| 'google_login' // Google OAuth authentication
|
||||
| 'vertex_ai' // Vertex AI authentication
|
||||
| 'none';
|
||||
|
||||
export interface GeminiAuthStatus {
|
||||
authenticated: boolean;
|
||||
method: GeminiAuthMethod;
|
||||
hasApiKey?: boolean;
|
||||
hasEnvApiKey?: boolean;
|
||||
hasCredentialsFile?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function getAuthMethodLabel(method: GeminiAuthMethod): string {
|
||||
switch (method) {
|
||||
case 'api_key':
|
||||
return 'API Key';
|
||||
case 'google_login':
|
||||
return 'Google OAuth';
|
||||
case 'vertex_ai':
|
||||
return 'Vertex AI';
|
||||
default:
|
||||
return method || 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
interface GeminiCliStatusProps {
|
||||
status: CliStatus | null;
|
||||
authStatus?: GeminiAuthStatus | null;
|
||||
isChecking: boolean;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export function GeminiCliStatusSkeleton() {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl overflow-hidden',
|
||||
'border border-border/50',
|
||||
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
|
||||
'shadow-sm shadow-black/5'
|
||||
)}
|
||||
>
|
||||
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<SkeletonPulse className="w-9 h-9 rounded-xl" />
|
||||
<SkeletonPulse className="h-6 w-36" />
|
||||
</div>
|
||||
<SkeletonPulse className="w-9 h-9 rounded-lg" />
|
||||
</div>
|
||||
<div className="ml-12">
|
||||
<SkeletonPulse className="h-4 w-80" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Installation status skeleton */}
|
||||
<div className="flex items-center gap-3 p-4 rounded-xl border border-border/30 bg-muted/10">
|
||||
<SkeletonPulse className="w-10 h-10 rounded-xl" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<SkeletonPulse className="h-4 w-40" />
|
||||
<SkeletonPulse className="h-3 w-32" />
|
||||
<SkeletonPulse className="h-3 w-48" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Auth status skeleton */}
|
||||
<div className="flex items-center gap-3 p-4 rounded-xl border border-border/30 bg-muted/10">
|
||||
<SkeletonPulse className="w-10 h-10 rounded-xl" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<SkeletonPulse className="h-4 w-28" />
|
||||
<SkeletonPulse className="h-3 w-36" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GeminiCliStatus({
|
||||
status,
|
||||
authStatus,
|
||||
isChecking,
|
||||
onRefresh,
|
||||
}: GeminiCliStatusProps) {
|
||||
if (!status) return <GeminiCliStatusSkeleton />;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl overflow-hidden',
|
||||
'border border-border/50',
|
||||
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
|
||||
'shadow-sm shadow-black/5'
|
||||
)}
|
||||
>
|
||||
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-blue-500/20 to-blue-600/10 flex items-center justify-center border border-blue-500/20">
|
||||
<GeminiIcon className="w-5 h-5 text-blue-500" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">Gemini CLI</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onRefresh}
|
||||
disabled={isChecking}
|
||||
data-testid="refresh-gemini-cli"
|
||||
title="Refresh Gemini CLI detection"
|
||||
className={cn(
|
||||
'h-9 w-9 rounded-lg',
|
||||
'hover:bg-accent/50 hover:scale-105',
|
||||
'transition-all duration-200'
|
||||
)}
|
||||
>
|
||||
{isChecking ? <Spinner size="sm" /> : <RefreshCw className="w-4 h-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||
Gemini CLI provides access to Google's Gemini AI models with thinking capabilities.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
{status.success && status.status === 'installed' ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3 p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/20">
|
||||
<div className="w-10 h-10 rounded-xl bg-emerald-500/15 flex items-center justify-center border border-emerald-500/20 shrink-0">
|
||||
<CheckCircle2 className="w-5 h-5 text-emerald-500" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-emerald-400">Gemini CLI Installed</p>
|
||||
<div className="text-xs text-emerald-400/70 mt-1.5 space-y-0.5">
|
||||
{status.method && (
|
||||
<p>
|
||||
Method: <span className="font-mono">{status.method}</span>
|
||||
</p>
|
||||
)}
|
||||
{status.version && (
|
||||
<p>
|
||||
Version: <span className="font-mono">{status.version}</span>
|
||||
</p>
|
||||
)}
|
||||
{status.path && (
|
||||
<p className="truncate" title={status.path}>
|
||||
Path: <span className="font-mono text-[10px]">{status.path}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Authentication Status */}
|
||||
{authStatus?.authenticated ? (
|
||||
<div className="flex items-center gap-3 p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/20">
|
||||
<div className="w-10 h-10 rounded-xl bg-emerald-500/15 flex items-center justify-center border border-emerald-500/20 shrink-0">
|
||||
<CheckCircle2 className="w-5 h-5 text-emerald-500" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-emerald-400">Authenticated</p>
|
||||
<div className="text-xs text-emerald-400/70 mt-1.5">
|
||||
{authStatus.method !== 'none' && (
|
||||
<p>
|
||||
Method:{' '}
|
||||
<span className="font-mono">{getAuthMethodLabel(authStatus.method)}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-start gap-3 p-4 rounded-xl bg-red-500/10 border border-red-500/20">
|
||||
<div className="w-10 h-10 rounded-xl bg-red-500/15 flex items-center justify-center border border-red-500/20 shrink-0 mt-0.5">
|
||||
<AlertCircle className="w-5 h-5 text-red-500" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-red-400">Authentication Failed</p>
|
||||
{authStatus?.error && (
|
||||
<p className="text-xs text-red-400/70 mt-1">{authStatus.error}</p>
|
||||
)}
|
||||
<p className="text-xs text-red-400/70 mt-2">
|
||||
Run <code className="font-mono bg-red-500/10 px-1 rounded">gemini</code>{' '}
|
||||
interactively in your terminal to log in with Google, or set the{' '}
|
||||
<code className="font-mono bg-red-500/10 px-1 rounded">GEMINI_API_KEY</code>{' '}
|
||||
environment variable.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status.recommendation && (
|
||||
<p className="text-xs text-muted-foreground/70 ml-1">{status.recommendation}</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3 p-4 rounded-xl bg-amber-500/10 border border-amber-500/20">
|
||||
<div className="w-10 h-10 rounded-xl bg-amber-500/15 flex items-center justify-center border border-amber-500/20 shrink-0 mt-0.5">
|
||||
<AlertCircle className="w-5 h-5 text-amber-500" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-amber-400">Gemini CLI Not Detected</p>
|
||||
<p className="text-xs text-amber-400/70 mt-1">
|
||||
{status.recommendation || 'Install Gemini CLI to use Google Gemini models.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{status.installCommands && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs font-medium text-foreground/80">Installation Commands:</p>
|
||||
<div className="space-y-2">
|
||||
{status.installCommands.npm && (
|
||||
<div className="p-3 rounded-xl bg-accent/30 border border-border/50">
|
||||
<p className="text-[10px] text-muted-foreground mb-1.5 font-medium uppercase tracking-wider">
|
||||
npm
|
||||
</p>
|
||||
<code className="text-xs text-foreground/80 font-mono break-all">
|
||||
{status.installCommands.npm}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
{status.installCommands.macos && (
|
||||
<div className="p-3 rounded-xl bg-accent/30 border border-border/50">
|
||||
<p className="text-[10px] text-muted-foreground mb-1.5 font-medium uppercase tracking-wider">
|
||||
macOS/Linux
|
||||
</p>
|
||||
<code className="text-xs text-foreground/80 font-mono break-all">
|
||||
{status.installCommands.macos}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user