mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-03 21:03:08 +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
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: macos-builds
|
name: macos-builds
|
||||||
path: apps/ui/release/*.{dmg,zip}
|
path: |
|
||||||
|
apps/ui/release/*.dmg
|
||||||
|
apps/ui/release/*.zip
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
- name: Upload Windows artifacts
|
- name: Upload Windows artifacts
|
||||||
@@ -78,7 +80,10 @@ jobs:
|
|||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: linux-builds
|
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
|
retention-days: 30
|
||||||
|
|
||||||
upload:
|
upload:
|
||||||
@@ -109,8 +114,14 @@ jobs:
|
|||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
files: |
|
files: |
|
||||||
artifacts/macos-builds/*.{dmg,zip,blockmap}
|
artifacts/macos-builds/*.dmg
|
||||||
artifacts/windows-builds/*.{exe,blockmap}
|
artifacts/macos-builds/*.zip
|
||||||
artifacts/linux-builds/*.{AppImage,deb,rpm,blockmap}
|
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:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
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/model-resolver/package*.json ./libs/model-resolver/
|
||||||
COPY libs/dependency-resolver/package*.json ./libs/dependency-resolver/
|
COPY libs/dependency-resolver/package*.json ./libs/dependency-resolver/
|
||||||
COPY libs/git-utils/package*.json ./libs/git-utils/
|
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 (needed by npm workspace)
|
||||||
COPY scripts ./scripts
|
COPY scripts ./scripts
|
||||||
|
|||||||
@@ -40,7 +40,8 @@
|
|||||||
"express": "5.2.1",
|
"express": "5.2.1",
|
||||||
"morgan": "1.10.1",
|
"morgan": "1.10.1",
|
||||||
"node-pty": "1.1.0-beta41",
|
"node-pty": "1.1.0-beta41",
|
||||||
"ws": "8.18.3"
|
"ws": "8.18.3",
|
||||||
|
"yaml": "2.7.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/cookie": "0.6.0",
|
"@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 { createWorktreeRoutes } from './routes/worktree/index.js';
|
||||||
import { createGitRoutes } from './routes/git/index.js';
|
import { createGitRoutes } from './routes/git/index.js';
|
||||||
import { createSetupRoutes } from './routes/setup/index.js';
|
import { createSetupRoutes } from './routes/setup/index.js';
|
||||||
import { createSuggestionsRoutes } from './routes/suggestions/index.js';
|
|
||||||
import { createModelsRoutes } from './routes/models/index.js';
|
import { createModelsRoutes } from './routes/models/index.js';
|
||||||
import { createRunningAgentsRoutes } from './routes/running-agents/index.js';
|
import { createRunningAgentsRoutes } from './routes/running-agents/index.js';
|
||||||
import { createWorkspaceRoutes } from './routes/workspace/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 { getNotificationService } from './services/notification-service.js';
|
||||||
import { createEventHistoryRoutes } from './routes/event-history/index.js';
|
import { createEventHistoryRoutes } from './routes/event-history/index.js';
|
||||||
import { getEventHistoryService } from './services/event-history-service.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
|
// Load environment variables
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
@@ -236,6 +238,7 @@ const codexModelCacheService = new CodexModelCacheService(DATA_DIR, codexAppServ
|
|||||||
const codexUsageService = new CodexUsageService(codexAppServerService);
|
const codexUsageService = new CodexUsageService(codexAppServerService);
|
||||||
const mcpTestService = new MCPTestService(settingsService);
|
const mcpTestService = new MCPTestService(settingsService);
|
||||||
const ideationService = new IdeationService(events, settingsService, featureLoader);
|
const ideationService = new IdeationService(events, settingsService, featureLoader);
|
||||||
|
const providerUsageTracker = new ProviderUsageTracker(codexUsageService);
|
||||||
|
|
||||||
// Initialize DevServerService with event emitter for real-time log streaming
|
// Initialize DevServerService with event emitter for real-time log streaming
|
||||||
const devServerService = getDevServerService();
|
const devServerService = getDevServerService();
|
||||||
@@ -248,6 +251,10 @@ notificationService.setEventEmitter(events);
|
|||||||
// Initialize Event History Service
|
// Initialize Event History Service
|
||||||
const eventHistoryService = getEventHistoryService();
|
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)
|
// Initialize Event Hook Service for custom event triggers (with history storage)
|
||||||
eventHookService.initialize(events, settingsService, eventHistoryService, featureLoader);
|
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/enhance-prompt', createEnhancePromptRoutes(settingsService));
|
||||||
app.use('/api/worktree', createWorktreeRoutes(events, settingsService));
|
app.use('/api/worktree', createWorktreeRoutes(events, settingsService));
|
||||||
app.use('/api/git', createGitRoutes());
|
app.use('/api/git', createGitRoutes());
|
||||||
app.use('/api/suggestions', createSuggestionsRoutes(events, settingsService));
|
|
||||||
app.use('/api/models', createModelsRoutes());
|
app.use('/api/models', createModelsRoutes());
|
||||||
app.use('/api/spec-regeneration', createSpecRegenerationRoutes(events, settingsService));
|
app.use('/api/spec-regeneration', createSpecRegenerationRoutes(events, settingsService));
|
||||||
app.use('/api/running-agents', createRunningAgentsRoutes(autoModeService));
|
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/ideation', createIdeationRoutes(events, ideationService, featureLoader));
|
||||||
app.use('/api/notifications', createNotificationsRoutes(notificationService));
|
app.use('/api/notifications', createNotificationsRoutes(notificationService));
|
||||||
app.use('/api/event-history', createEventHistoryRoutes(eventHistoryService, settingsService));
|
app.use('/api/event-history', createEventHistoryRoutes(eventHistoryService, settingsService));
|
||||||
|
app.use('/api/provider-usage', createProviderUsageRoutes(providerUsageTracker));
|
||||||
|
|
||||||
// Create HTTP server
|
// Create HTTP server
|
||||||
const server = createServer(app);
|
const server = createServer(app);
|
||||||
|
|||||||
@@ -337,10 +337,11 @@ export class CursorProvider extends CliProvider {
|
|||||||
'--stream-partial-output' // Real-time streaming
|
'--stream-partial-output' // Real-time streaming
|
||||||
);
|
);
|
||||||
|
|
||||||
// Only add --force if NOT in read-only mode
|
// In read-only mode, use --mode ask for Q&A style (no tools)
|
||||||
// Without --force, Cursor CLI suggests changes but doesn't apply them
|
// Otherwise, add --force to allow file edits
|
||||||
// With --force, Cursor CLI can actually edit files
|
if (options.readOnly) {
|
||||||
if (!options.readOnly) {
|
cliArgs.push('--mode', 'ask');
|
||||||
|
} else {
|
||||||
cliArgs.push('--force');
|
cliArgs.push('--force');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -672,10 +673,13 @@ export class CursorProvider extends CliProvider {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract prompt text to pass via stdin (avoids shell escaping issues)
|
// Embed system prompt into user prompt (Cursor CLI doesn't support separate system messages)
|
||||||
const promptText = this.extractPromptText(options);
|
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);
|
const subprocessOptions = this.buildSubprocessOptions(options, cliArgs);
|
||||||
|
|
||||||
// Pass prompt via stdin to avoid shell interpretation of special characters
|
// 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,
|
ProviderMessage,
|
||||||
InstallationStatus,
|
InstallationStatus,
|
||||||
ModelDefinition,
|
ModelDefinition,
|
||||||
|
AgentDefinition,
|
||||||
|
ReasoningEffort,
|
||||||
|
SystemPromptPreset,
|
||||||
|
ConversationMessage,
|
||||||
|
ContentBlock,
|
||||||
|
ValidationResult,
|
||||||
|
McpServerConfig,
|
||||||
|
McpStdioServerConfig,
|
||||||
|
McpSSEServerConfig,
|
||||||
|
McpHttpServerConfig,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
|
||||||
// Claude provider
|
// Claude provider
|
||||||
|
|||||||
@@ -7,7 +7,13 @@
|
|||||||
|
|
||||||
import { BaseProvider } from './base-provider.js';
|
import { BaseProvider } from './base-provider.js';
|
||||||
import type { InstallationStatus, ModelDefinition } from './types.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 fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
@@ -16,6 +22,7 @@ const DISCONNECTED_MARKERS: Record<string, string> = {
|
|||||||
codex: '.codex-disconnected',
|
codex: '.codex-disconnected',
|
||||||
cursor: '.cursor-disconnected',
|
cursor: '.cursor-disconnected',
|
||||||
opencode: '.opencode-disconnected',
|
opencode: '.opencode-disconnected',
|
||||||
|
gemini: '.gemini-disconnected',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -239,8 +246,8 @@ export class ProviderFactory {
|
|||||||
model.modelString === modelId ||
|
model.modelString === modelId ||
|
||||||
model.id.endsWith(`-${modelId}`) ||
|
model.id.endsWith(`-${modelId}`) ||
|
||||||
model.modelString.endsWith(`-${modelId}`) ||
|
model.modelString.endsWith(`-${modelId}`) ||
|
||||||
model.modelString === modelId.replace(/^(claude|cursor|codex)-/, '') ||
|
model.modelString === modelId.replace(/^(claude|cursor|codex|gemini)-/, '') ||
|
||||||
model.modelString === modelId.replace(/-(claude|cursor|codex)$/, '')
|
model.modelString === modelId.replace(/-(claude|cursor|codex|gemini)$/, '')
|
||||||
) {
|
) {
|
||||||
return model.supportsVision ?? true;
|
return model.supportsVision ?? true;
|
||||||
}
|
}
|
||||||
@@ -267,6 +274,7 @@ import { ClaudeProvider } from './claude-provider.js';
|
|||||||
import { CursorProvider } from './cursor-provider.js';
|
import { CursorProvider } from './cursor-provider.js';
|
||||||
import { CodexProvider } from './codex-provider.js';
|
import { CodexProvider } from './codex-provider.js';
|
||||||
import { OpencodeProvider } from './opencode-provider.js';
|
import { OpencodeProvider } from './opencode-provider.js';
|
||||||
|
import { GeminiProvider } from './gemini-provider.js';
|
||||||
|
|
||||||
// Register Claude provider
|
// Register Claude provider
|
||||||
registerProvider('claude', {
|
registerProvider('claude', {
|
||||||
@@ -301,3 +309,11 @@ registerProvider('opencode', {
|
|||||||
canHandleModel: (model: string) => isOpencodeModel(model),
|
canHandleModel: (model: string) => isOpencodeModel(model),
|
||||||
priority: 3, // Between codex (5) and claude (0)
|
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,
|
InstallationStatus,
|
||||||
ValidationResult,
|
ValidationResult,
|
||||||
ModelDefinition,
|
ModelDefinition,
|
||||||
|
AgentDefinition,
|
||||||
|
ReasoningEffort,
|
||||||
|
SystemPromptPreset,
|
||||||
} from '@automaker/types';
|
} from '@automaker/types';
|
||||||
|
|||||||
@@ -128,7 +128,10 @@ export async function generateBacklogPlan(
|
|||||||
let credentials: import('@automaker/types').Credentials | undefined;
|
let credentials: import('@automaker/types').Credentials | undefined;
|
||||||
|
|
||||||
if (effectiveModel) {
|
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();
|
credentials = await settingsService?.getCredentials();
|
||||||
} else if (settingsService) {
|
} else if (settingsService) {
|
||||||
// Use settings-based model with provider info
|
// 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 { createDeleteHandler } from './routes/delete.js';
|
||||||
import { createAgentOutputHandler, createRawOutputHandler } from './routes/agent-output.js';
|
import { createAgentOutputHandler, createRawOutputHandler } from './routes/agent-output.js';
|
||||||
import { createGenerateTitleHandler } from './routes/generate-title.js';
|
import { createGenerateTitleHandler } from './routes/generate-title.js';
|
||||||
|
import { createExportHandler } from './routes/export.js';
|
||||||
|
import { createImportHandler, createConflictCheckHandler } from './routes/import.js';
|
||||||
|
|
||||||
export function createFeaturesRoutes(
|
export function createFeaturesRoutes(
|
||||||
featureLoader: FeatureLoader,
|
featureLoader: FeatureLoader,
|
||||||
@@ -46,6 +48,13 @@ export function createFeaturesRoutes(
|
|||||||
router.post('/agent-output', createAgentOutputHandler(featureLoader));
|
router.post('/agent-output', createAgentOutputHandler(featureLoader));
|
||||||
router.post('/raw-output', createRawOutputHandler(featureLoader));
|
router.post('/raw-output', createRawOutputHandler(featureLoader));
|
||||||
router.post('/generate-title', createGenerateTitleHandler(settingsService));
|
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;
|
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 { createAuthOpencodeHandler } from './routes/auth-opencode.js';
|
||||||
import { createDeauthOpencodeHandler } from './routes/deauth-opencode.js';
|
import { createDeauthOpencodeHandler } from './routes/deauth-opencode.js';
|
||||||
import { createOpencodeStatusHandler } from './routes/opencode-status.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 {
|
import {
|
||||||
createGetOpencodeModelsHandler,
|
createGetOpencodeModelsHandler,
|
||||||
createRefreshOpencodeModelsHandler,
|
createRefreshOpencodeModelsHandler,
|
||||||
@@ -72,6 +75,11 @@ export function createSetupRoutes(): Router {
|
|||||||
router.post('/auth-opencode', createAuthOpencodeHandler());
|
router.post('/auth-opencode', createAuthOpencodeHandler());
|
||||||
router.post('/deauth-opencode', createDeauthOpencodeHandler());
|
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
|
// OpenCode Dynamic Model Discovery routes
|
||||||
router.get('/opencode/models', createGetOpencodeModelsHandler());
|
router.get('/opencode/models', createGetOpencodeModelsHandler());
|
||||||
router.post('/opencode/models/refresh', createRefreshOpencodeModelsHandler());
|
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 { createStopDevHandler } from './routes/stop-dev.js';
|
||||||
import { createListDevServersHandler } from './routes/list-dev-servers.js';
|
import { createListDevServersHandler } from './routes/list-dev-servers.js';
|
||||||
import { createGetDevServerLogsHandler } from './routes/dev-server-logs.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 {
|
import {
|
||||||
createGetInitScriptHandler,
|
createGetInitScriptHandler,
|
||||||
createPutInitScriptHandler,
|
createPutInitScriptHandler,
|
||||||
@@ -50,6 +53,7 @@ import {
|
|||||||
} from './routes/init-script.js';
|
} from './routes/init-script.js';
|
||||||
import { createDiscardChangesHandler } from './routes/discard-changes.js';
|
import { createDiscardChangesHandler } from './routes/discard-changes.js';
|
||||||
import { createListRemotesHandler } from './routes/list-remotes.js';
|
import { createListRemotesHandler } from './routes/list-remotes.js';
|
||||||
|
import { createAddRemoteHandler } from './routes/add-remote.js';
|
||||||
import type { SettingsService } from '../../services/settings-service.js';
|
import type { SettingsService } from '../../services/settings-service.js';
|
||||||
|
|
||||||
export function createWorktreeRoutes(
|
export function createWorktreeRoutes(
|
||||||
@@ -130,7 +134,7 @@ export function createWorktreeRoutes(
|
|||||||
router.post(
|
router.post(
|
||||||
'/start-dev',
|
'/start-dev',
|
||||||
validatePathParams('projectPath', 'worktreePath'),
|
validatePathParams('projectPath', 'worktreePath'),
|
||||||
createStartDevHandler()
|
createStartDevHandler(settingsService)
|
||||||
);
|
);
|
||||||
router.post('/stop-dev', createStopDevHandler());
|
router.post('/stop-dev', createStopDevHandler());
|
||||||
router.post('/list-dev-servers', createListDevServersHandler());
|
router.post('/list-dev-servers', createListDevServersHandler());
|
||||||
@@ -140,6 +144,15 @@ export function createWorktreeRoutes(
|
|||||||
createGetDevServerLogsHandler()
|
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
|
// Init script routes
|
||||||
router.get('/init-script', createGetInitScriptHandler());
|
router.get('/init-script', createGetInitScriptHandler());
|
||||||
router.put('/init-script', validatePathParams('projectPath'), createPutInitScriptHandler());
|
router.put('/init-script', validatePathParams('projectPath'), createPutInitScriptHandler());
|
||||||
@@ -166,5 +179,13 @@ export function createWorktreeRoutes(
|
|||||||
createListRemotesHandler()
|
createListRemotesHandler()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Add remote route
|
||||||
|
router.post(
|
||||||
|
'/add-remote',
|
||||||
|
validatePathParams('worktreePath'),
|
||||||
|
requireGitRepoOnly,
|
||||||
|
createAddRemoteHandler()
|
||||||
|
);
|
||||||
|
|
||||||
return router;
|
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
|
// Get ahead/behind count for current branch and check if remote branch exists
|
||||||
let aheadCount = 0;
|
let aheadCount = 0;
|
||||||
let behindCount = 0;
|
let behindCount = 0;
|
||||||
@@ -154,6 +166,7 @@ export function createListBranchesHandler() {
|
|||||||
aheadCount,
|
aheadCount,
|
||||||
behindCount,
|
behindCount,
|
||||||
hasRemoteBranch,
|
hasRemoteBranch,
|
||||||
|
hasAnyRemotes,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
/**
|
/**
|
||||||
* POST /start-dev endpoint - Start a dev server for a worktree
|
* POST /start-dev endpoint - Start a dev server for a worktree
|
||||||
*
|
*
|
||||||
* Spins up a development server (npm run dev) in the worktree directory
|
* Spins up a development server in the worktree directory on a unique port,
|
||||||
* on a unique port, allowing preview of the worktree's changes without
|
* allowing preview of the worktree's changes without affecting the main dev server.
|
||||||
* 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 { Request, Response } from 'express';
|
||||||
|
import type { SettingsService } from '../../../services/settings-service.js';
|
||||||
import { getDevServerService } from '../../../services/dev-server-service.js';
|
import { getDevServerService } from '../../../services/dev-server-service.js';
|
||||||
import { getErrorMessage, logError } from '../common.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> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { projectPath, worktreePath } = req.body as {
|
const { projectPath, worktreePath } = req.body as {
|
||||||
@@ -34,8 +40,25 @@ export function createStartDevHandler() {
|
|||||||
return;
|
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 devServerService = getDevServerService();
|
||||||
const result = await devServerService.startDevServer(projectPath, worktreePath);
|
const result = await devServerService.startDevServer(
|
||||||
|
projectPath,
|
||||||
|
worktreePath,
|
||||||
|
customCommand
|
||||||
|
);
|
||||||
|
|
||||||
if (result.success && result.result) {
|
if (result.success && result.result) {
|
||||||
res.json({
|
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;
|
const autoModeByWorktree = settings.autoModeByWorktree;
|
||||||
|
|
||||||
if (projectId && autoModeByWorktree && typeof autoModeByWorktree === 'object') {
|
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];
|
const entry = autoModeByWorktree[key];
|
||||||
if (entry && typeof entry.maxConcurrency === 'number') {
|
if (entry && typeof entry.maxConcurrency === 'number') {
|
||||||
return entry.maxConcurrency;
|
return entry.maxConcurrency;
|
||||||
@@ -1039,7 +1043,9 @@ export class AutoModeService {
|
|||||||
}> {
|
}> {
|
||||||
// Load feature to get branchName
|
// Load feature to get branchName
|
||||||
const feature = await this.loadFeature(projectPath, featureId);
|
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
|
// Get per-worktree limit
|
||||||
const maxAgents = await this.resolveMaxConcurrency(projectPath, branchName);
|
const maxAgents = await this.resolveMaxConcurrency(projectPath, branchName);
|
||||||
@@ -1281,7 +1287,11 @@ export class AutoModeService {
|
|||||||
|
|
||||||
// Check for pipeline steps and execute them
|
// Check for pipeline steps and execute them
|
||||||
const pipelineConfig = await pipelineService.getPipelineConfig(projectPath);
|
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) {
|
if (sortedSteps.length > 0) {
|
||||||
// Execute pipeline steps sequentially
|
// Execute pipeline steps sequentially
|
||||||
@@ -1743,15 +1753,76 @@ Complete the pipeline step instructions above. Review the previous work and appl
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const featureId = feature.id;
|
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
|
// Get the current step we're resuming from (using the index from unfiltered list)
|
||||||
if (startFromStepIndex < 0 || startFromStepIndex >= sortedSteps.length) {
|
if (startFromStepIndex < 0 || startFromStepIndex >= allSortedSteps.length) {
|
||||||
throw new Error(`Invalid step index: ${startFromStepIndex}`);
|
throw new Error(`Invalid step index: ${startFromStepIndex}`);
|
||||||
}
|
}
|
||||||
|
const currentStep = allSortedSteps[startFromStepIndex];
|
||||||
|
|
||||||
// Get steps to execute (from startFromStepIndex onwards)
|
// Filter out excluded pipeline steps
|
||||||
const stepsToExecute = sortedSteps.slice(startFromStepIndex);
|
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(
|
console.log(
|
||||||
`[AutoMode] Resuming pipeline for feature ${featureId} from step ${startFromStepIndex + 1}/${sortedSteps.length}`
|
`[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
|
* 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(
|
async startDevServer(
|
||||||
projectPath: string,
|
projectPath: string,
|
||||||
worktreePath: string
|
worktreePath: string,
|
||||||
|
customCommand?: string
|
||||||
): Promise<{
|
): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
result?: {
|
result?: {
|
||||||
@@ -311,22 +355,41 @@ class DevServerService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for package.json
|
// Determine the dev command to use
|
||||||
const packageJsonPath = path.join(worktreePath, 'package.json');
|
let devCommand: { cmd: string; args: string[] };
|
||||||
if (!(await this.fileExists(packageJsonPath))) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: `No package.json found in: ${worktreePath}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get dev command
|
// Normalize custom command: trim whitespace and treat empty strings as undefined
|
||||||
const devCommand = await this.getDevCommand(worktreePath);
|
const normalizedCustomCommand = customCommand?.trim();
|
||||||
if (!devCommand) {
|
|
||||||
return {
|
if (normalizedCustomCommand) {
|
||||||
success: false,
|
// Use the provided custom command
|
||||||
error: `Could not determine dev command for: ${worktreePath}`,
|
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
|
// 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 { SettingsService } from './settings-service.js';
|
||||||
import type { FeatureLoader } from './feature-loader.js';
|
import type { FeatureLoader } from './feature-loader.js';
|
||||||
import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.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 { 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');
|
const logger = createLogger('IdeationService');
|
||||||
|
|
||||||
@@ -684,8 +688,24 @@ export class IdeationService {
|
|||||||
existingWorkContext
|
existingWorkContext
|
||||||
);
|
);
|
||||||
|
|
||||||
// Resolve model alias to canonical identifier (with prefix)
|
// Get model from phase settings with provider info (ideationModel)
|
||||||
const modelId = resolveModelString('sonnet');
|
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
|
// Create SDK options
|
||||||
const sdkOptions = createChatOptions({
|
const sdkOptions = createChatOptions({
|
||||||
@@ -700,9 +720,6 @@ export class IdeationService {
|
|||||||
// Strip provider prefix - providers need bare model IDs
|
// Strip provider prefix - providers need bare model IDs
|
||||||
const bareModel = stripProviderPrefix(modelId);
|
const bareModel = stripProviderPrefix(modelId);
|
||||||
|
|
||||||
// Get credentials for API calls (uses hardcoded model, no phase setting)
|
|
||||||
const credentials = await this.settingsService?.getCredentials();
|
|
||||||
|
|
||||||
const executeOptions: ExecuteOptions = {
|
const executeOptions: ExecuteOptions = {
|
||||||
prompt: prompt.prompt,
|
prompt: prompt.prompt,
|
||||||
model: bareModel,
|
model: bareModel,
|
||||||
@@ -713,6 +730,8 @@ export class IdeationService {
|
|||||||
// Disable all tools - we just want text generation, not codebase analysis
|
// Disable all tools - we just want text generation, not codebase analysis
|
||||||
allowedTools: [],
|
allowedTools: [],
|
||||||
abortController: new AbortController(),
|
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
|
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.
|
* Determines what status a feature should transition to based on current status.
|
||||||
* Flow: in_progress -> pipeline_step_0 -> pipeline_step_1 -> ... -> final 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 currentStatus - Current feature status
|
||||||
* @param config - Pipeline configuration (or null if no pipeline)
|
* @param config - Pipeline configuration (or null if no pipeline)
|
||||||
* @param skipTests - Whether to skip tests (affects final status)
|
* @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
|
* @returns The next status in the pipeline flow
|
||||||
*/
|
*/
|
||||||
getNextStatus(
|
getNextStatus(
|
||||||
currentStatus: FeatureStatusWithPipeline,
|
currentStatus: FeatureStatusWithPipeline,
|
||||||
config: PipelineConfig | null,
|
config: PipelineConfig | null,
|
||||||
skipTests: boolean
|
skipTests: boolean,
|
||||||
|
excludedStepIds?: string[]
|
||||||
): FeatureStatusWithPipeline {
|
): FeatureStatusWithPipeline {
|
||||||
const steps = config?.steps || [];
|
const steps = config?.steps || [];
|
||||||
|
const exclusions = new Set(excludedStepIds || []);
|
||||||
|
|
||||||
// Sort steps by order
|
// Sort steps by order and filter out excluded steps
|
||||||
const sortedSteps = [...steps].sort((a, b) => a.order - b.order);
|
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 (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 skipTests ? 'waiting_approval' : 'verified';
|
||||||
}
|
}
|
||||||
return currentStatus;
|
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') {
|
if (currentStatus === 'in_progress') {
|
||||||
return `pipeline_${sortedSteps[0].id}`;
|
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_')) {
|
if (currentStatus.startsWith('pipeline_')) {
|
||||||
const currentStepId = currentStatus.replace('pipeline_', '');
|
const currentStepId = currentStatus.replace('pipeline_', '');
|
||||||
const currentIndex = sortedSteps.findIndex((s) => s.id === currentStepId);
|
const currentIndex = sortedSteps.findIndex((s) => s.id === currentStepId);
|
||||||
|
|
||||||
if (currentIndex === -1) {
|
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';
|
return skipTests ? 'waiting_approval' : 'verified';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentIndex < sortedSteps.length - 1) {
|
if (currentIndex < sortedSteps.length - 1) {
|
||||||
// Go to next step
|
// Go to next non-excluded step
|
||||||
return `pipeline_${sortedSteps[currentIndex + 1].id}`;
|
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';
|
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);
|
await writeSettingsJson(settingsPath, updated);
|
||||||
logger.info('Global settings updated');
|
logger.info('Global settings updated');
|
||||||
|
|
||||||
@@ -827,6 +842,30 @@ export class SettingsService {
|
|||||||
delete updated.phaseModelOverrides;
|
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);
|
await writeSettingsJson(settingsPath, updated);
|
||||||
logger.info(`Project settings updated for ${projectPath}`);
|
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 { CursorProvider } from '@/providers/cursor-provider.js';
|
||||||
import { CodexProvider } from '@/providers/codex-provider.js';
|
import { CodexProvider } from '@/providers/codex-provider.js';
|
||||||
import { OpencodeProvider } from '@/providers/opencode-provider.js';
|
import { OpencodeProvider } from '@/providers/opencode-provider.js';
|
||||||
|
import { GeminiProvider } from '@/providers/gemini-provider.js';
|
||||||
|
|
||||||
describe('provider-factory.ts', () => {
|
describe('provider-factory.ts', () => {
|
||||||
let consoleSpy: any;
|
let consoleSpy: any;
|
||||||
@@ -11,6 +12,7 @@ describe('provider-factory.ts', () => {
|
|||||||
let detectCursorSpy: any;
|
let detectCursorSpy: any;
|
||||||
let detectCodexSpy: any;
|
let detectCodexSpy: any;
|
||||||
let detectOpencodeSpy: any;
|
let detectOpencodeSpy: any;
|
||||||
|
let detectGeminiSpy: any;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
consoleSpy = {
|
consoleSpy = {
|
||||||
@@ -30,6 +32,9 @@ describe('provider-factory.ts', () => {
|
|||||||
detectOpencodeSpy = vi
|
detectOpencodeSpy = vi
|
||||||
.spyOn(OpencodeProvider.prototype, 'detectInstallation')
|
.spyOn(OpencodeProvider.prototype, 'detectInstallation')
|
||||||
.mockResolvedValue({ installed: true });
|
.mockResolvedValue({ installed: true });
|
||||||
|
detectGeminiSpy = vi
|
||||||
|
.spyOn(GeminiProvider.prototype, 'detectInstallation')
|
||||||
|
.mockResolvedValue({ installed: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -38,6 +43,7 @@ describe('provider-factory.ts', () => {
|
|||||||
detectCursorSpy.mockRestore();
|
detectCursorSpy.mockRestore();
|
||||||
detectCodexSpy.mockRestore();
|
detectCodexSpy.mockRestore();
|
||||||
detectOpencodeSpy.mockRestore();
|
detectOpencodeSpy.mockRestore();
|
||||||
|
detectGeminiSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getProviderForModel', () => {
|
describe('getProviderForModel', () => {
|
||||||
@@ -166,9 +172,15 @@ describe('provider-factory.ts', () => {
|
|||||||
expect(hasClaudeProvider).toBe(true);
|
expect(hasClaudeProvider).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return exactly 4 providers', () => {
|
it('should return exactly 5 providers', () => {
|
||||||
const providers = ProviderFactory.getAllProviders();
|
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', () => {
|
it('should include CursorProvider', () => {
|
||||||
@@ -206,7 +218,8 @@ describe('provider-factory.ts', () => {
|
|||||||
expect(keys).toContain('cursor');
|
expect(keys).toContain('cursor');
|
||||||
expect(keys).toContain('codex');
|
expect(keys).toContain('codex');
|
||||||
expect(keys).toContain('opencode');
|
expect(keys).toContain('opencode');
|
||||||
expect(keys).toHaveLength(4);
|
expect(keys).toContain('gemini');
|
||||||
|
expect(keys).toHaveLength(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should include cursor status', async () => {
|
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);
|
const nextStatus = pipelineService.getNextStatus('in_progress', config, false);
|
||||||
expect(nextStatus).toBe('pipeline_step1'); // Should use step1 (order 0), not step2
|
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', () => {
|
describe('getStep', () => {
|
||||||
|
|||||||
@@ -102,6 +102,8 @@
|
|||||||
"react-markdown": "10.1.0",
|
"react-markdown": "10.1.0",
|
||||||
"react-resizable-panels": "3.0.6",
|
"react-resizable-panels": "3.0.6",
|
||||||
"rehype-raw": "7.0.0",
|
"rehype-raw": "7.0.0",
|
||||||
|
"rehype-sanitize": "^6.0.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"sonner": "2.0.7",
|
"sonner": "2.0.7",
|
||||||
"tailwind-merge": "3.4.0",
|
"tailwind-merge": "3.4.0",
|
||||||
"usehooks-ts": "3.1.1",
|
"usehooks-ts": "3.1.1",
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ const E2E_SETTINGS = {
|
|||||||
featureGenerationModel: { model: 'sonnet' },
|
featureGenerationModel: { model: 'sonnet' },
|
||||||
backlogPlanningModel: { model: 'sonnet' },
|
backlogPlanningModel: { model: 'sonnet' },
|
||||||
projectAnalysisModel: { model: 'sonnet' },
|
projectAnalysisModel: { model: 'sonnet' },
|
||||||
suggestionsModel: { model: 'sonnet' },
|
ideationModel: { model: 'sonnet' },
|
||||||
},
|
},
|
||||||
enhancementModel: 'sonnet',
|
enhancementModel: 'sonnet',
|
||||||
validationModel: 'opus',
|
validationModel: 'opus',
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export function CollapseToggleButton({
|
|||||||
<button
|
<button
|
||||||
onClick={toggleSidebar}
|
onClick={toggleSidebar}
|
||||||
className={cn(
|
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',
|
'group/toggle items-center justify-center w-7 h-7 rounded-full',
|
||||||
// Glass morphism button
|
// Glass morphism button
|
||||||
'bg-card/95 backdrop-blur-sm border border-border/80',
|
'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 type { NavigateOptions } from '@tanstack/react-router';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { formatShortcut } from '@/store/app-store';
|
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 {
|
interface SidebarFooterProps {
|
||||||
sidebarOpen: boolean;
|
sidebarOpen: boolean;
|
||||||
isActiveRoute: (id: string) => boolean;
|
isActiveRoute: (id: string) => boolean;
|
||||||
navigate: (opts: NavigateOptions) => void;
|
navigate: (opts: NavigateOptions) => void;
|
||||||
hideRunningAgents: boolean;
|
hideRunningAgents: boolean;
|
||||||
|
hideWiki: boolean;
|
||||||
runningAgentsCount: number;
|
runningAgentsCount: number;
|
||||||
shortcuts: {
|
shortcuts: {
|
||||||
settings: string;
|
settings: string;
|
||||||
@@ -19,86 +37,225 @@ export function SidebarFooter({
|
|||||||
isActiveRoute,
|
isActiveRoute,
|
||||||
navigate,
|
navigate,
|
||||||
hideRunningAgents,
|
hideRunningAgents,
|
||||||
|
hideWiki,
|
||||||
runningAgentsCount,
|
runningAgentsCount,
|
||||||
shortcuts,
|
shortcuts,
|
||||||
}: SidebarFooterProps) {
|
}: 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 (
|
return (
|
||||||
<div
|
<div className="shrink-0">
|
||||||
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'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Running Agents Link */}
|
{/* Running Agents Link */}
|
||||||
{!hideRunningAgents && (
|
{!hideRunningAgents && (
|
||||||
<div className="p-2 pb-0">
|
<div className="px-3 py-0.5">
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate({ to: '/running-agents' })}
|
onClick={() => navigate({ to: '/running-agents' })}
|
||||||
className={cn(
|
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',
|
'transition-all duration-200 ease-out',
|
||||||
isActiveRoute('running-agents')
|
isActiveRoute('running-agents')
|
||||||
? [
|
? [
|
||||||
'bg-gradient-to-r from-brand-500/20 via-brand-500/15 to-brand-600/10',
|
'bg-gradient-to-r from-brand-500/20 via-brand-500/15 to-brand-600/10',
|
||||||
'text-foreground font-medium',
|
'text-foreground font-medium',
|
||||||
'border border-brand-500/30',
|
'border border-brand-500/30',
|
||||||
'shadow-md shadow-brand-500/10',
|
'shadow-sm shadow-brand-500/10',
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
'text-muted-foreground hover:text-foreground',
|
'text-muted-foreground hover:text-foreground',
|
||||||
'hover:bg-accent/50',
|
'hover:bg-accent/50',
|
||||||
'border border-transparent hover:border-border/40',
|
'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"
|
data-testid="running-agents-link"
|
||||||
>
|
>
|
||||||
<div className="relative">
|
<Activity
|
||||||
<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
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'ml-3 font-medium text-sm flex-1 text-left',
|
'w-[18px] h-[18px] shrink-0 transition-all duration-200',
|
||||||
sidebarOpen ? 'block' : 'hidden'
|
isActiveRoute('running-agents')
|
||||||
|
? 'text-brand-500 drop-shadow-sm'
|
||||||
|
: 'group-hover:text-brand-400'
|
||||||
)}
|
)}
|
||||||
>
|
/>
|
||||||
Running Agents
|
<span className="ml-3 text-sm flex-1 text-left">Running Agents</span>
|
||||||
</span>
|
{runningAgentsCount > 0 && (
|
||||||
{/* Running agents count badge - shown in expanded state */}
|
|
||||||
{sidebarOpen && runningAgentsCount > 0 && (
|
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center justify-center',
|
'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',
|
'bg-brand-500 text-white shadow-sm',
|
||||||
'animate-in fade-in zoom-in duration-200',
|
|
||||||
isActiveRoute('running-agents') && 'bg-brand-600'
|
isActiveRoute('running-agents') && 'bg-brand-600'
|
||||||
)}
|
)}
|
||||||
data-testid="running-agents-count"
|
data-testid="running-agents-count"
|
||||||
@@ -106,52 +263,30 @@ export function SidebarFooter({
|
|||||||
{runningAgentsCount > 99 ? '99' : runningAgentsCount}
|
{runningAgentsCount > 99 ? '99' : runningAgentsCount}
|
||||||
</span>
|
</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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Settings Link */}
|
{/* Settings Link */}
|
||||||
<div className="p-2">
|
<div className="px-3 py-0.5">
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate({ to: '/settings' })}
|
onClick={() => navigate({ to: '/settings' })}
|
||||||
className={cn(
|
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',
|
'transition-all duration-200 ease-out',
|
||||||
isActiveRoute('settings')
|
isActiveRoute('settings')
|
||||||
? [
|
? [
|
||||||
'bg-gradient-to-r from-brand-500/20 via-brand-500/15 to-brand-600/10',
|
'bg-gradient-to-r from-brand-500/20 via-brand-500/15 to-brand-600/10',
|
||||||
'text-foreground font-medium',
|
'text-foreground font-medium',
|
||||||
'border border-brand-500/30',
|
'border border-brand-500/30',
|
||||||
'shadow-md shadow-brand-500/10',
|
'shadow-sm shadow-brand-500/10',
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
'text-muted-foreground hover:text-foreground',
|
'text-muted-foreground hover:text-foreground',
|
||||||
'hover:bg-accent/50',
|
'hover:bg-accent/50',
|
||||||
'border border-transparent hover:border-border/40',
|
'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"
|
data-testid="settings-button"
|
||||||
>
|
>
|
||||||
<Settings
|
<Settings
|
||||||
@@ -159,49 +294,70 @@ export function SidebarFooter({
|
|||||||
'w-[18px] h-[18px] shrink-0 transition-all duration-200',
|
'w-[18px] h-[18px] shrink-0 transition-all duration-200',
|
||||||
isActiveRoute('settings')
|
isActiveRoute('settings')
|
||||||
? 'text-brand-500 drop-shadow-sm'
|
? '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
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'ml-3 font-medium text-sm flex-1 text-left',
|
'flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded transition-all duration-200',
|
||||||
sidebarOpen ? 'block' : 'hidden'
|
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>
|
</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>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,179 +1,411 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { Folder, LucideIcon, X, Menu, Check } from 'lucide-react';
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
|
import { ChevronsUpDown, Folder, Plus, FolderOpen } from 'lucide-react';
|
||||||
import * as LucideIcons from 'lucide-react';
|
import * as LucideIcons from 'lucide-react';
|
||||||
|
import type { LucideIcon } from 'lucide-react';
|
||||||
import { cn, isMac } from '@/lib/utils';
|
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 { isElectron, type Project } from '@/lib/electron';
|
||||||
import { useIsCompact } from '@/hooks/use-media-query';
|
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
|
||||||
import { useAppStore } from '@/store/app-store';
|
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 {
|
interface SidebarHeaderProps {
|
||||||
sidebarOpen: boolean;
|
sidebarOpen: boolean;
|
||||||
currentProject: Project | null;
|
currentProject: Project | null;
|
||||||
onClose?: () => void;
|
onNewProject: () => void;
|
||||||
onExpand?: () => void;
|
onOpenFolder: () => void;
|
||||||
|
onProjectContextMenu: (project: Project, event: React.MouseEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SidebarHeader({
|
export function SidebarHeader({
|
||||||
sidebarOpen,
|
sidebarOpen,
|
||||||
currentProject,
|
currentProject,
|
||||||
onClose,
|
onNewProject,
|
||||||
onExpand,
|
onOpenFolder,
|
||||||
|
onProjectContextMenu,
|
||||||
}: SidebarHeaderProps) {
|
}: SidebarHeaderProps) {
|
||||||
const isCompact = useIsCompact();
|
const navigate = useNavigate();
|
||||||
const [projectListOpen, setProjectListOpen] = useState(false);
|
|
||||||
const { projects, setCurrentProject } = useAppStore();
|
const { projects, setCurrentProject } = useAppStore();
|
||||||
// Get the icon component from lucide-react
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||||
const getIconComponent = (): LucideIcon => {
|
|
||||||
if (currentProject?.icon && currentProject.icon in LucideIcons) {
|
const handleLogoClick = useCallback(() => {
|
||||||
return (LucideIcons as unknown as Record<string, LucideIcon>)[currentProject.icon];
|
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;
|
return Folder;
|
||||||
};
|
};
|
||||||
|
|
||||||
const IconComponent = getIconComponent();
|
const renderProjectIcon = (project: Project, size: 'sm' | 'md' = 'md') => {
|
||||||
const hasCustomIcon = !!currentProject?.customIconPath;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'shrink-0 flex flex-col relative',
|
'shrink-0 flex flex-col relative px-3 pt-3 pb-2',
|
||||||
// Add padding on macOS Electron for traffic light buttons
|
|
||||||
isMac && isElectron() && 'pt-[10px]'
|
isMac && isElectron() && 'pt-[10px]'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Mobile close button - only visible on mobile when sidebar is open */}
|
{/* Header with logo and project dropdown */}
|
||||||
{sidebarOpen && onClose && (
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Logo */}
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={handleLogoClick}
|
||||||
className={cn(
|
className="group flex items-center shrink-0 titlebar-no-drag"
|
||||||
'lg:hidden absolute top-3 right-3 z-10',
|
title="Go to Dashboard"
|
||||||
'flex items-center justify-center w-8 h-8 rounded-lg',
|
data-testid="logo-button"
|
||||||
'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"
|
|
||||||
>
|
>
|
||||||
<X className="w-5 h-5" />
|
<svg
|
||||||
</button>
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
)}
|
viewBox="0 0 256 256"
|
||||||
{/* Mobile expand button - hamburger menu to expand sidebar when collapsed on mobile */}
|
role="img"
|
||||||
{!sidebarOpen && isCompact && onExpand && (
|
aria-label="Automaker Logo"
|
||||||
<button
|
className="h-8 w-8 group-hover:rotate-12 transition-transform duration-300 ease-out"
|
||||||
onClick={onExpand}
|
>
|
||||||
className={cn(
|
<defs>
|
||||||
'flex items-center justify-center w-10 h-10 mx-auto mt-2 rounded-lg',
|
<linearGradient
|
||||||
'bg-muted/50 hover:bg-muted',
|
id="bg-header"
|
||||||
'text-muted-foreground hover:text-foreground',
|
x1="0"
|
||||||
'transition-colors duration-200'
|
y1="0"
|
||||||
)}
|
x2="256"
|
||||||
aria-label="Expand navigation"
|
y2="256"
|
||||||
data-testid="sidebar-mobile-expand"
|
gradientUnits="userSpaceOnUse"
|
||||||
>
|
>
|
||||||
<Menu className="w-5 h-5" />
|
<stop offset="0%" style={{ stopColor: 'var(--brand-400)' }} />
|
||||||
</button>
|
<stop offset="100%" style={{ stopColor: 'var(--brand-600)' }} />
|
||||||
)}
|
</linearGradient>
|
||||||
{/* Project name and icon display - entire element clickable on mobile */}
|
</defs>
|
||||||
{currentProject && (
|
<rect x="16" y="16" width="224" height="224" rx="56" fill="url(#bg-header)" />
|
||||||
<Popover open={projectListOpen} onOpenChange={setProjectListOpen}>
|
<g
|
||||||
<PopoverTrigger asChild>
|
fill="none"
|
||||||
<button
|
stroke="#FFFFFF"
|
||||||
className={cn(
|
strokeWidth="20"
|
||||||
'flex items-center gap-3 px-4 pt-3 pb-1 w-full text-left',
|
strokeLinecap="round"
|
||||||
'rounded-lg transition-colors duration-150',
|
strokeLinejoin="round"
|
||||||
!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}
|
|
||||||
>
|
>
|
||||||
{/* Project Icon */}
|
<path d="M92 92 L52 128 L92 164" />
|
||||||
<div className="shrink-0">
|
<path d="M144 72 L116 184" />
|
||||||
{hasCustomIcon ? (
|
<path d="M164 92 L204 128 L164 164" />
|
||||||
<img
|
</g>
|
||||||
src={getAuthenticatedImageUrl(
|
</svg>
|
||||||
currentProject.customIconPath!,
|
</button>
|
||||||
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>
|
|
||||||
|
|
||||||
{/* Project Name - only show when sidebar is open */}
|
{/* Project Dropdown */}
|
||||||
{sidebarOpen && (
|
{currentProject ? (
|
||||||
<div className="flex-1 min-w-0">
|
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
||||||
<h2 className="text-sm font-semibold text-foreground truncate">
|
<DropdownMenuTrigger asChild>
|
||||||
{currentProject.name}
|
<button
|
||||||
</h2>
|
className={cn(
|
||||||
</div>
|
'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',
|
||||||
</button>
|
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1'
|
||||||
</PopoverTrigger>
|
)}
|
||||||
<PopoverContent className="w-64 p-2" align="start" side="bottom" sideOffset={8}>
|
onContextMenu={(e) => onProjectContextMenu(currentProject, e)}
|
||||||
<div className="space-y-1">
|
data-testid="project-dropdown-trigger"
|
||||||
<p className="text-xs font-medium text-muted-foreground px-2 py-1">Switch Project</p>
|
>
|
||||||
{projects.map((project) => {
|
{renderProjectIcon(currentProject, 'sm')}
|
||||||
const ProjectIcon =
|
<span className="flex-1 text-sm font-semibold text-foreground truncate text-left">
|
||||||
project.icon && project.icon in LucideIcons
|
{currentProject.name}
|
||||||
? (LucideIcons as unknown as Record<string, LucideIcon>)[project.icon]
|
</span>
|
||||||
: Folder;
|
<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 isActive = currentProject?.id === project.id;
|
||||||
|
const hotkeyLabel = index < 9 ? `${index + 1}` : index === 9 ? '0' : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<DropdownMenuItem
|
||||||
key={project.id}
|
key={project.id}
|
||||||
onClick={() => {
|
onClick={() => handleProjectSelect(project)}
|
||||||
setCurrentProject(project);
|
onContextMenu={(e) => {
|
||||||
setProjectListOpen(false);
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setDropdownOpen(false);
|
||||||
|
onProjectContextMenu(project, e);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className="flex items-center gap-3 cursor-pointer"
|
||||||
'w-full flex items-center gap-3 px-2 py-2 rounded-lg text-left',
|
data-testid={`project-item-${project.id}`}
|
||||||
'transition-colors duration-150',
|
|
||||||
isActive
|
|
||||||
? 'bg-brand-500/10 text-brand-500'
|
|
||||||
: 'hover:bg-accent text-foreground'
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{project.customIconPath ? (
|
{renderProjectIcon(project, 'sm')}
|
||||||
<img
|
<span
|
||||||
src={getAuthenticatedImageUrl(project.customIconPath, project.path)}
|
className={cn('flex-1 truncate', isActive && 'font-semibold text-foreground')}
|
||||||
alt={project.name}
|
>
|
||||||
className="w-6 h-6 rounded object-cover ring-1 ring-border/50"
|
{project.name}
|
||||||
/>
|
</span>
|
||||||
) : (
|
{hotkeyLabel && (
|
||||||
<div
|
<span className="text-xs text-muted-foreground">
|
||||||
className={cn(
|
{formatShortcut(`Cmd+${hotkeyLabel}`, true)}
|
||||||
'w-6 h-6 rounded flex items-center justify-center',
|
</span>
|
||||||
isActive ? 'bg-brand-500/20' : 'bg-muted'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<ProjectIcon
|
|
||||||
className={cn(
|
|
||||||
'w-4 h-4',
|
|
||||||
isActive ? 'text-brand-500' : 'text-muted-foreground'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
<span className="flex-1 text-sm truncate">{project.name}</span>
|
</DropdownMenuItem>
|
||||||
{isActive && <Check className="w-4 h-4 text-brand-500" />}
|
|
||||||
</button>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
<DropdownMenuSeparator />
|
||||||
</PopoverContent>
|
<DropdownMenuItem
|
||||||
</Popover>
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,24 @@
|
|||||||
|
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||||
import type { NavigateOptions } from '@tanstack/react-router';
|
import type { NavigateOptions } from '@tanstack/react-router';
|
||||||
|
import { ChevronDown, Wrench, Github } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { formatShortcut } from '@/store/app-store';
|
import { formatShortcut } from '@/store/app-store';
|
||||||
import type { NavSection } from '../types';
|
import type { NavSection } from '../types';
|
||||||
import type { Project } from '@/lib/electron';
|
import type { Project } from '@/lib/electron';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
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 {
|
interface SidebarNavigationProps {
|
||||||
currentProject: Project | null;
|
currentProject: Project | null;
|
||||||
@@ -11,6 +26,7 @@ interface SidebarNavigationProps {
|
|||||||
navSections: NavSection[];
|
navSections: NavSection[];
|
||||||
isActiveRoute: (id: string) => boolean;
|
isActiveRoute: (id: string) => boolean;
|
||||||
navigate: (opts: NavigateOptions) => void;
|
navigate: (opts: NavigateOptions) => void;
|
||||||
|
onScrollStateChange?: (canScrollDown: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SidebarNavigation({
|
export function SidebarNavigation({
|
||||||
@@ -19,174 +35,299 @@ export function SidebarNavigation({
|
|||||||
navSections,
|
navSections,
|
||||||
isActiveRoute,
|
isActiveRoute,
|
||||||
navigate,
|
navigate,
|
||||||
|
onScrollStateChange,
|
||||||
}: SidebarNavigationProps) {
|
}: 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 (
|
return (
|
||||||
<nav
|
<nav ref={navRef} className={cn('flex-1 overflow-y-auto scrollbar-hide px-3 pb-2 mt-1')}>
|
||||||
className={cn(
|
{/* Navigation sections */}
|
||||||
'flex-1 overflow-y-auto scrollbar-hide px-3 pb-2',
|
{visibleSections.map((section, sectionIdx) => {
|
||||||
sidebarOpen ? 'mt-1' : 'mt-1'
|
const isCollapsed = section.label ? collapsedSections[section.label] : false;
|
||||||
)}
|
const isCollapsible = section.collapsible && section.label && sidebarOpen;
|
||||||
>
|
|
||||||
{!currentProject && sidebarOpen ? (
|
const SectionIcon = section.label ? sectionIcons[section.label] : null;
|
||||||
// Placeholder when no project is selected (only in expanded state)
|
|
||||||
<div className="flex items-center justify-center h-full px-4">
|
return (
|
||||||
<p className="text-muted-foreground text-sm text-center">
|
<div key={sectionIdx} className={sectionIdx > 0 && sidebarOpen ? 'mt-4' : ''}>
|
||||||
<span className="block">Select or create a project above</span>
|
{/* Section Label - clickable if collapsible (expanded sidebar) */}
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : currentProject ? (
|
|
||||||
// Navigation sections when project is selected
|
|
||||||
navSections.map((section, sectionIdx) => (
|
|
||||||
<div key={sectionIdx} className={sectionIdx > 0 && sidebarOpen ? 'mt-6' : ''}>
|
|
||||||
{/* Section Label */}
|
|
||||||
{section.label && sidebarOpen && (
|
{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">
|
<span className="text-[10px] font-semibold text-muted-foreground/70 uppercase tracking-widest">
|
||||||
{section.label}
|
{section.label}
|
||||||
</span>
|
</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) */}
|
{/* Separator for sections without label (visual separation) */}
|
||||||
{!section.label && sectionIdx > 0 && sidebarOpen && (
|
{!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 && (
|
{(section.label || sectionIdx > 0) && !sidebarOpen && (
|
||||||
<div className="h-px bg-border/30 mx-2 my-1.5"></div>
|
<div className="h-px bg-border/30 mx-2 my-1.5"></div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Nav Items */}
|
{/* Nav Items - show when section is expanded, or when sidebar is collapsed and section doesn't use dropdown */}
|
||||||
<div className="space-y-1.5">
|
{!isCollapsed && (
|
||||||
{section.items.map((item) => {
|
<div className="space-y-1">
|
||||||
const isActive = isActiveRoute(item.id);
|
{section.items.map((item) => {
|
||||||
const Icon = item.icon;
|
const isActive = isActiveRoute(item.id);
|
||||||
|
const Icon = item.icon;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// Cast to the router's path type; item.id is constrained to known routes
|
// Cast to the router's path type; item.id is constrained to known routes
|
||||||
navigate({ to: `/${item.id}` as unknown as '/' });
|
navigate({ to: `/${item.id}` as unknown as '/' });
|
||||||
}}
|
}}
|
||||||
className={cn(
|
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',
|
'transition-all duration-200 ease-out',
|
||||||
isActive
|
isActive
|
||||||
? [
|
? [
|
||||||
// Active: Premium gradient with glow
|
// Active: Premium gradient with glow
|
||||||
'bg-gradient-to-r from-brand-500/20 via-brand-500/15 to-brand-600/10',
|
'bg-gradient-to-r from-brand-500/20 via-brand-500/15 to-brand-600/10',
|
||||||
'text-foreground font-medium',
|
'text-foreground font-medium',
|
||||||
'border border-brand-500/30',
|
'border border-brand-500/30',
|
||||||
'shadow-md shadow-brand-500/10',
|
'shadow-sm shadow-brand-500/10',
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
// Inactive: Subtle hover state
|
// Inactive: Subtle hover state
|
||||||
'text-muted-foreground hover:text-foreground',
|
'text-muted-foreground hover:text-foreground',
|
||||||
'hover:bg-accent/50',
|
'hover:bg-accent/50',
|
||||||
'border border-transparent hover:border-border/40',
|
'border border-transparent hover:border-border/40',
|
||||||
'hover:shadow-sm',
|
],
|
||||||
],
|
sidebarOpen ? 'justify-start' : 'justify-center'
|
||||||
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'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
{/* Count badge for collapsed state */}
|
title={!sidebarOpen ? item.label : undefined}
|
||||||
{!sidebarOpen && item.count !== undefined && item.count > 0 && (
|
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
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute -top-1.5 -right-1.5 flex items-center justify-center',
|
'flex items-center justify-center',
|
||||||
'min-w-4 h-4 px-1 text-[9px] font-bold rounded-full',
|
'min-w-5 h-5 px-1.5 text-[10px] font-bold rounded-full',
|
||||||
'bg-primary text-primary-foreground shadow-sm',
|
'bg-primary text-primary-foreground shadow-sm',
|
||||||
'animate-in fade-in zoom-in duration-200'
|
'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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
{item.shortcut && sidebarOpen && !item.count && (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'ml-3 font-medium text-sm flex-1 text-left',
|
'flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded transition-all duration-200',
|
||||||
sidebarOpen ? 'block' : 'hidden'
|
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 */}
|
||||||
{item.label}
|
{!sidebarOpen && (
|
||||||
</span>
|
<span
|
||||||
{/* Count badge */}
|
className={cn(
|
||||||
{item.count !== undefined && item.count > 0 && sidebarOpen && (
|
'absolute left-full ml-3 px-2.5 py-1.5 rounded-md',
|
||||||
<span
|
'bg-popover text-popover-foreground text-sm',
|
||||||
className={cn(
|
'border border-border shadow-lg',
|
||||||
'flex items-center justify-center',
|
'opacity-0 group-hover:opacity-100',
|
||||||
'min-w-5 h-5 px-1.5 text-[10px] font-bold rounded-full',
|
'transition-all duration-200 whitespace-nowrap z-50',
|
||||||
'bg-primary text-primary-foreground shadow-sm',
|
'translate-x-1 group-hover:translate-x-0'
|
||||||
'animate-in fade-in zoom-in duration-200'
|
)}
|
||||||
)}
|
data-testid={`sidebar-tooltip-${item.label.toLowerCase()}`}
|
||||||
data-testid={`count-${item.id}`}
|
>
|
||||||
>
|
{item.label}
|
||||||
{item.count > 99 ? '99+' : item.count}
|
{item.shortcut && (
|
||||||
</span>
|
<span className="ml-2 px-1.5 py-0.5 bg-muted rounded text-[10px] font-mono text-muted-foreground">
|
||||||
)}
|
{formatShortcut(item.shortcut, true)}
|
||||||
{item.shortcut && sidebarOpen && !item.count && (
|
</span>
|
||||||
<span
|
)}
|
||||||
className={cn(
|
</span>
|
||||||
'flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200',
|
)}
|
||||||
isActive
|
</button>
|
||||||
? 'bg-brand-500/20 text-brand-400'
|
);
|
||||||
: 'bg-muted text-muted-foreground group-hover:bg-accent'
|
})}
|
||||||
)}
|
</div>
|
||||||
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>
|
|
||||||
</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>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
Network,
|
Network,
|
||||||
Bell,
|
Bell,
|
||||||
Settings,
|
Settings,
|
||||||
|
Home,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { NavSection, NavItem } from '../types';
|
import type { NavSection, NavItem } from '../types';
|
||||||
import type { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts';
|
import type { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts';
|
||||||
@@ -174,13 +175,30 @@ export function useNavigation({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sections: NavSection[] = [
|
const sections: NavSection[] = [
|
||||||
|
// Dashboard - standalone at top
|
||||||
|
{
|
||||||
|
label: '',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'dashboard',
|
||||||
|
label: 'Dashboard',
|
||||||
|
icon: Home,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// Project section - expanded by default
|
||||||
{
|
{
|
||||||
label: 'Project',
|
label: 'Project',
|
||||||
items: projectItems,
|
items: projectItems,
|
||||||
|
collapsible: true,
|
||||||
|
defaultCollapsed: false,
|
||||||
},
|
},
|
||||||
|
// Tools section - collapsed by default
|
||||||
{
|
{
|
||||||
label: 'Tools',
|
label: 'Tools',
|
||||||
items: visibleToolsItems,
|
items: visibleToolsItems,
|
||||||
|
collapsible: true,
|
||||||
|
defaultCollapsed: true,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -203,6 +221,8 @@ export function useNavigation({
|
|||||||
shortcut: shortcuts.githubPrs,
|
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 { createLogger } from '@automaker/utils/logger';
|
||||||
import { useNavigate, useLocation } from '@tanstack/react-router';
|
import { useNavigate, useLocation } from '@tanstack/react-router';
|
||||||
|
import { PanelLeftClose, ChevronDown } from 'lucide-react';
|
||||||
const logger = createLogger('Sidebar');
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { useNotificationsStore } from '@/store/notifications-store';
|
import { useNotificationsStore } from '@/store/notifications-store';
|
||||||
@@ -10,22 +9,18 @@ import { useKeyboardShortcuts, useKeyboardShortcutsConfig } from '@/hooks/use-ke
|
|||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { initializeProject, hasAppSpec, hasAutomakerDir } from '@/lib/project-init';
|
import { initializeProject, hasAppSpec, hasAutomakerDir } from '@/lib/project-init';
|
||||||
import { toast } from 'sonner';
|
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 { useIsCompact } from '@/hooks/use-media-query';
|
||||||
import { PanelLeftClose } from 'lucide-react';
|
import type { Project } from '@/lib/electron';
|
||||||
import { TrashDialog, OnboardingDialog } from './sidebar/dialogs';
|
|
||||||
import { SIDEBAR_FEATURE_FLAGS } from './sidebar/constants';
|
// Sidebar components
|
||||||
|
import {
|
||||||
|
SidebarNavigation,
|
||||||
|
CollapseToggleButton,
|
||||||
|
MobileSidebarToggle,
|
||||||
|
SidebarHeader,
|
||||||
|
SidebarFooter,
|
||||||
|
} from './components';
|
||||||
|
import { SIDEBAR_FEATURE_FLAGS } from './constants';
|
||||||
import {
|
import {
|
||||||
useSidebarAutoCollapse,
|
useSidebarAutoCollapse,
|
||||||
useRunningAgents,
|
useRunningAgents,
|
||||||
@@ -35,7 +30,19 @@ import {
|
|||||||
useSetupDialog,
|
useSetupDialog,
|
||||||
useTrashOperations,
|
useTrashOperations,
|
||||||
useUnviewedValidations,
|
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() {
|
export function Sidebar() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -59,12 +66,14 @@ export function Sidebar() {
|
|||||||
moveProjectToTrash,
|
moveProjectToTrash,
|
||||||
specCreatingForProject,
|
specCreatingForProject,
|
||||||
setSpecCreatingForProject,
|
setSpecCreatingForProject,
|
||||||
|
setCurrentProject,
|
||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
|
|
||||||
const isCompact = useIsCompact();
|
const isCompact = useIsCompact();
|
||||||
|
|
||||||
// Environment variable flags for hiding sidebar items
|
// 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
|
// Get customizable keyboard shortcuts
|
||||||
const shortcuts = useKeyboardShortcutsConfig();
|
const shortcuts = useKeyboardShortcutsConfig();
|
||||||
@@ -72,6 +81,13 @@ export function Sidebar() {
|
|||||||
// Get unread notifications count
|
// Get unread notifications count
|
||||||
const unreadNotificationsCount = useNotificationsStore((s) => s.unreadCount);
|
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
|
// State for delete project confirmation dialog
|
||||||
const [showDeleteProjectDialog, setShowDeleteProjectDialog] = useState(false);
|
const [showDeleteProjectDialog, setShowDeleteProjectDialog] = useState(false);
|
||||||
|
|
||||||
@@ -129,7 +145,7 @@ export function Sidebar() {
|
|||||||
const isCurrentProjectGeneratingSpec =
|
const isCurrentProjectGeneratingSpec =
|
||||||
specCreatingForProject !== null && specCreatingForProject === currentProject?.path;
|
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 });
|
useSidebarAutoCollapse({ sidebarOpen, toggleSidebar });
|
||||||
|
|
||||||
// Running agents count
|
// Running agents count
|
||||||
@@ -163,9 +179,28 @@ export function Sidebar() {
|
|||||||
setNewProjectPath,
|
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.
|
* 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 handleOpenFolder = useCallback(async () => {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
@@ -173,14 +208,10 @@ export function Sidebar() {
|
|||||||
|
|
||||||
if (!result.canceled && result.filePaths[0]) {
|
if (!result.canceled && result.filePaths[0]) {
|
||||||
const path = 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';
|
const name = path.split(/[/\\]/).filter(Boolean).pop() || 'Untitled Project';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if this is a brand new project (no .automaker directory)
|
|
||||||
const hadAutomakerDir = await hasAutomakerDir(path);
|
const hadAutomakerDir = await hasAutomakerDir(path);
|
||||||
|
|
||||||
// Initialize the .automaker directory structure
|
|
||||||
const initResult = await initializeProject(path);
|
const initResult = await initializeProject(path);
|
||||||
|
|
||||||
if (!initResult.success) {
|
if (!initResult.success) {
|
||||||
@@ -190,15 +221,10 @@ export function Sidebar() {
|
|||||||
return;
|
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);
|
upsertAndSetCurrentProject(path, name);
|
||||||
|
|
||||||
// Check if app_spec.txt exists
|
|
||||||
const specExists = await hasAppSpec(path);
|
const specExists = await hasAppSpec(path);
|
||||||
|
|
||||||
if (!hadAutomakerDir && !specExists) {
|
if (!hadAutomakerDir && !specExists) {
|
||||||
// This is a brand new project - show setup dialog
|
|
||||||
setSetupProjectPath(path);
|
setSetupProjectPath(path);
|
||||||
setShowSetupDialog(true);
|
setShowSetupDialog(true);
|
||||||
toast.success('Project opened', {
|
toast.success('Project opened', {
|
||||||
@@ -213,6 +239,8 @@ export function Sidebar() {
|
|||||||
description: `Opened ${name}`,
|
description: `Opened ${name}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
navigate({ to: '/board' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to open project:', error);
|
logger.error('Failed to open project:', error);
|
||||||
toast.error('Failed to open project', {
|
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({
|
const { navSections, navigationShortcuts } = useNavigation({
|
||||||
shortcuts,
|
shortcuts,
|
||||||
hideSpecEditor,
|
hideSpecEditor,
|
||||||
@@ -244,12 +276,48 @@ export function Sidebar() {
|
|||||||
// Register keyboard shortcuts
|
// Register keyboard shortcuts
|
||||||
useKeyboardShortcuts(navigationShortcuts);
|
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) => {
|
const isActiveRoute = (id: string) => {
|
||||||
// Map view IDs to route paths
|
|
||||||
const routePath = id === 'welcome' ? '/' : `/${id}`;
|
const routePath = id === 'welcome' ? '/' : `/${id}`;
|
||||||
return location.pathname === routePath;
|
return location.pathname === routePath;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Track if nav can scroll down
|
||||||
|
const [canScrollDown, setCanScrollDown] = useState(false);
|
||||||
|
|
||||||
// Check if sidebar should be completely hidden on mobile
|
// Check if sidebar should be completely hidden on mobile
|
||||||
const shouldHideSidebar = isCompact && mobileSidebarHidden;
|
const shouldHideSidebar = isCompact && mobileSidebarHidden;
|
||||||
|
|
||||||
@@ -266,6 +334,7 @@ export function Sidebar() {
|
|||||||
data-testid="sidebar-backdrop"
|
data-testid="sidebar-backdrop"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex-shrink-0 flex flex-col z-30',
|
'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)]',
|
'transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]',
|
||||||
// Mobile: completely hidden when mobileSidebarHidden is true
|
// Mobile: completely hidden when mobileSidebarHidden is true
|
||||||
shouldHideSidebar && 'hidden',
|
shouldHideSidebar && 'hidden',
|
||||||
// Mobile: overlay when open, collapsed when closed
|
// Width based on state
|
||||||
!shouldHideSidebar &&
|
!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"
|
data-testid="sidebar"
|
||||||
>
|
>
|
||||||
@@ -313,8 +384,9 @@ export function Sidebar() {
|
|||||||
<SidebarHeader
|
<SidebarHeader
|
||||||
sidebarOpen={sidebarOpen}
|
sidebarOpen={sidebarOpen}
|
||||||
currentProject={currentProject}
|
currentProject={currentProject}
|
||||||
onClose={toggleSidebar}
|
onNewProject={handleNewProject}
|
||||||
onExpand={toggleSidebar}
|
onOpenFolder={handleOpenFolder}
|
||||||
|
onProjectContextMenu={handleContextMenu}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SidebarNavigation
|
<SidebarNavigation
|
||||||
@@ -323,17 +395,27 @@ export function Sidebar() {
|
|||||||
navSections={navSections}
|
navSections={navSections}
|
||||||
isActiveRoute={isActiveRoute}
|
isActiveRoute={isActiveRoute}
|
||||||
navigate={navigate}
|
navigate={navigate}
|
||||||
|
onScrollStateChange={setCanScrollDown}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<SidebarFooter
|
||||||
sidebarOpen={sidebarOpen}
|
sidebarOpen={sidebarOpen}
|
||||||
isActiveRoute={isActiveRoute}
|
isActiveRoute={isActiveRoute}
|
||||||
navigate={navigate}
|
navigate={navigate}
|
||||||
hideRunningAgents={hideRunningAgents}
|
hideRunningAgents={hideRunningAgents}
|
||||||
|
hideWiki={hideWiki}
|
||||||
runningAgentsCount={runningAgentsCount}
|
runningAgentsCount={runningAgentsCount}
|
||||||
shortcuts={{ settings: shortcuts.settings }}
|
shortcuts={{ settings: shortcuts.settings }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TrashDialog
|
<TrashDialog
|
||||||
open={showTrashDialog}
|
open={showTrashDialog}
|
||||||
onOpenChange={setShowTrashDialog}
|
onOpenChange={setShowTrashDialog}
|
||||||
@@ -392,6 +474,25 @@ export function Sidebar() {
|
|||||||
isCreating={isCreatingProject}
|
isCreating={isCreatingProject}
|
||||||
/>
|
/>
|
||||||
</aside>
|
</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 {
|
export interface NavSection {
|
||||||
label?: string;
|
label?: string;
|
||||||
items: NavItem[];
|
items: NavItem[];
|
||||||
|
/** Whether this section can be collapsed */
|
||||||
|
collapsible?: boolean;
|
||||||
|
/** Whether this section should start collapsed */
|
||||||
|
defaultCollapsed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NavItem {
|
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 rehypeRaw from 'rehype-raw';
|
||||||
import rehypeSanitize from 'rehype-sanitize';
|
import rehypeSanitize from 'rehype-sanitize';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Square, CheckSquare } from 'lucide-react';
|
||||||
|
|
||||||
interface MarkdownProps {
|
interface MarkdownProps {
|
||||||
children: string;
|
children: string;
|
||||||
className?: 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
|
* Reusable Markdown component for rendering markdown content
|
||||||
* Theme-aware styling that adapts to all predefined themes
|
* 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',
|
'[&_hr]:border-border [&_hr]:my-4',
|
||||||
// Images
|
// Images
|
||||||
'[&_img]:max-w-full [&_img]:h-auto [&_img]:rounded-lg [&_img]:my-2 [&_img]:border [&_img]:border-border',
|
'[&_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
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ReactMarkdown rehypePlugins={[rehypeRaw, rehypeSanitize]}>{children}</ReactMarkdown>
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
rehypePlugins={[rehypeRaw, rehypeSanitize]}
|
||||||
|
components={markdownComponents}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -395,6 +395,7 @@ export const PROVIDER_ICON_COMPONENTS: Record<
|
|||||||
cursor: CursorIcon,
|
cursor: CursorIcon,
|
||||||
codex: OpenAIIcon,
|
codex: OpenAIIcon,
|
||||||
opencode: OpenCodeIcon,
|
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 { useQueryClient } from '@tanstack/react-query';
|
||||||
import { queryKeys } from '@/lib/query-keys';
|
import { queryKeys } from '@/lib/query-keys';
|
||||||
import { useAutoModeQueryInvalidation } from '@/hooks/use-query-invalidation';
|
import { useAutoModeQueryInvalidation } from '@/hooks/use-query-invalidation';
|
||||||
|
import { useUpdateGlobalSettings } from '@/hooks/mutations/use-settings-mutations';
|
||||||
|
|
||||||
// Stable empty array to avoid infinite loop in selector
|
// Stable empty array to avoid infinite loop in selector
|
||||||
const EMPTY_WORKTREES: ReturnType<ReturnType<typeof useAppStore.getState>['getWorktrees']> = [];
|
const EMPTY_WORKTREES: ReturnType<ReturnType<typeof useAppStore.getState>['getWorktrees']> = [];
|
||||||
@@ -451,6 +452,8 @@ export function BoardView() {
|
|||||||
const maxConcurrency = autoMode.maxConcurrency;
|
const maxConcurrency = autoMode.maxConcurrency;
|
||||||
// Get worktree-specific setter
|
// Get worktree-specific setter
|
||||||
const setMaxConcurrencyForWorktree = useAppStore((state) => state.setMaxConcurrencyForWorktree);
|
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)
|
// Get the current branch from the selected worktree (not from store which may be stale)
|
||||||
const currentWorktreeBranch = selectedWorktree?.branch ?? null;
|
const currentWorktreeBranch = selectedWorktree?.branch ?? null;
|
||||||
@@ -1277,6 +1280,15 @@ export function BoardView() {
|
|||||||
if (currentProject && selectedWorktree) {
|
if (currentProject && selectedWorktree) {
|
||||||
const branchName = selectedWorktree.isMain ? null : selectedWorktree.branch;
|
const branchName = selectedWorktree.isMain ? null : selectedWorktree.branch;
|
||||||
setMaxConcurrencyForWorktree(currentProject.id, branchName, newMaxConcurrency);
|
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
|
// Also update backend if auto mode is running
|
||||||
if (autoMode.isRunning) {
|
if (autoMode.isRunning) {
|
||||||
// Restart auto mode with new concurrency (backend will handle this)
|
// Restart auto mode with new concurrency (backend will handle this)
|
||||||
@@ -1489,6 +1501,7 @@ export function BoardView() {
|
|||||||
branchSuggestions={branchSuggestions}
|
branchSuggestions={branchSuggestions}
|
||||||
branchCardCounts={branchCardCounts}
|
branchCardCounts={branchCardCounts}
|
||||||
currentBranch={currentWorktreeBranch || undefined}
|
currentBranch={currentWorktreeBranch || undefined}
|
||||||
|
projectPath={currentProject?.path}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Board Background Modal */}
|
{/* Board Background Modal */}
|
||||||
@@ -1538,6 +1551,7 @@ export function BoardView() {
|
|||||||
isMaximized={isMaximized}
|
isMaximized={isMaximized}
|
||||||
parentFeature={spawnParentFeature}
|
parentFeature={spawnParentFeature}
|
||||||
allFeatures={hookFeatures}
|
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
|
// When setting is enabled and a non-main worktree is selected, pass its branch to default to 'custom' work mode
|
||||||
selectedNonMainWorktreeBranch={
|
selectedNonMainWorktreeBranch={
|
||||||
addFeatureUseSelectedWorktreeBranch && currentWorktreePath !== null
|
addFeatureUseSelectedWorktreeBranch && currentWorktreePath !== null
|
||||||
@@ -1568,6 +1582,7 @@ export function BoardView() {
|
|||||||
currentBranch={currentWorktreeBranch || undefined}
|
currentBranch={currentWorktreeBranch || undefined}
|
||||||
isMaximized={isMaximized}
|
isMaximized={isMaximized}
|
||||||
allFeatures={hookFeatures}
|
allFeatures={hookFeatures}
|
||||||
|
projectPath={currentProject?.path}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Agent Output Modal */}
|
{/* Agent Output Modal */}
|
||||||
|
|||||||
@@ -10,20 +10,22 @@ interface BoardControlsProps {
|
|||||||
export function BoardControls({ isMounted, onShowBoardBackground }: BoardControlsProps) {
|
export function BoardControls({ isMounted, onShowBoardBackground }: BoardControlsProps) {
|
||||||
if (!isMounted) return null;
|
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 (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<div className="flex items-center gap-5">
|
<div className="flex items-center gap-2">
|
||||||
{/* Board Background Button */}
|
{/* Board Background Button */}
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
onClick={onShowBoardBackground}
|
onClick={onShowBoardBackground}
|
||||||
className={cn(
|
className={buttonClass}
|
||||||
'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'
|
|
||||||
)}
|
|
||||||
data-testid="board-background-button"
|
data-testid="board-background-button"
|
||||||
>
|
>
|
||||||
<ImageIcon className="w-4 h-4" />
|
<ImageIcon className="w-4 h-4" />
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useCallback, useState } from 'react';
|
|||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Wand2, GitBranch, ClipboardCheck } from 'lucide-react';
|
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 { useAppStore } from '@/store/app-store';
|
||||||
import { useSetupStore } from '@/store/setup-store';
|
import { useSetupStore } from '@/store/setup-store';
|
||||||
import { useIsTablet } from '@/hooks/use-media-query';
|
import { useIsTablet } from '@/hooks/use-media-query';
|
||||||
@@ -127,8 +127,8 @@ export function BoardHeader({
|
|||||||
<BoardControls isMounted={isMounted} onShowBoardBackground={onShowBoardBackground} />
|
<BoardControls isMounted={isMounted} onShowBoardBackground={onShowBoardBackground} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-4 items-center">
|
<div className="flex gap-4 items-center">
|
||||||
{/* Usage Popover - show if either provider is authenticated, only on desktop */}
|
{/* Provider Usage Bar - shows all available providers, only on desktop */}
|
||||||
{isMounted && !isTablet && (showClaudeUsage || showCodexUsage) && <UsagePopover />}
|
{isMounted && !isTablet && <ProviderUsageBar />}
|
||||||
|
|
||||||
{/* Tablet/Mobile view: show hamburger menu with all controls */}
|
{/* Tablet/Mobile view: show hamburger menu with all controls */}
|
||||||
{isMounted && isTablet && (
|
{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 { Feature, ThinkingLevel, ParsedTask } from '@/store/app-store';
|
||||||
import type { ReasoningEffort } from '@automaker/types';
|
import type { ReasoningEffort } from '@automaker/types';
|
||||||
import { getProviderFromModel } from '@/lib/utils';
|
import { getProviderFromModel } from '@/lib/utils';
|
||||||
@@ -69,21 +69,70 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
|
|||||||
const [taskStatusMap, setTaskStatusMap] = useState<
|
const [taskStatusMap, setTaskStatusMap] = useState<
|
||||||
Map<string, 'pending' | 'in_progress' | 'completed'>
|
Map<string, 'pending' | 'in_progress' | 'completed'>
|
||||||
>(new Map());
|
>(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
|
// Determine if we should poll for updates
|
||||||
const shouldPoll = isCurrentAutoTask || feature.status === 'in_progress';
|
|
||||||
const shouldFetchData = feature.status !== 'backlog';
|
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)
|
// Fetch fresh feature data for planSpec (store data can be stale for task progress)
|
||||||
const { data: freshFeature } = useFeature(projectPath, feature.id, {
|
const { data: freshFeature } = useFeature(projectPath, feature.id, {
|
||||||
enabled: shouldFetchData && !contextContent,
|
enabled: shouldFetchData && !contextContent,
|
||||||
pollingInterval: shouldPoll ? 3000 : false,
|
pollingInterval,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch agent output for parsing
|
// Fetch agent output for parsing
|
||||||
const { data: agentOutputContent } = useAgentOutput(projectPath, feature.id, {
|
const { data: agentOutputContent } = useAgentOutput(projectPath, feature.id, {
|
||||||
enabled: shouldFetchData && !contextContent,
|
enabled: shouldFetchData && !contextContent,
|
||||||
pollingInterval: shouldPoll ? 3000 : false,
|
pollingInterval,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Parse agent output into agentInfo
|
// Parse agent output into agentInfo
|
||||||
@@ -174,6 +223,9 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
|
|||||||
// Only handle events for this feature
|
// Only handle events for this feature
|
||||||
if (!('featureId' in event) || event.featureId !== feature.id) return;
|
if (!('featureId' in event) || event.featureId !== feature.id) return;
|
||||||
|
|
||||||
|
// Update timestamp for any event related to this feature
|
||||||
|
setLastWsEventTimestamp(Date.now());
|
||||||
|
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'auto_mode_task_started':
|
case 'auto_mode_task_started':
|
||||||
if ('taskId' in event) {
|
if ('taskId' in event) {
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import { memo, useEffect, useMemo, useState } from 'react';
|
|||||||
import { Feature, useAppStore } from '@/store/app-store';
|
import { Feature, useAppStore } from '@/store/app-store';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
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 { getBlockingDependencies } from '@automaker/dependency-resolver';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
import { usePipelineConfig } from '@/hooks/queries/use-pipeline';
|
||||||
|
|
||||||
/** Uniform badge style for all card badges */
|
/** Uniform badge style for all card badges */
|
||||||
const uniformBadgeClass =
|
const uniformBadgeClass =
|
||||||
@@ -51,9 +52,13 @@ export const CardBadges = memo(function CardBadges({ feature }: CardBadgesProps)
|
|||||||
|
|
||||||
interface PriorityBadgesProps {
|
interface PriorityBadgesProps {
|
||||||
feature: Feature;
|
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(
|
const { enableDependencyBlocking, features } = useAppStore(
|
||||||
useShallow((state) => ({
|
useShallow((state) => ({
|
||||||
enableDependencyBlocking: state.enableDependencyBlocking,
|
enableDependencyBlocking: state.enableDependencyBlocking,
|
||||||
@@ -62,6 +67,9 @@ export const PriorityBadges = memo(function PriorityBadges({ feature }: Priority
|
|||||||
);
|
);
|
||||||
const [currentTime, setCurrentTime] = useState(() => Date.now());
|
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)
|
// Calculate blocking dependencies (if feature is in backlog and has incomplete dependencies)
|
||||||
const blockingDependencies = useMemo(() => {
|
const blockingDependencies = useMemo(() => {
|
||||||
if (!enableDependencyBlocking || feature.status !== 'backlog') {
|
if (!enableDependencyBlocking || feature.status !== 'backlog') {
|
||||||
@@ -108,7 +116,19 @@ export const PriorityBadges = memo(function PriorityBadges({ feature }: Priority
|
|||||||
const showManualVerification =
|
const showManualVerification =
|
||||||
feature.skipTests && !feature.error && feature.status === 'backlog';
|
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) {
|
if (!showBadges) {
|
||||||
return null;
|
return null;
|
||||||
@@ -227,6 +247,39 @@ export const PriorityBadges = memo(function PriorityBadges({ feature }: Priority
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -136,8 +136,9 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Make the card a drop target for creating dependency links
|
// Make the card a drop target for creating dependency links
|
||||||
// Only backlog cards can be link targets (to avoid complexity with running features)
|
// All non-completed cards can be link targets to allow flexible dependency creation
|
||||||
const isDroppable = !isOverlay && feature.status === 'backlog' && !isSelectionMode;
|
// (completed features are excluded as they're already done)
|
||||||
|
const isDroppable = !isOverlay && feature.status !== 'completed' && !isSelectionMode;
|
||||||
const { setNodeRef: setDroppableRef, isOver } = useDroppable({
|
const { setNodeRef: setDroppableRef, isOver } = useDroppable({
|
||||||
id: `card-drop-${feature.id}`,
|
id: `card-drop-${feature.id}`,
|
||||||
disabled: !isDroppable,
|
disabled: !isDroppable,
|
||||||
@@ -236,7 +237,7 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Priority and Manual Verification badges */}
|
{/* Priority and Manual Verification badges */}
|
||||||
<PriorityBadges feature={feature} />
|
<PriorityBadges feature={feature} projectPath={currentProject?.path} />
|
||||||
|
|
||||||
{/* Card Header */}
|
{/* Card Header */}
|
||||||
<CardHeaderSection
|
<CardHeaderSection
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import {
|
|||||||
AncestorContextSection,
|
AncestorContextSection,
|
||||||
EnhanceWithAI,
|
EnhanceWithAI,
|
||||||
EnhancementHistoryButton,
|
EnhancementHistoryButton,
|
||||||
|
PipelineExclusionControls,
|
||||||
type BaseHistoryEntry,
|
type BaseHistoryEntry,
|
||||||
} from '../shared';
|
} from '../shared';
|
||||||
import type { WorkMode } from '../shared';
|
import type { WorkMode } from '../shared';
|
||||||
@@ -101,6 +102,7 @@ type FeatureData = {
|
|||||||
requirePlanApproval: boolean;
|
requirePlanApproval: boolean;
|
||||||
dependencies?: string[];
|
dependencies?: string[];
|
||||||
childDependencies?: string[]; // Feature IDs that should depend on this feature
|
childDependencies?: string[]; // Feature IDs that should depend on this feature
|
||||||
|
excludedPipelineSteps?: string[]; // Pipeline step IDs to skip for this feature
|
||||||
workMode: WorkMode;
|
workMode: WorkMode;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -118,6 +120,10 @@ interface AddFeatureDialogProps {
|
|||||||
isMaximized: boolean;
|
isMaximized: boolean;
|
||||||
parentFeature?: Feature | null;
|
parentFeature?: Feature | null;
|
||||||
allFeatures?: Feature[];
|
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 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.
|
* When set, the dialog will default to 'custom' work mode with this branch pre-filled.
|
||||||
@@ -151,6 +157,7 @@ export function AddFeatureDialog({
|
|||||||
isMaximized,
|
isMaximized,
|
||||||
parentFeature = null,
|
parentFeature = null,
|
||||||
allFeatures = [],
|
allFeatures = [],
|
||||||
|
projectPath,
|
||||||
selectedNonMainWorktreeBranch,
|
selectedNonMainWorktreeBranch,
|
||||||
forceCurrentBranchMode,
|
forceCurrentBranchMode,
|
||||||
}: AddFeatureDialogProps) {
|
}: AddFeatureDialogProps) {
|
||||||
@@ -194,9 +201,20 @@ export function AddFeatureDialog({
|
|||||||
const [parentDependencies, setParentDependencies] = useState<string[]>([]);
|
const [parentDependencies, setParentDependencies] = useState<string[]>([]);
|
||||||
const [childDependencies, setChildDependencies] = useState<string[]>([]);
|
const [childDependencies, setChildDependencies] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// Pipeline exclusion state
|
||||||
|
const [excludedPipelineSteps, setExcludedPipelineSteps] = useState<string[]>([]);
|
||||||
|
|
||||||
// Get defaults from store
|
// Get defaults from store
|
||||||
const { defaultPlanningMode, defaultRequirePlanApproval, useWorktrees, defaultFeatureModel } =
|
const {
|
||||||
useAppStore();
|
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
|
// Track previous open state to detect when dialog opens
|
||||||
const wasOpenRef = useRef(false);
|
const wasOpenRef = useRef(false);
|
||||||
@@ -216,7 +234,7 @@ export function AddFeatureDialog({
|
|||||||
);
|
);
|
||||||
setPlanningMode(defaultPlanningMode);
|
setPlanningMode(defaultPlanningMode);
|
||||||
setRequirePlanApproval(defaultRequirePlanApproval);
|
setRequirePlanApproval(defaultRequirePlanApproval);
|
||||||
setModelEntry(defaultFeatureModel);
|
setModelEntry(effectiveDefaultFeatureModel);
|
||||||
|
|
||||||
// Initialize description history (empty for new feature)
|
// Initialize description history (empty for new feature)
|
||||||
setDescriptionHistory([]);
|
setDescriptionHistory([]);
|
||||||
@@ -234,6 +252,9 @@ export function AddFeatureDialog({
|
|||||||
// Reset dependency selections
|
// Reset dependency selections
|
||||||
setParentDependencies([]);
|
setParentDependencies([]);
|
||||||
setChildDependencies([]);
|
setChildDependencies([]);
|
||||||
|
|
||||||
|
// Reset pipeline exclusions (all pipelines enabled by default)
|
||||||
|
setExcludedPipelineSteps([]);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
open,
|
open,
|
||||||
@@ -241,7 +262,7 @@ export function AddFeatureDialog({
|
|||||||
defaultBranch,
|
defaultBranch,
|
||||||
defaultPlanningMode,
|
defaultPlanningMode,
|
||||||
defaultRequirePlanApproval,
|
defaultRequirePlanApproval,
|
||||||
defaultFeatureModel,
|
effectiveDefaultFeatureModel,
|
||||||
useWorktrees,
|
useWorktrees,
|
||||||
selectedNonMainWorktreeBranch,
|
selectedNonMainWorktreeBranch,
|
||||||
forceCurrentBranchMode,
|
forceCurrentBranchMode,
|
||||||
@@ -328,6 +349,7 @@ export function AddFeatureDialog({
|
|||||||
requirePlanApproval,
|
requirePlanApproval,
|
||||||
dependencies: finalDependencies,
|
dependencies: finalDependencies,
|
||||||
childDependencies: childDependencies.length > 0 ? childDependencies : undefined,
|
childDependencies: childDependencies.length > 0 ? childDependencies : undefined,
|
||||||
|
excludedPipelineSteps: excludedPipelineSteps.length > 0 ? excludedPipelineSteps : undefined,
|
||||||
workMode,
|
workMode,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -343,7 +365,7 @@ export function AddFeatureDialog({
|
|||||||
// When a non-main worktree is selected, use its branch name for custom mode
|
// When a non-main worktree is selected, use its branch name for custom mode
|
||||||
setBranchName(selectedNonMainWorktreeBranch || '');
|
setBranchName(selectedNonMainWorktreeBranch || '');
|
||||||
setPriority(2);
|
setPriority(2);
|
||||||
setModelEntry(defaultFeatureModel);
|
setModelEntry(effectiveDefaultFeatureModel);
|
||||||
setWorkMode(
|
setWorkMode(
|
||||||
getDefaultWorkMode(useWorktrees, selectedNonMainWorktreeBranch, forceCurrentBranchMode)
|
getDefaultWorkMode(useWorktrees, selectedNonMainWorktreeBranch, forceCurrentBranchMode)
|
||||||
);
|
);
|
||||||
@@ -354,6 +376,7 @@ export function AddFeatureDialog({
|
|||||||
setDescriptionHistory([]);
|
setDescriptionHistory([]);
|
||||||
setParentDependencies([]);
|
setParentDependencies([]);
|
||||||
setChildDependencies([]);
|
setChildDependencies([]);
|
||||||
|
setExcludedPipelineSteps([]);
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -696,6 +719,16 @@ export function AddFeatureDialog({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Pipeline Exclusion Controls */}
|
||||||
|
<div className="pt-2">
|
||||||
|
<PipelineExclusionControls
|
||||||
|
projectPath={projectPath}
|
||||||
|
excludedPipelineSteps={excludedPipelineSteps}
|
||||||
|
onExcludedStepsChange={setExcludedPipelineSteps}
|
||||||
|
testIdPrefix="add-feature-pipeline"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { ArrowDown, ArrowUp, Link2, X } from 'lucide-react';
|
import { ArrowDown, ArrowUp, Link2, X } from 'lucide-react';
|
||||||
import type { Feature } from '@/store/app-store';
|
import type { Feature } from '@/store/app-store';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { StatusBadge } from '../components';
|
||||||
|
import type { FeatureStatusWithPipeline } from '@automaker/types';
|
||||||
|
|
||||||
export type DependencyLinkType = 'parent' | 'child';
|
export type DependencyLinkType = 'parent' | 'child';
|
||||||
|
|
||||||
@@ -57,7 +59,10 @@ export function DependencyLinkDialog({
|
|||||||
<div className="py-4 space-y-4">
|
<div className="py-4 space-y-4">
|
||||||
{/* Dragged feature */}
|
{/* Dragged feature */}
|
||||||
<div className="p-3 rounded-lg border bg-muted/30">
|
<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">
|
<div className="text-sm font-medium line-clamp-3 break-words">
|
||||||
{draggedFeature.description}
|
{draggedFeature.description}
|
||||||
</div>
|
</div>
|
||||||
@@ -71,7 +76,10 @@ export function DependencyLinkDialog({
|
|||||||
|
|
||||||
{/* Target feature */}
|
{/* Target feature */}
|
||||||
<div className="p-3 rounded-lg border bg-muted/30">
|
<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">
|
<div className="text-sm font-medium line-clamp-3 break-words">
|
||||||
{targetFeature.description}
|
{targetFeature.description}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
PlanningModeSelect,
|
PlanningModeSelect,
|
||||||
EnhanceWithAI,
|
EnhanceWithAI,
|
||||||
EnhancementHistoryButton,
|
EnhancementHistoryButton,
|
||||||
|
PipelineExclusionControls,
|
||||||
type EnhancementMode,
|
type EnhancementMode,
|
||||||
} from '../shared';
|
} from '../shared';
|
||||||
import type { WorkMode } from '../shared';
|
import type { WorkMode } from '../shared';
|
||||||
@@ -67,6 +68,7 @@ interface EditFeatureDialogProps {
|
|||||||
requirePlanApproval: boolean;
|
requirePlanApproval: boolean;
|
||||||
dependencies?: string[];
|
dependencies?: string[];
|
||||||
childDependencies?: string[]; // Feature IDs that should depend on this feature
|
childDependencies?: string[]; // Feature IDs that should depend on this feature
|
||||||
|
excludedPipelineSteps?: string[]; // Pipeline step IDs to skip for this feature
|
||||||
},
|
},
|
||||||
descriptionHistorySource?: 'enhance' | 'edit',
|
descriptionHistorySource?: 'enhance' | 'edit',
|
||||||
enhancementMode?: EnhancementMode,
|
enhancementMode?: EnhancementMode,
|
||||||
@@ -78,6 +80,7 @@ interface EditFeatureDialogProps {
|
|||||||
currentBranch?: string;
|
currentBranch?: string;
|
||||||
isMaximized: boolean;
|
isMaximized: boolean;
|
||||||
allFeatures: Feature[];
|
allFeatures: Feature[];
|
||||||
|
projectPath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EditFeatureDialog({
|
export function EditFeatureDialog({
|
||||||
@@ -90,6 +93,7 @@ export function EditFeatureDialog({
|
|||||||
currentBranch,
|
currentBranch,
|
||||||
isMaximized,
|
isMaximized,
|
||||||
allFeatures,
|
allFeatures,
|
||||||
|
projectPath,
|
||||||
}: EditFeatureDialogProps) {
|
}: EditFeatureDialogProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [editingFeature, setEditingFeature] = useState<Feature | null>(feature);
|
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);
|
return allFeatures.filter((f) => f.dependencies?.includes(feature.id)).map((f) => f.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Pipeline exclusion state
|
||||||
|
const [excludedPipelineSteps, setExcludedPipelineSteps] = useState<string[]>(
|
||||||
|
feature?.excludedPipelineSteps ?? []
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setEditingFeature(feature);
|
setEditingFeature(feature);
|
||||||
if (feature) {
|
if (feature) {
|
||||||
@@ -171,6 +180,8 @@ export function EditFeatureDialog({
|
|||||||
.map((f) => f.id);
|
.map((f) => f.id);
|
||||||
setChildDependencies(childDeps);
|
setChildDependencies(childDeps);
|
||||||
setOriginalChildDependencies(childDeps);
|
setOriginalChildDependencies(childDeps);
|
||||||
|
// Reset pipeline exclusion state
|
||||||
|
setExcludedPipelineSteps(feature.excludedPipelineSteps ?? []);
|
||||||
} else {
|
} else {
|
||||||
setEditFeaturePreviewMap(new Map());
|
setEditFeaturePreviewMap(new Map());
|
||||||
setDescriptionChangeSource(null);
|
setDescriptionChangeSource(null);
|
||||||
@@ -179,6 +190,7 @@ export function EditFeatureDialog({
|
|||||||
setParentDependencies([]);
|
setParentDependencies([]);
|
||||||
setChildDependencies([]);
|
setChildDependencies([]);
|
||||||
setOriginalChildDependencies([]);
|
setOriginalChildDependencies([]);
|
||||||
|
setExcludedPipelineSteps([]);
|
||||||
}
|
}
|
||||||
}, [feature, allFeatures]);
|
}, [feature, allFeatures]);
|
||||||
|
|
||||||
@@ -232,6 +244,7 @@ export function EditFeatureDialog({
|
|||||||
workMode,
|
workMode,
|
||||||
dependencies: parentDependencies,
|
dependencies: parentDependencies,
|
||||||
childDependencies: childDepsChanged ? childDependencies : undefined,
|
childDependencies: childDepsChanged ? childDependencies : undefined,
|
||||||
|
excludedPipelineSteps: excludedPipelineSteps.length > 0 ? excludedPipelineSteps : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Determine if description changed and what source to use
|
// Determine if description changed and what source to use
|
||||||
@@ -618,6 +631,16 @@ export function EditFeatureDialog({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Pipeline Exclusion Controls */}
|
||||||
|
<div className="pt-2">
|
||||||
|
<PipelineExclusionControls
|
||||||
|
projectPath={projectPath}
|
||||||
|
excludedPipelineSteps={excludedPipelineSteps}
|
||||||
|
onExcludedStepsChange={setExcludedPipelineSteps}
|
||||||
|
testIdPrefix="edit-feature-pipeline"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</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 { PullResolveConflictsDialog } from './pull-resolve-conflicts-dialog';
|
||||||
export { PushToRemoteDialog } from './push-to-remote-dialog';
|
export { PushToRemoteDialog } from './push-to-remote-dialog';
|
||||||
export { ViewWorktreeChangesDialog } from './view-worktree-changes-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 { AlertCircle } from 'lucide-react';
|
||||||
import { modelSupportsThinking } from '@/lib/utils';
|
import { modelSupportsThinking } from '@/lib/utils';
|
||||||
import { Feature, ModelAlias, ThinkingLevel, PlanningMode } from '@/store/app-store';
|
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 type { WorkMode } from '../shared';
|
||||||
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
|
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
|
||||||
import { isCursorModel, isClaudeModel, type PhaseModelEntry } from '@automaker/types';
|
import { isCursorModel, isClaudeModel, type PhaseModelEntry } from '@automaker/types';
|
||||||
@@ -28,6 +34,7 @@ interface MassEditDialogProps {
|
|||||||
branchSuggestions: string[];
|
branchSuggestions: string[];
|
||||||
branchCardCounts?: Record<string, number>;
|
branchCardCounts?: Record<string, number>;
|
||||||
currentBranch?: string;
|
currentBranch?: string;
|
||||||
|
projectPath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ApplyState {
|
interface ApplyState {
|
||||||
@@ -38,11 +45,13 @@ interface ApplyState {
|
|||||||
priority: boolean;
|
priority: boolean;
|
||||||
skipTests: boolean;
|
skipTests: boolean;
|
||||||
branchName: boolean;
|
branchName: boolean;
|
||||||
|
excludedPipelineSteps: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMixedValues(features: Feature[]): Record<string, boolean> {
|
function getMixedValues(features: Feature[]): Record<string, boolean> {
|
||||||
if (features.length === 0) return {};
|
if (features.length === 0) return {};
|
||||||
const first = features[0];
|
const first = features[0];
|
||||||
|
const firstExcludedSteps = JSON.stringify(first.excludedPipelineSteps || []);
|
||||||
return {
|
return {
|
||||||
model: !features.every((f) => f.model === first.model),
|
model: !features.every((f) => f.model === first.model),
|
||||||
thinkingLevel: !features.every((f) => f.thinkingLevel === first.thinkingLevel),
|
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),
|
priority: !features.every((f) => f.priority === first.priority),
|
||||||
skipTests: !features.every((f) => f.skipTests === first.skipTests),
|
skipTests: !features.every((f) => f.skipTests === first.skipTests),
|
||||||
branchName: !features.every((f) => f.branchName === first.branchName),
|
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,
|
branchSuggestions,
|
||||||
branchCardCounts,
|
branchCardCounts,
|
||||||
currentBranch,
|
currentBranch,
|
||||||
|
projectPath,
|
||||||
}: MassEditDialogProps) {
|
}: MassEditDialogProps) {
|
||||||
const [isApplying, setIsApplying] = useState(false);
|
const [isApplying, setIsApplying] = useState(false);
|
||||||
|
|
||||||
@@ -123,6 +136,7 @@ export function MassEditDialog({
|
|||||||
priority: false,
|
priority: false,
|
||||||
skipTests: false,
|
skipTests: false,
|
||||||
branchName: false,
|
branchName: false,
|
||||||
|
excludedPipelineSteps: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Field values
|
// Field values
|
||||||
@@ -146,6 +160,11 @@ export function MassEditDialog({
|
|||||||
return getInitialValue(selectedFeatures, 'branchName', '') as string;
|
return getInitialValue(selectedFeatures, 'branchName', '') as string;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Pipeline exclusion state
|
||||||
|
const [excludedPipelineSteps, setExcludedPipelineSteps] = useState<string[]>(() => {
|
||||||
|
return getInitialValue(selectedFeatures, 'excludedPipelineSteps', []) as string[];
|
||||||
|
});
|
||||||
|
|
||||||
// Calculate mixed values
|
// Calculate mixed values
|
||||||
const mixedValues = useMemo(() => getMixedValues(selectedFeatures), [selectedFeatures]);
|
const mixedValues = useMemo(() => getMixedValues(selectedFeatures), [selectedFeatures]);
|
||||||
|
|
||||||
@@ -160,6 +179,7 @@ export function MassEditDialog({
|
|||||||
priority: false,
|
priority: false,
|
||||||
skipTests: false,
|
skipTests: false,
|
||||||
branchName: false,
|
branchName: false,
|
||||||
|
excludedPipelineSteps: false,
|
||||||
});
|
});
|
||||||
setModel(getInitialValue(selectedFeatures, 'model', 'claude-sonnet') as ModelAlias);
|
setModel(getInitialValue(selectedFeatures, 'model', 'claude-sonnet') as ModelAlias);
|
||||||
setThinkingLevel(getInitialValue(selectedFeatures, 'thinkingLevel', 'none') as ThinkingLevel);
|
setThinkingLevel(getInitialValue(selectedFeatures, 'thinkingLevel', 'none') as ThinkingLevel);
|
||||||
@@ -172,6 +192,10 @@ export function MassEditDialog({
|
|||||||
const initialBranchName = getInitialValue(selectedFeatures, 'branchName', '') as string;
|
const initialBranchName = getInitialValue(selectedFeatures, 'branchName', '') as string;
|
||||||
setBranchName(initialBranchName);
|
setBranchName(initialBranchName);
|
||||||
setWorkMode(initialBranchName ? 'custom' : 'current');
|
setWorkMode(initialBranchName ? 'custom' : 'current');
|
||||||
|
// Reset pipeline exclusions
|
||||||
|
setExcludedPipelineSteps(
|
||||||
|
getInitialValue(selectedFeatures, 'excludedPipelineSteps', []) as string[]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, [open, selectedFeatures]);
|
}, [open, selectedFeatures]);
|
||||||
|
|
||||||
@@ -190,6 +214,10 @@ export function MassEditDialog({
|
|||||||
// For 'custom' mode, use the specified branch name
|
// For 'custom' mode, use the specified branch name
|
||||||
updates.branchName = workMode === 'custom' ? branchName : '';
|
updates.branchName = workMode === 'custom' ? branchName : '';
|
||||||
}
|
}
|
||||||
|
if (applyState.excludedPipelineSteps) {
|
||||||
|
updates.excludedPipelineSteps =
|
||||||
|
excludedPipelineSteps.length > 0 ? excludedPipelineSteps : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
if (Object.keys(updates).length === 0) {
|
if (Object.keys(updates).length === 0) {
|
||||||
onClose();
|
onClose();
|
||||||
@@ -353,6 +381,23 @@ export function MassEditDialog({
|
|||||||
testIdPrefix="mass-edit-work-mode"
|
testIdPrefix="mass-edit-work-mode"
|
||||||
/>
|
/>
|
||||||
</FieldWrapper>
|
</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>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
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 { Label } from '@/components/ui/label';
|
||||||
import { Feature } from '@/store/app-store';
|
import { Feature } from '@/store/app-store';
|
||||||
import { Check, RefreshCw, Edit2, Eye } from 'lucide-react';
|
import { Check, RefreshCw, Edit2, Eye } from 'lucide-react';
|
||||||
@@ -42,6 +42,10 @@ export function PlanApprovalDialog({
|
|||||||
const [editedPlan, setEditedPlan] = useState(planContent);
|
const [editedPlan, setEditedPlan] = useState(planContent);
|
||||||
const [showRejectFeedback, setShowRejectFeedback] = useState(false);
|
const [showRejectFeedback, setShowRejectFeedback] = useState(false);
|
||||||
const [rejectFeedback, setRejectFeedback] = useState('');
|
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
|
// Reset state when dialog opens or plan content changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -50,6 +54,7 @@ export function PlanApprovalDialog({
|
|||||||
setIsEditMode(false);
|
setIsEditMode(false);
|
||||||
setShowRejectFeedback(false);
|
setShowRejectFeedback(false);
|
||||||
setRejectFeedback('');
|
setRejectFeedback('');
|
||||||
|
setShowFullDescription(false);
|
||||||
}
|
}
|
||||||
}, [open, planContent]);
|
}, [open, planContent]);
|
||||||
|
|
||||||
@@ -82,15 +87,31 @@ export function PlanApprovalDialog({
|
|||||||
<Dialog open={open} onOpenChange={handleClose}>
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
<DialogContent className="max-w-4xl" data-testid="plan-approval-dialog">
|
<DialogContent className="max-w-4xl" data-testid="plan-approval-dialog">
|
||||||
<DialogHeader>
|
<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>
|
<DialogDescription>
|
||||||
{viewOnly
|
{viewOnly
|
||||||
? 'View the generated plan for this feature.'
|
? 'View the generated plan for this feature.'
|
||||||
: 'Review the generated plan before implementation begins.'}
|
: 'Review the generated plan before implementation begins.'}
|
||||||
{feature && (
|
{feature && (
|
||||||
<span className="block mt-2 text-primary">
|
<span className="block mt-2 text-primary">
|
||||||
Feature: {feature.description.slice(0, 150)}
|
Feature:{' '}
|
||||||
{feature.description.length > 150 ? '...' : ''}
|
{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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
@@ -135,9 +156,7 @@ export function PlanApprovalDialog({
|
|||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-4 overflow-auto">
|
<PlanContentViewer content={editedPlan || ''} className="p-4" />
|
||||||
<Markdown>{editedPlan || 'No plan content available.'}</Markdown>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 { createLogger } from '@automaker/utils/logger';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -18,8 +19,9 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||||
|
import { getErrorMessage } from '@/lib/utils';
|
||||||
import { toast } from 'sonner';
|
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 { Spinner } from '@/components/ui/spinner';
|
||||||
import type { WorktreeInfo } from '../worktree-panel/types';
|
import type { WorktreeInfo } from '../worktree-panel/types';
|
||||||
|
|
||||||
@@ -49,18 +51,76 @@ export function PushToRemoteDialog({
|
|||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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
|
// Fetch remotes when dialog opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && worktree) {
|
if (open && worktree) {
|
||||||
fetchRemotes();
|
fetchRemotes();
|
||||||
}
|
}
|
||||||
}, [open, worktree]);
|
}, [open, worktree, fetchRemotes]);
|
||||||
|
|
||||||
// Reset state when dialog closes
|
// Reset state when dialog closes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setSelectedRemote('');
|
setSelectedRemote('');
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setShowAddRemoteForm(false);
|
||||||
|
setNewRemoteName('origin');
|
||||||
|
setNewRemoteUrl('');
|
||||||
|
setAddRemoteError(null);
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
@@ -73,36 +133,12 @@ export function PushToRemoteDialog({
|
|||||||
}
|
}
|
||||||
}, [remotes, selectedRemote]);
|
}, [remotes, selectedRemote]);
|
||||||
|
|
||||||
const fetchRemotes = async () => {
|
// Show add remote form when no remotes (but not when there's an error)
|
||||||
if (!worktree) return;
|
useEffect(() => {
|
||||||
|
if (!isLoading && remotes.length === 0 && !error) {
|
||||||
setIsLoading(true);
|
setShowAddRemoteForm(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);
|
|
||||||
}
|
}
|
||||||
};
|
}, [isLoading, remotes.length, error]);
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
const handleRefresh = async () => {
|
||||||
if (!worktree) return;
|
if (!worktree) return;
|
||||||
@@ -115,47 +151,270 @@ export function PushToRemoteDialog({
|
|||||||
const result = await api.worktree.listRemotes(worktree.path);
|
const result = await api.worktree.listRemotes(worktree.path);
|
||||||
|
|
||||||
if (result.success && result.result) {
|
if (result.success && result.result) {
|
||||||
const remoteInfos: RemoteInfo[] = result.result.remotes.map((r) => ({
|
const remoteInfos = transformRemoteData(result.result.remotes);
|
||||||
name: r.name,
|
updateRemotesState(remoteInfos);
|
||||||
url: r.url,
|
|
||||||
}));
|
|
||||||
setRemotes(remoteInfos);
|
|
||||||
toast.success('Remotes refreshed');
|
toast.success('Remotes refreshed');
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.error || 'Failed to refresh remotes');
|
toast.error(result.error || 'Failed to refresh remotes');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Failed to refresh remotes:', err);
|
logger.error('Failed to refresh remotes:', err);
|
||||||
toast.error('Failed to refresh remotes');
|
toast.error(getErrorMessage(err));
|
||||||
} finally {
|
} finally {
|
||||||
setIsRefreshing(false);
|
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 = () => {
|
const handleConfirm = () => {
|
||||||
if (!worktree || !selectedRemote) return;
|
if (!worktree || !selectedRemote) return;
|
||||||
onConfirm(worktree, selectedRemote);
|
onConfirm(worktree, selectedRemote);
|
||||||
onOpenChange(false);
|
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 (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="sm:max-w-[450px]">
|
<DialogContent className="sm:max-w-[450px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<Upload className="w-5 h-5 text-primary" />
|
{showAddRemoteForm ? (
|
||||||
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">
|
<Plus className="w-5 h-5 text-primary" />
|
||||||
<Sparkles className="w-3 h-3" />
|
Add Remote
|
||||||
new
|
</>
|
||||||
</span>
|
) : (
|
||||||
|
<>
|
||||||
|
<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>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Push{' '}
|
{showAddRemoteForm ? (
|
||||||
<span className="font-mono text-foreground">
|
<>Add a remote repository to push your changes to.</>
|
||||||
{worktree?.branch || 'current branch'}
|
) : (
|
||||||
</span>{' '}
|
<>
|
||||||
to a remote repository for the first time.
|
Push{' '}
|
||||||
|
<span className="font-mono text-foreground">
|
||||||
|
{worktree?.branch || 'current branch'}
|
||||||
|
</span>{' '}
|
||||||
|
to a remote repository for the first time.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -163,7 +422,7 @@ export function PushToRemoteDialog({
|
|||||||
<div className="flex items-center justify-center py-8">
|
<div className="flex items-center justify-center py-8">
|
||||||
<Spinner size="lg" />
|
<Spinner size="lg" />
|
||||||
</div>
|
</div>
|
||||||
) : error ? (
|
) : error && !showAddRemoteForm ? (
|
||||||
<div className="flex flex-col items-center gap-4 py-6">
|
<div className="flex flex-col items-center gap-4 py-6">
|
||||||
<div className="flex items-center gap-2 text-destructive">
|
<div className="flex items-center gap-2 text-destructive">
|
||||||
<AlertTriangle className="w-5 h-5" />
|
<AlertTriangle className="w-5 h-5" />
|
||||||
@@ -174,68 +433,13 @@ export function PushToRemoteDialog({
|
|||||||
Retry
|
Retry
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
) : showAddRemoteForm ? (
|
||||||
|
renderAddRemoteForm()
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4 py-4">
|
renderRemoteSelector()
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DialogFooter>
|
{renderFooter()}
|
||||||
<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>
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -123,9 +123,34 @@ export function useBoardActions({
|
|||||||
}) => {
|
}) => {
|
||||||
const workMode = featureData.workMode || 'current';
|
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:
|
// Determine final branch name based on work mode:
|
||||||
// - 'current': Use current worktree's branch (or undefined if on main)
|
// - '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
|
// - 'custom': Use the provided branch name
|
||||||
let finalBranchName: string | undefined;
|
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
|
// This ensures features created on a non-main worktree are associated with that worktree
|
||||||
finalBranchName = currentWorktreeBranch || undefined;
|
finalBranchName = currentWorktreeBranch || undefined;
|
||||||
} else if (workMode === 'auto') {
|
} else if (workMode === 'auto') {
|
||||||
// Auto-generate a branch name based on primary branch (main/master) and timestamp
|
// Auto-generate a branch name based on feature title and timestamp
|
||||||
// Always use primary branch to avoid nested feature/feature/... paths
|
// Create a slug from the title: lowercase, replace non-alphanumeric with hyphens
|
||||||
const baseBranch =
|
const titleSlug =
|
||||||
(currentProject?.path ? getPrimaryWorktreeBranch(currentProject.path) : null) || 'main';
|
titleForBranch
|
||||||
const timestamp = Date.now();
|
.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);
|
const randomSuffix = Math.random().toString(36).substring(2, 6);
|
||||||
finalBranchName = `feature/${baseBranch}-${timestamp}-${randomSuffix}`;
|
finalBranchName = `feature/${titleSlug}-${randomSuffix}`;
|
||||||
} else {
|
} else {
|
||||||
// Custom mode - use provided branch name
|
// Custom mode - use provided branch name
|
||||||
finalBranchName = featureData.branchName || undefined;
|
finalBranchName = featureData.branchName || undefined;
|
||||||
@@ -183,12 +211,13 @@ export function useBoardActions({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we need to generate a title
|
// Check if we need to generate a title (only if we didn't already generate it for the branch name)
|
||||||
const needsTitleGeneration = !featureData.title.trim() && featureData.description.trim();
|
const needsTitleGeneration =
|
||||||
|
!titleWasGenerated && !featureData.title.trim() && featureData.description.trim();
|
||||||
|
|
||||||
const newFeatureData = {
|
const newFeatureData = {
|
||||||
...featureData,
|
...featureData,
|
||||||
title: featureData.title,
|
title: titleWasGenerated ? titleForBranch : featureData.title,
|
||||||
titleGenerating: needsTitleGeneration,
|
titleGenerating: needsTitleGeneration,
|
||||||
status: 'backlog' as const,
|
status: 'backlog' as const,
|
||||||
branchName: finalBranchName,
|
branchName: finalBranchName,
|
||||||
@@ -255,7 +284,6 @@ export function useBoardActions({
|
|||||||
projectPath,
|
projectPath,
|
||||||
onWorktreeCreated,
|
onWorktreeCreated,
|
||||||
onWorktreeAutoSelect,
|
onWorktreeAutoSelect,
|
||||||
getPrimaryWorktreeBranch,
|
|
||||||
features,
|
features,
|
||||||
currentWorktreeBranch,
|
currentWorktreeBranch,
|
||||||
]
|
]
|
||||||
@@ -287,6 +315,31 @@ export function useBoardActions({
|
|||||||
) => {
|
) => {
|
||||||
const workMode = updates.workMode || 'current';
|
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
|
// Determine final branch name based on work mode
|
||||||
let finalBranchName: string | undefined;
|
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
|
// This ensures features updated on a non-main worktree are associated with that worktree
|
||||||
finalBranchName = currentWorktreeBranch || undefined;
|
finalBranchName = currentWorktreeBranch || undefined;
|
||||||
} else if (workMode === 'auto') {
|
} else if (workMode === 'auto') {
|
||||||
// Auto-generate a branch name based on primary branch (main/master) and timestamp
|
// Preserve existing branch name if one exists (avoid orphaning worktrees on edit)
|
||||||
// Always use primary branch to avoid nested feature/feature/... paths
|
if (updates.branchName?.trim()) {
|
||||||
const baseBranch =
|
finalBranchName = updates.branchName;
|
||||||
(currentProject?.path ? getPrimaryWorktreeBranch(currentProject.path) : null) || 'main';
|
} else {
|
||||||
const timestamp = Date.now();
|
// Auto-generate a branch name based on feature title
|
||||||
const randomSuffix = Math.random().toString(36).substring(2, 6);
|
// Create a slug from the title: lowercase, replace non-alphanumeric with hyphens
|
||||||
finalBranchName = `feature/${baseBranch}-${timestamp}-${randomSuffix}`;
|
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 {
|
} else {
|
||||||
finalBranchName = updates.branchName || undefined;
|
finalBranchName = updates.branchName || undefined;
|
||||||
}
|
}
|
||||||
@@ -343,7 +404,7 @@ export function useBoardActions({
|
|||||||
|
|
||||||
const finalUpdates = {
|
const finalUpdates = {
|
||||||
...restUpdates,
|
...restUpdates,
|
||||||
title: updates.title,
|
title: titleWasGenerated ? titleForBranch : updates.title,
|
||||||
branchName: finalBranchName,
|
branchName: finalBranchName,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -406,7 +467,6 @@ export function useBoardActions({
|
|||||||
setEditingFeature,
|
setEditingFeature,
|
||||||
currentProject,
|
currentProject,
|
||||||
onWorktreeCreated,
|
onWorktreeCreated,
|
||||||
getPrimaryWorktreeBranch,
|
|
||||||
features,
|
features,
|
||||||
currentWorktreeBranch,
|
currentWorktreeBranch,
|
||||||
]
|
]
|
||||||
@@ -553,6 +613,11 @@ export function useBoardActions({
|
|||||||
};
|
};
|
||||||
updateFeature(feature.id, rollbackUpdates);
|
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 server is offline (connection refused), redirect to login page
|
||||||
if (isConnectionError(error)) {
|
if (isConnectionError(error)) {
|
||||||
handleServerOffline();
|
handleServerOffline();
|
||||||
|
|||||||
@@ -88,10 +88,10 @@ export function useBoardDragDrop({
|
|||||||
const targetFeature = features.find((f) => f.id === targetFeatureId);
|
const targetFeature = features.find((f) => f.id === targetFeatureId);
|
||||||
if (!targetFeature) return;
|
if (!targetFeature) return;
|
||||||
|
|
||||||
// Only allow linking backlog features (both must be in backlog)
|
// Don't allow linking completed features (they're already done)
|
||||||
if (draggedFeature.status !== 'backlog' || targetFeature.status !== 'backlog') {
|
if (draggedFeature.status === 'completed' || targetFeature.status === 'completed') {
|
||||||
toast.error('Cannot link features', {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ export * from './planning-mode-select';
|
|||||||
export * from './ancestor-context-section';
|
export * from './ancestor-context-section';
|
||||||
export * from './work-mode-selector';
|
export * from './work-mode-selector';
|
||||||
export * from './enhancement';
|
export * from './enhancement';
|
||||||
|
export * from './pipeline-exclusion-controls';
|
||||||
|
|||||||
@@ -4,9 +4,16 @@ import {
|
|||||||
CURSOR_MODEL_MAP,
|
CURSOR_MODEL_MAP,
|
||||||
CODEX_MODEL_MAP,
|
CODEX_MODEL_MAP,
|
||||||
OPENCODE_MODELS as OPENCODE_MODEL_CONFIGS,
|
OPENCODE_MODELS as OPENCODE_MODEL_CONFIGS,
|
||||||
|
GEMINI_MODEL_MAP,
|
||||||
} from '@automaker/types';
|
} from '@automaker/types';
|
||||||
import { Brain, Zap, Scale, Cpu, Rocket, Sparkles } from 'lucide-react';
|
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 = {
|
export type ModelOption = {
|
||||||
id: string; // All model IDs use canonical prefixed format (e.g., "claude-sonnet", "cursor-auto")
|
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[] = [
|
export const ALL_MODELS: ModelOption[] = [
|
||||||
...CLAUDE_MODELS,
|
...CLAUDE_MODELS,
|
||||||
...CURSOR_MODELS,
|
...CURSOR_MODELS,
|
||||||
...CODEX_MODELS,
|
...CODEX_MODELS,
|
||||||
...OPENCODE_MODELS,
|
...OPENCODE_MODELS,
|
||||||
|
...GEMINI_MODELS,
|
||||||
];
|
];
|
||||||
|
|
||||||
export const THINKING_LEVELS: ThinkingLevel[] = ['none', 'low', 'medium', 'high', 'ultrathink'];
|
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,
|
Cursor: CursorIcon,
|
||||||
Codex: OpenAIIcon,
|
Codex: OpenAIIcon,
|
||||||
OpenCode: OpenCodeIcon,
|
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,
|
Copy,
|
||||||
Eye,
|
Eye,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
Sparkles,
|
CloudOff,
|
||||||
Terminal,
|
Terminal,
|
||||||
SquarePlus,
|
SquarePlus,
|
||||||
SplitSquareHorizontal,
|
SplitSquareHorizontal,
|
||||||
Undo2,
|
Undo2,
|
||||||
Zap,
|
Zap,
|
||||||
|
FlaskConical,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { WorktreeInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types';
|
import type { WorktreeInfo, DevServerInfo, PRInfo, GitRepoStatus, TestSessionInfo } from '../types';
|
||||||
import { TooltipWrapper } from './tooltip-wrapper';
|
import { TooltipWrapper } from './tooltip-wrapper';
|
||||||
import { useAvailableEditors, useEffectiveDefaultEditor } from '../hooks/use-available-editors';
|
import { useAvailableEditors, useEffectiveDefaultEditor } from '../hooks/use-available-editors';
|
||||||
import {
|
import {
|
||||||
@@ -63,6 +64,14 @@ interface WorktreeActionsDropdownProps {
|
|||||||
standalone?: boolean;
|
standalone?: boolean;
|
||||||
/** Whether auto mode is running for this worktree */
|
/** Whether auto mode is running for this worktree */
|
||||||
isAutoModeRunning?: boolean;
|
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;
|
onOpenChange: (open: boolean) => void;
|
||||||
onPull: (worktree: WorktreeInfo) => void;
|
onPull: (worktree: WorktreeInfo) => void;
|
||||||
onPush: (worktree: WorktreeInfo) => void;
|
onPush: (worktree: WorktreeInfo) => void;
|
||||||
@@ -84,6 +93,12 @@ interface WorktreeActionsDropdownProps {
|
|||||||
onRunInitScript: (worktree: WorktreeInfo) => void;
|
onRunInitScript: (worktree: WorktreeInfo) => void;
|
||||||
onToggleAutoMode?: (worktree: WorktreeInfo) => void;
|
onToggleAutoMode?: (worktree: WorktreeInfo) => void;
|
||||||
onMerge: (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;
|
hasInitScript: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,6 +116,10 @@ export function WorktreeActionsDropdown({
|
|||||||
gitRepoStatus,
|
gitRepoStatus,
|
||||||
standalone = false,
|
standalone = false,
|
||||||
isAutoModeRunning = false,
|
isAutoModeRunning = false,
|
||||||
|
hasTestCommand = false,
|
||||||
|
isStartingTests = false,
|
||||||
|
isTestRunning = false,
|
||||||
|
testSessionInfo,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onPull,
|
onPull,
|
||||||
onPush,
|
onPush,
|
||||||
@@ -122,6 +141,9 @@ export function WorktreeActionsDropdown({
|
|||||||
onRunInitScript,
|
onRunInitScript,
|
||||||
onToggleAutoMode,
|
onToggleAutoMode,
|
||||||
onMerge,
|
onMerge,
|
||||||
|
onStartTests,
|
||||||
|
onStopTests,
|
||||||
|
onViewTestLogs,
|
||||||
hasInitScript,
|
hasInitScript,
|
||||||
}: WorktreeActionsDropdownProps) {
|
}: WorktreeActionsDropdownProps) {
|
||||||
// Get available editors for the "Open In" submenu
|
// Get available editors for the "Open In" submenu
|
||||||
@@ -231,6 +253,65 @@ export function WorktreeActionsDropdown({
|
|||||||
<DropdownMenuSeparator />
|
<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 */}
|
{/* Auto Mode toggle */}
|
||||||
{onToggleAutoMode && (
|
{onToggleAutoMode && (
|
||||||
<>
|
<>
|
||||||
@@ -284,9 +365,9 @@ export function WorktreeActionsDropdown({
|
|||||||
{isPushing ? 'Pushing...' : 'Push'}
|
{isPushing ? 'Pushing...' : 'Push'}
|
||||||
{!canPerformGitOps && <AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />}
|
{!canPerformGitOps && <AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />}
|
||||||
{canPerformGitOps && !hasRemoteBranch && (
|
{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">
|
<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">
|
||||||
<Sparkles className="w-2.5 h-2.5" />
|
<CloudOff className="w-2.5 h-2.5" />
|
||||||
new
|
local only
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{canPerformGitOps && hasRemoteBranch && aheadCount > 0 && (
|
{canPerformGitOps && hasRemoteBranch && aheadCount > 0 && (
|
||||||
|
|||||||
@@ -5,7 +5,14 @@ import { Spinner } from '@/components/ui/spinner';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { useDroppable } from '@dnd-kit/core';
|
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 { BranchSwitchDropdown } from './branch-switch-dropdown';
|
||||||
import { WorktreeActionsDropdown } from './worktree-actions-dropdown';
|
import { WorktreeActionsDropdown } from './worktree-actions-dropdown';
|
||||||
|
|
||||||
@@ -33,6 +40,12 @@ interface WorktreeTabProps {
|
|||||||
gitRepoStatus: GitRepoStatus;
|
gitRepoStatus: GitRepoStatus;
|
||||||
/** Whether auto mode is running for this worktree */
|
/** Whether auto mode is running for this worktree */
|
||||||
isAutoModeRunning?: boolean;
|
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;
|
onSelectWorktree: (worktree: WorktreeInfo) => void;
|
||||||
onBranchDropdownOpenChange: (open: boolean) => void;
|
onBranchDropdownOpenChange: (open: boolean) => void;
|
||||||
onActionsDropdownOpenChange: (open: boolean) => void;
|
onActionsDropdownOpenChange: (open: boolean) => void;
|
||||||
@@ -59,7 +72,15 @@ interface WorktreeTabProps {
|
|||||||
onViewDevServerLogs: (worktree: WorktreeInfo) => void;
|
onViewDevServerLogs: (worktree: WorktreeInfo) => void;
|
||||||
onRunInitScript: (worktree: WorktreeInfo) => void;
|
onRunInitScript: (worktree: WorktreeInfo) => void;
|
||||||
onToggleAutoMode?: (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;
|
hasInitScript: boolean;
|
||||||
|
/** Whether a test command is configured in project settings */
|
||||||
|
hasTestCommand?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WorktreeTab({
|
export function WorktreeTab({
|
||||||
@@ -85,6 +106,9 @@ export function WorktreeTab({
|
|||||||
hasRemoteBranch,
|
hasRemoteBranch,
|
||||||
gitRepoStatus,
|
gitRepoStatus,
|
||||||
isAutoModeRunning = false,
|
isAutoModeRunning = false,
|
||||||
|
isStartingTests = false,
|
||||||
|
isTestRunning = false,
|
||||||
|
testSessionInfo,
|
||||||
onSelectWorktree,
|
onSelectWorktree,
|
||||||
onBranchDropdownOpenChange,
|
onBranchDropdownOpenChange,
|
||||||
onActionsDropdownOpenChange,
|
onActionsDropdownOpenChange,
|
||||||
@@ -111,7 +135,11 @@ export function WorktreeTab({
|
|||||||
onViewDevServerLogs,
|
onViewDevServerLogs,
|
||||||
onRunInitScript,
|
onRunInitScript,
|
||||||
onToggleAutoMode,
|
onToggleAutoMode,
|
||||||
|
onStartTests,
|
||||||
|
onStopTests,
|
||||||
|
onViewTestLogs,
|
||||||
hasInitScript,
|
hasInitScript,
|
||||||
|
hasTestCommand = false,
|
||||||
}: WorktreeTabProps) {
|
}: WorktreeTabProps) {
|
||||||
// Make the worktree tab a drop target for feature cards
|
// Make the worktree tab a drop target for feature cards
|
||||||
const { setNodeRef, isOver } = useDroppable({
|
const { setNodeRef, isOver } = useDroppable({
|
||||||
@@ -395,6 +423,10 @@ export function WorktreeTab({
|
|||||||
devServerInfo={devServerInfo}
|
devServerInfo={devServerInfo}
|
||||||
gitRepoStatus={gitRepoStatus}
|
gitRepoStatus={gitRepoStatus}
|
||||||
isAutoModeRunning={isAutoModeRunning}
|
isAutoModeRunning={isAutoModeRunning}
|
||||||
|
hasTestCommand={hasTestCommand}
|
||||||
|
isStartingTests={isStartingTests}
|
||||||
|
isTestRunning={isTestRunning}
|
||||||
|
testSessionInfo={testSessionInfo}
|
||||||
onOpenChange={onActionsDropdownOpenChange}
|
onOpenChange={onActionsDropdownOpenChange}
|
||||||
onPull={onPull}
|
onPull={onPull}
|
||||||
onPush={onPush}
|
onPush={onPush}
|
||||||
@@ -416,6 +448,9 @@ export function WorktreeTab({
|
|||||||
onViewDevServerLogs={onViewDevServerLogs}
|
onViewDevServerLogs={onViewDevServerLogs}
|
||||||
onRunInitScript={onRunInitScript}
|
onRunInitScript={onRunInitScript}
|
||||||
onToggleAutoMode={onToggleAutoMode}
|
onToggleAutoMode={onToggleAutoMode}
|
||||||
|
onStartTests={onStartTests}
|
||||||
|
onStopTests={onStopTests}
|
||||||
|
onViewTestLogs={onViewTestLogs}
|
||||||
hasInitScript={hasInitScript}
|
hasInitScript={hasInitScript}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,6 +30,19 @@ export interface DevServerInfo {
|
|||||||
url: string;
|
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 {
|
export interface FeatureInfo {
|
||||||
id: string;
|
id: string;
|
||||||
branchName?: string;
|
branchName?: string;
|
||||||
|
|||||||
@@ -6,8 +6,15 @@ import { pathsEqual } from '@/lib/utils';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||||
import { useIsMobile } from '@/hooks/use-media-query';
|
import { useIsMobile } from '@/hooks/use-media-query';
|
||||||
import { useWorktreeInitScript } from '@/hooks/queries';
|
import { useWorktreeInitScript, useProjectSettings } from '@/hooks/queries';
|
||||||
import type { WorktreePanelProps, WorktreeInfo } from './types';
|
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 {
|
import {
|
||||||
useWorktrees,
|
useWorktrees,
|
||||||
useDevServers,
|
useDevServers,
|
||||||
@@ -25,6 +32,7 @@ import {
|
|||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { ViewWorktreeChangesDialog, PushToRemoteDialog, MergeWorktreeDialog } from '../dialogs';
|
import { ViewWorktreeChangesDialog, PushToRemoteDialog, MergeWorktreeDialog } from '../dialogs';
|
||||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||||
|
import { TestLogsPanel } from '@/components/ui/test-logs-panel';
|
||||||
import { Undo2 } from 'lucide-react';
|
import { Undo2 } from 'lucide-react';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
|
|
||||||
@@ -161,6 +169,194 @@ export function WorktreePanel({
|
|||||||
const { data: initScriptData } = useWorktreeInitScript(projectPath);
|
const { data: initScriptData } = useWorktreeInitScript(projectPath);
|
||||||
const hasInitScript = initScriptData?.exists ?? false;
|
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
|
// View changes dialog state
|
||||||
const [viewChangesDialogOpen, setViewChangesDialogOpen] = useState(false);
|
const [viewChangesDialogOpen, setViewChangesDialogOpen] = useState(false);
|
||||||
const [viewChangesWorktree, setViewChangesWorktree] = useState<WorktreeInfo | null>(null);
|
const [viewChangesWorktree, setViewChangesWorktree] = useState<WorktreeInfo | null>(null);
|
||||||
@@ -392,6 +588,10 @@ export function WorktreePanel({
|
|||||||
devServerInfo={getDevServerInfo(selectedWorktree)}
|
devServerInfo={getDevServerInfo(selectedWorktree)}
|
||||||
gitRepoStatus={gitRepoStatus}
|
gitRepoStatus={gitRepoStatus}
|
||||||
isAutoModeRunning={isAutoModeRunningForWorktree(selectedWorktree)}
|
isAutoModeRunning={isAutoModeRunningForWorktree(selectedWorktree)}
|
||||||
|
hasTestCommand={hasTestCommand}
|
||||||
|
isStartingTests={isStartingTests}
|
||||||
|
isTestRunning={isTestRunningForWorktree(selectedWorktree)}
|
||||||
|
testSessionInfo={getTestSessionInfo(selectedWorktree)}
|
||||||
onOpenChange={handleActionsDropdownOpenChange(selectedWorktree)}
|
onOpenChange={handleActionsDropdownOpenChange(selectedWorktree)}
|
||||||
onPull={handlePull}
|
onPull={handlePull}
|
||||||
onPush={handlePush}
|
onPush={handlePush}
|
||||||
@@ -413,6 +613,9 @@ export function WorktreePanel({
|
|||||||
onViewDevServerLogs={handleViewDevServerLogs}
|
onViewDevServerLogs={handleViewDevServerLogs}
|
||||||
onRunInitScript={handleRunInitScript}
|
onRunInitScript={handleRunInitScript}
|
||||||
onToggleAutoMode={handleToggleAutoMode}
|
onToggleAutoMode={handleToggleAutoMode}
|
||||||
|
onStartTests={handleStartTests}
|
||||||
|
onStopTests={handleStopTests}
|
||||||
|
onViewTestLogs={handleViewTestLogs}
|
||||||
hasInitScript={hasInitScript}
|
hasInitScript={hasInitScript}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -494,6 +697,17 @@ export function WorktreePanel({
|
|||||||
onMerged={handleMerged}
|
onMerged={handleMerged}
|
||||||
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
|
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Test Logs Panel */}
|
||||||
|
<TestLogsPanel
|
||||||
|
open={testLogsPanelOpen}
|
||||||
|
onClose={handleCloseTestLogsPanel}
|
||||||
|
worktreePath={testLogsPanelWorktree?.path ?? null}
|
||||||
|
branch={testLogsPanelWorktree?.branch}
|
||||||
|
onStopTests={
|
||||||
|
testLogsPanelWorktree ? () => handleStopTests(testLogsPanelWorktree) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -530,6 +744,9 @@ export function WorktreePanel({
|
|||||||
hasRemoteBranch={hasRemoteBranch}
|
hasRemoteBranch={hasRemoteBranch}
|
||||||
gitRepoStatus={gitRepoStatus}
|
gitRepoStatus={gitRepoStatus}
|
||||||
isAutoModeRunning={isAutoModeRunningForWorktree(mainWorktree)}
|
isAutoModeRunning={isAutoModeRunningForWorktree(mainWorktree)}
|
||||||
|
isStartingTests={isStartingTests}
|
||||||
|
isTestRunning={isTestRunningForWorktree(mainWorktree)}
|
||||||
|
testSessionInfo={getTestSessionInfo(mainWorktree)}
|
||||||
onSelectWorktree={handleSelectWorktree}
|
onSelectWorktree={handleSelectWorktree}
|
||||||
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(mainWorktree)}
|
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(mainWorktree)}
|
||||||
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(mainWorktree)}
|
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(mainWorktree)}
|
||||||
@@ -556,7 +773,11 @@ export function WorktreePanel({
|
|||||||
onViewDevServerLogs={handleViewDevServerLogs}
|
onViewDevServerLogs={handleViewDevServerLogs}
|
||||||
onRunInitScript={handleRunInitScript}
|
onRunInitScript={handleRunInitScript}
|
||||||
onToggleAutoMode={handleToggleAutoMode}
|
onToggleAutoMode={handleToggleAutoMode}
|
||||||
|
onStartTests={handleStartTests}
|
||||||
|
onStopTests={handleStopTests}
|
||||||
|
onViewTestLogs={handleViewTestLogs}
|
||||||
hasInitScript={hasInitScript}
|
hasInitScript={hasInitScript}
|
||||||
|
hasTestCommand={hasTestCommand}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -596,6 +817,9 @@ export function WorktreePanel({
|
|||||||
hasRemoteBranch={hasRemoteBranch}
|
hasRemoteBranch={hasRemoteBranch}
|
||||||
gitRepoStatus={gitRepoStatus}
|
gitRepoStatus={gitRepoStatus}
|
||||||
isAutoModeRunning={isAutoModeRunningForWorktree(worktree)}
|
isAutoModeRunning={isAutoModeRunningForWorktree(worktree)}
|
||||||
|
isStartingTests={isStartingTests}
|
||||||
|
isTestRunning={isTestRunningForWorktree(worktree)}
|
||||||
|
testSessionInfo={getTestSessionInfo(worktree)}
|
||||||
onSelectWorktree={handleSelectWorktree}
|
onSelectWorktree={handleSelectWorktree}
|
||||||
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)}
|
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)}
|
||||||
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)}
|
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)}
|
||||||
@@ -622,7 +846,11 @@ export function WorktreePanel({
|
|||||||
onViewDevServerLogs={handleViewDevServerLogs}
|
onViewDevServerLogs={handleViewDevServerLogs}
|
||||||
onRunInitScript={handleRunInitScript}
|
onRunInitScript={handleRunInitScript}
|
||||||
onToggleAutoMode={handleToggleAutoMode}
|
onToggleAutoMode={handleToggleAutoMode}
|
||||||
|
onStartTests={handleStartTests}
|
||||||
|
onStopTests={handleStopTests}
|
||||||
|
onViewTestLogs={handleViewTestLogs}
|
||||||
hasInitScript={hasInitScript}
|
hasInitScript={hasInitScript}
|
||||||
|
hasTestCommand={hasTestCommand}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -703,6 +931,17 @@ export function WorktreePanel({
|
|||||||
onMerged={handleMerged}
|
onMerged={handleMerged}
|
||||||
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
|
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Test Logs Panel */}
|
||||||
|
<TestLogsPanel
|
||||||
|
open={testLogsPanelOpen}
|
||||||
|
onClose={handleCloseTestLogsPanel}
|
||||||
|
worktreePath={testLogsPanelWorktree?.path ?? null}
|
||||||
|
branch={testLogsPanelWorktree?.branch}
|
||||||
|
onStopTests={
|
||||||
|
testLogsPanelWorktree ? () => handleStopTests(testLogsPanelWorktree) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -392,6 +392,7 @@ export function GraphViewPage() {
|
|||||||
currentBranch={currentWorktreeBranch || undefined}
|
currentBranch={currentWorktreeBranch || undefined}
|
||||||
isMaximized={false}
|
isMaximized={false}
|
||||||
allFeatures={hookFeatures}
|
allFeatures={hookFeatures}
|
||||||
|
projectPath={currentProject?.path}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Add Feature Dialog (for spawning) */}
|
{/* Add Feature Dialog (for spawning) */}
|
||||||
@@ -414,6 +415,7 @@ export function GraphViewPage() {
|
|||||||
isMaximized={false}
|
isMaximized={false}
|
||||||
parentFeature={spawnParentFeature}
|
parentFeature={spawnParentFeature}
|
||||||
allFeatures={hookFeatures}
|
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
|
// When setting is enabled and a non-main worktree is selected, pass its branch to default to 'custom' work mode
|
||||||
selectedNonMainWorktreeBranch={
|
selectedNonMainWorktreeBranch={
|
||||||
addFeatureUseSelectedWorktreeBranch && currentWorktreePath !== null
|
addFeatureUseSelectedWorktreeBranch && currentWorktreePath !== null
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { useIdeationStore } from '@/store/ideation-store';
|
|||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { useGenerateIdeationSuggestions } from '@/hooks/mutations';
|
import { useGenerateIdeationSuggestions } from '@/hooks/mutations';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useNavigate } from '@tanstack/react-router';
|
|
||||||
import type { IdeaCategory, IdeationPrompt } from '@automaker/types';
|
import type { IdeaCategory, IdeationPrompt } from '@automaker/types';
|
||||||
|
|
||||||
interface PromptListProps {
|
interface PromptListProps {
|
||||||
@@ -24,10 +23,8 @@ export function PromptList({ category, onBack }: PromptListProps) {
|
|||||||
const generationJobs = useIdeationStore((s) => s.generationJobs);
|
const generationJobs = useIdeationStore((s) => s.generationJobs);
|
||||||
const setMode = useIdeationStore((s) => s.setMode);
|
const setMode = useIdeationStore((s) => s.setMode);
|
||||||
const addGenerationJob = useIdeationStore((s) => s.addGenerationJob);
|
const addGenerationJob = useIdeationStore((s) => s.addGenerationJob);
|
||||||
const updateJobStatus = useIdeationStore((s) => s.updateJobStatus);
|
|
||||||
const [loadingPromptId, setLoadingPromptId] = useState<string | null>(null);
|
const [loadingPromptId, setLoadingPromptId] = useState<string | null>(null);
|
||||||
const [startedPrompts, setStartedPrompts] = useState<Set<string>>(new Set());
|
const [startedPrompts, setStartedPrompts] = useState<Set<string>>(new Set());
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
// React Query mutation
|
// React Query mutation
|
||||||
const generateMutation = useGenerateIdeationSuggestions(currentProject?.path ?? '');
|
const generateMutation = useGenerateIdeationSuggestions(currentProject?.path ?? '');
|
||||||
@@ -72,27 +69,13 @@ export function PromptList({ category, onBack }: PromptListProps) {
|
|||||||
toast.info(`Generating ideas for "${prompt.title}"...`);
|
toast.info(`Generating ideas for "${prompt.title}"...`);
|
||||||
setMode('dashboard');
|
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(
|
generateMutation.mutate(
|
||||||
{ promptId: prompt.id, category },
|
{ promptId: prompt.id, category, jobId, promptTitle: prompt.title },
|
||||||
{
|
{
|
||||||
onSuccess: (data) => {
|
// Optional: reset local loading state if component is still mounted
|
||||||
updateJobStatus(jobId, 'ready', data.suggestions);
|
onSettled: () => {
|
||||||
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);
|
|
||||||
setLoadingPromptId(null);
|
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 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';
|
import type { ProjectSettingsViewId } from '../hooks/use-project-settings-view';
|
||||||
|
|
||||||
export interface ProjectNavigationItem {
|
export interface ProjectNavigationItem {
|
||||||
@@ -11,7 +19,9 @@ export interface ProjectNavigationItem {
|
|||||||
export const PROJECT_SETTINGS_NAV_ITEMS: ProjectNavigationItem[] = [
|
export const PROJECT_SETTINGS_NAV_ITEMS: ProjectNavigationItem[] = [
|
||||||
{ id: 'identity', label: 'Identity', icon: User },
|
{ id: 'identity', label: 'Identity', icon: User },
|
||||||
{ id: 'worktrees', label: 'Worktrees', icon: GitBranch },
|
{ id: 'worktrees', label: 'Worktrees', icon: GitBranch },
|
||||||
|
{ id: 'commands', label: 'Commands', icon: Terminal },
|
||||||
{ id: 'theme', label: 'Theme', icon: Palette },
|
{ id: 'theme', label: 'Theme', icon: Palette },
|
||||||
{ id: 'claude', label: 'Models', icon: Workflow },
|
{ id: 'claude', label: 'Models', icon: Workflow },
|
||||||
|
{ id: 'data', label: 'Data', icon: Database },
|
||||||
{ id: 'danger', label: 'Danger Zone', icon: AlertTriangle },
|
{ 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';
|
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 {
|
interface UseProjectSettingsViewOptions {
|
||||||
initialView?: ProjectSettingsViewId;
|
initialView?: ProjectSettingsViewId;
|
||||||
|
|||||||
@@ -2,5 +2,6 @@ export { ProjectSettingsView } from './project-settings-view';
|
|||||||
export { ProjectIdentitySection } from './project-identity-section';
|
export { ProjectIdentitySection } from './project-identity-section';
|
||||||
export { ProjectThemeSection } from './project-theme-section';
|
export { ProjectThemeSection } from './project-theme-section';
|
||||||
export { WorktreePreferencesSection } from './worktree-preferences-section';
|
export { WorktreePreferencesSection } from './worktree-preferences-section';
|
||||||
|
export { CommandsSection } from './commands-section';
|
||||||
export { useProjectSettingsView, type ProjectSettingsViewId } from './hooks';
|
export { useProjectSettingsView, type ProjectSettingsViewId } from './hooks';
|
||||||
export { ProjectSettingsNavigation } from './components/project-settings-navigation';
|
export { ProjectSettingsNavigation } from './components/project-settings-navigation';
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import type {
|
|||||||
ClaudeCompatibleProvider,
|
ClaudeCompatibleProvider,
|
||||||
ClaudeModelAlias,
|
ClaudeModelAlias,
|
||||||
} from '@automaker/types';
|
} from '@automaker/types';
|
||||||
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
|
import { DEFAULT_PHASE_MODELS, DEFAULT_GLOBAL_SETTINGS } from '@automaker/types';
|
||||||
|
|
||||||
interface ProjectBulkReplaceDialogProps {
|
interface ProjectBulkReplaceDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -44,12 +44,16 @@ const PHASE_LABELS: Record<PhaseModelKey, string> = {
|
|||||||
featureGenerationModel: 'Feature Generation',
|
featureGenerationModel: 'Feature Generation',
|
||||||
backlogPlanningModel: 'Backlog Planning',
|
backlogPlanningModel: 'Backlog Planning',
|
||||||
projectAnalysisModel: 'Project Analysis',
|
projectAnalysisModel: 'Project Analysis',
|
||||||
suggestionsModel: 'AI Suggestions',
|
ideationModel: 'Ideation',
|
||||||
memoryExtractionModel: 'Memory Extraction',
|
memoryExtractionModel: 'Memory Extraction',
|
||||||
};
|
};
|
||||||
|
|
||||||
const ALL_PHASES = Object.keys(PHASE_LABELS) as PhaseModelKey[];
|
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
|
// Claude model display names
|
||||||
const CLAUDE_MODEL_DISPLAY: Record<ClaudeModelAlias, string> = {
|
const CLAUDE_MODEL_DISPLAY: Record<ClaudeModelAlias, string> = {
|
||||||
haiku: 'Claude Haiku',
|
haiku: 'Claude Haiku',
|
||||||
@@ -62,11 +66,18 @@ export function ProjectBulkReplaceDialog({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
project,
|
project,
|
||||||
}: ProjectBulkReplaceDialogProps) {
|
}: ProjectBulkReplaceDialogProps) {
|
||||||
const { phaseModels, setProjectPhaseModelOverride, claudeCompatibleProviders } = useAppStore();
|
const {
|
||||||
|
phaseModels,
|
||||||
|
setProjectPhaseModelOverride,
|
||||||
|
claudeCompatibleProviders,
|
||||||
|
defaultFeatureModel,
|
||||||
|
setProjectDefaultFeatureModel,
|
||||||
|
} = useAppStore();
|
||||||
const [selectedProvider, setSelectedProvider] = useState<string>('anthropic');
|
const [selectedProvider, setSelectedProvider] = useState<string>('anthropic');
|
||||||
|
|
||||||
// Get project-level overrides
|
// Get project-level overrides
|
||||||
const projectOverrides = project.phaseModelOverrides || {};
|
const projectOverrides = project.phaseModelOverrides || {};
|
||||||
|
const projectDefaultFeatureModel = project.defaultFeatureModel;
|
||||||
|
|
||||||
// Get enabled providers
|
// Get enabled providers
|
||||||
const enabledProviders = useMemo(() => {
|
const enabledProviders = useMemo(() => {
|
||||||
@@ -122,11 +133,15 @@ export function ProjectBulkReplaceDialog({
|
|||||||
const findModelForClaudeAlias = (
|
const findModelForClaudeAlias = (
|
||||||
provider: ClaudeCompatibleProvider | null,
|
provider: ClaudeCompatibleProvider | null,
|
||||||
claudeAlias: ClaudeModelAlias,
|
claudeAlias: ClaudeModelAlias,
|
||||||
phase: PhaseModelKey
|
key: ExtendedPhaseKey
|
||||||
): PhaseModelEntry => {
|
): PhaseModelEntry => {
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
// Anthropic Direct - reset to default phase model (includes correct thinking levels)
|
// 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
|
// Find model that maps to this Claude alias
|
||||||
@@ -146,60 +161,91 @@ export function ProjectBulkReplaceDialog({
|
|||||||
return { model: claudeAlias };
|
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
|
// Generate preview of changes
|
||||||
const preview = useMemo(() => {
|
const preview = useMemo(() => {
|
||||||
return ALL_PHASES.map((phase) => {
|
// Default feature model entry (first in the list)
|
||||||
// Current effective value (project override or global)
|
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 globalEntry = phaseModels[phase] ?? DEFAULT_PHASE_MODELS[phase];
|
||||||
const currentEntry = projectOverrides[phase] || globalEntry;
|
const currentEntry = projectOverrides[phase] || globalEntry;
|
||||||
const claudeAlias = getClaudeModelAlias(currentEntry);
|
return generatePreviewItem(phase, PHASE_LABELS[phase], 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,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
}, [phaseModels, projectOverrides, selectedProviderConfig, enabledProviders]);
|
|
||||||
|
return [defaultFeaturePreview, ...phasePreview];
|
||||||
|
}, [
|
||||||
|
phaseModels,
|
||||||
|
projectOverrides,
|
||||||
|
selectedProviderConfig,
|
||||||
|
enabledProviders,
|
||||||
|
defaultFeatureModel,
|
||||||
|
projectDefaultFeatureModel,
|
||||||
|
]);
|
||||||
|
|
||||||
// Count how many will change
|
// Count how many will change
|
||||||
const changeCount = preview.filter((p) => p.isChanged).length;
|
const changeCount = preview.filter((p) => p.isChanged).length;
|
||||||
|
|
||||||
// Apply the bulk replace as project overrides
|
// Apply the bulk replace as project overrides
|
||||||
const handleApply = () => {
|
const handleApply = () => {
|
||||||
preview.forEach(({ phase, newEntry, isChanged }) => {
|
preview.forEach(({ key, newEntry, isChanged }) => {
|
||||||
if (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);
|
onOpenChange(false);
|
||||||
@@ -295,7 +341,7 @@ export function ProjectBulkReplaceDialog({
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className="text-sm font-medium">Preview Changes</label>
|
<label className="text-sm font-medium">Preview Changes</label>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{changeCount} of {ALL_PHASES.length} will be overridden
|
{changeCount} of {preview.length} will be overridden
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="border rounded-lg overflow-hidden max-h-[300px] overflow-y-auto">
|
<div className="border rounded-lg overflow-hidden max-h-[300px] overflow-y-auto">
|
||||||
@@ -311,15 +357,23 @@ export function ProjectBulkReplaceDialog({
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{preview.map(({ phase, label, currentDisplay, newDisplay, isChanged }) => (
|
{preview.map(({ key, label, currentDisplay, newDisplay, isChanged }) => (
|
||||||
<tr
|
<tr
|
||||||
key={phase}
|
key={key}
|
||||||
className={cn(
|
className={cn(
|
||||||
'border-t border-border/50',
|
'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-muted-foreground">{currentDisplay}</td>
|
||||||
<td className="p-2 text-center">
|
<td className="p-2 text-center">
|
||||||
{isChanged ? (
|
{isChanged ? (
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { Button } from '@/components/ui/button';
|
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 { cn } from '@/lib/utils';
|
||||||
import type { Project } from '@/lib/electron';
|
import type { Project } from '@/lib/electron';
|
||||||
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
|
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
|
||||||
import { ProjectBulkReplaceDialog } from './project-bulk-replace-dialog';
|
import { ProjectBulkReplaceDialog } from './project-bulk-replace-dialog';
|
||||||
import type { PhaseModelKey, PhaseModelEntry } from '@automaker/types';
|
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 {
|
interface ProjectModelsSectionProps {
|
||||||
project: Project;
|
project: Project;
|
||||||
@@ -72,9 +72,9 @@ const GENERATION_TASKS: PhaseConfig[] = [
|
|||||||
description: 'Analyzes project structure for suggestions',
|
description: 'Analyzes project structure for suggestions',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'suggestionsModel',
|
key: 'ideationModel',
|
||||||
label: 'AI Suggestions',
|
label: 'Ideation',
|
||||||
description: 'Model for feature, refactoring, security, and performance suggestions',
|
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];
|
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({
|
function PhaseOverrideItem({
|
||||||
phase,
|
phase,
|
||||||
project,
|
project,
|
||||||
@@ -234,8 +355,10 @@ export function ProjectModelsSection({ project }: ProjectModelsSectionProps) {
|
|||||||
useAppStore();
|
useAppStore();
|
||||||
const [showBulkReplace, setShowBulkReplace] = useState(false);
|
const [showBulkReplace, setShowBulkReplace] = useState(false);
|
||||||
|
|
||||||
// Count how many overrides are set
|
// Count how many overrides are set (including defaultFeatureModel)
|
||||||
const overrideCount = Object.keys(project.phaseModelOverrides || {}).length;
|
const phaseOverrideCount = Object.keys(project.phaseModelOverrides || {}).length;
|
||||||
|
const hasDefaultFeatureModelOverride = !!project.defaultFeatureModel;
|
||||||
|
const overrideCount = phaseOverrideCount + (hasDefaultFeatureModelOverride ? 1 : 0);
|
||||||
|
|
||||||
// Check if Claude is available
|
// Check if Claude is available
|
||||||
const isClaudeDisabled = disabledProviders.includes('claude');
|
const isClaudeDisabled = disabledProviders.includes('claude');
|
||||||
@@ -328,6 +451,9 @@ export function ProjectModelsSection({ project }: ProjectModelsSectionProps) {
|
|||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="p-6 space-y-8">
|
<div className="p-6 space-y-8">
|
||||||
|
{/* Feature Defaults */}
|
||||||
|
<FeatureDefaultModelOverrideSection project={project} />
|
||||||
|
|
||||||
{/* Quick Tasks */}
|
{/* Quick Tasks */}
|
||||||
<PhaseGroup
|
<PhaseGroup
|
||||||
title="Quick Tasks"
|
title="Quick Tasks"
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { ProjectIdentitySection } from './project-identity-section';
|
import { ProjectIdentitySection } from './project-identity-section';
|
||||||
import { ProjectThemeSection } from './project-theme-section';
|
import { ProjectThemeSection } from './project-theme-section';
|
||||||
import { WorktreePreferencesSection } from './worktree-preferences-section';
|
import { WorktreePreferencesSection } from './worktree-preferences-section';
|
||||||
|
import { CommandsSection } from './commands-section';
|
||||||
import { ProjectModelsSection } from './project-models-section';
|
import { ProjectModelsSection } from './project-models-section';
|
||||||
|
import { DataManagementSection } from './data-management-section';
|
||||||
import { DangerZoneSection } from '../settings-view/danger-zone/danger-zone-section';
|
import { DangerZoneSection } from '../settings-view/danger-zone/danger-zone-section';
|
||||||
import { DeleteProjectDialog } from '../settings-view/components/delete-project-dialog';
|
import { DeleteProjectDialog } from '../settings-view/components/delete-project-dialog';
|
||||||
import { ProjectSettingsNavigation } from './components/project-settings-navigation';
|
import { ProjectSettingsNavigation } from './components/project-settings-navigation';
|
||||||
@@ -85,8 +87,12 @@ export function ProjectSettingsView() {
|
|||||||
return <ProjectThemeSection project={currentProject} />;
|
return <ProjectThemeSection project={currentProject} />;
|
||||||
case 'worktrees':
|
case 'worktrees':
|
||||||
return <WorktreePreferencesSection project={currentProject} />;
|
return <WorktreePreferencesSection project={currentProject} />;
|
||||||
|
case 'commands':
|
||||||
|
return <CommandsSection project={currentProject} />;
|
||||||
case 'claude':
|
case 'claude':
|
||||||
return <ProjectModelsSection project={currentProject} />;
|
return <ProjectModelsSection project={currentProject} />;
|
||||||
|
case 'data':
|
||||||
|
return <DataManagementSection project={currentProject} />;
|
||||||
case 'danger':
|
case 'danger':
|
||||||
return (
|
return (
|
||||||
<DangerZoneSection
|
<DangerZoneSection
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
CursorSettingsTab,
|
CursorSettingsTab,
|
||||||
CodexSettingsTab,
|
CodexSettingsTab,
|
||||||
OpencodeSettingsTab,
|
OpencodeSettingsTab,
|
||||||
|
GeminiSettingsTab,
|
||||||
} from './settings-view/providers';
|
} from './settings-view/providers';
|
||||||
import { MCPServersSection } from './settings-view/mcp-servers';
|
import { MCPServersSection } from './settings-view/mcp-servers';
|
||||||
import { PromptCustomizationSection } from './settings-view/prompts';
|
import { PromptCustomizationSection } from './settings-view/prompts';
|
||||||
@@ -123,6 +124,8 @@ export function SettingsView() {
|
|||||||
return <CodexSettingsTab />;
|
return <CodexSettingsTab />;
|
||||||
case 'opencode-provider':
|
case 'opencode-provider':
|
||||||
return <OpencodeSettingsTab />;
|
return <OpencodeSettingsTab />;
|
||||||
|
case 'gemini-provider':
|
||||||
|
return <GeminiSettingsTab />;
|
||||||
case 'providers':
|
case 'providers':
|
||||||
case 'claude': // Backwards compatibility - redirect to claude-provider
|
case 'claude': // Backwards compatibility - redirect to claude-provider
|
||||||
return <ClaudeSettingsTab />;
|
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