mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 08:13:37 +00:00
Compare commits
33 Commits
claude/add
...
fix/spec-g
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7d2033277 | ||
|
|
0b92349890 | ||
|
|
51a75ae589 | ||
|
|
650edd69ca | ||
|
|
46abd34444 | ||
|
|
5cf817e9de | ||
|
|
42ee4f211d | ||
|
|
372cfe6982 | ||
|
|
1430fb6926 | ||
|
|
9e15f3609a | ||
|
|
b34ffd9565 | ||
|
|
ac9f33bd2b | ||
|
|
269b1c9478 | ||
|
|
7bc7918cc6 | ||
|
|
860d6836b9 | ||
|
|
5281b81ddf | ||
|
|
7a33940816 | ||
|
|
ee4464bdad | ||
|
|
7e1095b773 | ||
|
|
9d297c650a | ||
|
|
68d78f2f5b | ||
|
|
fb6d6bbf2f | ||
|
|
c8ed3fafce | ||
|
|
5939c5d20b | ||
|
|
ad6fc01045 | ||
|
|
ea34f304cb | ||
|
|
53ad78dfc8 | ||
|
|
26b819291f | ||
|
|
a4214276d7 | ||
|
|
d09da4af20 | ||
|
|
433e6016c3 | ||
|
|
02dfda108e | ||
|
|
c55654b737 |
10
.github/workflows/release.yml
vendored
10
.github/workflows/release.yml
vendored
@@ -4,6 +4,9 @@ on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
@@ -65,6 +68,7 @@ jobs:
|
||||
path: |
|
||||
apps/ui/release/*.dmg
|
||||
apps/ui/release/*.zip
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload Windows artifacts
|
||||
@@ -73,6 +77,7 @@ jobs:
|
||||
with:
|
||||
name: windows-builds
|
||||
path: apps/ui/release/*.exe
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload Linux artifacts
|
||||
@@ -84,6 +89,7 @@ jobs:
|
||||
apps/ui/release/*.AppImage
|
||||
apps/ui/release/*.deb
|
||||
apps/ui/release/*.rpm
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
upload:
|
||||
@@ -113,15 +119,13 @@ jobs:
|
||||
- name: Upload to GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
fail_on_unmatched_files: true
|
||||
files: |
|
||||
artifacts/macos-builds/*.dmg
|
||||
artifacts/macos-builds/*.zip
|
||||
artifacts/macos-builds/*.blockmap
|
||||
artifacts/windows-builds/*.exe
|
||||
artifacts/windows-builds/*.blockmap
|
||||
artifacts/linux-builds/*.AppImage
|
||||
artifacts/linux-builds/*.deb
|
||||
artifacts/linux-builds/*.rpm
|
||||
artifacts/linux-builds/*.blockmap
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"@automaker/prompts": "1.0.0",
|
||||
"@automaker/types": "1.0.0",
|
||||
"@automaker/utils": "1.0.0",
|
||||
"@github/copilot-sdk": "^0.1.16",
|
||||
"@modelcontextprotocol/sdk": "1.25.2",
|
||||
"@openai/codex-sdk": "^0.77.0",
|
||||
"cookie-parser": "1.4.7",
|
||||
|
||||
@@ -83,8 +83,7 @@ import { getNotificationService } from './services/notification-service.js';
|
||||
import { createEventHistoryRoutes } from './routes/event-history/index.js';
|
||||
import { getEventHistoryService } from './services/event-history-service.js';
|
||||
import { getTestRunnerService } from './services/test-runner-service.js';
|
||||
import { createProviderUsageRoutes } from './routes/provider-usage/index.js';
|
||||
import { ProviderUsageTracker } from './services/provider-usage-tracker.js';
|
||||
import { createProjectsRoutes } from './routes/projects/index.js';
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
@@ -238,7 +237,6 @@ const codexModelCacheService = new CodexModelCacheService(DATA_DIR, codexAppServ
|
||||
const codexUsageService = new CodexUsageService(codexAppServerService);
|
||||
const mcpTestService = new MCPTestService(settingsService);
|
||||
const ideationService = new IdeationService(events, settingsService, featureLoader);
|
||||
const providerUsageTracker = new ProviderUsageTracker(codexUsageService);
|
||||
|
||||
// Initialize DevServerService with event emitter for real-time log streaming
|
||||
const devServerService = getDevServerService();
|
||||
@@ -350,7 +348,10 @@ app.use('/api/pipeline', createPipelineRoutes(pipelineService));
|
||||
app.use('/api/ideation', createIdeationRoutes(events, ideationService, featureLoader));
|
||||
app.use('/api/notifications', createNotificationsRoutes(notificationService));
|
||||
app.use('/api/event-history', createEventHistoryRoutes(eventHistoryService, settingsService));
|
||||
app.use('/api/provider-usage', createProviderUsageRoutes(providerUsageTracker));
|
||||
app.use(
|
||||
'/api/projects',
|
||||
createProjectsRoutes(featureLoader, autoModeService, settingsService, notificationService)
|
||||
);
|
||||
|
||||
// Create HTTP server
|
||||
const server = createServer(app);
|
||||
|
||||
942
apps/server/src/providers/copilot-provider.ts
Normal file
942
apps/server/src/providers/copilot-provider.ts
Normal file
@@ -0,0 +1,942 @@
|
||||
/**
|
||||
* Copilot Provider - Executes queries using the GitHub Copilot SDK
|
||||
*
|
||||
* Uses the official @github/copilot-sdk for:
|
||||
* - Session management and streaming responses
|
||||
* - GitHub OAuth authentication (via gh CLI)
|
||||
* - Tool call handling and permission management
|
||||
* - Runtime model discovery
|
||||
*
|
||||
* Based on https://github.com/github/copilot-sdk
|
||||
*/
|
||||
|
||||
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,
|
||||
} from './types.js';
|
||||
// Note: validateBareModelId is not used because Copilot's bare model IDs
|
||||
// legitimately contain prefixes like claude-, gemini-, gpt-
|
||||
import {
|
||||
COPILOT_MODEL_MAP,
|
||||
type CopilotAuthStatus,
|
||||
type CopilotRuntimeModel,
|
||||
} from '@automaker/types';
|
||||
import { createLogger, isAbortError } from '@automaker/utils';
|
||||
import { CopilotClient, type PermissionRequest } from '@github/copilot-sdk';
|
||||
import {
|
||||
normalizeTodos,
|
||||
normalizeFilePathInput,
|
||||
normalizeCommandInput,
|
||||
normalizePatternInput,
|
||||
} from './tool-normalization.js';
|
||||
|
||||
// Create logger for this module
|
||||
const logger = createLogger('CopilotProvider');
|
||||
|
||||
// Default bare model (without copilot- prefix) for SDK calls
|
||||
const DEFAULT_BARE_MODEL = 'claude-sonnet-4.5';
|
||||
|
||||
// =============================================================================
|
||||
// SDK Event Types (from @github/copilot-sdk)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* SDK session event data types
|
||||
*/
|
||||
interface SdkEvent {
|
||||
type: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
interface SdkMessageEvent extends SdkEvent {
|
||||
type: 'assistant.message';
|
||||
data: {
|
||||
content: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Note: SdkMessageDeltaEvent is not used - we skip delta events to reduce noise
|
||||
// The final assistant.message event contains the complete content
|
||||
|
||||
interface SdkToolExecutionStartEvent extends SdkEvent {
|
||||
type: 'tool.execution_start';
|
||||
data: {
|
||||
toolName: string;
|
||||
toolCallId: string;
|
||||
input?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
interface SdkToolExecutionEndEvent extends SdkEvent {
|
||||
type: 'tool.execution_end';
|
||||
data: {
|
||||
toolName: string;
|
||||
toolCallId: string;
|
||||
result?: string;
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface SdkSessionIdleEvent extends SdkEvent {
|
||||
type: 'session.idle';
|
||||
}
|
||||
|
||||
interface SdkSessionErrorEvent extends SdkEvent {
|
||||
type: 'session.error';
|
||||
data: {
|
||||
message: string;
|
||||
code?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Error Codes
|
||||
// =============================================================================
|
||||
|
||||
export enum CopilotErrorCode {
|
||||
NOT_INSTALLED = 'COPILOT_NOT_INSTALLED',
|
||||
NOT_AUTHENTICATED = 'COPILOT_NOT_AUTHENTICATED',
|
||||
RATE_LIMITED = 'COPILOT_RATE_LIMITED',
|
||||
MODEL_UNAVAILABLE = 'COPILOT_MODEL_UNAVAILABLE',
|
||||
NETWORK_ERROR = 'COPILOT_NETWORK_ERROR',
|
||||
PROCESS_CRASHED = 'COPILOT_PROCESS_CRASHED',
|
||||
TIMEOUT = 'COPILOT_TIMEOUT',
|
||||
CLI_ERROR = 'COPILOT_CLI_ERROR',
|
||||
SDK_ERROR = 'COPILOT_SDK_ERROR',
|
||||
UNKNOWN = 'COPILOT_UNKNOWN_ERROR',
|
||||
}
|
||||
|
||||
export interface CopilotError extends Error {
|
||||
code: CopilotErrorCode;
|
||||
recoverable: boolean;
|
||||
suggestion?: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tool Name Normalization
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Copilot SDK tool name to standard tool name mapping
|
||||
*
|
||||
* Maps Copilot CLI tool names to our standard tool names for consistent UI display.
|
||||
* Tool names are case-insensitive (normalized to lowercase before lookup).
|
||||
*/
|
||||
const COPILOT_TOOL_NAME_MAP: Record<string, string> = {
|
||||
// File operations
|
||||
read_file: 'Read',
|
||||
read: 'Read',
|
||||
view: 'Read', // Copilot uses 'view' for reading files
|
||||
read_many_files: 'Read',
|
||||
write_file: 'Write',
|
||||
write: 'Write',
|
||||
create_file: 'Write',
|
||||
edit_file: 'Edit',
|
||||
edit: 'Edit',
|
||||
replace: 'Edit',
|
||||
patch: 'Edit',
|
||||
// Shell operations
|
||||
run_shell: 'Bash',
|
||||
run_shell_command: 'Bash',
|
||||
shell: 'Bash',
|
||||
bash: 'Bash',
|
||||
execute: 'Bash',
|
||||
terminal: 'Bash',
|
||||
// Search operations
|
||||
search: 'Grep',
|
||||
grep: 'Grep',
|
||||
search_file_content: 'Grep',
|
||||
find_files: 'Glob',
|
||||
glob: 'Glob',
|
||||
list_dir: 'Ls',
|
||||
list_directory: 'Ls',
|
||||
ls: 'Ls',
|
||||
// Web operations
|
||||
web_fetch: 'WebFetch',
|
||||
fetch: 'WebFetch',
|
||||
web_search: 'WebSearch',
|
||||
search_web: 'WebSearch',
|
||||
google_web_search: 'WebSearch',
|
||||
// Todo operations
|
||||
todo_write: 'TodoWrite',
|
||||
write_todos: 'TodoWrite',
|
||||
update_todos: 'TodoWrite',
|
||||
// Planning/intent operations (Copilot-specific)
|
||||
report_intent: 'ReportIntent', // Keep as-is, it's a planning tool
|
||||
think: 'Think',
|
||||
plan: 'Plan',
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize Copilot tool names to standard tool names
|
||||
*/
|
||||
function normalizeCopilotToolName(copilotToolName: string): string {
|
||||
const lowerName = copilotToolName.toLowerCase();
|
||||
return COPILOT_TOOL_NAME_MAP[lowerName] || copilotToolName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize Copilot tool input parameters to standard format
|
||||
*
|
||||
* Maps Copilot's parameter names to our standard parameter names.
|
||||
* Uses shared utilities from tool-normalization.ts for common normalizations.
|
||||
*/
|
||||
function normalizeCopilotToolInput(
|
||||
toolName: string,
|
||||
input: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
const normalizedName = normalizeCopilotToolName(toolName);
|
||||
|
||||
// Normalize todo_write / write_todos: ensure proper format
|
||||
if (normalizedName === 'TodoWrite' && Array.isArray(input.todos)) {
|
||||
return { todos: normalizeTodos(input.todos) };
|
||||
}
|
||||
|
||||
// Normalize file path parameters for Read/Write/Edit tools
|
||||
if (normalizedName === 'Read' || normalizedName === 'Write' || normalizedName === 'Edit') {
|
||||
return normalizeFilePathInput(input);
|
||||
}
|
||||
|
||||
// Normalize shell command parameters for Bash tool
|
||||
if (normalizedName === 'Bash') {
|
||||
return normalizeCommandInput(input);
|
||||
}
|
||||
|
||||
// Normalize search parameters for Grep tool
|
||||
if (normalizedName === 'Grep') {
|
||||
return normalizePatternInput(input);
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
/**
|
||||
* CopilotProvider - Integrates GitHub Copilot SDK as an AI provider
|
||||
*
|
||||
* Features:
|
||||
* - GitHub OAuth authentication
|
||||
* - SDK-based session management
|
||||
* - Runtime model discovery
|
||||
* - Tool call normalization
|
||||
* - Per-execution working directory support
|
||||
*/
|
||||
export class CopilotProvider extends CliProvider {
|
||||
private runtimeModels: CopilotRuntimeModel[] | null = null;
|
||||
|
||||
constructor(config: ProviderConfig = {}) {
|
||||
super(config);
|
||||
// Trigger CLI detection on construction
|
||||
this.ensureCliDetected();
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// CliProvider Abstract Method Implementations
|
||||
// ==========================================================================
|
||||
|
||||
getName(): string {
|
||||
return 'copilot';
|
||||
}
|
||||
|
||||
getCliName(): string {
|
||||
return 'copilot';
|
||||
}
|
||||
|
||||
getSpawnConfig(): CliSpawnConfig {
|
||||
return {
|
||||
windowsStrategy: 'npx', // Copilot CLI can be run via npx
|
||||
npxPackage: '@github/copilot', // Official GitHub Copilot CLI package
|
||||
commonPaths: {
|
||||
linux: [
|
||||
path.join(os.homedir(), '.local/bin/copilot'),
|
||||
'/usr/local/bin/copilot',
|
||||
path.join(os.homedir(), '.npm-global/bin/copilot'),
|
||||
],
|
||||
darwin: [
|
||||
path.join(os.homedir(), '.local/bin/copilot'),
|
||||
'/usr/local/bin/copilot',
|
||||
'/opt/homebrew/bin/copilot',
|
||||
path.join(os.homedir(), '.npm-global/bin/copilot'),
|
||||
],
|
||||
win32: [
|
||||
path.join(os.homedir(), 'AppData', 'Roaming', 'npm', 'copilot.cmd'),
|
||||
path.join(os.homedir(), '.npm-global', 'copilot.cmd'),
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract prompt text from ExecuteOptions
|
||||
*
|
||||
* Note: CopilotProvider does not yet support vision/image inputs.
|
||||
* If non-text content is provided, an error is thrown.
|
||||
*/
|
||||
private extractPromptText(options: ExecuteOptions): string {
|
||||
if (typeof options.prompt === 'string') {
|
||||
return options.prompt;
|
||||
} else if (Array.isArray(options.prompt)) {
|
||||
// Check for non-text content (images, etc.) which we don't support yet
|
||||
const hasNonText = options.prompt.some((p) => p.type !== 'text');
|
||||
if (hasNonText) {
|
||||
throw new Error(
|
||||
'CopilotProvider does not yet support non-text prompt parts (e.g., images). ' +
|
||||
'Please use text-only prompts or switch to a provider that supports vision.'
|
||||
);
|
||||
}
|
||||
return options.prompt
|
||||
.filter((p) => p.type === 'text' && p.text)
|
||||
.map((p) => p.text)
|
||||
.join('\n');
|
||||
} else {
|
||||
throw new Error('Invalid prompt format');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Not used with SDK approach - kept for interface compatibility
|
||||
*/
|
||||
buildCliArgs(_options: ExecuteOptions): string[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert SDK event to AutoMaker ProviderMessage format
|
||||
*/
|
||||
normalizeEvent(event: unknown): ProviderMessage | null {
|
||||
const sdkEvent = event as SdkEvent;
|
||||
|
||||
switch (sdkEvent.type) {
|
||||
case 'assistant.message': {
|
||||
const messageEvent = sdkEvent as SdkMessageEvent;
|
||||
return {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: messageEvent.data.content }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'assistant.message_delta': {
|
||||
// Skip delta events - they create too much noise
|
||||
// The final assistant.message event has the complete content
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'tool.execution_start': {
|
||||
const toolEvent = sdkEvent as SdkToolExecutionStartEvent;
|
||||
const normalizedName = normalizeCopilotToolName(toolEvent.data.toolName);
|
||||
const normalizedInput = toolEvent.data.input
|
||||
? normalizeCopilotToolInput(toolEvent.data.toolName, toolEvent.data.input)
|
||||
: {};
|
||||
|
||||
return {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
name: normalizedName,
|
||||
tool_use_id: toolEvent.data.toolCallId,
|
||||
input: normalizedInput,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'tool.execution_end': {
|
||||
const toolResultEvent = sdkEvent as SdkToolExecutionEndEvent;
|
||||
const isError = !!toolResultEvent.data.error;
|
||||
const content = isError
|
||||
? `[ERROR] ${toolResultEvent.data.error}`
|
||||
: toolResultEvent.data.result || '';
|
||||
|
||||
return {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: toolResultEvent.data.toolCallId,
|
||||
content,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'session.idle': {
|
||||
logger.debug('Copilot session idle');
|
||||
return {
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
};
|
||||
}
|
||||
|
||||
case 'session.error': {
|
||||
const errorEvent = sdkEvent as SdkSessionErrorEvent;
|
||||
return {
|
||||
type: 'error',
|
||||
error: errorEvent.data.message || 'Unknown error',
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
logger.debug(`Unknown Copilot SDK event type: ${sdkEvent.type}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// CliProvider Overrides
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Override error mapping for Copilot-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('authentication required') ||
|
||||
lower.includes('github login')
|
||||
) {
|
||||
return {
|
||||
code: CopilotErrorCode.NOT_AUTHENTICATED,
|
||||
message: 'GitHub Copilot is not authenticated',
|
||||
recoverable: true,
|
||||
suggestion: 'Run "gh auth login" or "copilot auth login" to authenticate with GitHub',
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
lower.includes('rate limit') ||
|
||||
lower.includes('too many requests') ||
|
||||
lower.includes('429') ||
|
||||
lower.includes('quota exceeded')
|
||||
) {
|
||||
return {
|
||||
code: CopilotErrorCode.RATE_LIMITED,
|
||||
message: 'Copilot API rate limit exceeded',
|
||||
recoverable: true,
|
||||
suggestion: 'Wait a few minutes and try again',
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
lower.includes('model not available') ||
|
||||
lower.includes('invalid model') ||
|
||||
lower.includes('unknown model') ||
|
||||
lower.includes('model not found') ||
|
||||
(lower.includes('not found') && lower.includes('404'))
|
||||
) {
|
||||
return {
|
||||
code: CopilotErrorCode.MODEL_UNAVAILABLE,
|
||||
message: 'Requested model is not available',
|
||||
recoverable: true,
|
||||
suggestion: `Try using "${DEFAULT_BARE_MODEL}" or select a different model`,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
lower.includes('network') ||
|
||||
lower.includes('connection') ||
|
||||
lower.includes('econnrefused') ||
|
||||
lower.includes('timeout')
|
||||
) {
|
||||
return {
|
||||
code: CopilotErrorCode.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: CopilotErrorCode.PROCESS_CRASHED,
|
||||
message: 'Copilot CLI process was terminated',
|
||||
recoverable: true,
|
||||
suggestion: 'The process may have run out of memory. Try a simpler task.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
code: CopilotErrorCode.UNKNOWN,
|
||||
message: stderr || `Copilot CLI exited with code ${exitCode}`,
|
||||
recoverable: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Override install instructions for Copilot-specific guidance
|
||||
*/
|
||||
protected getInstallInstructions(): string {
|
||||
return 'Install with: npm install -g @github/copilot (or visit https://github.com/github/copilot)';
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a prompt using Copilot SDK with real-time streaming
|
||||
*
|
||||
* Creates a new CopilotClient for each execution with the correct working directory.
|
||||
* Streams tool execution events in real-time for UI display.
|
||||
*/
|
||||
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
|
||||
this.ensureCliDetected();
|
||||
|
||||
// Note: We don't use validateBareModelId here because Copilot's model IDs
|
||||
// legitimately contain prefixes like claude-, gemini-, gpt- which are the
|
||||
// actual model names from the Copilot CLI. We only need to ensure the
|
||||
// copilot- prefix has been stripped by the ProviderFactory.
|
||||
if (options.model?.startsWith('copilot-')) {
|
||||
throw new Error(
|
||||
`[CopilotProvider] Model ID should not have 'copilot-' prefix. Got: '${options.model}'. ` +
|
||||
`The ProviderFactory should strip this prefix before passing to the provider.`
|
||||
);
|
||||
}
|
||||
|
||||
if (!this.cliPath) {
|
||||
throw this.createError(
|
||||
CopilotErrorCode.NOT_INSTALLED,
|
||||
'Copilot CLI is not installed',
|
||||
true,
|
||||
this.getInstallInstructions()
|
||||
);
|
||||
}
|
||||
|
||||
const promptText = this.extractPromptText(options);
|
||||
const bareModel = options.model || DEFAULT_BARE_MODEL;
|
||||
const workingDirectory = options.cwd || process.cwd();
|
||||
|
||||
logger.debug(
|
||||
`CopilotProvider.executeQuery called with model: "${bareModel}", cwd: "${workingDirectory}"`
|
||||
);
|
||||
logger.debug(`Prompt length: ${promptText.length} characters`);
|
||||
|
||||
// Create a client for this execution with the correct working directory
|
||||
const client = new CopilotClient({
|
||||
logLevel: 'warning',
|
||||
autoRestart: false,
|
||||
cwd: workingDirectory,
|
||||
});
|
||||
|
||||
// Use an async queue to bridge callback-based SDK events to async generator
|
||||
const eventQueue: SdkEvent[] = [];
|
||||
let resolveWaiting: (() => void) | null = null;
|
||||
let sessionComplete = false;
|
||||
let sessionError: Error | null = null;
|
||||
|
||||
const pushEvent = (event: SdkEvent) => {
|
||||
eventQueue.push(event);
|
||||
if (resolveWaiting) {
|
||||
resolveWaiting();
|
||||
resolveWaiting = null;
|
||||
}
|
||||
};
|
||||
|
||||
const waitForEvent = (): Promise<void> => {
|
||||
if (eventQueue.length > 0 || sessionComplete) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
resolveWaiting = resolve;
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
await client.start();
|
||||
logger.debug(`CopilotClient started with cwd: ${workingDirectory}`);
|
||||
|
||||
// Create session with streaming enabled for real-time events
|
||||
const session = await client.createSession({
|
||||
model: bareModel,
|
||||
streaming: true,
|
||||
// AUTONOMOUS MODE: Auto-approve all permission requests.
|
||||
// AutoMaker is designed for fully autonomous AI agent operation.
|
||||
// Security boundary is provided by Docker containerization (see CLAUDE.md).
|
||||
// User is warned about this at app startup.
|
||||
onPermissionRequest: async (
|
||||
request: PermissionRequest
|
||||
): Promise<{ kind: 'approved' } | { kind: 'denied-interactively-by-user' }> => {
|
||||
logger.debug(`Permission request: ${request.kind}`);
|
||||
return { kind: 'approved' };
|
||||
},
|
||||
});
|
||||
|
||||
const sessionId = session.sessionId;
|
||||
logger.debug(`Session created: ${sessionId}`);
|
||||
|
||||
// Set up event handler to push events to queue
|
||||
session.on((event: SdkEvent) => {
|
||||
logger.debug(`SDK event: ${event.type}`);
|
||||
|
||||
if (event.type === 'session.idle') {
|
||||
sessionComplete = true;
|
||||
pushEvent(event);
|
||||
} else if (event.type === 'session.error') {
|
||||
const errorEvent = event as SdkSessionErrorEvent;
|
||||
sessionError = new Error(errorEvent.data.message);
|
||||
sessionComplete = true;
|
||||
pushEvent(event);
|
||||
} else {
|
||||
// Push all other events (tool.execution_start, tool.execution_end, assistant.message, etc.)
|
||||
pushEvent(event);
|
||||
}
|
||||
});
|
||||
|
||||
// Send the prompt (non-blocking)
|
||||
await session.send({ prompt: promptText });
|
||||
|
||||
// Process events as they arrive
|
||||
while (!sessionComplete || eventQueue.length > 0) {
|
||||
await waitForEvent();
|
||||
|
||||
// Check for errors first (before processing events to avoid race condition)
|
||||
if (sessionError) {
|
||||
await session.destroy();
|
||||
await client.stop();
|
||||
throw sessionError;
|
||||
}
|
||||
|
||||
// Process all queued events
|
||||
while (eventQueue.length > 0) {
|
||||
const event = eventQueue.shift()!;
|
||||
const normalized = this.normalizeEvent(event);
|
||||
if (normalized) {
|
||||
// Add session_id if not present
|
||||
if (!normalized.session_id) {
|
||||
normalized.session_id = sessionId;
|
||||
}
|
||||
yield normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
await session.destroy();
|
||||
await client.stop();
|
||||
logger.debug('CopilotClient stopped successfully');
|
||||
} catch (error) {
|
||||
// Ensure client is stopped on error
|
||||
try {
|
||||
await client.stop();
|
||||
} catch (cleanupError) {
|
||||
// Log but don't throw cleanup errors - the original error is more important
|
||||
logger.debug(`Failed to stop client during cleanup: ${cleanupError}`);
|
||||
}
|
||||
|
||||
if (isAbortError(error)) {
|
||||
logger.debug('Query aborted');
|
||||
return;
|
||||
}
|
||||
|
||||
// Map errors to CopilotError
|
||||
if (error instanceof Error) {
|
||||
logger.error(`Copilot SDK error: ${error.message}`);
|
||||
const errorInfo = this.mapError(error.message, null);
|
||||
throw this.createError(
|
||||
errorInfo.code as CopilotErrorCode,
|
||||
errorInfo.message,
|
||||
errorInfo.recoverable,
|
||||
errorInfo.suggestion
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Copilot-Specific Methods
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Create a CopilotError with details
|
||||
*/
|
||||
private createError(
|
||||
code: CopilotErrorCode,
|
||||
message: string,
|
||||
recoverable: boolean = false,
|
||||
suggestion?: string
|
||||
): CopilotError {
|
||||
const error = new Error(message) as CopilotError;
|
||||
error.code = code;
|
||||
error.recoverable = recoverable;
|
||||
error.suggestion = suggestion;
|
||||
error.name = 'CopilotError';
|
||||
return error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Copilot 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 GitHub CLI (gh) to check Copilot authentication status.
|
||||
* The Copilot CLI relies on gh auth for authentication.
|
||||
*/
|
||||
async checkAuth(): Promise<CopilotAuthStatus> {
|
||||
this.ensureCliDetected();
|
||||
if (!this.cliPath) {
|
||||
logger.debug('checkAuth: CLI not found');
|
||||
return { authenticated: false, method: 'none' };
|
||||
}
|
||||
|
||||
logger.debug('checkAuth: Starting credential check');
|
||||
|
||||
// Try to check GitHub CLI authentication status first
|
||||
// The Copilot CLI uses gh auth for authentication
|
||||
try {
|
||||
const ghStatus = execSync('gh auth status --hostname github.com', {
|
||||
encoding: 'utf8',
|
||||
timeout: 10000,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
logger.debug(`checkAuth: gh auth status output: ${ghStatus.substring(0, 200)}`);
|
||||
|
||||
// Parse gh auth status output
|
||||
const loggedInMatch = ghStatus.match(/Logged in to github\.com account (\S+)/);
|
||||
if (loggedInMatch) {
|
||||
return {
|
||||
authenticated: true,
|
||||
method: 'oauth',
|
||||
login: loggedInMatch[1],
|
||||
host: 'github.com',
|
||||
};
|
||||
}
|
||||
|
||||
// Check for token auth
|
||||
if (ghStatus.includes('Logged in') || ghStatus.includes('Token:')) {
|
||||
return {
|
||||
authenticated: true,
|
||||
method: 'oauth',
|
||||
host: 'github.com',
|
||||
};
|
||||
}
|
||||
} catch (ghError) {
|
||||
logger.debug(`checkAuth: gh auth status failed: ${ghError}`);
|
||||
}
|
||||
|
||||
// Try Copilot-specific auth check if gh is not available
|
||||
try {
|
||||
const result = execSync(`"${this.cliPath}" auth status`, {
|
||||
encoding: 'utf8',
|
||||
timeout: 10000,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
logger.debug(`checkAuth: copilot auth status output: ${result.substring(0, 200)}`);
|
||||
|
||||
if (result.includes('authenticated') || result.includes('logged in')) {
|
||||
return {
|
||||
authenticated: true,
|
||||
method: 'cli',
|
||||
};
|
||||
}
|
||||
} catch (copilotError) {
|
||||
logger.debug(`checkAuth: copilot auth status failed: ${copilotError}`);
|
||||
}
|
||||
|
||||
// Check for GITHUB_TOKEN environment variable
|
||||
if (process.env.GITHUB_TOKEN) {
|
||||
logger.debug('checkAuth: Found GITHUB_TOKEN environment variable');
|
||||
return {
|
||||
authenticated: true,
|
||||
method: 'oauth',
|
||||
statusMessage: 'Using GITHUB_TOKEN environment variable',
|
||||
};
|
||||
}
|
||||
|
||||
// Check for gh config file
|
||||
const ghConfigPath = path.join(os.homedir(), '.config', 'gh', 'hosts.yml');
|
||||
try {
|
||||
await fs.access(ghConfigPath);
|
||||
const content = await fs.readFile(ghConfigPath, 'utf8');
|
||||
if (content.includes('github.com') && content.includes('oauth_token')) {
|
||||
logger.debug('checkAuth: Found gh config with oauth_token');
|
||||
return {
|
||||
authenticated: true,
|
||||
method: 'oauth',
|
||||
host: 'github.com',
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
logger.debug('checkAuth: No gh config found');
|
||||
}
|
||||
|
||||
// No credentials found
|
||||
logger.debug('checkAuth: No valid credentials found');
|
||||
return {
|
||||
authenticated: false,
|
||||
method: 'none',
|
||||
error:
|
||||
'No authentication configured. Run "gh auth login" or install GitHub Copilot extension.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch available models from the CLI at runtime
|
||||
*/
|
||||
async fetchRuntimeModels(): Promise<CopilotRuntimeModel[]> {
|
||||
this.ensureCliDetected();
|
||||
if (!this.cliPath) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
// Try to list models using the CLI
|
||||
const result = execSync(`"${this.cliPath}" models list --format json`, {
|
||||
encoding: 'utf8',
|
||||
timeout: 15000,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
const models = JSON.parse(result) as CopilotRuntimeModel[];
|
||||
this.runtimeModels = models;
|
||||
logger.debug(`Fetched ${models.length} runtime models from Copilot CLI`);
|
||||
return models;
|
||||
} catch (error) {
|
||||
// Clear cache on failure to avoid returning stale data
|
||||
this.runtimeModels = null;
|
||||
logger.debug(`Failed to fetch runtime models: ${error}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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',
|
||||
authenticated: auth.authenticated,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the detected CLI path (public accessor for status endpoints)
|
||||
*/
|
||||
getCliPath(): string | null {
|
||||
this.ensureCliDetected();
|
||||
return this.cliPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available Copilot models
|
||||
*
|
||||
* Returns both static model definitions and runtime-discovered models
|
||||
*/
|
||||
getAvailableModels(): ModelDefinition[] {
|
||||
// Start with static model definitions - explicitly typed to allow runtime models
|
||||
const staticModels: ModelDefinition[] = Object.entries(COPILOT_MODEL_MAP).map(
|
||||
([id, config]) => ({
|
||||
id, // Full model ID with copilot- prefix
|
||||
name: config.label,
|
||||
modelString: id.replace('copilot-', ''), // Bare model for CLI
|
||||
provider: 'copilot',
|
||||
description: config.description,
|
||||
supportsTools: config.supportsTools,
|
||||
supportsVision: config.supportsVision,
|
||||
contextWindow: config.contextWindow,
|
||||
})
|
||||
);
|
||||
|
||||
// Add runtime models if available (discovered via CLI)
|
||||
if (this.runtimeModels) {
|
||||
for (const runtimeModel of this.runtimeModels) {
|
||||
// Skip if already in static list
|
||||
const staticId = `copilot-${runtimeModel.id}`;
|
||||
if (staticModels.some((m) => m.id === staticId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
staticModels.push({
|
||||
id: staticId,
|
||||
name: runtimeModel.name || runtimeModel.id,
|
||||
modelString: runtimeModel.id,
|
||||
provider: 'copilot',
|
||||
description: `Dynamic model: ${runtimeModel.name || runtimeModel.id}`,
|
||||
supportsTools: true,
|
||||
supportsVision: runtimeModel.capabilities?.supportsVision ?? false,
|
||||
contextWindow: runtimeModel.capabilities?.maxInputTokens,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return staticModels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a feature is supported
|
||||
*
|
||||
* Note: Vision is NOT currently supported - the SDK doesn't handle image inputs yet.
|
||||
* This may change in future versions of the Copilot SDK.
|
||||
*/
|
||||
supportsFeature(feature: string): boolean {
|
||||
const supported = ['tools', 'text', 'streaming'];
|
||||
return supported.includes(feature);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if runtime models have been cached
|
||||
*/
|
||||
hasCachedModels(): boolean {
|
||||
return this.runtimeModels !== null && this.runtimeModels.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the runtime model cache
|
||||
*/
|
||||
clearModelCache(): void {
|
||||
this.runtimeModels = null;
|
||||
logger.debug('Cleared Copilot model cache');
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh models from CLI and return all available models
|
||||
*/
|
||||
async refreshModels(): Promise<ModelDefinition[]> {
|
||||
logger.debug('Refreshing Copilot models from CLI');
|
||||
await this.fetchRuntimeModels();
|
||||
return this.getAvailableModels();
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ 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';
|
||||
import { normalizeTodos } from './tool-normalization.js';
|
||||
|
||||
// Create logger for this module
|
||||
const logger = createLogger('GeminiProvider');
|
||||
@@ -150,6 +151,8 @@ function normalizeGeminiToolName(geminiToolName: string): string {
|
||||
/**
|
||||
* Normalize Gemini tool input parameters to standard format
|
||||
*
|
||||
* Uses shared normalizeTodos utility for consistent todo normalization.
|
||||
*
|
||||
* Gemini `write_todos` format:
|
||||
* {"todos": [{"description": "Task text", "status": "pending|in_progress|completed|cancelled"}]}
|
||||
*
|
||||
@@ -160,17 +163,9 @@ function normalizeGeminiToolInput(
|
||||
toolName: string,
|
||||
input: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
// Normalize write_todos: map 'description' to 'content', handle 'cancelled' status
|
||||
// Normalize write_todos using shared utility
|
||||
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 { todos: normalizeTodos(input.todos) };
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
@@ -38,6 +38,12 @@ export { CursorConfigManager } from './cursor-config-manager.js';
|
||||
// OpenCode provider
|
||||
export { OpencodeProvider } from './opencode-provider.js';
|
||||
|
||||
// Gemini provider
|
||||
export { GeminiProvider, GeminiErrorCode } from './gemini-provider.js';
|
||||
|
||||
// Copilot provider (GitHub Copilot SDK)
|
||||
export { CopilotProvider, CopilotErrorCode } from './copilot-provider.js';
|
||||
|
||||
// Provider factory
|
||||
export { ProviderFactory } from './provider-factory.js';
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
isCodexModel,
|
||||
isOpencodeModel,
|
||||
isGeminiModel,
|
||||
isCopilotModel,
|
||||
type ModelProvider,
|
||||
} from '@automaker/types';
|
||||
import * as fs from 'fs';
|
||||
@@ -23,6 +24,7 @@ const DISCONNECTED_MARKERS: Record<string, string> = {
|
||||
cursor: '.cursor-disconnected',
|
||||
opencode: '.opencode-disconnected',
|
||||
gemini: '.gemini-disconnected',
|
||||
copilot: '.copilot-disconnected',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -275,6 +277,7 @@ import { CursorProvider } from './cursor-provider.js';
|
||||
import { CodexProvider } from './codex-provider.js';
|
||||
import { OpencodeProvider } from './opencode-provider.js';
|
||||
import { GeminiProvider } from './gemini-provider.js';
|
||||
import { CopilotProvider } from './copilot-provider.js';
|
||||
|
||||
// Register Claude provider
|
||||
registerProvider('claude', {
|
||||
@@ -317,3 +320,11 @@ registerProvider('gemini', {
|
||||
canHandleModel: (model: string) => isGeminiModel(model),
|
||||
priority: 4, // Between opencode (3) and codex (5)
|
||||
});
|
||||
|
||||
// Register Copilot provider (GitHub Copilot SDK)
|
||||
registerProvider('copilot', {
|
||||
factory: () => new CopilotProvider(),
|
||||
aliases: ['github-copilot', 'github'],
|
||||
canHandleModel: (model: string) => isCopilotModel(model),
|
||||
priority: 6, // High priority - check before Codex since both can handle GPT models
|
||||
});
|
||||
|
||||
112
apps/server/src/providers/tool-normalization.ts
Normal file
112
apps/server/src/providers/tool-normalization.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Shared tool normalization utilities for AI providers
|
||||
*
|
||||
* These utilities help normalize tool inputs from various AI providers
|
||||
* to the standard format expected by the application.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Valid todo status values in the standard format
|
||||
*/
|
||||
type TodoStatus = 'pending' | 'in_progress' | 'completed';
|
||||
|
||||
/**
|
||||
* Set of valid status values for validation
|
||||
*/
|
||||
const VALID_STATUSES = new Set<TodoStatus>(['pending', 'in_progress', 'completed']);
|
||||
|
||||
/**
|
||||
* Todo item from various AI providers (Gemini, Copilot, etc.)
|
||||
*/
|
||||
interface ProviderTodo {
|
||||
description?: string;
|
||||
content?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard todo format used by the application
|
||||
*/
|
||||
interface NormalizedTodo {
|
||||
content: string;
|
||||
status: TodoStatus;
|
||||
activeForm: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a provider status value to a valid TodoStatus
|
||||
*/
|
||||
function normalizeStatus(status: string | undefined): TodoStatus {
|
||||
if (!status) return 'pending';
|
||||
if (status === 'cancelled' || status === 'canceled') return 'completed';
|
||||
if (VALID_STATUSES.has(status as TodoStatus)) return status as TodoStatus;
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize todos array from provider format to standard format
|
||||
*
|
||||
* Handles different formats from providers:
|
||||
* - Gemini: { description, status } with 'cancelled' as possible status
|
||||
* - Copilot: { content/description, status } with 'cancelled' as possible status
|
||||
*
|
||||
* Output format (Claude/Standard):
|
||||
* - { content, status, activeForm } where status is 'pending'|'in_progress'|'completed'
|
||||
*/
|
||||
export function normalizeTodos(todos: ProviderTodo[] | null | undefined): NormalizedTodo[] {
|
||||
if (!todos) return [];
|
||||
return todos.map((todo) => ({
|
||||
content: todo.content || todo.description || '',
|
||||
status: normalizeStatus(todo.status),
|
||||
// Use content/description as activeForm since providers may not have it
|
||||
activeForm: todo.content || todo.description || '',
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize file path parameters from various provider formats
|
||||
*
|
||||
* Different providers use different parameter names for file paths:
|
||||
* - path, file, filename, filePath -> file_path
|
||||
*/
|
||||
export function normalizeFilePathInput(input: Record<string, unknown>): Record<string, unknown> {
|
||||
const normalized = { ...input };
|
||||
if (!normalized.file_path) {
|
||||
if (input.path) normalized.file_path = input.path;
|
||||
else if (input.file) normalized.file_path = input.file;
|
||||
else if (input.filename) normalized.file_path = input.filename;
|
||||
else if (input.filePath) normalized.file_path = input.filePath;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize shell command parameters from various provider formats
|
||||
*
|
||||
* Different providers use different parameter names for commands:
|
||||
* - cmd, script -> command
|
||||
*/
|
||||
export function normalizeCommandInput(input: Record<string, unknown>): Record<string, unknown> {
|
||||
const normalized = { ...input };
|
||||
if (!normalized.command) {
|
||||
if (input.cmd) normalized.command = input.cmd;
|
||||
else if (input.script) normalized.command = input.script;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize search pattern parameters from various provider formats
|
||||
*
|
||||
* Different providers use different parameter names for search patterns:
|
||||
* - query, search, regex -> pattern
|
||||
*/
|
||||
export function normalizePatternInput(input: Record<string, unknown>): Record<string, unknown> {
|
||||
const normalized = { ...input };
|
||||
if (!normalized.pattern) {
|
||||
if (input.query) normalized.pattern = input.query;
|
||||
else if (input.search) normalized.pattern = input.search;
|
||||
else if (input.regex) normalized.pattern = input.regex;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import * as secureFs from '../../lib/secure-fs.js';
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
import { specOutputSchema, specToXml, type SpecOutput } from '../../lib/app-spec-format.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { DEFAULT_PHASE_MODELS, isCursorModel } from '@automaker/types';
|
||||
import { DEFAULT_PHASE_MODELS, isClaudeModel, isCodexModel } from '@automaker/types';
|
||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||
import { extractJson } from '../../lib/json-extractor.js';
|
||||
import { streamingQuery } from '../../providers/simple-query-service.js';
|
||||
@@ -120,8 +120,8 @@ ${prompts.appSpec.structuredSpecInstructions}`;
|
||||
let responseText = '';
|
||||
let structuredOutput: SpecOutput | null = null;
|
||||
|
||||
// Determine if we should use structured output (Claude supports it, Cursor doesn't)
|
||||
const useStructuredOutput = !isCursorModel(model);
|
||||
// Determine if we should use structured output (only Claude and Codex support it)
|
||||
const useStructuredOutput = isClaudeModel(model) || isCodexModel(model);
|
||||
|
||||
// Build the final prompt - for Cursor, include JSON schema instructions
|
||||
let finalPrompt = prompt;
|
||||
|
||||
12
apps/server/src/routes/projects/common.ts
Normal file
12
apps/server/src/routes/projects/common.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Common utilities for projects routes
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
|
||||
|
||||
const logger = createLogger('Projects');
|
||||
|
||||
// Re-export shared utilities
|
||||
export { getErrorMessageShared as getErrorMessage };
|
||||
export const logError = createLogError(logger);
|
||||
27
apps/server/src/routes/projects/index.ts
Normal file
27
apps/server/src/routes/projects/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Projects routes - HTTP API for multi-project overview and management
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import type { FeatureLoader } from '../../services/feature-loader.js';
|
||||
import type { AutoModeService } from '../../services/auto-mode-service.js';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
import type { NotificationService } from '../../services/notification-service.js';
|
||||
import { createOverviewHandler } from './routes/overview.js';
|
||||
|
||||
export function createProjectsRoutes(
|
||||
featureLoader: FeatureLoader,
|
||||
autoModeService: AutoModeService,
|
||||
settingsService: SettingsService,
|
||||
notificationService: NotificationService
|
||||
): Router {
|
||||
const router = Router();
|
||||
|
||||
// GET /overview - Get aggregate status for all projects
|
||||
router.get(
|
||||
'/overview',
|
||||
createOverviewHandler(featureLoader, autoModeService, settingsService, notificationService)
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
317
apps/server/src/routes/projects/routes/overview.ts
Normal file
317
apps/server/src/routes/projects/routes/overview.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* GET /overview endpoint - Get aggregate status for all projects
|
||||
*
|
||||
* Returns a complete overview of all projects including:
|
||||
* - Individual project status (features, auto-mode state)
|
||||
* - Aggregate metrics across all projects
|
||||
* - Recent activity feed (placeholder for future implementation)
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { FeatureLoader } from '../../../services/feature-loader.js';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
import type { NotificationService } from '../../../services/notification-service.js';
|
||||
import type {
|
||||
ProjectStatus,
|
||||
AggregateStatus,
|
||||
MultiProjectOverview,
|
||||
FeatureStatusCounts,
|
||||
AggregateFeatureCounts,
|
||||
AggregateProjectCounts,
|
||||
ProjectHealthStatus,
|
||||
Feature,
|
||||
ProjectRef,
|
||||
} from '@automaker/types';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
/**
|
||||
* Compute feature status counts from a list of features
|
||||
*/
|
||||
function computeFeatureCounts(features: Feature[]): FeatureStatusCounts {
|
||||
const counts: FeatureStatusCounts = {
|
||||
pending: 0,
|
||||
running: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
verified: 0,
|
||||
};
|
||||
|
||||
for (const feature of features) {
|
||||
switch (feature.status) {
|
||||
case 'pending':
|
||||
case 'ready':
|
||||
counts.pending++;
|
||||
break;
|
||||
case 'running':
|
||||
case 'generating_spec':
|
||||
case 'in_progress':
|
||||
counts.running++;
|
||||
break;
|
||||
case 'waiting_approval':
|
||||
// waiting_approval means agent finished, needs human review - count as pending
|
||||
counts.pending++;
|
||||
break;
|
||||
case 'completed':
|
||||
counts.completed++;
|
||||
break;
|
||||
case 'failed':
|
||||
counts.failed++;
|
||||
break;
|
||||
case 'verified':
|
||||
counts.verified++;
|
||||
break;
|
||||
default:
|
||||
// Unknown status, treat as pending
|
||||
counts.pending++;
|
||||
}
|
||||
}
|
||||
|
||||
return counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the overall health status of a project based on its feature statuses
|
||||
*/
|
||||
function computeHealthStatus(
|
||||
featureCounts: FeatureStatusCounts,
|
||||
isAutoModeRunning: boolean
|
||||
): ProjectHealthStatus {
|
||||
const totalFeatures =
|
||||
featureCounts.pending +
|
||||
featureCounts.running +
|
||||
featureCounts.completed +
|
||||
featureCounts.failed +
|
||||
featureCounts.verified;
|
||||
|
||||
// If there are failed features, the project has errors
|
||||
if (featureCounts.failed > 0) {
|
||||
return 'error';
|
||||
}
|
||||
|
||||
// If there are running features or auto mode is running with pending work
|
||||
if (featureCounts.running > 0 || (isAutoModeRunning && featureCounts.pending > 0)) {
|
||||
return 'active';
|
||||
}
|
||||
|
||||
// Pending work but no active execution
|
||||
if (featureCounts.pending > 0) {
|
||||
return 'waiting';
|
||||
}
|
||||
|
||||
// If all features are completed or verified
|
||||
if (totalFeatures > 0 && featureCounts.pending === 0 && featureCounts.running === 0) {
|
||||
return 'completed';
|
||||
}
|
||||
|
||||
// Default to idle
|
||||
return 'idle';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the most recent activity timestamp from features
|
||||
*/
|
||||
function getLastActivityAt(features: Feature[]): string | undefined {
|
||||
if (features.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let latestTimestamp: number = 0;
|
||||
|
||||
for (const feature of features) {
|
||||
// Check startedAt timestamp (the main timestamp available on Feature)
|
||||
if (feature.startedAt) {
|
||||
const timestamp = new Date(feature.startedAt).getTime();
|
||||
if (!isNaN(timestamp) && timestamp > latestTimestamp) {
|
||||
latestTimestamp = timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
// Also check planSpec timestamps if available
|
||||
if (feature.planSpec?.generatedAt) {
|
||||
const timestamp = new Date(feature.planSpec.generatedAt).getTime();
|
||||
if (!isNaN(timestamp) && timestamp > latestTimestamp) {
|
||||
latestTimestamp = timestamp;
|
||||
}
|
||||
}
|
||||
if (feature.planSpec?.approvedAt) {
|
||||
const timestamp = new Date(feature.planSpec.approvedAt).getTime();
|
||||
if (!isNaN(timestamp) && timestamp > latestTimestamp) {
|
||||
latestTimestamp = timestamp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return latestTimestamp > 0 ? new Date(latestTimestamp).toISOString() : undefined;
|
||||
}
|
||||
|
||||
export function createOverviewHandler(
|
||||
featureLoader: FeatureLoader,
|
||||
autoModeService: AutoModeService,
|
||||
settingsService: SettingsService,
|
||||
notificationService: NotificationService
|
||||
) {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// Get all projects from settings
|
||||
const settings = await settingsService.getGlobalSettings();
|
||||
const projectRefs: ProjectRef[] = settings.projects || [];
|
||||
|
||||
// Get all running agents once to count live running features per project
|
||||
const allRunningAgents = await autoModeService.getRunningAgents();
|
||||
|
||||
// Collect project statuses in parallel
|
||||
const projectStatusPromises = projectRefs.map(async (projectRef): Promise<ProjectStatus> => {
|
||||
try {
|
||||
// Load features for this project
|
||||
const features = await featureLoader.getAll(projectRef.path);
|
||||
const featureCounts = computeFeatureCounts(features);
|
||||
const totalFeatures = features.length;
|
||||
|
||||
// Get auto-mode status for this project (main worktree, branchName = null)
|
||||
const autoModeStatus = autoModeService.getStatusForProject(projectRef.path, null);
|
||||
const isAutoModeRunning = autoModeStatus.isAutoLoopRunning;
|
||||
|
||||
// Count live running features for this project (across all branches)
|
||||
// This ensures we only count features that are actually running in memory
|
||||
const liveRunningCount = allRunningAgents.filter(
|
||||
(agent) => agent.projectPath === projectRef.path
|
||||
).length;
|
||||
featureCounts.running = liveRunningCount;
|
||||
|
||||
// Get notification count for this project
|
||||
let unreadNotificationCount = 0;
|
||||
try {
|
||||
const notifications = await notificationService.getNotifications(projectRef.path);
|
||||
unreadNotificationCount = notifications.filter((n) => !n.read).length;
|
||||
} catch {
|
||||
// Ignore notification errors - project may not have any notifications yet
|
||||
}
|
||||
|
||||
// Compute health status
|
||||
const healthStatus = computeHealthStatus(featureCounts, isAutoModeRunning);
|
||||
|
||||
// Get last activity timestamp
|
||||
const lastActivityAt = getLastActivityAt(features);
|
||||
|
||||
return {
|
||||
projectId: projectRef.id,
|
||||
projectName: projectRef.name,
|
||||
projectPath: projectRef.path,
|
||||
healthStatus,
|
||||
featureCounts,
|
||||
totalFeatures,
|
||||
lastActivityAt,
|
||||
isAutoModeRunning,
|
||||
activeBranch: autoModeStatus.branchName ?? undefined,
|
||||
unreadNotificationCount,
|
||||
};
|
||||
} catch (error) {
|
||||
logError(error, `Failed to load project status: ${projectRef.name}`);
|
||||
// Return a minimal status for projects that fail to load
|
||||
return {
|
||||
projectId: projectRef.id,
|
||||
projectName: projectRef.name,
|
||||
projectPath: projectRef.path,
|
||||
healthStatus: 'error' as ProjectHealthStatus,
|
||||
featureCounts: {
|
||||
pending: 0,
|
||||
running: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
verified: 0,
|
||||
},
|
||||
totalFeatures: 0,
|
||||
isAutoModeRunning: false,
|
||||
unreadNotificationCount: 0,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const projectStatuses = await Promise.all(projectStatusPromises);
|
||||
|
||||
// Compute aggregate metrics
|
||||
const aggregateFeatureCounts: AggregateFeatureCounts = {
|
||||
total: 0,
|
||||
pending: 0,
|
||||
running: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
verified: 0,
|
||||
};
|
||||
|
||||
const aggregateProjectCounts: AggregateProjectCounts = {
|
||||
total: projectStatuses.length,
|
||||
active: 0,
|
||||
idle: 0,
|
||||
waiting: 0,
|
||||
withErrors: 0,
|
||||
allCompleted: 0,
|
||||
};
|
||||
|
||||
let totalUnreadNotifications = 0;
|
||||
let projectsWithAutoModeRunning = 0;
|
||||
|
||||
for (const status of projectStatuses) {
|
||||
// Aggregate feature counts
|
||||
aggregateFeatureCounts.total += status.totalFeatures;
|
||||
aggregateFeatureCounts.pending += status.featureCounts.pending;
|
||||
aggregateFeatureCounts.running += status.featureCounts.running;
|
||||
aggregateFeatureCounts.completed += status.featureCounts.completed;
|
||||
aggregateFeatureCounts.failed += status.featureCounts.failed;
|
||||
aggregateFeatureCounts.verified += status.featureCounts.verified;
|
||||
|
||||
// Aggregate project counts by health status
|
||||
switch (status.healthStatus) {
|
||||
case 'active':
|
||||
aggregateProjectCounts.active++;
|
||||
break;
|
||||
case 'idle':
|
||||
aggregateProjectCounts.idle++;
|
||||
break;
|
||||
case 'waiting':
|
||||
aggregateProjectCounts.waiting++;
|
||||
break;
|
||||
case 'error':
|
||||
aggregateProjectCounts.withErrors++;
|
||||
break;
|
||||
case 'completed':
|
||||
aggregateProjectCounts.allCompleted++;
|
||||
break;
|
||||
}
|
||||
|
||||
// Aggregate notifications
|
||||
totalUnreadNotifications += status.unreadNotificationCount;
|
||||
|
||||
// Count projects with auto-mode running
|
||||
if (status.isAutoModeRunning) {
|
||||
projectsWithAutoModeRunning++;
|
||||
}
|
||||
}
|
||||
|
||||
const aggregateStatus: AggregateStatus = {
|
||||
projectCounts: aggregateProjectCounts,
|
||||
featureCounts: aggregateFeatureCounts,
|
||||
totalUnreadNotifications,
|
||||
projectsWithAutoModeRunning,
|
||||
computedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Build the response (recentActivity is empty for now - can be populated later)
|
||||
const overview: MultiProjectOverview = {
|
||||
projects: projectStatuses,
|
||||
aggregate: aggregateStatus,
|
||||
recentActivity: [], // Placeholder for future activity feed implementation
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
...overview,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Get project overview failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -52,3 +52,8 @@ export async function persistApiKeyToEnv(key: string, value: string): Promise<vo
|
||||
// Re-export shared utilities
|
||||
export { getErrorMessageShared as getErrorMessage };
|
||||
export const logError = createLogError(logger);
|
||||
|
||||
/**
|
||||
* Marker file used to indicate a provider has been explicitly disconnected by user
|
||||
*/
|
||||
export const COPILOT_DISCONNECTED_MARKER_FILE = '.copilot-disconnected';
|
||||
|
||||
@@ -27,6 +27,14 @@ 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 { createCopilotStatusHandler } from './routes/copilot-status.js';
|
||||
import { createAuthCopilotHandler } from './routes/auth-copilot.js';
|
||||
import { createDeauthCopilotHandler } from './routes/deauth-copilot.js';
|
||||
import {
|
||||
createGetCopilotModelsHandler,
|
||||
createRefreshCopilotModelsHandler,
|
||||
createClearCopilotCacheHandler,
|
||||
} from './routes/copilot-models.js';
|
||||
import {
|
||||
createGetOpencodeModelsHandler,
|
||||
createRefreshOpencodeModelsHandler,
|
||||
@@ -80,6 +88,16 @@ export function createSetupRoutes(): Router {
|
||||
router.post('/auth-gemini', createAuthGeminiHandler());
|
||||
router.post('/deauth-gemini', createDeauthGeminiHandler());
|
||||
|
||||
// Copilot CLI routes
|
||||
router.get('/copilot-status', createCopilotStatusHandler());
|
||||
router.post('/auth-copilot', createAuthCopilotHandler());
|
||||
router.post('/deauth-copilot', createDeauthCopilotHandler());
|
||||
|
||||
// Copilot Dynamic Model Discovery routes
|
||||
router.get('/copilot/models', createGetCopilotModelsHandler());
|
||||
router.post('/copilot/models/refresh', createRefreshCopilotModelsHandler());
|
||||
router.post('/copilot/cache/clear', createClearCopilotCacheHandler());
|
||||
|
||||
// OpenCode Dynamic Model Discovery routes
|
||||
router.get('/opencode/models', createGetOpencodeModelsHandler());
|
||||
router.post('/opencode/models/refresh', createRefreshOpencodeModelsHandler());
|
||||
|
||||
30
apps/server/src/routes/setup/routes/auth-copilot.ts
Normal file
30
apps/server/src/routes/setup/routes/auth-copilot.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* POST /auth-copilot endpoint - Connect Copilot CLI to the app
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import { connectCopilot } from '../../../services/copilot-connection-service.js';
|
||||
|
||||
/**
|
||||
* Creates handler for POST /api/setup/auth-copilot
|
||||
* Removes the disconnection marker to allow Copilot CLI to be used
|
||||
*/
|
||||
export function createAuthCopilotHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
await connectCopilot();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Copilot CLI connected to app',
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Auth Copilot failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
139
apps/server/src/routes/setup/routes/copilot-models.ts
Normal file
139
apps/server/src/routes/setup/routes/copilot-models.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Copilot Dynamic Models API Routes
|
||||
*
|
||||
* Provides endpoints for:
|
||||
* - GET /api/setup/copilot/models - Get available models (cached or refreshed)
|
||||
* - POST /api/setup/copilot/models/refresh - Force refresh models from CLI
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { CopilotProvider } from '../../../providers/copilot-provider.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import type { ModelDefinition } from '@automaker/types';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('CopilotModelsRoute');
|
||||
|
||||
// Singleton provider instance for caching
|
||||
let providerInstance: CopilotProvider | null = null;
|
||||
|
||||
function getProvider(): CopilotProvider {
|
||||
if (!providerInstance) {
|
||||
providerInstance = new CopilotProvider();
|
||||
}
|
||||
return providerInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response type for models endpoint
|
||||
*/
|
||||
interface ModelsResponse {
|
||||
success: boolean;
|
||||
models?: ModelDefinition[];
|
||||
count?: number;
|
||||
cached?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates handler for GET /api/setup/copilot/models
|
||||
*
|
||||
* Returns currently available models (from cache if available).
|
||||
* Query params:
|
||||
* - refresh=true: Force refresh from CLI before returning
|
||||
*
|
||||
* Note: If cache is empty, this will trigger a refresh to get dynamic models.
|
||||
*/
|
||||
export function createGetCopilotModelsHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const provider = getProvider();
|
||||
const forceRefresh = req.query.refresh === 'true';
|
||||
|
||||
let models: ModelDefinition[];
|
||||
let cached = true;
|
||||
|
||||
if (forceRefresh) {
|
||||
models = await provider.refreshModels();
|
||||
cached = false;
|
||||
} else {
|
||||
// Check if we have cached models
|
||||
if (!provider.hasCachedModels()) {
|
||||
models = await provider.refreshModels();
|
||||
cached = false;
|
||||
} else {
|
||||
models = provider.getAvailableModels();
|
||||
}
|
||||
}
|
||||
|
||||
const response: ModelsResponse = {
|
||||
success: true,
|
||||
models,
|
||||
count: models.length,
|
||||
cached,
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
logError(error, 'Get Copilot models failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
} as ModelsResponse);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates handler for POST /api/setup/copilot/models/refresh
|
||||
*
|
||||
* Forces a refresh of models from the Copilot CLI.
|
||||
*/
|
||||
export function createRefreshCopilotModelsHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const provider = getProvider();
|
||||
const models = await provider.refreshModels();
|
||||
|
||||
const response: ModelsResponse = {
|
||||
success: true,
|
||||
models,
|
||||
count: models.length,
|
||||
cached: false,
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
logError(error, 'Refresh Copilot models failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
} as ModelsResponse);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates handler for POST /api/setup/copilot/cache/clear
|
||||
*
|
||||
* Clears the model cache, forcing a fresh fetch on next access.
|
||||
*/
|
||||
export function createClearCopilotCacheHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const provider = getProvider();
|
||||
provider.clearModelCache();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Copilot model cache cleared',
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Clear Copilot cache failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
78
apps/server/src/routes/setup/routes/copilot-status.ts
Normal file
78
apps/server/src/routes/setup/routes/copilot-status.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* GET /copilot-status endpoint - Get Copilot CLI installation and auth status
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { CopilotProvider } from '../../../providers/copilot-provider.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
const DISCONNECTED_MARKER_FILE = '.copilot-disconnected';
|
||||
|
||||
async function isCopilotDisconnectedFromApp(): 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/copilot-status
|
||||
* Returns Copilot CLI installation and authentication status
|
||||
*/
|
||||
export function createCopilotStatusHandler() {
|
||||
const installCommand = 'npm install -g @github/copilot';
|
||||
const loginCommand = 'gh auth login';
|
||||
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// Check if user has manually disconnected from the app
|
||||
if (await isCopilotDisconnectedFromApp()) {
|
||||
res.json({
|
||||
success: true,
|
||||
installed: true,
|
||||
version: null,
|
||||
path: null,
|
||||
auth: {
|
||||
authenticated: false,
|
||||
method: 'none',
|
||||
},
|
||||
installCommand,
|
||||
loginCommand,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const provider = new CopilotProvider();
|
||||
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,
|
||||
login: auth.login,
|
||||
host: auth.host,
|
||||
error: auth.error,
|
||||
},
|
||||
installCommand,
|
||||
loginCommand,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Get Copilot status failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
30
apps/server/src/routes/setup/routes/deauth-copilot.ts
Normal file
30
apps/server/src/routes/setup/routes/deauth-copilot.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* POST /deauth-copilot endpoint - Disconnect Copilot CLI from the app
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import { disconnectCopilot } from '../../../services/copilot-connection-service.js';
|
||||
|
||||
/**
|
||||
* Creates handler for POST /api/setup/deauth-copilot
|
||||
* Creates a marker file to disconnect Copilot CLI from the app
|
||||
*/
|
||||
export function createDeauthCopilotHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
await disconnectCopilot();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Copilot CLI disconnected from app',
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Deauth Copilot failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
80
apps/server/src/services/copilot-connection-service.ts
Normal file
80
apps/server/src/services/copilot-connection-service.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Copilot Connection Service
|
||||
*
|
||||
* Handles the connection and disconnection of Copilot CLI to the app.
|
||||
* Uses a marker file to track the disconnected state.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { COPILOT_DISCONNECTED_MARKER_FILE } from '../routes/setup/common.js';
|
||||
|
||||
const logger = createLogger('CopilotConnectionService');
|
||||
|
||||
/**
|
||||
* Get the path to the disconnected marker file
|
||||
*/
|
||||
function getMarkerPath(projectRoot?: string): string {
|
||||
const root = projectRoot || process.cwd();
|
||||
const automakerDir = path.join(root, '.automaker');
|
||||
return path.join(automakerDir, COPILOT_DISCONNECTED_MARKER_FILE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect Copilot CLI to the app by removing the disconnected marker
|
||||
*
|
||||
* @param projectRoot - Optional project root directory (defaults to cwd)
|
||||
* @returns Promise that resolves when the connection is established
|
||||
*/
|
||||
export async function connectCopilot(projectRoot?: string): Promise<void> {
|
||||
const markerPath = getMarkerPath(projectRoot);
|
||||
|
||||
try {
|
||||
await fs.unlink(markerPath);
|
||||
logger.info('Copilot CLI connected to app (marker removed)');
|
||||
} catch (error) {
|
||||
// File doesn't exist - that's fine, Copilot is already connected
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
logger.error('Failed to remove disconnected marker:', error);
|
||||
throw error;
|
||||
}
|
||||
logger.debug('Copilot already connected (no marker file found)');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect Copilot CLI from the app by creating the disconnected marker
|
||||
*
|
||||
* @param projectRoot - Optional project root directory (defaults to cwd)
|
||||
* @returns Promise that resolves when the disconnection is complete
|
||||
*/
|
||||
export async function disconnectCopilot(projectRoot?: string): Promise<void> {
|
||||
const root = projectRoot || process.cwd();
|
||||
const automakerDir = path.join(root, '.automaker');
|
||||
const markerPath = path.join(automakerDir, COPILOT_DISCONNECTED_MARKER_FILE);
|
||||
|
||||
// Ensure .automaker directory exists
|
||||
await fs.mkdir(automakerDir, { recursive: true });
|
||||
|
||||
// Create the disconnection marker
|
||||
await fs.writeFile(markerPath, 'Copilot CLI disconnected from app');
|
||||
logger.info('Copilot CLI disconnected from app (marker created)');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Copilot CLI is connected (not disconnected)
|
||||
*
|
||||
* @param projectRoot - Optional project root directory (defaults to cwd)
|
||||
* @returns Promise that resolves to true if connected, false if disconnected
|
||||
*/
|
||||
export async function isCopilotConnected(projectRoot?: string): Promise<boolean> {
|
||||
const markerPath = getMarkerPath(projectRoot);
|
||||
|
||||
try {
|
||||
await fs.access(markerPath);
|
||||
return false; // Marker exists = disconnected
|
||||
} catch {
|
||||
return true; // Marker doesn't exist = connected
|
||||
}
|
||||
}
|
||||
@@ -1,288 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -1,331 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -1,362 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -1,260 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -1,447 +0,0 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
517
apps/server/tests/unit/providers/copilot-provider.test.ts
Normal file
517
apps/server/tests/unit/providers/copilot-provider.test.ts
Normal file
@@ -0,0 +1,517 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { CopilotProvider, CopilotErrorCode } from '@/providers/copilot-provider.js';
|
||||
|
||||
// Mock the Copilot SDK
|
||||
vi.mock('@github/copilot-sdk', () => ({
|
||||
CopilotClient: vi.fn().mockImplementation(() => ({
|
||||
start: vi.fn().mockResolvedValue(undefined),
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
createSession: vi.fn().mockResolvedValue({
|
||||
sessionId: 'test-session',
|
||||
send: vi.fn().mockResolvedValue(undefined),
|
||||
destroy: vi.fn().mockResolvedValue(undefined),
|
||||
on: vi.fn(),
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock child_process with all needed exports
|
||||
vi.mock('child_process', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('child_process')>();
|
||||
return {
|
||||
...actual,
|
||||
execSync: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock fs (synchronous) for CLI detection (existsSync)
|
||||
vi.mock('fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('fs')>();
|
||||
return {
|
||||
...actual,
|
||||
existsSync: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock fs/promises
|
||||
vi.mock('fs/promises', () => ({
|
||||
access: vi.fn().mockRejectedValue(new Error('Not found')),
|
||||
readFile: vi.fn().mockRejectedValue(new Error('Not found')),
|
||||
mkdir: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// Import execSync after mocking
|
||||
import { execSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
|
||||
describe('copilot-provider.ts', () => {
|
||||
let provider: CopilotProvider;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Mock fs.existsSync for CLI path validation
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
|
||||
// Mock CLI detection to find the CLI
|
||||
// The CliProvider base class uses 'which copilot' (Unix) or 'where copilot' (Windows)
|
||||
// to find the CLI path, then validates with fs.existsSync
|
||||
vi.mocked(execSync).mockImplementation((cmd: string) => {
|
||||
// CLI path detection (which/where command)
|
||||
if (cmd.startsWith('which ') || cmd.startsWith('where ')) {
|
||||
return '/usr/local/bin/copilot';
|
||||
}
|
||||
if (cmd.includes('--version')) {
|
||||
return '1.0.0';
|
||||
}
|
||||
if (cmd.includes('gh auth status')) {
|
||||
return 'Logged in to github.com account testuser';
|
||||
}
|
||||
if (cmd.includes('models list')) {
|
||||
return JSON.stringify([{ id: 'claude-sonnet-4.5', name: 'Claude Sonnet 4.5' }]);
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
provider = new CopilotProvider();
|
||||
delete process.env.GITHUB_TOKEN;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('getName', () => {
|
||||
it("should return 'copilot' as provider name", () => {
|
||||
expect(provider.getName()).toBe('copilot');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCliName', () => {
|
||||
it("should return 'copilot' as CLI name", () => {
|
||||
expect(provider.getCliName()).toBe('copilot');
|
||||
});
|
||||
});
|
||||
|
||||
describe('supportsFeature', () => {
|
||||
it('should support tools feature', () => {
|
||||
expect(provider.supportsFeature('tools')).toBe(true);
|
||||
});
|
||||
|
||||
it('should support text feature', () => {
|
||||
expect(provider.supportsFeature('text')).toBe(true);
|
||||
});
|
||||
|
||||
it('should support streaming feature', () => {
|
||||
expect(provider.supportsFeature('streaming')).toBe(true);
|
||||
});
|
||||
|
||||
it('should NOT support vision feature (not implemented yet)', () => {
|
||||
expect(provider.supportsFeature('vision')).toBe(false);
|
||||
});
|
||||
|
||||
it('should not support unknown feature', () => {
|
||||
expect(provider.supportsFeature('unknown')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAvailableModels', () => {
|
||||
it('should return static model definitions', () => {
|
||||
const models = provider.getAvailableModels();
|
||||
expect(Array.isArray(models)).toBe(true);
|
||||
expect(models.length).toBeGreaterThan(0);
|
||||
|
||||
// All models should have required fields
|
||||
models.forEach((model) => {
|
||||
expect(model.id).toBeDefined();
|
||||
expect(model.name).toBeDefined();
|
||||
expect(model.provider).toBe('copilot');
|
||||
});
|
||||
});
|
||||
|
||||
it('should include copilot- prefix in model IDs', () => {
|
||||
const models = provider.getAvailableModels();
|
||||
models.forEach((model) => {
|
||||
expect(model.id).toMatch(/^copilot-/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkAuth', () => {
|
||||
it('should return authenticated status when gh CLI is logged in', async () => {
|
||||
// Set up mocks BEFORE creating provider to ensure CLI detection succeeds
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(execSync).mockImplementation((cmd: string) => {
|
||||
// CLI path detection (which/where command)
|
||||
if (cmd.startsWith('which ') || cmd.startsWith('where ')) {
|
||||
return '/usr/local/bin/copilot';
|
||||
}
|
||||
if (cmd.includes('--version')) {
|
||||
return '1.0.0';
|
||||
}
|
||||
if (cmd.includes('gh auth status')) {
|
||||
return 'Logged in to github.com account testuser';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
// Create fresh provider with the mock in place
|
||||
const freshProvider = new CopilotProvider();
|
||||
const status = await freshProvider.checkAuth();
|
||||
expect(status.authenticated).toBe(true);
|
||||
expect(status.method).toBe('oauth');
|
||||
expect(status.login).toBe('testuser');
|
||||
});
|
||||
|
||||
it('should return unauthenticated when gh auth fails', async () => {
|
||||
// Set up mocks BEFORE creating provider
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(execSync).mockImplementation((cmd: string) => {
|
||||
// CLI path detection (which/where command)
|
||||
if (cmd.startsWith('which ') || cmd.startsWith('where ')) {
|
||||
return '/usr/local/bin/copilot';
|
||||
}
|
||||
if (cmd.includes('--version')) {
|
||||
return '1.0.0';
|
||||
}
|
||||
if (cmd.includes('gh auth status')) {
|
||||
throw new Error('Not logged in');
|
||||
}
|
||||
if (cmd.includes('copilot auth status')) {
|
||||
throw new Error('Not logged in');
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
// Create fresh provider with the mock in place
|
||||
const freshProvider = new CopilotProvider();
|
||||
const status = await freshProvider.checkAuth();
|
||||
expect(status.authenticated).toBe(false);
|
||||
expect(status.method).toBe('none');
|
||||
});
|
||||
|
||||
it('should detect GITHUB_TOKEN environment variable', async () => {
|
||||
process.env.GITHUB_TOKEN = 'test-token';
|
||||
|
||||
// Set up mocks BEFORE creating provider
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(execSync).mockImplementation((cmd: string) => {
|
||||
// CLI path detection (which/where command)
|
||||
if (cmd.startsWith('which ') || cmd.startsWith('where ')) {
|
||||
return '/usr/local/bin/copilot';
|
||||
}
|
||||
if (cmd.includes('--version')) {
|
||||
return '1.0.0';
|
||||
}
|
||||
if (cmd.includes('gh auth status')) {
|
||||
throw new Error('Not logged in');
|
||||
}
|
||||
if (cmd.includes('copilot auth status')) {
|
||||
throw new Error('Not logged in');
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
// Create fresh provider with the mock in place
|
||||
const freshProvider = new CopilotProvider();
|
||||
const status = await freshProvider.checkAuth();
|
||||
expect(status.authenticated).toBe(true);
|
||||
expect(status.method).toBe('oauth');
|
||||
|
||||
delete process.env.GITHUB_TOKEN;
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectInstallation', () => {
|
||||
it('should detect installed CLI', async () => {
|
||||
// Set up mocks BEFORE creating provider
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(execSync).mockImplementation((cmd: string) => {
|
||||
// CLI path detection (which/where command)
|
||||
if (cmd.startsWith('which ') || cmd.startsWith('where ')) {
|
||||
return '/usr/local/bin/copilot';
|
||||
}
|
||||
if (cmd.includes('--version')) {
|
||||
return '1.2.3';
|
||||
}
|
||||
if (cmd.includes('gh auth status')) {
|
||||
return 'Logged in to github.com account testuser';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
// Create fresh provider with the mock in place
|
||||
const freshProvider = new CopilotProvider();
|
||||
const status = await freshProvider.detectInstallation();
|
||||
expect(status.installed).toBe(true);
|
||||
expect(status.version).toBe('1.2.3');
|
||||
expect(status.authenticated).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeEvent', () => {
|
||||
it('should normalize assistant.message event', () => {
|
||||
const event = {
|
||||
type: 'assistant.message',
|
||||
data: { content: 'Hello, world!' },
|
||||
};
|
||||
|
||||
const result = provider.normalizeEvent(event);
|
||||
expect(result).toEqual({
|
||||
type: 'assistant',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'Hello, world!' }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip assistant.message_delta event', () => {
|
||||
const event = {
|
||||
type: 'assistant.message_delta',
|
||||
data: { delta: 'partial' },
|
||||
};
|
||||
|
||||
const result = provider.normalizeEvent(event);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should normalize tool.execution_start event', () => {
|
||||
const event = {
|
||||
type: 'tool.execution_start',
|
||||
data: {
|
||||
toolName: 'read_file',
|
||||
toolCallId: 'call-123',
|
||||
input: { path: '/test/file.txt' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = provider.normalizeEvent(event);
|
||||
expect(result).toEqual({
|
||||
type: 'assistant',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
name: 'Read', // Normalized from read_file
|
||||
tool_use_id: 'call-123',
|
||||
input: { path: '/test/file.txt', file_path: '/test/file.txt' }, // Path normalized
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should normalize tool.execution_end event', () => {
|
||||
const event = {
|
||||
type: 'tool.execution_end',
|
||||
data: {
|
||||
toolName: 'read_file',
|
||||
toolCallId: 'call-123',
|
||||
result: 'file content',
|
||||
},
|
||||
};
|
||||
|
||||
const result = provider.normalizeEvent(event);
|
||||
expect(result).toEqual({
|
||||
type: 'assistant',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'call-123',
|
||||
content: 'file content',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle tool.execution_end with error', () => {
|
||||
const event = {
|
||||
type: 'tool.execution_end',
|
||||
data: {
|
||||
toolName: 'bash',
|
||||
toolCallId: 'call-456',
|
||||
error: 'Command failed',
|
||||
},
|
||||
};
|
||||
|
||||
const result = provider.normalizeEvent(event);
|
||||
expect(result?.message?.content?.[0]).toMatchObject({
|
||||
type: 'tool_result',
|
||||
content: '[ERROR] Command failed',
|
||||
});
|
||||
});
|
||||
|
||||
it('should normalize session.idle to success result', () => {
|
||||
const event = { type: 'session.idle' };
|
||||
|
||||
const result = provider.normalizeEvent(event);
|
||||
expect(result).toEqual({
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
});
|
||||
});
|
||||
|
||||
it('should normalize session.error to error event', () => {
|
||||
const event = {
|
||||
type: 'session.error',
|
||||
data: { message: 'Something went wrong' },
|
||||
};
|
||||
|
||||
const result = provider.normalizeEvent(event);
|
||||
expect(result).toEqual({
|
||||
type: 'error',
|
||||
error: 'Something went wrong',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null for unknown event types', () => {
|
||||
const event = { type: 'unknown.event' };
|
||||
|
||||
const result = provider.normalizeEvent(event);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapError', () => {
|
||||
it('should map authentication errors', () => {
|
||||
const errorInfo = (provider as any).mapError('not authenticated', null);
|
||||
expect(errorInfo.code).toBe(CopilotErrorCode.NOT_AUTHENTICATED);
|
||||
expect(errorInfo.recoverable).toBe(true);
|
||||
});
|
||||
|
||||
it('should map rate limit errors', () => {
|
||||
const errorInfo = (provider as any).mapError('rate limit exceeded', null);
|
||||
expect(errorInfo.code).toBe(CopilotErrorCode.RATE_LIMITED);
|
||||
expect(errorInfo.recoverable).toBe(true);
|
||||
});
|
||||
|
||||
it('should map model unavailable errors', () => {
|
||||
const errorInfo = (provider as any).mapError('model not available', null);
|
||||
expect(errorInfo.code).toBe(CopilotErrorCode.MODEL_UNAVAILABLE);
|
||||
expect(errorInfo.recoverable).toBe(true);
|
||||
});
|
||||
|
||||
it('should map network errors', () => {
|
||||
const errorInfo = (provider as any).mapError('connection refused', null);
|
||||
expect(errorInfo.code).toBe(CopilotErrorCode.NETWORK_ERROR);
|
||||
expect(errorInfo.recoverable).toBe(true);
|
||||
});
|
||||
|
||||
it('should map process crash (exit code 137)', () => {
|
||||
const errorInfo = (provider as any).mapError('', 137);
|
||||
expect(errorInfo.code).toBe(CopilotErrorCode.PROCESS_CRASHED);
|
||||
expect(errorInfo.recoverable).toBe(true);
|
||||
});
|
||||
|
||||
it('should return unknown error for unrecognized errors', () => {
|
||||
const errorInfo = (provider as any).mapError('some random error', 1);
|
||||
expect(errorInfo.code).toBe(CopilotErrorCode.UNKNOWN);
|
||||
expect(errorInfo.recoverable).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('model cache', () => {
|
||||
it('should indicate when cache is empty', () => {
|
||||
expect(provider.hasCachedModels()).toBe(false);
|
||||
});
|
||||
|
||||
it('should clear model cache', () => {
|
||||
provider.clearModelCache();
|
||||
expect(provider.hasCachedModels()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tool name normalization', () => {
|
||||
it('should normalize read_file to Read', () => {
|
||||
const event = {
|
||||
type: 'tool.execution_start',
|
||||
data: { toolName: 'read_file', toolCallId: 'id', input: {} },
|
||||
};
|
||||
const result = provider.normalizeEvent(event);
|
||||
expect(result?.message?.content?.[0]).toMatchObject({ name: 'Read' });
|
||||
});
|
||||
|
||||
it('should normalize write_file to Write', () => {
|
||||
const event = {
|
||||
type: 'tool.execution_start',
|
||||
data: { toolName: 'write_file', toolCallId: 'id', input: {} },
|
||||
};
|
||||
const result = provider.normalizeEvent(event);
|
||||
expect(result?.message?.content?.[0]).toMatchObject({ name: 'Write' });
|
||||
});
|
||||
|
||||
it('should normalize run_shell to Bash', () => {
|
||||
const event = {
|
||||
type: 'tool.execution_start',
|
||||
data: { toolName: 'run_shell', toolCallId: 'id', input: {} },
|
||||
};
|
||||
const result = provider.normalizeEvent(event);
|
||||
expect(result?.message?.content?.[0]).toMatchObject({ name: 'Bash' });
|
||||
});
|
||||
|
||||
it('should normalize search to Grep', () => {
|
||||
const event = {
|
||||
type: 'tool.execution_start',
|
||||
data: { toolName: 'search', toolCallId: 'id', input: {} },
|
||||
};
|
||||
const result = provider.normalizeEvent(event);
|
||||
expect(result?.message?.content?.[0]).toMatchObject({ name: 'Grep' });
|
||||
});
|
||||
|
||||
it('should normalize todo_write to TodoWrite', () => {
|
||||
const event = {
|
||||
type: 'tool.execution_start',
|
||||
data: {
|
||||
toolName: 'todo_write',
|
||||
toolCallId: 'id',
|
||||
input: {
|
||||
todos: [{ description: 'Test task', status: 'pending' }],
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = provider.normalizeEvent(event);
|
||||
expect(result?.message?.content?.[0]).toMatchObject({ name: 'TodoWrite' });
|
||||
});
|
||||
|
||||
it('should normalize todo content from description', () => {
|
||||
const event = {
|
||||
type: 'tool.execution_start',
|
||||
data: {
|
||||
toolName: 'todo_write',
|
||||
toolCallId: 'id',
|
||||
input: {
|
||||
todos: [{ description: 'Test task', status: 'pending' }],
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = provider.normalizeEvent(event);
|
||||
const todoInput = (result?.message?.content?.[0] as any)?.input;
|
||||
expect(todoInput.todos[0]).toMatchObject({
|
||||
content: 'Test task',
|
||||
status: 'pending',
|
||||
activeForm: 'Test task',
|
||||
});
|
||||
});
|
||||
|
||||
it('should map cancelled status to completed', () => {
|
||||
const event = {
|
||||
type: 'tool.execution_start',
|
||||
data: {
|
||||
toolName: 'todo_write',
|
||||
toolCallId: 'id',
|
||||
input: {
|
||||
todos: [{ description: 'Cancelled task', status: 'cancelled' }],
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = provider.normalizeEvent(event);
|
||||
const todoInput = (result?.message?.content?.[0] as any)?.input;
|
||||
expect(todoInput.todos[0].status).toBe('completed');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { CursorProvider } from '@/providers/cursor-provider.js';
|
||||
import { CodexProvider } from '@/providers/codex-provider.js';
|
||||
import { OpencodeProvider } from '@/providers/opencode-provider.js';
|
||||
import { GeminiProvider } from '@/providers/gemini-provider.js';
|
||||
import { CopilotProvider } from '@/providers/copilot-provider.js';
|
||||
|
||||
describe('provider-factory.ts', () => {
|
||||
let consoleSpy: any;
|
||||
@@ -13,6 +14,7 @@ describe('provider-factory.ts', () => {
|
||||
let detectCodexSpy: any;
|
||||
let detectOpencodeSpy: any;
|
||||
let detectGeminiSpy: any;
|
||||
let detectCopilotSpy: any;
|
||||
|
||||
beforeEach(() => {
|
||||
consoleSpy = {
|
||||
@@ -35,6 +37,9 @@ describe('provider-factory.ts', () => {
|
||||
detectGeminiSpy = vi
|
||||
.spyOn(GeminiProvider.prototype, 'detectInstallation')
|
||||
.mockResolvedValue({ installed: true });
|
||||
detectCopilotSpy = vi
|
||||
.spyOn(CopilotProvider.prototype, 'detectInstallation')
|
||||
.mockResolvedValue({ installed: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -44,6 +49,7 @@ describe('provider-factory.ts', () => {
|
||||
detectCodexSpy.mockRestore();
|
||||
detectOpencodeSpy.mockRestore();
|
||||
detectGeminiSpy.mockRestore();
|
||||
detectCopilotSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('getProviderForModel', () => {
|
||||
@@ -172,9 +178,15 @@ describe('provider-factory.ts', () => {
|
||||
expect(hasClaudeProvider).toBe(true);
|
||||
});
|
||||
|
||||
it('should return exactly 5 providers', () => {
|
||||
it('should return exactly 6 providers', () => {
|
||||
const providers = ProviderFactory.getAllProviders();
|
||||
expect(providers).toHaveLength(5);
|
||||
expect(providers).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('should include CopilotProvider', () => {
|
||||
const providers = ProviderFactory.getAllProviders();
|
||||
const hasCopilotProvider = providers.some((p) => p instanceof CopilotProvider);
|
||||
expect(hasCopilotProvider).toBe(true);
|
||||
});
|
||||
|
||||
it('should include GeminiProvider', () => {
|
||||
@@ -219,7 +231,8 @@ describe('provider-factory.ts', () => {
|
||||
expect(keys).toContain('codex');
|
||||
expect(keys).toContain('opencode');
|
||||
expect(keys).toContain('gemini');
|
||||
expect(keys).toHaveLength(5);
|
||||
expect(keys).toContain('copilot');
|
||||
expect(keys).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('should include cursor status', async () => {
|
||||
|
||||
10
apps/ui/src/assets/icons/gemini-icon.svg
Normal file
10
apps/ui/src/assets/icons/gemini-icon.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14 28C14 26.0633 13.6267 24.2433 12.88 22.54C12.1567 20.8367 11.165 19.355 9.905 18.095C8.645 16.835 7.16333 15.8433 5.46 15.12C3.75667 14.3733 1.93667 14 0 14C1.93667 14 3.75667 13.6383 5.46 12.915C7.16333 12.1683 8.645 11.165 9.905 9.905C11.165 8.645 12.1567 7.16333 12.88 5.46C13.6267 3.75667 14 1.93667 14 0C14 1.93667 14.3617 3.75667 15.085 5.46C15.8317 7.16333 16.835 8.645 18.095 9.905C19.355 11.165 20.8367 12.1683 22.54 12.915C24.2433 13.6383 26.0633 14 28 14C26.0633 14 24.2433 14.3733 22.54 15.12C20.8367 15.8433 19.355 16.835 18.095 18.095C16.835 19.355 15.8317 20.8367 15.085 22.54C14.3617 24.2433 14 26.0633 14 28Z" fill="url(#paint0_radial_16771_53212)"/>
|
||||
<defs>
|
||||
<radialGradient id="paint0_radial_16771_53212" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(2.77876 11.3795) rotate(18.6832) scale(29.8025 238.737)">
|
||||
<stop offset="0.0671246" stop-color="#9168C0"/>
|
||||
<stop offset="0.342551" stop-color="#5684D1"/>
|
||||
<stop offset="0.672076" stop-color="#1BA1E3"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -32,7 +32,7 @@ export function AutomakerLogo({ sidebarOpen, navigate }: AutomakerLogoProps) {
|
||||
'flex items-center gap-3 titlebar-no-drag cursor-pointer group',
|
||||
!sidebarOpen && 'flex-col gap-1'
|
||||
)}
|
||||
onClick={() => navigate({ to: '/dashboard' })}
|
||||
onClick={() => navigate({ to: '/overview' })}
|
||||
data-testid="logo-button"
|
||||
>
|
||||
{/* Collapsed logo - only shown when sidebar is closed */}
|
||||
|
||||
@@ -217,7 +217,15 @@ export function SidebarFooter({
|
||||
|
||||
// Expanded state
|
||||
return (
|
||||
<div className="shrink-0">
|
||||
<div
|
||||
className={cn(
|
||||
'shrink-0',
|
||||
// Top border with gradient fade
|
||||
'border-t border-border/40',
|
||||
// Elevated background for visual separation
|
||||
'bg-gradient-to-t from-background/10 via-sidebar/50 to-transparent'
|
||||
)}
|
||||
>
|
||||
{/* Running Agents Link */}
|
||||
{!hideRunningAgents && (
|
||||
<div className="px-3 py-0.5">
|
||||
|
||||
@@ -37,7 +37,7 @@ export function SidebarHeader({
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
|
||||
const handleLogoClick = useCallback(() => {
|
||||
navigate({ to: '/dashboard' });
|
||||
navigate({ to: '/overview' });
|
||||
}, [navigate]);
|
||||
|
||||
const handleProjectSelect = useCallback(
|
||||
|
||||
@@ -89,7 +89,7 @@ export function SidebarNavigation({
|
||||
// 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')) {
|
||||
if (!section.label && section.items.some((item) => item.id === 'overview')) {
|
||||
return true;
|
||||
}
|
||||
// Show other sections only when project is selected
|
||||
|
||||
@@ -175,12 +175,12 @@ export function useNavigation({
|
||||
}
|
||||
|
||||
const sections: NavSection[] = [
|
||||
// Dashboard - standalone at top
|
||||
// Dashboard - standalone at top (links to projects overview)
|
||||
{
|
||||
label: '',
|
||||
items: [
|
||||
{
|
||||
id: 'dashboard',
|
||||
id: 'overview',
|
||||
label: 'Dashboard',
|
||||
icon: Home,
|
||||
},
|
||||
|
||||
@@ -1,389 +0,0 @@
|
||||
/**
|
||||
* 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,4 +1,4 @@
|
||||
import type { ComponentType, SVGProps } from 'react';
|
||||
import type { ComponentType, ImgHTMLAttributes, SVGProps } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { AgentModel, ModelProvider } from '@automaker/types';
|
||||
import { getProviderFromModel } from '@/lib/utils';
|
||||
@@ -19,6 +19,7 @@ const PROVIDER_ICON_KEYS = {
|
||||
minimax: 'minimax',
|
||||
glm: 'glm',
|
||||
bigpickle: 'bigpickle',
|
||||
copilot: 'copilot',
|
||||
} as const;
|
||||
|
||||
type ProviderIconKey = keyof typeof PROVIDER_ICON_KEYS;
|
||||
@@ -113,6 +114,12 @@ const PROVIDER_ICON_DEFINITIONS: Record<ProviderIconKey, ProviderIconDefinition>
|
||||
path: 'M8 4c-2.21 0-4 1.79-4 4v8c0 2.21 1.79 4 4 4h8c2.21 0 4-1.79 4-4V8c0-2.21-1.79-4-4-4H8zm0 2h8c1.103 0 2 .897 2 2v8c0 1.103-.897 2-2 2H8c-1.103 0-2-.897-2-2V8c0-1.103.897-2 2-2zm2 3a1 1 0 100 2 1 1 0 000-2zm4 0a1 1 0 100 2 1 1 0 000-2zm-4 4a1 1 0 100 2 1 1 0 000-2zm4 0a1 1 0 100 2 1 1 0 000-2z',
|
||||
fill: '#4ADE80',
|
||||
},
|
||||
copilot: {
|
||||
viewBox: '0 0 98 96',
|
||||
// Official GitHub Octocat logo mark
|
||||
path: 'M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z',
|
||||
fill: '#ffffff',
|
||||
},
|
||||
};
|
||||
|
||||
export interface ProviderIconProps extends Omit<SVGProps<SVGSVGElement>, 'viewBox'> {
|
||||
@@ -166,8 +173,40 @@ export function CursorIcon(props: Omit<ProviderIconProps, 'provider'>) {
|
||||
return <ProviderIcon provider={PROVIDER_ICON_KEYS.cursor} {...props} />;
|
||||
}
|
||||
|
||||
export function GeminiIcon(props: Omit<ProviderIconProps, 'provider'>) {
|
||||
return <ProviderIcon provider={PROVIDER_ICON_KEYS.gemini} {...props} />;
|
||||
const GEMINI_ICON_URL = new URL('../../assets/icons/gemini-icon.svg', import.meta.url).toString();
|
||||
const GEMINI_ICON_ALT = 'Gemini';
|
||||
|
||||
type GeminiIconProps = Omit<ImgHTMLAttributes<HTMLImageElement>, 'src'> & {
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export function GeminiIcon({ title, className, ...props }: GeminiIconProps) {
|
||||
const {
|
||||
role,
|
||||
'aria-label': ariaLabel,
|
||||
'aria-labelledby': ariaLabelledby,
|
||||
'aria-hidden': ariaHidden,
|
||||
...rest
|
||||
} = props;
|
||||
const hasAccessibleLabel = Boolean(title || ariaLabel || ariaLabelledby);
|
||||
const fallbackAlt = hasAccessibleLabel ? (title ?? ariaLabel ?? GEMINI_ICON_ALT) : '';
|
||||
|
||||
return (
|
||||
<img
|
||||
src={GEMINI_ICON_URL}
|
||||
className={cn('inline-block', className)}
|
||||
role={role ?? (hasAccessibleLabel ? 'img' : 'presentation')}
|
||||
aria-hidden={ariaHidden ?? !hasAccessibleLabel}
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabelledby}
|
||||
alt={fallbackAlt}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CopilotIcon(props: Omit<ProviderIconProps, 'provider'>) {
|
||||
return <ProviderIcon provider={PROVIDER_ICON_KEYS.copilot} {...props} />;
|
||||
}
|
||||
|
||||
export function GrokIcon(props: Omit<ProviderIconProps, 'provider'>) {
|
||||
@@ -396,6 +435,7 @@ export const PROVIDER_ICON_COMPONENTS: Record<
|
||||
codex: OpenAIIcon,
|
||||
opencode: OpenCodeIcon,
|
||||
gemini: GeminiIcon,
|
||||
copilot: CopilotIcon,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -546,6 +586,10 @@ function getUnderlyingModelIcon(model?: AgentModel | string): ProviderIconKey {
|
||||
if (modelStr.includes('grok')) {
|
||||
return 'grok';
|
||||
}
|
||||
// GitHub Copilot models
|
||||
if (modelStr.includes('copilot')) {
|
||||
return 'copilot';
|
||||
}
|
||||
// Cursor models - canonical format includes 'cursor-' prefix
|
||||
// Also support legacy IDs for backward compatibility
|
||||
if (
|
||||
@@ -563,6 +607,7 @@ function getUnderlyingModelIcon(model?: AgentModel | string): ProviderIconKey {
|
||||
if (provider === 'codex') return 'openai';
|
||||
if (provider === 'cursor') return 'cursor';
|
||||
if (provider === 'opencode') return 'opencode';
|
||||
if (provider === 'copilot') return 'copilot';
|
||||
return 'anthropic';
|
||||
}
|
||||
|
||||
@@ -587,6 +632,7 @@ export function getProviderIconForModel(
|
||||
minimax: MiniMaxIcon,
|
||||
glm: GlmIcon,
|
||||
bigpickle: BigPickleIcon,
|
||||
copilot: CopilotIcon,
|
||||
};
|
||||
|
||||
return iconMap[iconKey] || AnthropicIcon;
|
||||
|
||||
@@ -27,6 +27,7 @@ type UsageError = {
|
||||
|
||||
// Fixed refresh interval (45 seconds)
|
||||
const REFRESH_INTERVAL_SECONDS = 45;
|
||||
const CLAUDE_SESSION_WINDOW_HOURS = 5;
|
||||
|
||||
// Helper to format reset time for Codex
|
||||
function formatCodexResetTime(unixTimestamp: number): string {
|
||||
@@ -226,9 +227,7 @@ export function UsagePopover() {
|
||||
};
|
||||
|
||||
// Calculate max percentage for header button
|
||||
const claudeMaxPercentage = claudeUsage
|
||||
? Math.max(claudeUsage.sessionPercentage || 0, claudeUsage.weeklyPercentage || 0)
|
||||
: 0;
|
||||
const claudeSessionPercentage = claudeUsage?.sessionPercentage || 0;
|
||||
|
||||
const codexMaxPercentage = codexUsage?.rateLimits
|
||||
? Math.max(
|
||||
@@ -237,7 +236,6 @@ export function UsagePopover() {
|
||||
)
|
||||
: 0;
|
||||
|
||||
const maxPercentage = Math.max(claudeMaxPercentage, codexMaxPercentage);
|
||||
const isStale = activeTab === 'claude' ? isClaudeStale : isCodexStale;
|
||||
|
||||
const getProgressBarColor = (percentage: number) => {
|
||||
@@ -246,25 +244,38 @@ export function UsagePopover() {
|
||||
return 'bg-green-500';
|
||||
};
|
||||
|
||||
// Determine which provider icon and percentage to show based on active tab
|
||||
const getTabInfo = () => {
|
||||
if (activeTab === 'claude') {
|
||||
return {
|
||||
icon: AnthropicIcon,
|
||||
percentage: claudeMaxPercentage,
|
||||
isStale: isClaudeStale,
|
||||
};
|
||||
}
|
||||
return {
|
||||
icon: OpenAIIcon,
|
||||
percentage: codexMaxPercentage,
|
||||
isStale: isCodexStale,
|
||||
};
|
||||
};
|
||||
const codexPrimaryWindowMinutes = codexUsage?.rateLimits?.primary?.windowDurationMins ?? null;
|
||||
const codexSecondaryWindowMinutes = codexUsage?.rateLimits?.secondary?.windowDurationMins ?? null;
|
||||
const codexWindowMinutes =
|
||||
codexSecondaryWindowMinutes && codexPrimaryWindowMinutes
|
||||
? Math.min(codexPrimaryWindowMinutes, codexSecondaryWindowMinutes)
|
||||
: (codexSecondaryWindowMinutes ?? codexPrimaryWindowMinutes);
|
||||
const codexWindowLabel = codexWindowMinutes
|
||||
? getCodexWindowLabel(codexWindowMinutes).title
|
||||
: 'Window';
|
||||
const codexWindowUsage =
|
||||
codexWindowMinutes === codexSecondaryWindowMinutes
|
||||
? codexUsage?.rateLimits?.secondary?.usedPercent
|
||||
: codexUsage?.rateLimits?.primary?.usedPercent;
|
||||
|
||||
const tabInfo = getTabInfo();
|
||||
const statusColor = getStatusInfo(tabInfo.percentage).color;
|
||||
const ProviderIcon = tabInfo.icon;
|
||||
// Determine which provider icon and percentage to show based on active tab
|
||||
const indicatorInfo =
|
||||
activeTab === 'claude'
|
||||
? {
|
||||
icon: AnthropicIcon,
|
||||
percentage: claudeSessionPercentage,
|
||||
isStale: isClaudeStale,
|
||||
title: `Session usage (${CLAUDE_SESSION_WINDOW_HOURS}h window)`,
|
||||
}
|
||||
: {
|
||||
icon: OpenAIIcon,
|
||||
percentage: codexWindowUsage ?? 0,
|
||||
isStale: isCodexStale,
|
||||
title: `Usage (${codexWindowLabel})`,
|
||||
};
|
||||
|
||||
const statusColor = getStatusInfo(indicatorInfo.percentage).color;
|
||||
const ProviderIcon = indicatorInfo.icon;
|
||||
|
||||
const trigger = (
|
||||
<Button variant="ghost" size="sm" className="h-9 gap-2 bg-secondary border border-border px-3">
|
||||
@@ -272,17 +283,18 @@ export function UsagePopover() {
|
||||
<span className="text-sm font-medium">Usage</span>
|
||||
{(claudeUsage || codexUsage) && (
|
||||
<div
|
||||
title={indicatorInfo.title}
|
||||
className={cn(
|
||||
'h-1.5 w-16 bg-muted-foreground/20 rounded-full overflow-hidden transition-opacity',
|
||||
tabInfo.isStale && 'opacity-60'
|
||||
indicatorInfo.isStale && 'opacity-60'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'h-full transition-all duration-500',
|
||||
getProgressBarColor(tabInfo.percentage)
|
||||
getProgressBarColor(indicatorInfo.percentage)
|
||||
)}
|
||||
style={{ width: `${Math.min(tabInfo.percentage, 100)}%` }}
|
||||
style={{ width: `${Math.min(indicatorInfo.percentage, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -377,13 +389,6 @@ export function UsagePopover() {
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<UsageCard
|
||||
title="Weekly"
|
||||
subtitle="All models"
|
||||
percentage={claudeUsage.weeklyPercentage}
|
||||
resetText={claudeUsage.weeklyResetText}
|
||||
stale={isClaudeStale}
|
||||
/>
|
||||
<UsageCard
|
||||
title="Sonnet"
|
||||
subtitle="Weekly"
|
||||
@@ -391,6 +396,13 @@ export function UsagePopover() {
|
||||
resetText={claudeUsage.sonnetResetText}
|
||||
stale={isClaudeStale}
|
||||
/>
|
||||
<UsageCard
|
||||
title="Weekly"
|
||||
subtitle="All models"
|
||||
percentage={claudeUsage.weeklyPercentage}
|
||||
resetText={claudeUsage.weeklyResetText}
|
||||
stale={isClaudeStale}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{claudeUsage.costLimit && claudeUsage.costLimit > 0 && (
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useState } from 'react';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Wand2, GitBranch, ClipboardCheck } from 'lucide-react';
|
||||
import { ProviderUsageBar } from '@/components/provider-usage-bar';
|
||||
import { UsagePopover } from '@/components/usage-popover';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useSetupStore } from '@/store/setup-store';
|
||||
import { useIsTablet } from '@/hooks/use-media-query';
|
||||
@@ -127,8 +127,8 @@ export function BoardHeader({
|
||||
<BoardControls isMounted={isMounted} onShowBoardBackground={onShowBoardBackground} />
|
||||
</div>
|
||||
<div className="flex gap-4 items-center">
|
||||
{/* Provider Usage Bar - shows all available providers, only on desktop */}
|
||||
{isMounted && !isTablet && <ProviderUsageBar />}
|
||||
{/* Usage Popover - show if either provider is authenticated, only on desktop */}
|
||||
{isMounted && !isTablet && (showClaudeUsage || showCodexUsage) && <UsagePopover />}
|
||||
|
||||
{/* Tablet/Mobile view: show hamburger menu with all controls */}
|
||||
{isMounted && isTablet && (
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
CODEX_MODEL_MAP,
|
||||
OPENCODE_MODELS as OPENCODE_MODEL_CONFIGS,
|
||||
GEMINI_MODEL_MAP,
|
||||
COPILOT_MODEL_MAP,
|
||||
} from '@automaker/types';
|
||||
import { Brain, Zap, Scale, Cpu, Rocket, Sparkles } from 'lucide-react';
|
||||
import {
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
OpenAIIcon,
|
||||
OpenCodeIcon,
|
||||
GeminiIcon,
|
||||
CopilotIcon,
|
||||
} from '@/components/ui/provider-icon';
|
||||
|
||||
export type ModelOption = {
|
||||
@@ -140,7 +142,22 @@ export const GEMINI_MODELS: ModelOption[] = Object.entries(GEMINI_MODEL_MAP).map
|
||||
);
|
||||
|
||||
/**
|
||||
* All available models (Claude + Cursor + Codex + OpenCode + Gemini)
|
||||
* Copilot models derived from COPILOT_MODEL_MAP
|
||||
* Model IDs already have 'copilot-' prefix
|
||||
*/
|
||||
export const COPILOT_MODELS: ModelOption[] = Object.entries(COPILOT_MODEL_MAP).map(
|
||||
([id, config]) => ({
|
||||
id, // IDs already have copilot- prefix (e.g., 'copilot-gpt-4o')
|
||||
label: config.label,
|
||||
description: config.description,
|
||||
badge: config.supportsVision ? 'Vision' : 'Standard',
|
||||
provider: 'copilot' as ModelProvider,
|
||||
hasThinking: false,
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* All available models (Claude + Cursor + Codex + OpenCode + Gemini + Copilot)
|
||||
*/
|
||||
export const ALL_MODELS: ModelOption[] = [
|
||||
...CLAUDE_MODELS,
|
||||
@@ -148,6 +165,7 @@ export const ALL_MODELS: ModelOption[] = [
|
||||
...CODEX_MODELS,
|
||||
...OPENCODE_MODELS,
|
||||
...GEMINI_MODELS,
|
||||
...COPILOT_MODELS,
|
||||
];
|
||||
|
||||
export const THINKING_LEVELS: ThinkingLevel[] = ['none', 'low', 'medium', 'high', 'ultrathink'];
|
||||
@@ -195,4 +213,5 @@ export const PROFILE_ICONS: Record<string, React.ComponentType<{ className?: str
|
||||
Codex: OpenAIIcon,
|
||||
OpenCode: OpenCodeIcon,
|
||||
Gemini: GeminiIcon,
|
||||
Copilot: CopilotIcon,
|
||||
};
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
Trash2,
|
||||
Search,
|
||||
X,
|
||||
LayoutDashboard,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
@@ -556,9 +557,32 @@ export function DashboardView() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Projects Overview button */}
|
||||
{hasProjects && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate({ to: '/overview' })}
|
||||
className="hidden sm:flex gap-2 titlebar-no-drag"
|
||||
data-testid="projects-overview-button"
|
||||
>
|
||||
<LayoutDashboard className="w-4 h-4" />
|
||||
Overview
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Mobile action buttons in header */}
|
||||
{hasProjects && (
|
||||
<div className="flex sm:hidden gap-2 titlebar-no-drag">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => navigate({ to: '/overview' })}
|
||||
title="Projects Overview"
|
||||
data-testid="projects-overview-button-mobile"
|
||||
>
|
||||
<LayoutDashboard className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" onClick={handleOpenProject}>
|
||||
<FolderOpen className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
519
apps/ui/src/components/views/overview-view.tsx
Normal file
519
apps/ui/src/components/views/overview-view.tsx
Normal file
@@ -0,0 +1,519 @@
|
||||
/**
|
||||
* OverviewView - Multi-project dashboard showing status across all projects
|
||||
*
|
||||
* Provides a unified view of all projects with active features, running agents,
|
||||
* recent completions, and alerts. Quick navigation to any project or feature.
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { useMultiProjectStatus } from '@/hooks/use-multi-project-status';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { isElectron, getElectronAPI } from '@/lib/electron';
|
||||
import { isMac } from '@/lib/utils';
|
||||
import { initializeProject } from '@/lib/project-init';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import { toast } from 'sonner';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { NewProjectModal } from '@/components/dialogs/new-project-modal';
|
||||
import { WorkspacePickerModal } from '@/components/dialogs/workspace-picker-modal';
|
||||
import { ProjectStatusCard } from './overview/project-status-card';
|
||||
import { RecentActivityFeed } from './overview/recent-activity-feed';
|
||||
import { RunningAgentsPanel } from './overview/running-agents-panel';
|
||||
import type { StarterTemplate } from '@/lib/templates';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
RefreshCw,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
Plus,
|
||||
Activity,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Clock,
|
||||
Bot,
|
||||
Bell,
|
||||
} from 'lucide-react';
|
||||
|
||||
const logger = createLogger('OverviewView');
|
||||
|
||||
export function OverviewView() {
|
||||
const navigate = useNavigate();
|
||||
const { overview, isLoading, error, refresh } = useMultiProjectStatus(15000); // Refresh every 15s
|
||||
const { upsertAndSetCurrentProject } = useAppStore();
|
||||
|
||||
// Modal state
|
||||
const [showNewProjectModal, setShowNewProjectModal] = useState(false);
|
||||
const [showWorkspacePicker, setShowWorkspacePicker] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
const initializeAndOpenProject = useCallback(
|
||||
async (path: string, name: string) => {
|
||||
try {
|
||||
const initResult = await initializeProject(path);
|
||||
if (!initResult.success) {
|
||||
toast.error('Failed to initialize project', {
|
||||
description: initResult.error || 'Unknown error occurred',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
upsertAndSetCurrentProject(path, name);
|
||||
|
||||
toast.success('Project opened', { description: `Opened ${name}` });
|
||||
navigate({ to: '/board' });
|
||||
} catch (error) {
|
||||
logger.error('[Overview] Failed to open project:', error);
|
||||
toast.error('Failed to open project', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
},
|
||||
[upsertAndSetCurrentProject, navigate]
|
||||
);
|
||||
|
||||
const handleOpenProject = useCallback(async () => {
|
||||
try {
|
||||
const httpClient = getHttpApiClient();
|
||||
const configResult = await httpClient.workspace.getConfig();
|
||||
|
||||
if (configResult.success && configResult.configured) {
|
||||
setShowWorkspacePicker(true);
|
||||
} else {
|
||||
const api = getElectronAPI();
|
||||
const result = await api.openDirectory();
|
||||
|
||||
if (!result.canceled && result.filePaths[0]) {
|
||||
const path = result.filePaths[0];
|
||||
const name = path.split(/[/\\]/).filter(Boolean).pop() || 'Untitled Project';
|
||||
await initializeAndOpenProject(path, name);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[Overview] Failed to check workspace config:', error);
|
||||
const api = getElectronAPI();
|
||||
const result = await api.openDirectory();
|
||||
|
||||
if (!result.canceled && result.filePaths[0]) {
|
||||
const path = result.filePaths[0];
|
||||
const name = path.split(/[/\\]/).filter(Boolean).pop() || 'Untitled Project';
|
||||
await initializeAndOpenProject(path, name);
|
||||
}
|
||||
}
|
||||
}, [initializeAndOpenProject]);
|
||||
|
||||
const handleWorkspaceSelect = useCallback(
|
||||
async (path: string, name: string) => {
|
||||
setShowWorkspacePicker(false);
|
||||
await initializeAndOpenProject(path, name);
|
||||
},
|
||||
[initializeAndOpenProject]
|
||||
);
|
||||
|
||||
const handleCreateBlankProject = useCallback(
|
||||
async (projectName: string, parentDir: string) => {
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
const projectPath = `${parentDir}/${projectName}`;
|
||||
|
||||
await api.mkdir(projectPath);
|
||||
|
||||
const initResult = await initializeProject(projectPath);
|
||||
if (!initResult.success) {
|
||||
toast.error('Failed to initialize project', {
|
||||
description: initResult.error || 'Unknown error occurred',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await api.writeFile(
|
||||
`${projectPath}/.automaker/app_spec.txt`,
|
||||
`<project_specification>
|
||||
<project_name>${projectName}</project_name>
|
||||
<overview>Describe your project here.</overview>
|
||||
<technology_stack></technology_stack>
|
||||
<core_capabilities></core_capabilities>
|
||||
<implemented_features></implemented_features>
|
||||
</project_specification>`
|
||||
);
|
||||
|
||||
upsertAndSetCurrentProject(projectPath, projectName);
|
||||
setShowNewProjectModal(false);
|
||||
|
||||
toast.success('Project created', { description: `Created ${projectName}` });
|
||||
navigate({ to: '/board' });
|
||||
} catch (error) {
|
||||
logger.error('Failed to create project:', error);
|
||||
toast.error('Failed to create project', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
},
|
||||
[upsertAndSetCurrentProject, navigate]
|
||||
);
|
||||
|
||||
const handleCreateFromTemplate = useCallback(
|
||||
async (template: StarterTemplate, projectName: string, parentDir: string) => {
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const httpClient = getHttpApiClient();
|
||||
const cloneResult = await httpClient.templates.clone(
|
||||
template.repoUrl,
|
||||
projectName,
|
||||
parentDir
|
||||
);
|
||||
|
||||
if (!cloneResult.success || !cloneResult.projectPath) {
|
||||
toast.error('Failed to clone template', {
|
||||
description: cloneResult.error || 'Unknown error occurred',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const initResult = await initializeProject(cloneResult.projectPath);
|
||||
if (!initResult.success) {
|
||||
toast.error('Failed to initialize project', {
|
||||
description: initResult.error || 'Unknown error occurred',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
upsertAndSetCurrentProject(cloneResult.projectPath, projectName);
|
||||
setShowNewProjectModal(false);
|
||||
|
||||
toast.success('Project created from template', {
|
||||
description: `Created ${projectName} from ${template.name}`,
|
||||
});
|
||||
navigate({ to: '/board' });
|
||||
} catch (error) {
|
||||
logger.error('Failed to create from template:', error);
|
||||
toast.error('Failed to create project', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
},
|
||||
[upsertAndSetCurrentProject, navigate]
|
||||
);
|
||||
|
||||
const handleCreateFromCustomUrl = useCallback(
|
||||
async (repoUrl: string, projectName: string, parentDir: string) => {
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const httpClient = getHttpApiClient();
|
||||
const cloneResult = await httpClient.templates.clone(repoUrl, projectName, parentDir);
|
||||
|
||||
if (!cloneResult.success || !cloneResult.projectPath) {
|
||||
toast.error('Failed to clone repository', {
|
||||
description: cloneResult.error || 'Unknown error occurred',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const initResult = await initializeProject(cloneResult.projectPath);
|
||||
if (!initResult.success) {
|
||||
toast.error('Failed to initialize project', {
|
||||
description: initResult.error || 'Unknown error occurred',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
upsertAndSetCurrentProject(cloneResult.projectPath, projectName);
|
||||
setShowNewProjectModal(false);
|
||||
|
||||
toast.success('Project created from repository', { description: `Created ${projectName}` });
|
||||
navigate({ to: '/board' });
|
||||
} catch (error) {
|
||||
logger.error('Failed to create from custom URL:', error);
|
||||
toast.error('Failed to create project', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
},
|
||||
[upsertAndSetCurrentProject, navigate]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-screen content-bg" data-testid="overview-view">
|
||||
{/* Header */}
|
||||
<header className="shrink-0 border-b border-border bg-glass backdrop-blur-md">
|
||||
{/* Electron titlebar drag region */}
|
||||
{isElectron() && (
|
||||
<div
|
||||
className={`absolute top-0 left-0 right-0 h-6 titlebar-drag-region z-40 pointer-events-none ${isMac ? 'pl-20' : ''}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<div className="px-4 sm:px-8 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 titlebar-no-drag">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-lg bg-brand-500/10 flex items-center justify-center">
|
||||
<LayoutDashboard className="w-4 h-4 text-brand-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-foreground">Automaker Dashboard</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{overview ? `${overview.aggregate.projectCounts.total} projects` : 'Loading...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 titlebar-no-drag">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={refresh}
|
||||
disabled={isLoading}
|
||||
className="gap-2"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleOpenProject} className="gap-2">
|
||||
<FolderOpen className="w-4 h-4" />
|
||||
Open Project
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setShowNewProjectModal(true)}
|
||||
className="gap-2 bg-brand-500 hover:bg-brand-600 text-white"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
New Project
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 overflow-y-auto p-4 sm:p-6">
|
||||
{/* Loading state */}
|
||||
{isLoading && !overview && (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Spinner size="lg" />
|
||||
<p className="text-sm text-muted-foreground">Loading project overview...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{error && !overview && (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-red-500/10 flex items-center justify-center">
|
||||
<XCircle className="w-6 h-6 text-red-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-foreground mb-1">Failed to load overview</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">{error}</p>
|
||||
<Button variant="outline" size="sm" onClick={refresh}>
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{overview && (
|
||||
<div className="max-w-7xl mx-auto space-y-6">
|
||||
{/* Aggregate stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
||||
<Card className="bg-card/60">
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-muted flex items-center justify-center">
|
||||
<Folder className="w-5 h-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-foreground">
|
||||
{overview.aggregate.projectCounts.total}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Projects</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card/60">
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-green-500/10 flex items-center justify-center">
|
||||
<Activity className="w-5 h-5 text-green-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-foreground">
|
||||
{overview.aggregate.featureCounts.running}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Running</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card/60">
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-yellow-500/10 flex items-center justify-center">
|
||||
<Clock className="w-5 h-5 text-yellow-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-foreground">
|
||||
{overview.aggregate.featureCounts.pending}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Pending</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card/60">
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center">
|
||||
<CheckCircle2 className="w-5 h-5 text-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-foreground">
|
||||
{overview.aggregate.featureCounts.completed}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Completed</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card/60">
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-red-500/10 flex items-center justify-center">
|
||||
<XCircle className="w-5 h-5 text-red-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-foreground">
|
||||
{overview.aggregate.featureCounts.failed}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Failed</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card/60">
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-brand-500/10 flex items-center justify-center">
|
||||
<Bot className="w-5 h-5 text-brand-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-foreground">
|
||||
{overview.aggregate.projectsWithAutoModeRunning}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Auto-mode</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Main content grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Left column: Project cards */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-foreground">All Projects</h2>
|
||||
{overview.aggregate.totalUnreadNotifications > 0 && (
|
||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Bell className="w-4 h-4" />
|
||||
{overview.aggregate.totalUnreadNotifications} unread notifications
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{overview.projects.length === 0 ? (
|
||||
<Card className="bg-card/60">
|
||||
<CardContent className="py-12 text-center">
|
||||
<Folder className="w-12 h-12 mx-auto mb-4 text-muted-foreground opacity-50" />
|
||||
<h3 className="font-medium text-foreground mb-1">No projects yet</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Create or open a project to get started
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Use the sidebar to create or open a project
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{overview.projects.map((project) => (
|
||||
<ProjectStatusCard key={project.projectId} project={project} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right column: Running agents and activity */}
|
||||
<div className="space-y-4">
|
||||
{/* Running agents */}
|
||||
<Card className="bg-card/60">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Bot className="w-4 h-4 text-green-500" />
|
||||
Running Agents
|
||||
{overview.aggregate.projectsWithAutoModeRunning > 0 && (
|
||||
<span className="text-xs font-normal text-muted-foreground ml-auto">
|
||||
{overview.aggregate.projectsWithAutoModeRunning} active
|
||||
</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<RunningAgentsPanel projects={overview.projects} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Recent activity */}
|
||||
<Card className="bg-card/60">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Activity className="w-4 h-4 text-brand-500" />
|
||||
Recent Activity
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<RecentActivityFeed activities={overview.recentActivity} maxItems={8} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer timestamp */}
|
||||
<div className="text-center text-xs text-muted-foreground pt-4">
|
||||
Last updated:{' '}
|
||||
{new Date(overview.generatedAt).toLocaleTimeString(undefined, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
<NewProjectModal
|
||||
open={showNewProjectModal}
|
||||
onOpenChange={setShowNewProjectModal}
|
||||
onCreateBlankProject={handleCreateBlankProject}
|
||||
onCreateFromTemplate={handleCreateFromTemplate}
|
||||
onCreateFromCustomUrl={handleCreateFromCustomUrl}
|
||||
isCreating={isCreating}
|
||||
/>
|
||||
|
||||
<WorkspacePickerModal
|
||||
open={showWorkspacePicker}
|
||||
onOpenChange={setShowWorkspacePicker}
|
||||
onSelect={handleWorkspaceSelect}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
207
apps/ui/src/components/views/overview/project-status-card.tsx
Normal file
207
apps/ui/src/components/views/overview/project-status-card.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* ProjectStatusCard - Individual project card for multi-project dashboard
|
||||
*
|
||||
* Displays project health, feature counts, and agent status with quick navigation.
|
||||
*/
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { initializeProject } from '@/lib/project-init';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ProjectStatus, ProjectHealthStatus } from '@automaker/types';
|
||||
import { Folder, Activity, CheckCircle2, XCircle, Clock, Pause, Bot, Bell } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
interface ProjectStatusCardProps {
|
||||
project: ProjectStatus;
|
||||
onProjectClick?: (projectId: string) => void;
|
||||
}
|
||||
|
||||
const healthStatusConfig: Record<
|
||||
ProjectHealthStatus,
|
||||
{ icon: typeof Activity; color: string; label: string; bgColor: string }
|
||||
> = {
|
||||
active: {
|
||||
icon: Activity,
|
||||
color: 'text-green-500',
|
||||
label: 'Active',
|
||||
bgColor: 'bg-green-500/10',
|
||||
},
|
||||
idle: {
|
||||
icon: Pause,
|
||||
color: 'text-muted-foreground',
|
||||
label: 'Idle',
|
||||
bgColor: 'bg-muted/50',
|
||||
},
|
||||
waiting: {
|
||||
icon: Clock,
|
||||
color: 'text-yellow-500',
|
||||
label: 'Waiting',
|
||||
bgColor: 'bg-yellow-500/10',
|
||||
},
|
||||
completed: {
|
||||
icon: CheckCircle2,
|
||||
color: 'text-blue-500',
|
||||
label: 'Completed',
|
||||
bgColor: 'bg-blue-500/10',
|
||||
},
|
||||
error: {
|
||||
icon: XCircle,
|
||||
color: 'text-red-500',
|
||||
label: 'Error',
|
||||
bgColor: 'bg-red-500/10',
|
||||
},
|
||||
};
|
||||
|
||||
export function ProjectStatusCard({ project, onProjectClick }: ProjectStatusCardProps) {
|
||||
const navigate = useNavigate();
|
||||
const { upsertAndSetCurrentProject } = useAppStore();
|
||||
|
||||
const statusConfig = healthStatusConfig[project.healthStatus];
|
||||
const StatusIcon = statusConfig.icon;
|
||||
|
||||
const handleClick = useCallback(async () => {
|
||||
if (onProjectClick) {
|
||||
onProjectClick(project.projectId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Default behavior: navigate to project
|
||||
try {
|
||||
const initResult = await initializeProject(project.projectPath);
|
||||
if (!initResult.success) {
|
||||
toast.error('Failed to open project', {
|
||||
description: initResult.error || 'Unknown error',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
upsertAndSetCurrentProject(project.projectPath, project.projectName);
|
||||
navigate({ to: '/board' });
|
||||
} catch (error) {
|
||||
toast.error('Failed to open project', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}, [project, onProjectClick, upsertAndSetCurrentProject, navigate]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleClick();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
'group relative rounded-xl border bg-card/60 backdrop-blur-sm transition-all duration-300 cursor-pointer hover:-translate-y-0.5',
|
||||
project.healthStatus === 'active' && 'border-green-500/30 hover:border-green-500/50',
|
||||
project.healthStatus === 'error' && 'border-red-500/30 hover:border-red-500/50',
|
||||
project.healthStatus === 'waiting' && 'border-yellow-500/30 hover:border-yellow-500/50',
|
||||
project.healthStatus === 'completed' && 'border-blue-500/30 hover:border-blue-500/50',
|
||||
project.healthStatus === 'idle' && 'border-border hover:border-brand-500/40'
|
||||
)}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-label={`Open project ${project.projectName}`}
|
||||
data-testid={`project-status-card-${project.projectId}`}
|
||||
>
|
||||
<div className="p-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-3 mb-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div
|
||||
className={cn(
|
||||
'w-10 h-10 rounded-lg flex items-center justify-center shrink-0 transition-colors',
|
||||
statusConfig.bgColor
|
||||
)}
|
||||
>
|
||||
<Folder className={cn('w-5 h-5', statusConfig.color)} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-medium text-foreground truncate group-hover:text-brand-500 transition-colors">
|
||||
{project.projectName}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground truncate">{project.projectPath}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status badge */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{project.unreadNotificationCount > 0 && (
|
||||
<Badge variant="destructive" className="h-5 px-1.5 text-xs">
|
||||
<Bell className="w-3 h-3 mr-1" />
|
||||
{project.unreadNotificationCount}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'h-6 px-2 text-xs gap-1',
|
||||
statusConfig.color,
|
||||
project.healthStatus === 'active' && 'border-green-500/30 bg-green-500/10',
|
||||
project.healthStatus === 'error' && 'border-red-500/30 bg-red-500/10'
|
||||
)}
|
||||
>
|
||||
<StatusIcon className="w-3 h-3" />
|
||||
{statusConfig.label}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feature counts */}
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{project.featureCounts.running > 0 && (
|
||||
<div className="flex items-center gap-1.5 text-xs px-2 py-1 rounded-md bg-green-500/10 text-green-600 dark:text-green-400">
|
||||
<Activity className="w-3 h-3" />
|
||||
{project.featureCounts.running} running
|
||||
</div>
|
||||
)}
|
||||
{project.featureCounts.pending > 0 && (
|
||||
<div className="flex items-center gap-1.5 text-xs px-2 py-1 rounded-md bg-yellow-500/10 text-yellow-600 dark:text-yellow-400">
|
||||
<Clock className="w-3 h-3" />
|
||||
{project.featureCounts.pending} pending
|
||||
</div>
|
||||
)}
|
||||
{project.featureCounts.completed > 0 && (
|
||||
<div className="flex items-center gap-1.5 text-xs px-2 py-1 rounded-md bg-blue-500/10 text-blue-600 dark:text-blue-400">
|
||||
<CheckCircle2 className="w-3 h-3" />
|
||||
{project.featureCounts.completed} completed
|
||||
</div>
|
||||
)}
|
||||
{project.featureCounts.failed > 0 && (
|
||||
<div className="flex items-center gap-1.5 text-xs px-2 py-1 rounded-md bg-red-500/10 text-red-600 dark:text-red-400">
|
||||
<XCircle className="w-3 h-3" />
|
||||
{project.featureCounts.failed} failed
|
||||
</div>
|
||||
)}
|
||||
{project.featureCounts.verified > 0 && (
|
||||
<div className="flex items-center gap-1.5 text-xs px-2 py-1 rounded-md bg-purple-500/10 text-purple-600 dark:text-purple-400">
|
||||
<CheckCircle2 className="w-3 h-3" />
|
||||
{project.featureCounts.verified} verified
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer: Total features and auto-mode status */}
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground pt-2 border-t border-border/50">
|
||||
<span>{project.totalFeatures} total features</span>
|
||||
{project.isAutoModeRunning && (
|
||||
<div className="flex items-center gap-1.5 text-green-500">
|
||||
<Bot className="w-3.5 h-3.5 animate-pulse" />
|
||||
<span className="font-medium">Auto-mode active</span>
|
||||
</div>
|
||||
)}
|
||||
{project.lastActivityAt && !project.isAutoModeRunning && (
|
||||
<span>Last activity: {new Date(project.lastActivityAt).toLocaleDateString()}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
223
apps/ui/src/components/views/overview/recent-activity-feed.tsx
Normal file
223
apps/ui/src/components/views/overview/recent-activity-feed.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* RecentActivityFeed - Timeline of recent activity across all projects
|
||||
*
|
||||
* Shows completed features, failures, and auto-mode events.
|
||||
*/
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { initializeProject } from '@/lib/project-init';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { RecentActivity, ActivityType, ActivitySeverity } from '@automaker/types';
|
||||
import { CheckCircle2, XCircle, Play, Bot, AlertTriangle, Info, Clock } from 'lucide-react';
|
||||
|
||||
interface RecentActivityFeedProps {
|
||||
activities: RecentActivity[];
|
||||
maxItems?: number;
|
||||
}
|
||||
|
||||
const activityTypeConfig: Record<
|
||||
ActivityType,
|
||||
{ icon: typeof CheckCircle2; defaultColor: string; label: string }
|
||||
> = {
|
||||
feature_created: {
|
||||
icon: Info,
|
||||
defaultColor: 'text-blue-500',
|
||||
label: 'Feature created',
|
||||
},
|
||||
feature_completed: {
|
||||
icon: CheckCircle2,
|
||||
defaultColor: 'text-blue-500',
|
||||
label: 'Feature completed',
|
||||
},
|
||||
feature_verified: {
|
||||
icon: CheckCircle2,
|
||||
defaultColor: 'text-purple-500',
|
||||
label: 'Feature verified',
|
||||
},
|
||||
feature_failed: {
|
||||
icon: XCircle,
|
||||
defaultColor: 'text-red-500',
|
||||
label: 'Feature failed',
|
||||
},
|
||||
feature_started: {
|
||||
icon: Play,
|
||||
defaultColor: 'text-green-500',
|
||||
label: 'Feature started',
|
||||
},
|
||||
auto_mode_started: {
|
||||
icon: Bot,
|
||||
defaultColor: 'text-green-500',
|
||||
label: 'Auto-mode started',
|
||||
},
|
||||
auto_mode_stopped: {
|
||||
icon: Bot,
|
||||
defaultColor: 'text-muted-foreground',
|
||||
label: 'Auto-mode stopped',
|
||||
},
|
||||
ideation_session_started: {
|
||||
icon: Play,
|
||||
defaultColor: 'text-brand-500',
|
||||
label: 'Ideation session started',
|
||||
},
|
||||
ideation_session_ended: {
|
||||
icon: Info,
|
||||
defaultColor: 'text-muted-foreground',
|
||||
label: 'Ideation session ended',
|
||||
},
|
||||
idea_created: {
|
||||
icon: Info,
|
||||
defaultColor: 'text-brand-500',
|
||||
label: 'Idea created',
|
||||
},
|
||||
idea_converted: {
|
||||
icon: CheckCircle2,
|
||||
defaultColor: 'text-green-500',
|
||||
label: 'Idea converted to feature',
|
||||
},
|
||||
notification_created: {
|
||||
icon: AlertTriangle,
|
||||
defaultColor: 'text-yellow-500',
|
||||
label: 'Notification',
|
||||
},
|
||||
project_opened: {
|
||||
icon: Info,
|
||||
defaultColor: 'text-blue-500',
|
||||
label: 'Project opened',
|
||||
},
|
||||
};
|
||||
|
||||
const severityColors: Record<ActivitySeverity, string> = {
|
||||
info: 'text-blue-500',
|
||||
success: 'text-green-500',
|
||||
warning: 'text-yellow-500',
|
||||
error: 'text-red-500',
|
||||
};
|
||||
|
||||
function formatRelativeTime(timestamp: string): string {
|
||||
const now = new Date();
|
||||
const date = new Date(timestamp);
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return 'just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
export function RecentActivityFeed({ activities, maxItems = 10 }: RecentActivityFeedProps) {
|
||||
const navigate = useNavigate();
|
||||
const { upsertAndSetCurrentProject } = useAppStore();
|
||||
|
||||
const displayActivities = activities.slice(0, maxItems);
|
||||
|
||||
const handleActivityClick = useCallback(
|
||||
async (activity: RecentActivity) => {
|
||||
try {
|
||||
// Get project path from the activity (projectId is actually the path in our data model)
|
||||
const projectPath = activity.projectPath || activity.projectId;
|
||||
const projectName = activity.projectName;
|
||||
|
||||
const initResult = await initializeProject(projectPath);
|
||||
|
||||
if (!initResult.success) {
|
||||
toast.error('Failed to initialize project', {
|
||||
description: initResult.error || 'Unknown error',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
upsertAndSetCurrentProject(projectPath, projectName);
|
||||
|
||||
if (activity.featureId) {
|
||||
// Navigate to the specific feature
|
||||
navigate({ to: '/board', search: { featureId: activity.featureId } });
|
||||
} else {
|
||||
navigate({ to: '/board' });
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to navigate to activity', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
},
|
||||
[navigate, upsertAndSetCurrentProject]
|
||||
);
|
||||
|
||||
const handleActivityKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent, activity: RecentActivity) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleActivityClick(activity);
|
||||
}
|
||||
},
|
||||
[handleActivityClick]
|
||||
);
|
||||
|
||||
if (displayActivities.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
|
||||
<Clock className="w-8 h-8 mb-2 opacity-50" />
|
||||
<p className="text-sm">No recent activity</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{displayActivities.map((activity) => {
|
||||
const config = activityTypeConfig[activity.type];
|
||||
const Icon = config.icon;
|
||||
const iconColor = severityColors[activity.severity] || config.defaultColor;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={activity.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="group flex items-start gap-3 p-2 rounded-lg hover:bg-muted/50 cursor-pointer transition-colors"
|
||||
onClick={() => handleActivityClick(activity)}
|
||||
onKeyDown={(e) => handleActivityKeyDown(e, activity)}
|
||||
aria-label={`${config.label}: ${activity.featureName || activity.message} in ${activity.projectName}`}
|
||||
data-testid={`activity-item-${activity.id}`}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div
|
||||
className={cn(
|
||||
'w-8 h-8 rounded-full flex items-center justify-center shrink-0 mt-0.5',
|
||||
activity.severity === 'error' && 'bg-red-500/10',
|
||||
activity.severity === 'success' && 'bg-green-500/10',
|
||||
activity.severity === 'warning' && 'bg-yellow-500/10',
|
||||
activity.severity === 'info' && 'bg-blue-500/10'
|
||||
)}
|
||||
>
|
||||
<Icon className={cn('w-4 h-4', iconColor)} />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{activity.projectName}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground/50">
|
||||
{formatRelativeTime(activity.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-foreground truncate group-hover:text-brand-500 transition-colors">
|
||||
{activity.featureTitle || activity.description}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate mt-0.5">{config.label}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
141
apps/ui/src/components/views/overview/running-agents-panel.tsx
Normal file
141
apps/ui/src/components/views/overview/running-agents-panel.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* RunningAgentsPanel - Shows all currently running agents across projects
|
||||
*
|
||||
* Displays active AI agents with their status and quick access to features.
|
||||
*/
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { initializeProject } from '@/lib/project-init';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ProjectStatus } from '@automaker/types';
|
||||
import { Bot, Activity, GitBranch, ArrowRight } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface RunningAgentsPanelProps {
|
||||
projects: ProjectStatus[];
|
||||
}
|
||||
|
||||
interface RunningAgent {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
projectPath: string;
|
||||
featureCount: number;
|
||||
isAutoMode: boolean;
|
||||
activeBranch?: string;
|
||||
}
|
||||
|
||||
export function RunningAgentsPanel({ projects }: RunningAgentsPanelProps) {
|
||||
const navigate = useNavigate();
|
||||
const { upsertAndSetCurrentProject } = useAppStore();
|
||||
|
||||
// Extract running agents from projects
|
||||
const runningAgents: RunningAgent[] = projects
|
||||
.filter((p) => p.isAutoModeRunning || p.featureCounts.running > 0)
|
||||
.map((p) => ({
|
||||
projectId: p.projectId,
|
||||
projectName: p.projectName,
|
||||
projectPath: p.projectPath,
|
||||
featureCount: p.featureCounts.running,
|
||||
isAutoMode: p.isAutoModeRunning,
|
||||
activeBranch: p.activeBranch,
|
||||
}));
|
||||
|
||||
const handleAgentClick = useCallback(
|
||||
async (agent: RunningAgent) => {
|
||||
try {
|
||||
const initResult = await initializeProject(agent.projectPath);
|
||||
if (!initResult.success) {
|
||||
toast.error('Failed to open project', {
|
||||
description: initResult.error || 'Unknown error',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
upsertAndSetCurrentProject(agent.projectPath, agent.projectName);
|
||||
navigate({ to: '/board' });
|
||||
} catch (error) {
|
||||
toast.error('Failed to navigate to agent', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
},
|
||||
[navigate, upsertAndSetCurrentProject]
|
||||
);
|
||||
|
||||
const handleAgentKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent, agent: RunningAgent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleAgentClick(agent);
|
||||
}
|
||||
},
|
||||
[handleAgentClick]
|
||||
);
|
||||
|
||||
if (runningAgents.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
|
||||
<Bot className="w-8 h-8 mb-2 opacity-50" />
|
||||
<p className="text-sm">No agents running</p>
|
||||
<p className="text-xs mt-1">Start auto-mode on a project to see activity here</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{runningAgents.map((agent) => (
|
||||
<div
|
||||
key={agent.projectId}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="group flex items-center gap-3 p-3 rounded-lg border border-green-500/20 bg-green-500/5 hover:bg-green-500/10 cursor-pointer transition-all"
|
||||
onClick={() => handleAgentClick(agent)}
|
||||
onKeyDown={(e) => handleAgentKeyDown(e, agent)}
|
||||
aria-label={`View running agent for ${agent.projectName}`}
|
||||
data-testid={`running-agent-${agent.projectId}`}
|
||||
>
|
||||
{/* Animated icon */}
|
||||
<div className="relative w-10 h-10 rounded-lg bg-green-500/20 flex items-center justify-center shrink-0">
|
||||
<Bot className="w-5 h-5 text-green-500" />
|
||||
<span className="absolute -top-1 -right-1 w-3 h-3 bg-green-500 rounded-full animate-pulse" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground truncate group-hover:text-green-500 transition-colors">
|
||||
{agent.projectName}
|
||||
</span>
|
||||
{agent.isAutoMode && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-green-500/20 text-green-500 font-medium">
|
||||
Auto
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5 text-xs text-muted-foreground">
|
||||
{agent.featureCount > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Activity className="w-3 h-3" />
|
||||
{agent.featureCount} feature{agent.featureCount !== 1 ? 's' : ''} running
|
||||
</span>
|
||||
)}
|
||||
{agent.activeBranch && (
|
||||
<span className="flex items-center gap-1">
|
||||
<GitBranch className="w-3 h-3" />
|
||||
{agent.activeBranch}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Arrow */}
|
||||
<ArrowRight className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
CodexSettingsTab,
|
||||
OpencodeSettingsTab,
|
||||
GeminiSettingsTab,
|
||||
CopilotSettingsTab,
|
||||
} from './settings-view/providers';
|
||||
import { MCPServersSection } from './settings-view/mcp-servers';
|
||||
import { PromptCustomizationSection } from './settings-view/prompts';
|
||||
@@ -126,6 +127,8 @@ export function SettingsView() {
|
||||
return <OpencodeSettingsTab />;
|
||||
case 'gemini-provider':
|
||||
return <GeminiSettingsTab />;
|
||||
case 'copilot-provider':
|
||||
return <CopilotSettingsTab />;
|
||||
case 'providers':
|
||||
case 'claude': // Backwards compatibility - redirect to claude-provider
|
||||
return <ClaudeSettingsTab />;
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { SkeletonPulse } from '@/components/ui/skeleton';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { CheckCircle2, AlertCircle, RefreshCw } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { CliStatus } from '../shared/types';
|
||||
import { CopilotIcon } from '@/components/ui/provider-icon';
|
||||
import type { CopilotAuthStatus } from '@automaker/types';
|
||||
|
||||
// Re-export for backwards compatibility
|
||||
export type { CopilotAuthStatus };
|
||||
|
||||
function getAuthMethodLabel(method: CopilotAuthStatus['method']): string {
|
||||
switch (method) {
|
||||
case 'oauth':
|
||||
return 'GitHub OAuth';
|
||||
case 'cli':
|
||||
return 'Copilot CLI';
|
||||
default:
|
||||
return method || 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
interface CopilotCliStatusProps {
|
||||
status: CliStatus | null;
|
||||
authStatus?: CopilotAuthStatus | null;
|
||||
isChecking: boolean;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export function CopilotCliStatusSkeleton() {
|
||||
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 CopilotCliStatus({
|
||||
status,
|
||||
authStatus,
|
||||
isChecking,
|
||||
onRefresh,
|
||||
}: CopilotCliStatusProps) {
|
||||
if (!status) return <CopilotCliStatusSkeleton />;
|
||||
|
||||
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-violet-500/20 to-violet-600/10 flex items-center justify-center border border-violet-500/20">
|
||||
<CopilotIcon className="w-5 h-5 text-violet-500" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">
|
||||
GitHub Copilot CLI
|
||||
</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onRefresh}
|
||||
disabled={isChecking}
|
||||
data-testid="refresh-copilot-cli"
|
||||
title="Refresh Copilot CLI detection"
|
||||
aria-label="Refresh Copilot 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">
|
||||
GitHub Copilot CLI provides access to GPT and Claude models via your Copilot subscription.
|
||||
</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">Copilot 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>
|
||||
)}
|
||||
{authStatus.login && (
|
||||
<p>
|
||||
User: <span className="font-mono">{authStatus.login}</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 Required</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">gh auth login</code>{' '}
|
||||
in your terminal to authenticate with GitHub.
|
||||
</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">Copilot CLI Not Detected</p>
|
||||
<p className="text-xs text-amber-400/70 mt-1">
|
||||
{status.recommendation ||
|
||||
'Install GitHub Copilot CLI to use models via your Copilot subscription.'}
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ const NAV_ID_TO_PROVIDER: Record<string, ModelProvider> = {
|
||||
'codex-provider': 'codex',
|
||||
'opencode-provider': 'opencode',
|
||||
'gemini-provider': 'gemini',
|
||||
'copilot-provider': 'copilot',
|
||||
};
|
||||
|
||||
interface SettingsNavigationProps {
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
OpenAIIcon,
|
||||
OpenCodeIcon,
|
||||
GeminiIcon,
|
||||
CopilotIcon,
|
||||
} from '@/components/ui/provider-icon';
|
||||
import type { SettingsViewId } from '../hooks/use-settings-view';
|
||||
|
||||
@@ -58,6 +59,7 @@ export const GLOBAL_NAV_GROUPS: NavigationGroup[] = [
|
||||
{ id: 'codex-provider', label: 'Codex', icon: OpenAIIcon },
|
||||
{ id: 'opencode-provider', label: 'OpenCode', icon: OpenCodeIcon },
|
||||
{ id: 'gemini-provider', label: 'Gemini', icon: GeminiIcon },
|
||||
{ id: 'copilot-provider', label: 'Copilot', icon: CopilotIcon },
|
||||
],
|
||||
},
|
||||
{ id: 'mcp-servers', label: 'MCP Servers', icon: Plug },
|
||||
|
||||
@@ -9,6 +9,7 @@ export type SettingsViewId =
|
||||
| 'codex-provider'
|
||||
| 'opencode-provider'
|
||||
| 'gemini-provider'
|
||||
| 'copilot-provider'
|
||||
| 'mcp-servers'
|
||||
| 'prompts'
|
||||
| 'model-defaults'
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
CodexModelId,
|
||||
OpencodeModelId,
|
||||
GeminiModelId,
|
||||
CopilotModelId,
|
||||
GroupedModel,
|
||||
PhaseModelEntry,
|
||||
ClaudeCompatibleProvider,
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
CURSOR_MODELS,
|
||||
OPENCODE_MODELS,
|
||||
GEMINI_MODELS,
|
||||
COPILOT_MODELS,
|
||||
THINKING_LEVELS,
|
||||
THINKING_LEVEL_LABELS,
|
||||
REASONING_EFFORT_LEVELS,
|
||||
@@ -42,6 +44,7 @@ import {
|
||||
GlmIcon,
|
||||
MiniMaxIcon,
|
||||
GeminiIcon,
|
||||
CopilotIcon,
|
||||
getProviderIconForModel,
|
||||
} from '@/components/ui/provider-icon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -172,6 +175,7 @@ export function PhaseModelSelector({
|
||||
const {
|
||||
enabledCursorModels,
|
||||
enabledGeminiModels,
|
||||
enabledCopilotModels,
|
||||
favoriteModels,
|
||||
toggleFavoriteModel,
|
||||
codexModels,
|
||||
@@ -331,6 +335,11 @@ export function PhaseModelSelector({
|
||||
return enabledGeminiModels.includes(model.id as GeminiModelId);
|
||||
});
|
||||
|
||||
// Filter Copilot models to only show enabled ones
|
||||
const availableCopilotModels = COPILOT_MODELS.filter((model) => {
|
||||
return enabledCopilotModels.includes(model.id as CopilotModelId);
|
||||
});
|
||||
|
||||
// Helper to find current selected model details
|
||||
const currentModel = useMemo(() => {
|
||||
const claudeModel = CLAUDE_MODELS.find((m) => m.id === selectedModel);
|
||||
@@ -378,6 +387,15 @@ export function PhaseModelSelector({
|
||||
};
|
||||
}
|
||||
|
||||
// Check Copilot models
|
||||
const copilotModel = availableCopilotModels.find((m) => m.id === selectedModel);
|
||||
if (copilotModel) {
|
||||
return {
|
||||
...copilotModel,
|
||||
icon: CopilotIcon,
|
||||
};
|
||||
}
|
||||
|
||||
// Check OpenCode models (static) - use dynamic icon resolution for provider-specific icons
|
||||
const opencodeModel = OPENCODE_MODELS.find((m) => m.id === selectedModel);
|
||||
if (opencodeModel) return { ...opencodeModel, icon: getProviderIconForModel(opencodeModel.id) };
|
||||
@@ -479,6 +497,7 @@ export function PhaseModelSelector({
|
||||
selectedThinkingLevel,
|
||||
availableCursorModels,
|
||||
availableGeminiModels,
|
||||
availableCopilotModels,
|
||||
transformedCodexModels,
|
||||
dynamicOpencodeModels,
|
||||
enabledProviders,
|
||||
@@ -545,19 +564,22 @@ export function PhaseModelSelector({
|
||||
// Check if providers are disabled (needed for rendering conditions)
|
||||
const isCursorDisabled = disabledProviders.includes('cursor');
|
||||
const isGeminiDisabled = disabledProviders.includes('gemini');
|
||||
const isCopilotDisabled = disabledProviders.includes('copilot');
|
||||
|
||||
// Group models (filtering out disabled providers)
|
||||
const { favorites, claude, cursor, codex, gemini, opencode } = useMemo(() => {
|
||||
const { favorites, claude, cursor, codex, gemini, copilot, opencode } = useMemo(() => {
|
||||
const favs: typeof CLAUDE_MODELS = [];
|
||||
const cModels: typeof CLAUDE_MODELS = [];
|
||||
const curModels: typeof CURSOR_MODELS = [];
|
||||
const codModels: typeof transformedCodexModels = [];
|
||||
const gemModels: typeof GEMINI_MODELS = [];
|
||||
const copModels: typeof COPILOT_MODELS = [];
|
||||
const ocModels: ModelOption[] = [];
|
||||
|
||||
const isClaudeDisabled = disabledProviders.includes('claude');
|
||||
const isCodexDisabled = disabledProviders.includes('codex');
|
||||
const isGeminiDisabledInner = disabledProviders.includes('gemini');
|
||||
const isCopilotDisabledInner = disabledProviders.includes('copilot');
|
||||
const isOpencodeDisabled = disabledProviders.includes('opencode');
|
||||
|
||||
// Process Claude Models (skip if provider is disabled)
|
||||
@@ -604,6 +626,17 @@ export function PhaseModelSelector({
|
||||
});
|
||||
}
|
||||
|
||||
// Process Copilot Models (skip if provider is disabled)
|
||||
if (!isCopilotDisabledInner) {
|
||||
availableCopilotModels.forEach((model) => {
|
||||
if (favoriteModels.includes(model.id)) {
|
||||
favs.push(model);
|
||||
} else {
|
||||
copModels.push(model);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Process OpenCode Models (skip if provider is disabled)
|
||||
if (!isOpencodeDisabled) {
|
||||
allOpencodeModels.forEach((model) => {
|
||||
@@ -621,12 +654,14 @@ export function PhaseModelSelector({
|
||||
cursor: curModels,
|
||||
codex: codModels,
|
||||
gemini: gemModels,
|
||||
copilot: copModels,
|
||||
opencode: ocModels,
|
||||
};
|
||||
}, [
|
||||
favoriteModels,
|
||||
availableCursorModels,
|
||||
availableGeminiModels,
|
||||
availableCopilotModels,
|
||||
transformedCodexModels,
|
||||
allOpencodeModels,
|
||||
disabledProviders,
|
||||
@@ -1117,6 +1152,59 @@ export function PhaseModelSelector({
|
||||
);
|
||||
};
|
||||
|
||||
// Render Copilot model item - simple selector without thinking level
|
||||
const renderCopilotModelItem = (model: (typeof COPILOT_MODELS)[0]) => {
|
||||
const isSelected = selectedModel === model.id;
|
||||
const isFavorite = favoriteModels.includes(model.id);
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
key={model.id}
|
||||
value={model.label}
|
||||
onSelect={() => {
|
||||
onChange({ model: model.id as CopilotModelId });
|
||||
setOpen(false);
|
||||
}}
|
||||
className="group flex items-center justify-between py-2"
|
||||
>
|
||||
<div className="flex items-center gap-3 overflow-hidden">
|
||||
<CopilotIcon
|
||||
className={cn(
|
||||
'h-4 w-4 shrink-0',
|
||||
isSelected ? 'text-primary' : 'text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col truncate">
|
||||
<span className={cn('truncate font-medium', isSelected && 'text-primary')}>
|
||||
{model.label}
|
||||
</span>
|
||||
<span className="truncate text-xs text-muted-foreground">{model.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
'h-6 w-6 hover:bg-transparent hover:text-yellow-500 focus:ring-0',
|
||||
isFavorite
|
||||
? 'text-yellow-500 opacity-100'
|
||||
: 'opacity-0 group-hover:opacity-100 text-muted-foreground'
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleFavoriteModel(model.id);
|
||||
}}
|
||||
>
|
||||
<Star className={cn('h-3.5 w-3.5', isFavorite && 'fill-current')} />
|
||||
</Button>
|
||||
{isSelected && <Check className="h-4 w-4 text-primary shrink-0" />}
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
};
|
||||
|
||||
// Render ClaudeCompatibleProvider model item with thinking level support
|
||||
const renderProviderModelItem = (
|
||||
provider: ClaudeCompatibleProvider,
|
||||
@@ -1933,6 +2021,10 @@ export function PhaseModelSelector({
|
||||
if (model.provider === 'gemini') {
|
||||
return renderGeminiModelItem(model as (typeof GEMINI_MODELS)[0]);
|
||||
}
|
||||
// Copilot model
|
||||
if (model.provider === 'copilot') {
|
||||
return renderCopilotModelItem(model as (typeof COPILOT_MODELS)[0]);
|
||||
}
|
||||
// OpenCode model
|
||||
if (model.provider === 'opencode') {
|
||||
return renderOpencodeModelItem(model);
|
||||
@@ -2017,6 +2109,12 @@ export function PhaseModelSelector({
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{!isCopilotDisabled && copilot.length > 0 && (
|
||||
<CommandGroup heading="Copilot Models">
|
||||
{copilot.map((model) => renderCopilotModelItem(model))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{opencodeSections.length > 0 && (
|
||||
<CommandGroup heading={OPENCODE_CLI_GROUP_LABEL}>
|
||||
{opencodeSections.map((section, sectionIndex) => (
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { CopilotModelId } from '@automaker/types';
|
||||
import { CopilotIcon } from '@/components/ui/provider-icon';
|
||||
import { COPILOT_MODEL_MAP } from '@automaker/types';
|
||||
import { BaseModelConfiguration, type BaseModelInfo } from './shared/base-model-configuration';
|
||||
|
||||
interface CopilotModelConfigurationProps {
|
||||
enabledCopilotModels: CopilotModelId[];
|
||||
copilotDefaultModel: CopilotModelId;
|
||||
isSaving: boolean;
|
||||
onDefaultModelChange: (model: CopilotModelId) => void;
|
||||
onModelToggle: (model: CopilotModelId, enabled: boolean) => void;
|
||||
}
|
||||
|
||||
interface CopilotModelInfo extends BaseModelInfo<CopilotModelId> {
|
||||
supportsVision: boolean;
|
||||
}
|
||||
|
||||
// Build model info from the COPILOT_MODEL_MAP
|
||||
const COPILOT_MODELS: CopilotModelInfo[] = Object.entries(COPILOT_MODEL_MAP).map(
|
||||
([id, config]) => ({
|
||||
id: id as CopilotModelId,
|
||||
label: config.label,
|
||||
description: config.description,
|
||||
supportsVision: config.supportsVision,
|
||||
})
|
||||
);
|
||||
|
||||
export function CopilotModelConfiguration({
|
||||
enabledCopilotModels,
|
||||
copilotDefaultModel,
|
||||
isSaving,
|
||||
onDefaultModelChange,
|
||||
onModelToggle,
|
||||
}: CopilotModelConfigurationProps) {
|
||||
return (
|
||||
<BaseModelConfiguration<CopilotModelId>
|
||||
providerName="Copilot"
|
||||
icon={<CopilotIcon className="w-5 h-5 text-violet-500" />}
|
||||
iconGradient="from-violet-500/20 to-violet-600/10"
|
||||
iconBorder="border-violet-500/20"
|
||||
models={COPILOT_MODELS}
|
||||
enabledModels={enabledCopilotModels}
|
||||
defaultModel={copilotDefaultModel}
|
||||
isSaving={isSaving}
|
||||
onDefaultModelChange={onDefaultModelChange}
|
||||
onModelToggle={onModelToggle}
|
||||
getFeatureBadge={(model) => {
|
||||
const copilotModel = model as CopilotModelInfo;
|
||||
return copilotModel.supportsVision ? { show: true, label: 'Vision' } : null;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { CopilotCliStatus, CopilotCliStatusSkeleton } from '../cli-status/copilot-cli-status';
|
||||
import { CopilotModelConfiguration } from './copilot-model-configuration';
|
||||
import { ProviderToggle } from './provider-toggle';
|
||||
import { useCopilotCliStatus } from '@/hooks/queries';
|
||||
import { queryKeys } from '@/lib/query-keys';
|
||||
import type { CliStatus as SharedCliStatus } from '../shared/types';
|
||||
import type { CopilotAuthStatus } from '../cli-status/copilot-cli-status';
|
||||
import type { CopilotModelId } from '@automaker/types';
|
||||
|
||||
export function CopilotSettingsTab() {
|
||||
const queryClient = useQueryClient();
|
||||
const { enabledCopilotModels, copilotDefaultModel, setCopilotDefaultModel, toggleCopilotModel } =
|
||||
useAppStore();
|
||||
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// React Query hooks for data fetching
|
||||
const {
|
||||
data: cliStatusData,
|
||||
isLoading: isCheckingCopilotCli,
|
||||
refetch: refetchCliStatus,
|
||||
} = useCopilotCliStatus();
|
||||
|
||||
const isCliInstalled = cliStatusData?.installed ?? false;
|
||||
|
||||
// Transform CLI status to the expected format
|
||||
const cliStatus = useMemo((): SharedCliStatus | null => {
|
||||
if (!cliStatusData) return null;
|
||||
return {
|
||||
success: cliStatusData.success ?? false,
|
||||
status: cliStatusData.installed ? 'installed' : 'not_installed',
|
||||
method: cliStatusData.auth?.method,
|
||||
version: cliStatusData.version,
|
||||
path: cliStatusData.path,
|
||||
recommendation: cliStatusData.recommendation,
|
||||
// Server sends installCommand (singular), transform to expected format
|
||||
installCommands: cliStatusData.installCommand
|
||||
? { npm: cliStatusData.installCommand }
|
||||
: cliStatusData.installCommands,
|
||||
};
|
||||
}, [cliStatusData]);
|
||||
|
||||
// Transform auth status to the expected format
|
||||
const authStatus = useMemo((): CopilotAuthStatus | null => {
|
||||
if (!cliStatusData?.auth) return null;
|
||||
return {
|
||||
authenticated: cliStatusData.auth.authenticated,
|
||||
method: (cliStatusData.auth.method as CopilotAuthStatus['method']) || 'none',
|
||||
login: cliStatusData.auth.login,
|
||||
host: cliStatusData.auth.host,
|
||||
error: cliStatusData.auth.error,
|
||||
};
|
||||
}, [cliStatusData]);
|
||||
|
||||
// Refresh all copilot-related queries
|
||||
const handleRefreshCopilotCli = useCallback(async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.cli.copilot() });
|
||||
await refetchCliStatus();
|
||||
toast.success('Copilot CLI refreshed');
|
||||
}, [queryClient, refetchCliStatus]);
|
||||
|
||||
const handleDefaultModelChange = useCallback(
|
||||
(model: CopilotModelId) => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
setCopilotDefaultModel(model);
|
||||
toast.success('Default model updated');
|
||||
} catch {
|
||||
toast.error('Failed to update default model');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[setCopilotDefaultModel]
|
||||
);
|
||||
|
||||
const handleModelToggle = useCallback(
|
||||
(model: CopilotModelId, enabled: boolean) => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
toggleCopilotModel(model, enabled);
|
||||
} catch {
|
||||
toast.error('Failed to update models');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[toggleCopilotModel]
|
||||
);
|
||||
|
||||
// Show skeleton only while checking CLI status initially
|
||||
if (!cliStatus && isCheckingCopilotCli) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<CopilotCliStatusSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Provider Visibility Toggle */}
|
||||
<ProviderToggle provider="copilot" providerLabel="GitHub Copilot" />
|
||||
|
||||
<CopilotCliStatus
|
||||
status={cliStatus}
|
||||
authStatus={authStatus}
|
||||
isChecking={isCheckingCopilotCli}
|
||||
onRefresh={handleRefreshCopilotCli}
|
||||
/>
|
||||
|
||||
{/* Model Configuration - Only show when CLI is installed */}
|
||||
{isCliInstalled && (
|
||||
<CopilotModelConfiguration
|
||||
enabledCopilotModels={enabledCopilotModels}
|
||||
copilotDefaultModel={copilotDefaultModel}
|
||||
isSaving={isSaving}
|
||||
onDefaultModelChange={handleDefaultModelChange}
|
||||
onModelToggle={handleModelToggle}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CopilotSettingsTab;
|
||||
@@ -1,17 +1,7 @@
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { GeminiModelId } from '@automaker/types';
|
||||
import { GeminiIcon } from '@/components/ui/provider-icon';
|
||||
import { GEMINI_MODEL_MAP } from '@automaker/types';
|
||||
import { BaseModelConfiguration, type BaseModelInfo } from './shared/base-model-configuration';
|
||||
|
||||
interface GeminiModelConfigurationProps {
|
||||
enabledGeminiModels: GeminiModelId[];
|
||||
@@ -21,25 +11,17 @@ interface GeminiModelConfigurationProps {
|
||||
onModelToggle: (model: GeminiModelId, enabled: boolean) => void;
|
||||
}
|
||||
|
||||
interface GeminiModelInfo {
|
||||
id: GeminiModelId;
|
||||
label: string;
|
||||
description: string;
|
||||
interface GeminiModelInfo extends BaseModelInfo<GeminiModelId> {
|
||||
supportsThinking: boolean;
|
||||
}
|
||||
|
||||
// Build model info from the GEMINI_MODEL_MAP
|
||||
const GEMINI_MODEL_INFO: Record<GeminiModelId, GeminiModelInfo> = Object.fromEntries(
|
||||
Object.entries(GEMINI_MODEL_MAP).map(([id, config]) => [
|
||||
id as GeminiModelId,
|
||||
{
|
||||
id: id as GeminiModelId,
|
||||
label: config.label,
|
||||
description: config.description,
|
||||
supportsThinking: config.supportsThinking,
|
||||
},
|
||||
])
|
||||
) as Record<GeminiModelId, GeminiModelInfo>;
|
||||
const GEMINI_MODELS: GeminiModelInfo[] = Object.entries(GEMINI_MODEL_MAP).map(([id, config]) => ({
|
||||
id: id as GeminiModelId,
|
||||
label: config.label,
|
||||
description: config.description,
|
||||
supportsThinking: config.supportsThinking,
|
||||
}));
|
||||
|
||||
export function GeminiModelConfiguration({
|
||||
enabledGeminiModels,
|
||||
@@ -48,99 +30,22 @@ export function GeminiModelConfiguration({
|
||||
onDefaultModelChange,
|
||||
onModelToggle,
|
||||
}: GeminiModelConfigurationProps) {
|
||||
const availableModels = Object.values(GEMINI_MODEL_INFO);
|
||||
|
||||
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-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">
|
||||
Model Configuration
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||
Configure which Gemini models are available in the feature modal
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label>Default Model</Label>
|
||||
<Select
|
||||
value={geminiDefaultModel}
|
||||
onValueChange={(v) => onDefaultModelChange(v as GeminiModelId)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableModels.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{model.label}</span>
|
||||
{model.supportsThinking && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Thinking
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label>Available Models</Label>
|
||||
<div className="grid gap-3">
|
||||
{availableModels.map((model) => {
|
||||
const isEnabled = enabledGeminiModels.includes(model.id);
|
||||
const isDefault = model.id === geminiDefaultModel;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={model.id}
|
||||
className="flex items-center justify-between p-3 rounded-xl border border-border/50 bg-card/50 hover:bg-accent/30 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
checked={isEnabled}
|
||||
onCheckedChange={(checked) => onModelToggle(model.id, !!checked)}
|
||||
disabled={isSaving || isDefault}
|
||||
/>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{model.label}</span>
|
||||
{model.supportsThinking && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Thinking
|
||||
</Badge>
|
||||
)}
|
||||
{isDefault && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Default
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{model.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<BaseModelConfiguration<GeminiModelId>
|
||||
providerName="Gemini"
|
||||
icon={<GeminiIcon className="w-5 h-5 text-blue-500" />}
|
||||
iconGradient="from-blue-500/20 to-blue-600/10"
|
||||
iconBorder="border-blue-500/20"
|
||||
models={GEMINI_MODELS}
|
||||
enabledModels={enabledGeminiModels}
|
||||
defaultModel={geminiDefaultModel}
|
||||
isSaving={isSaving}
|
||||
onDefaultModelChange={onDefaultModelChange}
|
||||
onModelToggle={onModelToggle}
|
||||
getFeatureBadge={(model) => {
|
||||
const geminiModel = model as GeminiModelInfo;
|
||||
return geminiModel.supportsThinking ? { show: true, label: 'Thinking' } : null;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,3 +4,4 @@ export { CursorSettingsTab } from './cursor-settings-tab';
|
||||
export { CodexSettingsTab } from './codex-settings-tab';
|
||||
export { OpencodeSettingsTab } from './opencode-settings-tab';
|
||||
export { GeminiSettingsTab } from './gemini-settings-tab';
|
||||
export { CopilotSettingsTab } from './copilot-settings-tab';
|
||||
|
||||
@@ -6,21 +6,23 @@ import {
|
||||
OpenAIIcon,
|
||||
GeminiIcon,
|
||||
OpenCodeIcon,
|
||||
CopilotIcon,
|
||||
} from '@/components/ui/provider-icon';
|
||||
import { CursorSettingsTab } from './cursor-settings-tab';
|
||||
import { ClaudeSettingsTab } from './claude-settings-tab';
|
||||
import { CodexSettingsTab } from './codex-settings-tab';
|
||||
import { OpencodeSettingsTab } from './opencode-settings-tab';
|
||||
import { GeminiSettingsTab } from './gemini-settings-tab';
|
||||
import { CopilotSettingsTab } from './copilot-settings-tab';
|
||||
|
||||
interface ProviderTabsProps {
|
||||
defaultTab?: 'claude' | 'cursor' | 'codex' | 'opencode' | 'gemini';
|
||||
defaultTab?: 'claude' | 'cursor' | 'codex' | 'opencode' | 'gemini' | 'copilot';
|
||||
}
|
||||
|
||||
export function ProviderTabs({ defaultTab = 'claude' }: ProviderTabsProps) {
|
||||
return (
|
||||
<Tabs defaultValue={defaultTab} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-5 mb-6">
|
||||
<TabsList className="grid w-full grid-cols-6 mb-6">
|
||||
<TabsTrigger value="claude" className="flex items-center gap-2">
|
||||
<AnthropicIcon className="w-4 h-4" />
|
||||
Claude
|
||||
@@ -41,6 +43,10 @@ export function ProviderTabs({ defaultTab = 'claude' }: ProviderTabsProps) {
|
||||
<GeminiIcon className="w-4 h-4" />
|
||||
Gemini
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="copilot" className="flex items-center gap-2">
|
||||
<CopilotIcon className="w-4 h-4" />
|
||||
Copilot
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="claude">
|
||||
@@ -62,6 +68,10 @@ export function ProviderTabs({ defaultTab = 'claude' }: ProviderTabsProps) {
|
||||
<TabsContent value="gemini">
|
||||
<GeminiSettingsTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="copilot">
|
||||
<CopilotSettingsTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* Generic model info structure for model configuration components
|
||||
*/
|
||||
export interface BaseModelInfo<T extends string> {
|
||||
id: T;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Badge configuration for feature indicators
|
||||
*/
|
||||
export interface FeatureBadge {
|
||||
show: boolean;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the base model configuration component
|
||||
*/
|
||||
export interface BaseModelConfigurationProps<T extends string> {
|
||||
/** Provider name for display (e.g., "Gemini", "Copilot") */
|
||||
providerName: string;
|
||||
/** Icon component to display in header */
|
||||
icon: ReactNode;
|
||||
/** Icon container gradient classes (e.g., "from-blue-500/20 to-blue-600/10") */
|
||||
iconGradient: string;
|
||||
/** Icon border color class (e.g., "border-blue-500/20") */
|
||||
iconBorder: string;
|
||||
/** List of available models */
|
||||
models: BaseModelInfo<T>[];
|
||||
/** Currently enabled model IDs */
|
||||
enabledModels: T[];
|
||||
/** Currently selected default model */
|
||||
defaultModel: T;
|
||||
/** Whether saving is in progress */
|
||||
isSaving: boolean;
|
||||
/** Callback when default model changes */
|
||||
onDefaultModelChange: (model: T) => void;
|
||||
/** Callback when a model is toggled */
|
||||
onModelToggle: (model: T, enabled: boolean) => void;
|
||||
/** Function to determine if a model should show a feature badge */
|
||||
getFeatureBadge?: (model: BaseModelInfo<T>) => FeatureBadge | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base component for provider model configuration
|
||||
*
|
||||
* Provides a consistent UI for configuring which models are available
|
||||
* and which is the default. Individual provider components can customize
|
||||
* by providing their own icon, colors, and feature badges.
|
||||
*/
|
||||
export function BaseModelConfiguration<T extends string>({
|
||||
providerName,
|
||||
icon,
|
||||
iconGradient,
|
||||
iconBorder,
|
||||
models,
|
||||
enabledModels,
|
||||
defaultModel,
|
||||
isSaving,
|
||||
onDefaultModelChange,
|
||||
onModelToggle,
|
||||
getFeatureBadge,
|
||||
}: BaseModelConfigurationProps<T>) {
|
||||
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={cn(
|
||||
'w-9 h-9 rounded-xl flex items-center justify-center border',
|
||||
`bg-gradient-to-br ${iconGradient}`,
|
||||
iconBorder
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">
|
||||
Model Configuration
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||
Configure which {providerName} models are available in the feature modal
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label>Default Model</Label>
|
||||
<Select
|
||||
value={defaultModel}
|
||||
onValueChange={(v) => onDefaultModelChange(v as T)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{models.map((model) => {
|
||||
const badge = getFeatureBadge?.(model);
|
||||
return (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{model.label}</span>
|
||||
{badge?.show && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{badge.label}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label>Available Models</Label>
|
||||
<div className="grid gap-3">
|
||||
{models.map((model) => {
|
||||
const isDefault = model.id === defaultModel;
|
||||
// Default model is always considered enabled
|
||||
const isEnabled = isDefault || enabledModels.includes(model.id);
|
||||
const badge = getFeatureBadge?.(model);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={model.id}
|
||||
className="flex items-center justify-between p-3 rounded-xl border border-border/50 bg-card/50 hover:bg-accent/30 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
checked={isEnabled}
|
||||
onCheckedChange={(checked) => onModelToggle(model.id, !!checked)}
|
||||
disabled={isSaving || isDefault}
|
||||
/>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{model.label}</span>
|
||||
{badge?.show && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{badge.label}
|
||||
</Badge>
|
||||
)}
|
||||
{isDefault && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Default
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{model.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
OpenAIIcon,
|
||||
OpenCodeIcon,
|
||||
GeminiIcon,
|
||||
CopilotIcon,
|
||||
} from '@/components/ui/provider-icon';
|
||||
import { TerminalOutput } from '../components';
|
||||
import { useCliInstallation, useTokenSave } from '../hooks';
|
||||
@@ -46,7 +47,7 @@ interface ProvidersSetupStepProps {
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
type ProviderTab = 'claude' | 'cursor' | 'codex' | 'opencode' | 'gemini';
|
||||
type ProviderTab = 'claude' | 'cursor' | 'codex' | 'opencode' | 'gemini' | 'copilot';
|
||||
|
||||
// ============================================================================
|
||||
// Claude Content
|
||||
@@ -1527,6 +1528,245 @@ function GeminiContent() {
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Copilot Content
|
||||
// ============================================================================
|
||||
function CopilotContent() {
|
||||
const { copilotCliStatus, setCopilotCliStatus } = useSetupStore();
|
||||
const [isChecking, setIsChecking] = useState(false);
|
||||
const [isLoggingIn, setIsLoggingIn] = useState(false);
|
||||
const pollIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const checkStatus = useCallback(async () => {
|
||||
setIsChecking(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api.setup?.getCopilotStatus) return;
|
||||
const result = await api.setup.getCopilotStatus();
|
||||
if (result.success) {
|
||||
setCopilotCliStatus({
|
||||
installed: result.installed ?? false,
|
||||
version: result.version,
|
||||
path: result.path,
|
||||
auth: result.auth,
|
||||
installCommand: result.installCommand,
|
||||
loginCommand: result.loginCommand,
|
||||
});
|
||||
if (result.auth?.authenticated) {
|
||||
toast.success('Copilot CLI is ready!');
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
}
|
||||
}, [setCopilotCliStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
checkStatus();
|
||||
return () => {
|
||||
if (pollIntervalRef.current) clearInterval(pollIntervalRef.current);
|
||||
};
|
||||
}, [checkStatus]);
|
||||
|
||||
const copyCommand = (command: string) => {
|
||||
navigator.clipboard.writeText(command);
|
||||
toast.success('Command copied to clipboard');
|
||||
};
|
||||
|
||||
const handleLogin = async () => {
|
||||
setIsLoggingIn(true);
|
||||
try {
|
||||
const loginCommand = copilotCliStatus?.loginCommand || 'gh auth login';
|
||||
await navigator.clipboard.writeText(loginCommand);
|
||||
toast.info('Login command copied! Paste in terminal to authenticate.');
|
||||
|
||||
let attempts = 0;
|
||||
pollIntervalRef.current = setInterval(async () => {
|
||||
attempts++;
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api.setup?.getCopilotStatus) return;
|
||||
const result = await api.setup.getCopilotStatus();
|
||||
if (result.auth?.authenticated) {
|
||||
if (pollIntervalRef.current) {
|
||||
clearInterval(pollIntervalRef.current);
|
||||
pollIntervalRef.current = null;
|
||||
}
|
||||
setCopilotCliStatus({
|
||||
...copilotCliStatus,
|
||||
installed: result.installed ?? true,
|
||||
version: result.version,
|
||||
path: result.path,
|
||||
auth: result.auth,
|
||||
});
|
||||
setIsLoggingIn(false);
|
||||
toast.success('Successfully authenticated with GitHub!');
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
if (attempts >= 60) {
|
||||
if (pollIntervalRef.current) {
|
||||
clearInterval(pollIntervalRef.current);
|
||||
pollIntervalRef.current = null;
|
||||
}
|
||||
setIsLoggingIn(false);
|
||||
toast.error('Login timed out. Please try again.');
|
||||
}
|
||||
}, 2000);
|
||||
} catch {
|
||||
toast.error('Failed to start login process');
|
||||
setIsLoggingIn(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isReady = copilotCliStatus?.installed && copilotCliStatus?.auth?.authenticated;
|
||||
|
||||
return (
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<CopilotIcon className="w-5 h-5" />
|
||||
GitHub Copilot CLI Status
|
||||
</CardTitle>
|
||||
<Button variant="ghost" size="sm" onClick={checkStatus} disabled={isChecking}>
|
||||
{isChecking ? <Spinner size="sm" /> : <RefreshCw className="w-4 h-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{copilotCliStatus?.installed
|
||||
? copilotCliStatus.auth?.authenticated
|
||||
? `Authenticated${copilotCliStatus.version ? ` (v${copilotCliStatus.version})` : ''}`
|
||||
: 'Installed but not authenticated'
|
||||
: 'Not installed on your system'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{isReady && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3 p-4 rounded-lg bg-green-500/10 border border-green-500/20">
|
||||
<CheckCircle2 className="w-5 h-5 text-green-500" />
|
||||
<div>
|
||||
<p className="font-medium text-foreground">SDK Installed</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{copilotCliStatus?.version && `Version: ${copilotCliStatus.version}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-4 rounded-lg bg-green-500/10 border border-green-500/20">
|
||||
<CheckCircle2 className="w-5 h-5 text-green-500" />
|
||||
<div>
|
||||
<p className="font-medium text-foreground">Authenticated</p>
|
||||
{copilotCliStatus?.auth?.login && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Logged in as {copilotCliStatus.auth.login}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!copilotCliStatus?.installed && !isChecking && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3 p-4 rounded-lg bg-muted/30 border border-border">
|
||||
<XCircle className="w-5 h-5 text-muted-foreground shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-foreground">Copilot CLI not found</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Install the GitHub Copilot CLI to use Copilot models.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3 p-4 rounded-lg bg-muted/30 border border-border">
|
||||
<p className="font-medium text-foreground text-sm">Install Copilot CLI:</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground overflow-x-auto">
|
||||
{copilotCliStatus?.installCommand || 'npm install -g @github/copilot'}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
copyCommand(
|
||||
copilotCliStatus?.installCommand || 'npm install -g @github/copilot'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{copilotCliStatus?.installed && !copilotCliStatus?.auth?.authenticated && !isChecking && (
|
||||
<div className="space-y-4">
|
||||
{/* Show SDK installed toast */}
|
||||
<div className="flex items-center gap-3 p-4 rounded-lg bg-green-500/10 border border-green-500/20">
|
||||
<CheckCircle2 className="w-5 h-5 text-green-500" />
|
||||
<div>
|
||||
<p className="font-medium text-foreground">SDK Installed</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{copilotCliStatus?.version && `Version: ${copilotCliStatus.version}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 p-4 rounded-lg bg-amber-500/10 border border-amber-500/20">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-foreground">GitHub not authenticated</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Run the GitHub CLI login command to authenticate.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3 p-4 rounded-lg bg-muted/30 border border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground">
|
||||
{copilotCliStatus?.loginCommand || 'gh auth login'}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => copyCommand(copilotCliStatus?.loginCommand || 'gh auth login')}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleLogin}
|
||||
disabled={isLoggingIn}
|
||||
className="w-full bg-brand-500 hover:bg-brand-600 text-white"
|
||||
>
|
||||
{isLoggingIn ? (
|
||||
<>
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Waiting for login...
|
||||
</>
|
||||
) : (
|
||||
'Copy Command & Wait for Login'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isChecking && (
|
||||
<div className="flex items-center gap-3 p-4 rounded-lg bg-blue-500/10 border border-blue-500/20">
|
||||
<Spinner size="md" />
|
||||
<p className="font-medium text-foreground">Checking Copilot CLI status...</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Component
|
||||
// ============================================================================
|
||||
@@ -1544,12 +1784,14 @@ export function ProvidersSetupStep({ onNext, onBack }: ProvidersSetupStepProps)
|
||||
codexAuthStatus,
|
||||
opencodeCliStatus,
|
||||
geminiCliStatus,
|
||||
copilotCliStatus,
|
||||
setClaudeCliStatus,
|
||||
setCursorCliStatus,
|
||||
setCodexCliStatus,
|
||||
setCodexAuthStatus,
|
||||
setOpencodeCliStatus,
|
||||
setGeminiCliStatus,
|
||||
setCopilotCliStatus,
|
||||
} = useSetupStore();
|
||||
|
||||
// Check all providers on mount
|
||||
@@ -1659,8 +1901,35 @@ export function ProvidersSetupStep({ onNext, onBack }: ProvidersSetupStepProps)
|
||||
}
|
||||
};
|
||||
|
||||
// Check Copilot
|
||||
const checkCopilot = async () => {
|
||||
try {
|
||||
if (!api.setup?.getCopilotStatus) return;
|
||||
const result = await api.setup.getCopilotStatus();
|
||||
if (result.success) {
|
||||
setCopilotCliStatus({
|
||||
installed: result.installed ?? false,
|
||||
version: result.version,
|
||||
path: result.path,
|
||||
auth: result.auth,
|
||||
installCommand: result.installCommand,
|
||||
loginCommand: result.loginCommand,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
};
|
||||
|
||||
// Run all checks in parallel
|
||||
await Promise.all([checkClaude(), checkCursor(), checkCodex(), checkOpencode(), checkGemini()]);
|
||||
await Promise.all([
|
||||
checkClaude(),
|
||||
checkCursor(),
|
||||
checkCodex(),
|
||||
checkOpencode(),
|
||||
checkGemini(),
|
||||
checkCopilot(),
|
||||
]);
|
||||
setIsInitialChecking(false);
|
||||
}, [
|
||||
setClaudeCliStatus,
|
||||
@@ -1669,6 +1938,7 @@ export function ProvidersSetupStep({ onNext, onBack }: ProvidersSetupStepProps)
|
||||
setCodexAuthStatus,
|
||||
setOpencodeCliStatus,
|
||||
setGeminiCliStatus,
|
||||
setCopilotCliStatus,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1698,12 +1968,16 @@ export function ProvidersSetupStep({ onNext, onBack }: ProvidersSetupStepProps)
|
||||
const isGeminiInstalled = geminiCliStatus?.installed === true;
|
||||
const isGeminiAuthenticated = geminiCliStatus?.auth?.authenticated === true;
|
||||
|
||||
const isCopilotInstalled = copilotCliStatus?.installed === true;
|
||||
const isCopilotAuthenticated = copilotCliStatus?.auth?.authenticated === true;
|
||||
|
||||
const hasAtLeastOneProvider =
|
||||
isClaudeAuthenticated ||
|
||||
isCursorAuthenticated ||
|
||||
isCodexAuthenticated ||
|
||||
isOpencodeAuthenticated ||
|
||||
isGeminiAuthenticated;
|
||||
isGeminiAuthenticated ||
|
||||
isCopilotAuthenticated;
|
||||
|
||||
type ProviderStatus = 'not_installed' | 'installed_not_auth' | 'authenticated' | 'verifying';
|
||||
|
||||
@@ -1754,6 +2028,13 @@ export function ProvidersSetupStep({ onNext, onBack }: ProvidersSetupStepProps)
|
||||
status: getProviderStatus(isGeminiInstalled, isGeminiAuthenticated),
|
||||
color: 'text-blue-500',
|
||||
},
|
||||
{
|
||||
id: 'copilot' as const,
|
||||
label: 'Copilot',
|
||||
icon: CopilotIcon,
|
||||
status: getProviderStatus(isCopilotInstalled, isCopilotAuthenticated),
|
||||
color: 'text-violet-500',
|
||||
},
|
||||
];
|
||||
|
||||
const renderStatusIcon = (status: ProviderStatus) => {
|
||||
@@ -1790,7 +2071,7 @@ export function ProvidersSetupStep({ onNext, onBack }: ProvidersSetupStepProps)
|
||||
)}
|
||||
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as ProviderTab)}>
|
||||
<TabsList className="grid w-full grid-cols-5 h-auto p-1">
|
||||
<TabsList className="grid w-full grid-cols-6 h-auto p-1">
|
||||
{providers.map((provider) => {
|
||||
const Icon = provider.icon;
|
||||
return (
|
||||
@@ -1839,6 +2120,9 @@ export function ProvidersSetupStep({ onNext, onBack }: ProvidersSetupStepProps)
|
||||
<TabsContent value="gemini" className="mt-0">
|
||||
<GeminiContent />
|
||||
</TabsContent>
|
||||
<TabsContent value="copilot" className="mt-0">
|
||||
<CopilotContent />
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
|
||||
|
||||
@@ -23,13 +23,7 @@ export {
|
||||
} from './use-github';
|
||||
|
||||
// Usage
|
||||
export {
|
||||
useClaudeUsage,
|
||||
useCodexUsage,
|
||||
useAllProvidersUsage,
|
||||
useProviderUsage,
|
||||
useProviderAvailability,
|
||||
} from './use-usage';
|
||||
export { useClaudeUsage, useCodexUsage } from './use-usage';
|
||||
|
||||
// Running Agents
|
||||
export { useRunningAgents, useRunningAgentsCount } from './use-running-agents';
|
||||
@@ -70,6 +64,7 @@ export {
|
||||
useCodexCliStatus,
|
||||
useOpencodeCliStatus,
|
||||
useGeminiCliStatus,
|
||||
useCopilotCliStatus,
|
||||
useGitHubCliStatus,
|
||||
useApiKeysStatus,
|
||||
usePlatformInfo,
|
||||
|
||||
@@ -109,6 +109,26 @@ export function useGeminiCliStatus() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Copilot SDK status
|
||||
*
|
||||
* @returns Query result with Copilot SDK status
|
||||
*/
|
||||
export function useCopilotCliStatus() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.cli.copilot(),
|
||||
queryFn: async () => {
|
||||
const api = getElectronAPI();
|
||||
const result = await api.setup.getCopilotStatus();
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to fetch Copilot status');
|
||||
}
|
||||
return result;
|
||||
},
|
||||
staleTime: STALE_TIMES.CLI_STATUS,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch GitHub CLI status
|
||||
*
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Usage Query Hooks
|
||||
*
|
||||
* React Query hooks for fetching Claude, Codex, and all provider API usage data.
|
||||
* React Query hooks for fetching Claude and Codex API usage data.
|
||||
* These hooks include automatic polling for real-time usage updates.
|
||||
*/
|
||||
|
||||
@@ -10,7 +10,6 @@ import { getElectronAPI } from '@/lib/electron';
|
||||
import { queryKeys } from '@/lib/query-keys';
|
||||
import { STALE_TIMES } from '@/lib/query-client';
|
||||
import type { ClaudeUsage, CodexUsage } from '@/store/app-store';
|
||||
import type { AllProvidersUsage, UsageProviderId } from '@automaker/types';
|
||||
|
||||
/** Polling interval for usage data (60 seconds) */
|
||||
const USAGE_POLLING_INTERVAL = 60 * 1000;
|
||||
@@ -82,85 +81,3 @@ export function useCodexUsage(enabled = true) {
|
||||
refetchOnReconnect: USAGE_REFETCH_ON_RECONNECT,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch usage data for all enabled AI providers
|
||||
*
|
||||
* @param enabled - Whether the query should run (default: true)
|
||||
* @returns Query result with all providers usage data
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { data: allUsage, isLoading } = useAllProvidersUsage(isPopoverOpen);
|
||||
* ```
|
||||
*/
|
||||
export function useAllProvidersUsage(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.usage.all(),
|
||||
queryFn: async (): Promise<AllProvidersUsage> => {
|
||||
const api = getElectronAPI();
|
||||
const result = await api.providerUsage.getAll();
|
||||
if (!result.success || !result.data) {
|
||||
throw new Error(result.error || 'Failed to fetch provider usage');
|
||||
}
|
||||
return result.data;
|
||||
},
|
||||
enabled,
|
||||
staleTime: STALE_TIMES.USAGE,
|
||||
refetchInterval: enabled ? USAGE_POLLING_INTERVAL : false,
|
||||
placeholderData: (previousData) => previousData,
|
||||
refetchOnWindowFocus: USAGE_REFETCH_ON_FOCUS,
|
||||
refetchOnReconnect: USAGE_REFETCH_ON_RECONNECT,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch usage data for a specific provider
|
||||
*
|
||||
* @param providerId - The provider to fetch usage for
|
||||
* @param enabled - Whether the query should run (default: true)
|
||||
* @returns Query result with provider usage data
|
||||
*/
|
||||
export function useProviderUsage(providerId: UsageProviderId, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.usage.provider(providerId),
|
||||
queryFn: async () => {
|
||||
const api = getElectronAPI();
|
||||
const result = await api.providerUsage.getProvider(providerId);
|
||||
if (!result.success || !result.data) {
|
||||
throw new Error(result.error || 'Failed to fetch provider usage');
|
||||
}
|
||||
return result.data;
|
||||
},
|
||||
enabled,
|
||||
staleTime: STALE_TIMES.USAGE,
|
||||
refetchInterval: enabled ? USAGE_POLLING_INTERVAL : false,
|
||||
placeholderData: (previousData) => previousData,
|
||||
refetchOnWindowFocus: USAGE_REFETCH_ON_FOCUS,
|
||||
refetchOnReconnect: USAGE_REFETCH_ON_RECONNECT,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check availability of all providers
|
||||
*
|
||||
* @param enabled - Whether the query should run (default: true)
|
||||
* @returns Query result with provider availability map
|
||||
*/
|
||||
export function useProviderAvailability(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.usage.availability(),
|
||||
queryFn: async (): Promise<Record<UsageProviderId, boolean>> => {
|
||||
const api = getElectronAPI();
|
||||
const result = await api.providerUsage.getAvailability();
|
||||
if (!result.success || !result.data) {
|
||||
throw new Error(result.error || 'Failed to fetch provider availability');
|
||||
}
|
||||
return result.data;
|
||||
},
|
||||
enabled,
|
||||
staleTime: STALE_TIMES.STATUS,
|
||||
refetchOnWindowFocus: USAGE_REFETCH_ON_FOCUS,
|
||||
refetchOnReconnect: USAGE_REFETCH_ON_RECONNECT,
|
||||
});
|
||||
}
|
||||
|
||||
121
apps/ui/src/hooks/use-multi-project-status.ts
Normal file
121
apps/ui/src/hooks/use-multi-project-status.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Hook for fetching multi-project overview data
|
||||
*
|
||||
* Provides real-time status across all projects for the unified dashboard.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import type { MultiProjectOverview } from '@automaker/types';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import {
|
||||
getApiKey,
|
||||
getSessionToken,
|
||||
waitForApiKeyInit,
|
||||
getServerUrlSync,
|
||||
} from '@/lib/http-api-client';
|
||||
|
||||
const logger = createLogger('useMultiProjectStatus');
|
||||
|
||||
interface UseMultiProjectStatusResult {
|
||||
overview: MultiProjectOverview | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom fetch function for projects overview
|
||||
* Uses the same pattern as HttpApiClient for proper authentication
|
||||
*/
|
||||
async function fetchProjectsOverview(): Promise<MultiProjectOverview> {
|
||||
// Ensure API key is initialized before making request (handles Electron/web mode timing)
|
||||
await waitForApiKeyInit();
|
||||
|
||||
const serverUrl = getServerUrlSync();
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
// Electron mode: use API key
|
||||
const apiKey = getApiKey();
|
||||
if (apiKey) {
|
||||
headers['X-API-Key'] = apiKey;
|
||||
} else {
|
||||
// Web mode: use session token if available
|
||||
const sessionToken = getSessionToken();
|
||||
if (sessionToken) {
|
||||
headers['X-Session-Token'] = sessionToken;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(`${serverUrl}/api/projects/overview`, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
credentials: 'include', // Include cookies for session auth
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || `HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Failed to fetch project overview');
|
||||
}
|
||||
|
||||
return {
|
||||
projects: data.projects,
|
||||
aggregate: data.aggregate,
|
||||
recentActivity: data.recentActivity,
|
||||
generatedAt: data.generatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch and manage multi-project overview data
|
||||
*
|
||||
* @param refreshInterval - Optional interval in ms to auto-refresh (default: 30000)
|
||||
*/
|
||||
export function useMultiProjectStatus(refreshInterval = 30000): UseMultiProjectStatusResult {
|
||||
const [overview, setOverview] = useState<MultiProjectOverview | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setError(null);
|
||||
const data = await fetchProjectsOverview();
|
||||
setOverview(data);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch overview';
|
||||
logger.error('Failed to fetch project overview:', err);
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
// Auto-refresh interval
|
||||
useEffect(() => {
|
||||
if (refreshInterval <= 0) return;
|
||||
|
||||
const intervalId = setInterval(refresh, refreshInterval);
|
||||
return () => clearInterval(intervalId);
|
||||
}, [refresh, refreshInterval]);
|
||||
|
||||
return {
|
||||
overview,
|
||||
isLoading,
|
||||
error,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
@@ -22,11 +22,13 @@ import { waitForMigrationComplete, resetMigrationState } from './use-settings-mi
|
||||
import {
|
||||
DEFAULT_OPENCODE_MODEL,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
DEFAULT_COPILOT_MODEL,
|
||||
DEFAULT_MAX_CONCURRENCY,
|
||||
getAllOpencodeModelIds,
|
||||
getAllCursorModelIds,
|
||||
getAllCodexModelIds,
|
||||
getAllGeminiModelIds,
|
||||
getAllCopilotModelIds,
|
||||
migrateCursorModelIds,
|
||||
migrateOpencodeModelIds,
|
||||
migratePhaseModelEntry,
|
||||
@@ -35,6 +37,7 @@ import {
|
||||
type OpencodeModelId,
|
||||
type CodexModelId,
|
||||
type GeminiModelId,
|
||||
type CopilotModelId,
|
||||
} from '@automaker/types';
|
||||
|
||||
const logger = createLogger('SettingsSync');
|
||||
@@ -75,6 +78,8 @@ const SETTINGS_FIELDS_TO_SYNC = [
|
||||
'codexDefaultModel',
|
||||
'enabledGeminiModels',
|
||||
'geminiDefaultModel',
|
||||
'enabledCopilotModels',
|
||||
'copilotDefaultModel',
|
||||
'enabledDynamicModelIds',
|
||||
'disabledProviders',
|
||||
'autoLoadClaudeMd',
|
||||
@@ -607,6 +612,21 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
||||
sanitizedEnabledGeminiModels.push(sanitizedGeminiDefaultModel);
|
||||
}
|
||||
|
||||
// Sanitize Copilot models
|
||||
const validCopilotModelIds = new Set(getAllCopilotModelIds());
|
||||
const sanitizedEnabledCopilotModels = (serverSettings.enabledCopilotModels ?? []).filter(
|
||||
(id): id is CopilotModelId => validCopilotModelIds.has(id as CopilotModelId)
|
||||
);
|
||||
const sanitizedCopilotDefaultModel = validCopilotModelIds.has(
|
||||
serverSettings.copilotDefaultModel as CopilotModelId
|
||||
)
|
||||
? (serverSettings.copilotDefaultModel as CopilotModelId)
|
||||
: DEFAULT_COPILOT_MODEL;
|
||||
|
||||
if (!sanitizedEnabledCopilotModels.includes(sanitizedCopilotDefaultModel)) {
|
||||
sanitizedEnabledCopilotModels.push(sanitizedCopilotDefaultModel);
|
||||
}
|
||||
|
||||
const persistedDynamicModelIds =
|
||||
serverSettings.enabledDynamicModelIds ?? currentAppState.enabledDynamicModelIds;
|
||||
const sanitizedDynamicModelIds = persistedDynamicModelIds.filter(
|
||||
@@ -703,6 +723,8 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
||||
codexDefaultModel: sanitizedCodexDefaultModel,
|
||||
enabledGeminiModels: sanitizedEnabledGeminiModels,
|
||||
geminiDefaultModel: sanitizedGeminiDefaultModel,
|
||||
enabledCopilotModels: sanitizedEnabledCopilotModels,
|
||||
copilotDefaultModel: sanitizedCopilotDefaultModel,
|
||||
enabledDynamicModelIds: sanitizedDynamicModelIds,
|
||||
disabledProviders: serverSettings.disabledProviders ?? [],
|
||||
autoLoadClaudeMd: serverSettings.autoLoadClaudeMd ?? false,
|
||||
|
||||
@@ -840,26 +840,6 @@ export interface ElectronAPI {
|
||||
error?: string;
|
||||
}>;
|
||||
};
|
||||
providerUsage?: {
|
||||
getAll: (refresh?: boolean) => Promise<{
|
||||
success: boolean;
|
||||
data?: import('@automaker/types').AllProvidersUsage;
|
||||
error?: string;
|
||||
}>;
|
||||
getProvider: (
|
||||
providerId: import('@automaker/types').UsageProviderId,
|
||||
refresh?: boolean
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
data?: import('@automaker/types').ProviderUsage;
|
||||
error?: string;
|
||||
}>;
|
||||
getAvailability: () => Promise<{
|
||||
success: boolean;
|
||||
data?: Record<import('@automaker/types').UsageProviderId, boolean>;
|
||||
error?: string;
|
||||
}>;
|
||||
};
|
||||
settings?: {
|
||||
getStatus: () => Promise<{
|
||||
success: boolean;
|
||||
|
||||
@@ -1697,6 +1697,27 @@ export class HttpApiClient implements ElectronAPI {
|
||||
error?: string;
|
||||
}> => this.post('/api/setup/deauth-gemini'),
|
||||
|
||||
// Copilot SDK methods
|
||||
getCopilotStatus: (): Promise<{
|
||||
success: boolean;
|
||||
status?: string;
|
||||
installed?: boolean;
|
||||
method?: string;
|
||||
version?: string;
|
||||
path?: string;
|
||||
recommendation?: string;
|
||||
auth?: {
|
||||
authenticated: boolean;
|
||||
method: string;
|
||||
login?: string;
|
||||
host?: string;
|
||||
error?: string;
|
||||
};
|
||||
loginCommand?: string;
|
||||
installCommand?: string;
|
||||
error?: string;
|
||||
}> => this.get('/api/setup/copilot-status'),
|
||||
|
||||
onInstallProgress: (callback: (progress: unknown) => void) => {
|
||||
return this.subscribeToEvent('agent:stream', callback);
|
||||
},
|
||||
@@ -2596,38 +2617,6 @@ export class HttpApiClient implements ElectronAPI {
|
||||
},
|
||||
};
|
||||
|
||||
// Provider Usage API (unified usage tracking for all providers)
|
||||
providerUsage = {
|
||||
getAll: (
|
||||
refresh = false
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
data?: import('@automaker/types').AllProvidersUsage;
|
||||
error?: string;
|
||||
}> => {
|
||||
const url = `/api/provider-usage${refresh ? '?refresh=true' : ''}`;
|
||||
return this.get(url);
|
||||
},
|
||||
|
||||
getProvider: (
|
||||
providerId: import('@automaker/types').UsageProviderId,
|
||||
refresh = false
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
data?: import('@automaker/types').ProviderUsage;
|
||||
error?: string;
|
||||
}> => {
|
||||
const url = `/api/provider-usage/${providerId}${refresh ? '?refresh=true' : ''}`;
|
||||
return this.get(url);
|
||||
},
|
||||
|
||||
getAvailability: (): Promise<{
|
||||
success: boolean;
|
||||
data?: Record<import('@automaker/types').UsageProviderId, boolean>;
|
||||
error?: string;
|
||||
}> => this.get('/api/provider-usage/availability'),
|
||||
};
|
||||
|
||||
// Context API
|
||||
context = {
|
||||
describeImage: (
|
||||
|
||||
@@ -99,12 +99,6 @@ export const queryKeys = {
|
||||
claude: () => ['usage', 'claude'] as const,
|
||||
/** Codex API usage */
|
||||
codex: () => ['usage', 'codex'] as const,
|
||||
/** All providers usage */
|
||||
all: () => ['usage', 'all'] as const,
|
||||
/** Single provider usage */
|
||||
provider: (providerId: string) => ['usage', 'provider', providerId] as const,
|
||||
/** Provider availability */
|
||||
availability: () => ['usage', 'availability'] as const,
|
||||
},
|
||||
|
||||
// ============================================
|
||||
@@ -184,6 +178,8 @@ export const queryKeys = {
|
||||
opencode: () => ['cli', 'opencode'] as const,
|
||||
/** Gemini CLI status */
|
||||
gemini: () => ['cli', 'gemini'] as const,
|
||||
/** Copilot SDK status */
|
||||
copilot: () => ['cli', 'copilot'] as const,
|
||||
/** GitHub CLI status */
|
||||
github: () => ['cli', 'github'] as const,
|
||||
/** API keys status */
|
||||
|
||||
6
apps/ui/src/routes/overview.tsx
Normal file
6
apps/ui/src/routes/overview.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { OverviewView } from '@/components/views/overview-view';
|
||||
|
||||
export const Route = createFileRoute('/overview')({
|
||||
component: OverviewView,
|
||||
});
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
CodexModelId,
|
||||
OpencodeModelId,
|
||||
GeminiModelId,
|
||||
CopilotModelId,
|
||||
PhaseModelConfig,
|
||||
PhaseModelKey,
|
||||
PhaseModelEntry,
|
||||
@@ -41,9 +42,11 @@ import {
|
||||
getAllCodexModelIds,
|
||||
getAllOpencodeModelIds,
|
||||
getAllGeminiModelIds,
|
||||
getAllCopilotModelIds,
|
||||
DEFAULT_PHASE_MODELS,
|
||||
DEFAULT_OPENCODE_MODEL,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
DEFAULT_COPILOT_MODEL,
|
||||
DEFAULT_MAX_CONCURRENCY,
|
||||
DEFAULT_GLOBAL_SETTINGS,
|
||||
} from '@automaker/types';
|
||||
@@ -736,6 +739,10 @@ export interface AppState {
|
||||
enabledGeminiModels: GeminiModelId[]; // Which Gemini models are available in feature modal
|
||||
geminiDefaultModel: GeminiModelId; // Default Gemini model selection
|
||||
|
||||
// Copilot SDK Settings (global)
|
||||
enabledCopilotModels: CopilotModelId[]; // Which Copilot models are available in feature modal
|
||||
copilotDefaultModel: CopilotModelId; // Default Copilot model selection
|
||||
|
||||
// Provider Visibility Settings
|
||||
disabledProviders: ModelProvider[]; // Providers that are disabled and hidden from dropdowns
|
||||
|
||||
@@ -1230,6 +1237,11 @@ export interface AppActions {
|
||||
setGeminiDefaultModel: (model: GeminiModelId) => void;
|
||||
toggleGeminiModel: (model: GeminiModelId, enabled: boolean) => void;
|
||||
|
||||
// Copilot SDK Settings actions
|
||||
setEnabledCopilotModels: (models: CopilotModelId[]) => void;
|
||||
setCopilotDefaultModel: (model: CopilotModelId) => void;
|
||||
toggleCopilotModel: (model: CopilotModelId, enabled: boolean) => void;
|
||||
|
||||
// Provider Visibility Settings actions
|
||||
setDisabledProviders: (providers: ModelProvider[]) => void;
|
||||
toggleProviderDisabled: (provider: ModelProvider, disabled: boolean) => void;
|
||||
@@ -1517,6 +1529,8 @@ const initialState: AppState = {
|
||||
opencodeModelsLastFailedAt: null,
|
||||
enabledGeminiModels: getAllGeminiModelIds(), // All Gemini models enabled by default
|
||||
geminiDefaultModel: DEFAULT_GEMINI_MODEL, // Default to Gemini 2.5 Flash
|
||||
enabledCopilotModels: getAllCopilotModelIds(), // All Copilot models enabled by default
|
||||
copilotDefaultModel: DEFAULT_COPILOT_MODEL, // Default to Claude Sonnet 4.5
|
||||
disabledProviders: [], // No providers disabled by default
|
||||
autoLoadClaudeMd: false, // Default to disabled (user must opt-in)
|
||||
skipSandboxWarning: false, // Default to disabled (show sandbox warning dialog)
|
||||
@@ -2759,6 +2773,16 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
: state.enabledGeminiModels.filter((m) => m !== model),
|
||||
})),
|
||||
|
||||
// Copilot SDK Settings actions
|
||||
setEnabledCopilotModels: (models) => set({ enabledCopilotModels: models }),
|
||||
setCopilotDefaultModel: (model) => set({ copilotDefaultModel: model }),
|
||||
toggleCopilotModel: (model, enabled) =>
|
||||
set((state) => ({
|
||||
enabledCopilotModels: enabled
|
||||
? [...state.enabledCopilotModels, model]
|
||||
: state.enabledCopilotModels.filter((m) => m !== model),
|
||||
})),
|
||||
|
||||
// Provider Visibility Settings actions
|
||||
setDisabledProviders: (providers) => set({ disabledProviders: providers }),
|
||||
toggleProviderDisabled: (provider, disabled) =>
|
||||
|
||||
@@ -79,6 +79,22 @@ export interface GeminiCliStatus {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Copilot SDK Status
|
||||
export interface CopilotCliStatus {
|
||||
installed: boolean;
|
||||
version?: string | null;
|
||||
path?: string | null;
|
||||
auth?: {
|
||||
authenticated: boolean;
|
||||
method: string;
|
||||
login?: string;
|
||||
host?: string;
|
||||
};
|
||||
installCommand?: string;
|
||||
loginCommand?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Codex Auth Method
|
||||
export type CodexAuthMethod =
|
||||
| 'api_key_env' // OPENAI_API_KEY environment variable
|
||||
@@ -137,6 +153,7 @@ export type SetupStep =
|
||||
| 'codex'
|
||||
| 'opencode'
|
||||
| 'gemini'
|
||||
| 'copilot'
|
||||
| 'github'
|
||||
| 'complete';
|
||||
|
||||
@@ -169,6 +186,9 @@ export interface SetupState {
|
||||
// Gemini CLI state
|
||||
geminiCliStatus: GeminiCliStatus | null;
|
||||
|
||||
// Copilot SDK state
|
||||
copilotCliStatus: CopilotCliStatus | null;
|
||||
|
||||
// Setup preferences
|
||||
skipClaudeSetup: boolean;
|
||||
}
|
||||
@@ -206,6 +226,9 @@ export interface SetupActions {
|
||||
// Gemini CLI
|
||||
setGeminiCliStatus: (status: GeminiCliStatus | null) => void;
|
||||
|
||||
// Copilot SDK
|
||||
setCopilotCliStatus: (status: CopilotCliStatus | null) => void;
|
||||
|
||||
// Preferences
|
||||
setSkipClaudeSetup: (skip: boolean) => void;
|
||||
}
|
||||
@@ -241,6 +264,8 @@ const initialState: SetupState = {
|
||||
|
||||
geminiCliStatus: null,
|
||||
|
||||
copilotCliStatus: null,
|
||||
|
||||
skipClaudeSetup: shouldSkipSetup,
|
||||
};
|
||||
|
||||
@@ -316,6 +341,9 @@ export const useSetupStore = create<SetupState & SetupActions>()((set, get) => (
|
||||
// Gemini CLI
|
||||
setGeminiCliStatus: (status) => set({ geminiCliStatus: status }),
|
||||
|
||||
// Copilot SDK
|
||||
setCopilotCliStatus: (status) => set({ copilotCliStatus: status }),
|
||||
|
||||
// Preferences
|
||||
setSkipClaudeSetup: (skip) => set({ skipClaudeSetup: skip }),
|
||||
}));
|
||||
|
||||
394
apps/ui/tests/projects/overview-dashboard.spec.ts
Normal file
394
apps/ui/tests/projects/overview-dashboard.spec.ts
Normal file
@@ -0,0 +1,394 @@
|
||||
/**
|
||||
* Projects Overview Dashboard End-to-End Test
|
||||
*
|
||||
* Tests the multi-project overview dashboard that shows status across all projects.
|
||||
* This verifies that:
|
||||
* 1. The overview view can be accessed via the sidebar
|
||||
* 2. The overview displays aggregate statistics
|
||||
* 3. Navigation back to dashboard works correctly
|
||||
* 4. The UI responds to API data correctly
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
setupMockMultipleProjects,
|
||||
authenticateForTests,
|
||||
handleLoginScreenIfPresent,
|
||||
} from '../utils';
|
||||
|
||||
test.describe('Projects Overview Dashboard', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Set up mock projects state
|
||||
await setupMockMultipleProjects(page, 3);
|
||||
await authenticateForTests(page);
|
||||
});
|
||||
|
||||
test('should navigate to overview from sidebar and display overview UI', async ({ page }) => {
|
||||
// Mock the projects overview API response
|
||||
await page.route('**/api/projects/overview', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
projects: [],
|
||||
aggregate: {
|
||||
projectCounts: {
|
||||
total: 0,
|
||||
active: 0,
|
||||
idle: 0,
|
||||
waiting: 0,
|
||||
withErrors: 0,
|
||||
allCompleted: 0,
|
||||
},
|
||||
featureCounts: {
|
||||
total: 0,
|
||||
pending: 0,
|
||||
running: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
verified: 0,
|
||||
},
|
||||
totalUnreadNotifications: 0,
|
||||
projectsWithAutoModeRunning: 0,
|
||||
computedAt: new Date().toISOString(),
|
||||
},
|
||||
recentActivity: [],
|
||||
generatedAt: new Date().toISOString(),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
// Go to the app
|
||||
await page.goto('/board');
|
||||
await page.waitForLoadState('load');
|
||||
await handleLoginScreenIfPresent(page);
|
||||
|
||||
// Wait for the board view to load
|
||||
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Expand sidebar if collapsed
|
||||
const expandSidebarButton = page.locator('button:has-text("Expand sidebar")');
|
||||
if (await expandSidebarButton.isVisible()) {
|
||||
await expandSidebarButton.click();
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
// Click on the Dashboard link in the sidebar (navigates to /overview)
|
||||
const overviewLink = page.locator('[data-testid="nav-overview"]');
|
||||
await expect(overviewLink).toBeVisible({ timeout: 5000 });
|
||||
await overviewLink.click();
|
||||
|
||||
// Wait for the overview view to appear
|
||||
await expect(page.locator('[data-testid="overview-view"]')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Verify the header is visible with title
|
||||
await expect(page.getByText('Automaker Dashboard')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Verify the refresh button is present
|
||||
await expect(page.getByRole('button', { name: /Refresh/i })).toBeVisible();
|
||||
|
||||
// Verify the Open Project and New Project buttons are present
|
||||
await expect(page.getByRole('button', { name: /Open Project/i })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /New Project/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should display aggregate statistics cards', async ({ page }) => {
|
||||
// Mock the projects overview API response
|
||||
await page.route('**/api/projects/overview', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
projects: [
|
||||
{
|
||||
projectId: 'test-project-1',
|
||||
projectName: 'Test Project 1',
|
||||
projectPath: '/mock/test-project-1',
|
||||
healthStatus: 'active',
|
||||
featureCounts: { pending: 2, running: 1, completed: 3, failed: 0, verified: 2 },
|
||||
totalFeatures: 8,
|
||||
isAutoModeRunning: true,
|
||||
unreadNotificationCount: 1,
|
||||
},
|
||||
{
|
||||
projectId: 'test-project-2',
|
||||
projectName: 'Test Project 2',
|
||||
projectPath: '/mock/test-project-2',
|
||||
healthStatus: 'idle',
|
||||
featureCounts: { pending: 5, running: 0, completed: 10, failed: 1, verified: 8 },
|
||||
totalFeatures: 24,
|
||||
isAutoModeRunning: false,
|
||||
unreadNotificationCount: 0,
|
||||
},
|
||||
],
|
||||
aggregate: {
|
||||
projectCounts: {
|
||||
total: 2,
|
||||
active: 1,
|
||||
idle: 1,
|
||||
waiting: 0,
|
||||
withErrors: 1,
|
||||
allCompleted: 0,
|
||||
},
|
||||
featureCounts: {
|
||||
total: 32,
|
||||
pending: 7,
|
||||
running: 1,
|
||||
completed: 13,
|
||||
failed: 1,
|
||||
verified: 10,
|
||||
},
|
||||
totalUnreadNotifications: 1,
|
||||
projectsWithAutoModeRunning: 1,
|
||||
computedAt: new Date().toISOString(),
|
||||
},
|
||||
recentActivity: [
|
||||
{
|
||||
id: 'activity-1',
|
||||
projectId: 'test-project-1',
|
||||
projectName: 'Test Project 1',
|
||||
type: 'feature_completed',
|
||||
description: 'Feature completed: Add login form',
|
||||
severity: 'success',
|
||||
timestamp: new Date().toISOString(),
|
||||
featureId: 'feature-1',
|
||||
featureTitle: 'Add login form',
|
||||
},
|
||||
],
|
||||
generatedAt: new Date().toISOString(),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
// Navigate directly to overview
|
||||
await page.goto('/overview');
|
||||
await page.waitForLoadState('load');
|
||||
await handleLoginScreenIfPresent(page);
|
||||
|
||||
// Wait for the overview view to appear
|
||||
await expect(page.locator('[data-testid="overview-view"]')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Verify aggregate stat cards are displayed
|
||||
// Projects count card
|
||||
await expect(page.getByText('Projects').first()).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Running features card
|
||||
await expect(page.getByText('Running').first()).toBeVisible();
|
||||
|
||||
// Pending features card
|
||||
await expect(page.getByText('Pending').first()).toBeVisible();
|
||||
|
||||
// Completed features card
|
||||
await expect(page.getByText('Completed').first()).toBeVisible();
|
||||
|
||||
// Auto-mode card
|
||||
await expect(page.getByText('Auto-mode').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('should display project status cards', async ({ page }) => {
|
||||
// Mock the projects overview API response
|
||||
await page.route('**/api/projects/overview', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
projects: [
|
||||
{
|
||||
projectId: 'test-project-1',
|
||||
projectName: 'Test Project 1',
|
||||
projectPath: '/mock/test-project-1',
|
||||
healthStatus: 'active',
|
||||
featureCounts: { pending: 2, running: 1, completed: 3, failed: 0, verified: 2 },
|
||||
totalFeatures: 8,
|
||||
isAutoModeRunning: true,
|
||||
unreadNotificationCount: 1,
|
||||
},
|
||||
],
|
||||
aggregate: {
|
||||
projectCounts: {
|
||||
total: 1,
|
||||
active: 1,
|
||||
idle: 0,
|
||||
waiting: 0,
|
||||
withErrors: 0,
|
||||
allCompleted: 0,
|
||||
},
|
||||
featureCounts: {
|
||||
total: 8,
|
||||
pending: 2,
|
||||
running: 1,
|
||||
completed: 3,
|
||||
failed: 0,
|
||||
verified: 2,
|
||||
},
|
||||
totalUnreadNotifications: 1,
|
||||
projectsWithAutoModeRunning: 1,
|
||||
computedAt: new Date().toISOString(),
|
||||
},
|
||||
recentActivity: [],
|
||||
generatedAt: new Date().toISOString(),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
// Navigate directly to overview
|
||||
await page.goto('/overview');
|
||||
await page.waitForLoadState('load');
|
||||
await handleLoginScreenIfPresent(page);
|
||||
|
||||
// Wait for the overview view to appear
|
||||
await expect(page.locator('[data-testid="overview-view"]')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Verify project status card is displayed
|
||||
const projectCard = page.locator('[data-testid="project-status-card-test-project-1"]');
|
||||
await expect(projectCard).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Verify project name is displayed
|
||||
await expect(projectCard.getByText('Test Project 1')).toBeVisible();
|
||||
|
||||
// Verify the Active status badge (use .first() to avoid strict mode violation due to "Auto-mode active" also containing "active")
|
||||
await expect(projectCard.getByText('Active').first()).toBeVisible();
|
||||
|
||||
// Verify auto-mode indicator is shown
|
||||
await expect(projectCard.getByText('Auto-mode active')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should navigate to board when clicking on a project card', async ({ page }) => {
|
||||
// Mock the projects overview API response
|
||||
await page.route('**/api/projects/overview', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
projects: [
|
||||
{
|
||||
projectId: 'test-project-1',
|
||||
projectName: 'Test Project 1',
|
||||
projectPath: '/mock/test-project-1',
|
||||
healthStatus: 'idle',
|
||||
featureCounts: { pending: 0, running: 0, completed: 0, failed: 0, verified: 0 },
|
||||
totalFeatures: 0,
|
||||
isAutoModeRunning: false,
|
||||
unreadNotificationCount: 0,
|
||||
},
|
||||
],
|
||||
aggregate: {
|
||||
projectCounts: {
|
||||
total: 1,
|
||||
active: 0,
|
||||
idle: 1,
|
||||
waiting: 0,
|
||||
withErrors: 0,
|
||||
allCompleted: 0,
|
||||
},
|
||||
featureCounts: {
|
||||
total: 0,
|
||||
pending: 0,
|
||||
running: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
verified: 0,
|
||||
},
|
||||
totalUnreadNotifications: 0,
|
||||
projectsWithAutoModeRunning: 0,
|
||||
computedAt: new Date().toISOString(),
|
||||
},
|
||||
recentActivity: [],
|
||||
generatedAt: new Date().toISOString(),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
// Navigate directly to overview
|
||||
await page.goto('/overview');
|
||||
await page.waitForLoadState('load');
|
||||
await handleLoginScreenIfPresent(page);
|
||||
|
||||
// Wait for the overview view to appear
|
||||
await expect(page.locator('[data-testid="overview-view"]')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Verify project card is displayed (clicking it would navigate to board, but requires more mocking)
|
||||
const projectCard = page.locator('[data-testid="project-status-card-test-project-1"]');
|
||||
await expect(projectCard).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('should display empty state when no projects exist', async ({ page }) => {
|
||||
// Mock empty projects overview API response
|
||||
await page.route('**/api/projects/overview', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
projects: [],
|
||||
aggregate: {
|
||||
projectCounts: {
|
||||
total: 0,
|
||||
active: 0,
|
||||
idle: 0,
|
||||
waiting: 0,
|
||||
withErrors: 0,
|
||||
allCompleted: 0,
|
||||
},
|
||||
featureCounts: {
|
||||
total: 0,
|
||||
pending: 0,
|
||||
running: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
verified: 0,
|
||||
},
|
||||
totalUnreadNotifications: 0,
|
||||
projectsWithAutoModeRunning: 0,
|
||||
computedAt: new Date().toISOString(),
|
||||
},
|
||||
recentActivity: [],
|
||||
generatedAt: new Date().toISOString(),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
// Navigate directly to overview
|
||||
await page.goto('/overview');
|
||||
await page.waitForLoadState('load');
|
||||
await handleLoginScreenIfPresent(page);
|
||||
|
||||
// Wait for the overview view to appear
|
||||
await expect(page.locator('[data-testid="overview-view"]')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Verify empty state message
|
||||
await expect(page.getByText('No projects yet')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText('Create or open a project to get started')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show error state when API fails', async ({ page }) => {
|
||||
// Mock API error
|
||||
await page.route('**/api/projects/overview', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 500,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
error: 'Internal server error',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
// Navigate directly to overview
|
||||
await page.goto('/overview');
|
||||
await page.waitForLoadState('load');
|
||||
await handleLoginScreenIfPresent(page);
|
||||
|
||||
// Wait for the overview view to appear
|
||||
await expect(page.locator('[data-testid="overview-view"]')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Verify error state message
|
||||
await expect(page.getByText('Failed to load overview')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Verify the "Try again" button is visible
|
||||
await expect(page.getByRole('button', { name: /Try again/i })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -4,12 +4,16 @@
|
||||
* Provides centralized model resolution logic:
|
||||
* - Maps Claude model aliases to full model strings
|
||||
* - Passes through Cursor models unchanged (handled by CursorProvider)
|
||||
* - Passes through Copilot models unchanged (handled by CopilotProvider)
|
||||
* - Passes through Gemini models unchanged (handled by GeminiProvider)
|
||||
* - Provides default models per provider
|
||||
* - Handles multiple model sources with priority
|
||||
*
|
||||
* With canonical model IDs:
|
||||
* - Cursor: cursor-auto, cursor-composer-1, cursor-gpt-5.2
|
||||
* - OpenCode: opencode-big-pickle, opencode-grok-code
|
||||
* - Copilot: copilot-gpt-5.1, copilot-claude-sonnet-4.5, copilot-gemini-3-pro-preview
|
||||
* - Gemini: gemini-2.5-flash, gemini-2.5-pro
|
||||
* - Claude: claude-haiku, claude-sonnet, claude-opus (also supports legacy aliases)
|
||||
*/
|
||||
|
||||
@@ -22,6 +26,8 @@ import {
|
||||
PROVIDER_PREFIXES,
|
||||
isCursorModel,
|
||||
isOpencodeModel,
|
||||
isCopilotModel,
|
||||
isGeminiModel,
|
||||
stripProviderPrefix,
|
||||
migrateModelId,
|
||||
type PhaseModelEntry,
|
||||
@@ -83,6 +89,18 @@ export function resolveModelString(
|
||||
return canonicalKey;
|
||||
}
|
||||
|
||||
// Copilot model with explicit prefix (e.g., "copilot-gpt-5.1", "copilot-claude-sonnet-4.5")
|
||||
if (isCopilotModel(canonicalKey)) {
|
||||
console.log(`[ModelResolver] Using Copilot model: ${canonicalKey}`);
|
||||
return canonicalKey;
|
||||
}
|
||||
|
||||
// Gemini model with explicit prefix (e.g., "gemini-2.5-flash", "gemini-2.5-pro")
|
||||
if (isGeminiModel(canonicalKey)) {
|
||||
console.log(`[ModelResolver] Using Gemini model: ${canonicalKey}`);
|
||||
return canonicalKey;
|
||||
}
|
||||
|
||||
// Claude canonical ID (claude-haiku, claude-sonnet, claude-opus)
|
||||
// Map to full model string
|
||||
if (canonicalKey in CLAUDE_CANONICAL_MAP) {
|
||||
|
||||
194
libs/types/src/copilot-models.ts
Normal file
194
libs/types/src/copilot-models.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* GitHub Copilot CLI Model Definitions
|
||||
*
|
||||
* Defines available models for GitHub Copilot CLI integration.
|
||||
* Based on https://github.com/github/copilot
|
||||
*
|
||||
* The CLI provides runtime model discovery, but we define common models
|
||||
* for UI consistency and offline use.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Copilot model configuration
|
||||
*/
|
||||
export interface CopilotModelConfig {
|
||||
label: string;
|
||||
description: string;
|
||||
supportsVision: boolean;
|
||||
supportsTools: boolean;
|
||||
contextWindow?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Available Copilot models via the GitHub Copilot CLI
|
||||
*
|
||||
* Model IDs use 'copilot-' prefix for consistent provider routing.
|
||||
* When passed to the CLI, the prefix is stripped.
|
||||
*
|
||||
* Note: Actual available models depend on the user's Copilot subscription
|
||||
* and can be discovered at runtime via the CLI's listModels() method.
|
||||
*/
|
||||
export const COPILOT_MODEL_MAP = {
|
||||
// Claude models (Anthropic via GitHub Copilot)
|
||||
'copilot-claude-sonnet-4.5': {
|
||||
label: 'Claude Sonnet 4.5',
|
||||
description: 'Anthropic Claude Sonnet 4.5 via GitHub Copilot.',
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
contextWindow: 200000,
|
||||
},
|
||||
'copilot-claude-haiku-4.5': {
|
||||
label: 'Claude Haiku 4.5',
|
||||
description: 'Fast and efficient Claude Haiku 4.5 via GitHub Copilot.',
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
contextWindow: 200000,
|
||||
},
|
||||
'copilot-claude-opus-4.5': {
|
||||
label: 'Claude Opus 4.5',
|
||||
description: 'Most capable Claude Opus 4.5 via GitHub Copilot.',
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
contextWindow: 200000,
|
||||
},
|
||||
'copilot-claude-sonnet-4': {
|
||||
label: 'Claude Sonnet 4',
|
||||
description: 'Anthropic Claude Sonnet 4 via GitHub Copilot.',
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
contextWindow: 200000,
|
||||
},
|
||||
// GPT-5 series (OpenAI via GitHub Copilot)
|
||||
'copilot-gpt-5.2-codex': {
|
||||
label: 'GPT-5.2 Codex',
|
||||
description: 'OpenAI GPT-5.2 Codex for advanced coding tasks.',
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
contextWindow: 128000,
|
||||
},
|
||||
'copilot-gpt-5.1-codex-max': {
|
||||
label: 'GPT-5.1 Codex Max',
|
||||
description: 'Maximum capability GPT-5.1 Codex model.',
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
contextWindow: 128000,
|
||||
},
|
||||
'copilot-gpt-5.1-codex': {
|
||||
label: 'GPT-5.1 Codex',
|
||||
description: 'OpenAI GPT-5.1 Codex for coding tasks.',
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
contextWindow: 128000,
|
||||
},
|
||||
'copilot-gpt-5.2': {
|
||||
label: 'GPT-5.2',
|
||||
description: 'Latest OpenAI GPT-5.2 model.',
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
contextWindow: 128000,
|
||||
},
|
||||
'copilot-gpt-5.1': {
|
||||
label: 'GPT-5.1',
|
||||
description: 'OpenAI GPT-5.1 model.',
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
contextWindow: 128000,
|
||||
},
|
||||
'copilot-gpt-5': {
|
||||
label: 'GPT-5',
|
||||
description: 'OpenAI GPT-5 base model.',
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
contextWindow: 128000,
|
||||
},
|
||||
'copilot-gpt-5.1-codex-mini': {
|
||||
label: 'GPT-5.1 Codex Mini',
|
||||
description: 'Fast and efficient GPT-5.1 Codex Mini.',
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
contextWindow: 128000,
|
||||
},
|
||||
'copilot-gpt-5-mini': {
|
||||
label: 'GPT-5 Mini',
|
||||
description: 'Lightweight GPT-5 Mini model.',
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
contextWindow: 128000,
|
||||
},
|
||||
'copilot-gpt-4.1': {
|
||||
label: 'GPT-4.1',
|
||||
description: 'OpenAI GPT-4.1 model.',
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
contextWindow: 128000,
|
||||
},
|
||||
// Gemini models (Google via GitHub Copilot)
|
||||
'copilot-gemini-3-pro-preview': {
|
||||
label: 'Gemini 3 Pro Preview',
|
||||
description: 'Google Gemini 3 Pro Preview via GitHub Copilot.',
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
contextWindow: 1000000,
|
||||
},
|
||||
} as const satisfies Record<string, CopilotModelConfig>;
|
||||
|
||||
/**
|
||||
* Copilot model ID type (keys have copilot- prefix)
|
||||
*/
|
||||
export type CopilotModelId = keyof typeof COPILOT_MODEL_MAP;
|
||||
|
||||
/**
|
||||
* Get all Copilot model IDs
|
||||
*/
|
||||
export function getAllCopilotModelIds(): CopilotModelId[] {
|
||||
return Object.keys(COPILOT_MODEL_MAP) as CopilotModelId[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Default Copilot model
|
||||
*/
|
||||
export const DEFAULT_COPILOT_MODEL: CopilotModelId = 'copilot-claude-sonnet-4.5';
|
||||
|
||||
/**
|
||||
* GitHub Copilot authentication status
|
||||
*/
|
||||
export interface CopilotAuthStatus {
|
||||
authenticated: boolean;
|
||||
method: 'oauth' | 'cli' | 'none';
|
||||
authType?: string;
|
||||
login?: string;
|
||||
host?: string;
|
||||
statusMessage?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copilot CLI status (used for installation detection)
|
||||
*/
|
||||
export interface CopilotCliStatus {
|
||||
installed: boolean;
|
||||
version?: string;
|
||||
path?: string;
|
||||
auth?: CopilotAuthStatus;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copilot model info from SDK runtime discovery
|
||||
*/
|
||||
export interface CopilotRuntimeModel {
|
||||
id: string;
|
||||
name: string;
|
||||
capabilities?: {
|
||||
supportsVision?: boolean;
|
||||
maxInputTokens?: number;
|
||||
maxOutputTokens?: number;
|
||||
};
|
||||
policy?: {
|
||||
state: 'enabled' | 'disabled' | 'unconfigured';
|
||||
terms?: string;
|
||||
};
|
||||
billing?: {
|
||||
multiplier: number;
|
||||
};
|
||||
}
|
||||
@@ -253,6 +253,9 @@ export * from './opencode-models.js';
|
||||
// Gemini types
|
||||
export * from './gemini-models.js';
|
||||
|
||||
// Copilot types
|
||||
export * from './copilot-models.js';
|
||||
|
||||
// Provider utilities
|
||||
export {
|
||||
PROVIDER_PREFIXES,
|
||||
@@ -261,6 +264,7 @@ export {
|
||||
isCodexModel,
|
||||
isOpencodeModel,
|
||||
isGeminiModel,
|
||||
isCopilotModel,
|
||||
getModelProvider,
|
||||
stripProviderPrefix,
|
||||
addProviderPrefix,
|
||||
@@ -354,30 +358,18 @@ export type { TerminalInfo } from './terminal.js';
|
||||
// Test runner types
|
||||
export type { TestRunnerInfo } from './test-runner.js';
|
||||
|
||||
// Provider usage types
|
||||
// Project overview types (multi-project dashboard)
|
||||
export type {
|
||||
UsageWindow,
|
||||
ProviderPlan,
|
||||
UsageCost,
|
||||
UsageProviderId,
|
||||
BaseProviderUsage,
|
||||
ClaudeProviderUsage,
|
||||
CodexProviderUsage,
|
||||
CursorProviderUsage,
|
||||
GeminiProviderUsage,
|
||||
CopilotProviderUsage,
|
||||
OpenCodeProviderUsage,
|
||||
MiniMaxProviderUsage,
|
||||
GLMProviderUsage,
|
||||
ProviderUsage,
|
||||
AllProvidersUsage,
|
||||
ProviderUsageResponse,
|
||||
ProviderUsageOptions,
|
||||
ProviderDisplayInfo,
|
||||
} from './provider-usage.js';
|
||||
export {
|
||||
PROVIDER_DISPLAY_INFO,
|
||||
getMaxUsagePercent,
|
||||
getUsageStatusColor,
|
||||
formatResetTime,
|
||||
} from './provider-usage.js';
|
||||
ProjectHealthStatus,
|
||||
FeatureStatusCounts,
|
||||
ProjectStatus,
|
||||
AggregateFeatureCounts,
|
||||
AggregateProjectCounts,
|
||||
AggregateStatus,
|
||||
ActivityType,
|
||||
ActivitySeverity,
|
||||
RecentActivity,
|
||||
ActivityFeedOptions,
|
||||
MultiProjectOverview,
|
||||
ProjectOverviewError,
|
||||
} from './project-overview.js';
|
||||
|
||||
244
libs/types/src/project-overview.ts
Normal file
244
libs/types/src/project-overview.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* Project Overview Types - Multi-project dashboard data structures
|
||||
*
|
||||
* Defines types for aggregating and displaying status across multiple projects,
|
||||
* including individual project health, aggregate metrics, and recent activity feeds.
|
||||
* Used by the multi-project overview dashboard for at-a-glance monitoring.
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// Project Status Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* ProjectHealthStatus - Overall health indicator for a project
|
||||
*
|
||||
* Represents the computed health state based on feature statuses:
|
||||
* - idle: No active work, all features are pending or completed
|
||||
* - active: Features are currently running or in progress
|
||||
* - waiting: Features are waiting for user approval or input
|
||||
* - error: One or more features have failed
|
||||
* - completed: All features have been completed successfully
|
||||
*/
|
||||
export type ProjectHealthStatus = 'idle' | 'active' | 'waiting' | 'error' | 'completed';
|
||||
|
||||
/**
|
||||
* FeatureStatusCounts - Breakdown of features by status
|
||||
*
|
||||
* Provides counts for each feature status to show progress at a glance.
|
||||
*/
|
||||
export interface FeatureStatusCounts {
|
||||
/** Number of features waiting to be started */
|
||||
pending: number;
|
||||
/** Number of features currently executing */
|
||||
running: number;
|
||||
/** Number of features that completed successfully */
|
||||
completed: number;
|
||||
/** Number of features that encountered errors */
|
||||
failed: number;
|
||||
/** Number of features that passed verification */
|
||||
verified: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* ProjectStatus - Status summary for an individual project
|
||||
*
|
||||
* Contains all information needed to display a project's current state
|
||||
* in the multi-project overview dashboard.
|
||||
*/
|
||||
export interface ProjectStatus {
|
||||
/** Project unique identifier (matches ProjectRef.id) */
|
||||
projectId: string;
|
||||
/** Project display name */
|
||||
projectName: string;
|
||||
/** Absolute filesystem path to project */
|
||||
projectPath: string;
|
||||
/** Computed overall health status */
|
||||
healthStatus: ProjectHealthStatus;
|
||||
/** Breakdown of features by status */
|
||||
featureCounts: FeatureStatusCounts;
|
||||
/** Total number of features in the project */
|
||||
totalFeatures: number;
|
||||
/** ISO timestamp of last activity in this project */
|
||||
lastActivityAt?: string;
|
||||
/** Whether auto-mode is currently running */
|
||||
isAutoModeRunning: boolean;
|
||||
/** Name of the currently active branch (if in a worktree) */
|
||||
activeBranch?: string;
|
||||
/** Number of unread notifications for this project */
|
||||
unreadNotificationCount: number;
|
||||
/** Extensibility for future properties */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Aggregate Status Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* AggregateFeatureCounts - Total feature counts across all projects
|
||||
*/
|
||||
export interface AggregateFeatureCounts {
|
||||
/** Total features across all projects */
|
||||
total: number;
|
||||
/** Total pending features */
|
||||
pending: number;
|
||||
/** Total running features */
|
||||
running: number;
|
||||
/** Total completed features */
|
||||
completed: number;
|
||||
/** Total failed features */
|
||||
failed: number;
|
||||
/** Total verified features */
|
||||
verified: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* AggregateProjectCounts - Project counts by health status
|
||||
*/
|
||||
export interface AggregateProjectCounts {
|
||||
/** Total number of projects */
|
||||
total: number;
|
||||
/** Projects with active work */
|
||||
active: number;
|
||||
/** Projects in idle state */
|
||||
idle: number;
|
||||
/** Projects waiting for input */
|
||||
waiting: number;
|
||||
/** Projects with errors */
|
||||
withErrors: number;
|
||||
/** Projects with all work completed */
|
||||
allCompleted: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* AggregateStatus - Summary metrics across all projects
|
||||
*
|
||||
* Provides a bird's-eye view of work status across the entire workspace,
|
||||
* useful for dashboard headers and summary widgets.
|
||||
*/
|
||||
export interface AggregateStatus {
|
||||
/** Counts of projects by health status */
|
||||
projectCounts: AggregateProjectCounts;
|
||||
/** Aggregate feature counts across all projects */
|
||||
featureCounts: AggregateFeatureCounts;
|
||||
/** Total unread notifications across all projects */
|
||||
totalUnreadNotifications: number;
|
||||
/** Number of projects with auto-mode running */
|
||||
projectsWithAutoModeRunning: number;
|
||||
/** ISO timestamp when this aggregate was computed */
|
||||
computedAt: string;
|
||||
/** Extensibility for future properties */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Recent Activity Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* ActivityType - Types of activities that can appear in the activity feed
|
||||
*
|
||||
* Maps to significant events that users would want to see in an overview.
|
||||
*/
|
||||
export type ActivityType =
|
||||
| 'feature_created'
|
||||
| 'feature_started'
|
||||
| 'feature_completed'
|
||||
| 'feature_failed'
|
||||
| 'feature_verified'
|
||||
| 'auto_mode_started'
|
||||
| 'auto_mode_stopped'
|
||||
| 'ideation_session_started'
|
||||
| 'ideation_session_ended'
|
||||
| 'idea_created'
|
||||
| 'idea_converted'
|
||||
| 'notification_created'
|
||||
| 'project_opened';
|
||||
|
||||
/**
|
||||
* ActivitySeverity - Visual importance level for activity items
|
||||
*/
|
||||
export type ActivitySeverity = 'info' | 'success' | 'warning' | 'error';
|
||||
|
||||
/**
|
||||
* RecentActivity - A single activity entry for the activity feed
|
||||
*
|
||||
* Represents a notable event that occurred in a project, displayed
|
||||
* in chronological order in the activity feed widget.
|
||||
*/
|
||||
export interface RecentActivity {
|
||||
/** Unique identifier for this activity entry */
|
||||
id: string;
|
||||
/** Project this activity belongs to */
|
||||
projectId: string;
|
||||
/** Project display name (denormalized for display) */
|
||||
projectName: string;
|
||||
/** Type of activity */
|
||||
type: ActivityType;
|
||||
/** Human-readable description of what happened */
|
||||
description: string;
|
||||
/** Visual importance level */
|
||||
severity: ActivitySeverity;
|
||||
/** ISO timestamp when the activity occurred */
|
||||
timestamp: string;
|
||||
/** Related feature ID if applicable */
|
||||
featureId?: string;
|
||||
/** Related feature title if applicable */
|
||||
featureTitle?: string;
|
||||
/** Related ideation session ID if applicable */
|
||||
sessionId?: string;
|
||||
/** Related idea ID if applicable */
|
||||
ideaId?: string;
|
||||
/** Extensibility for future properties */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* ActivityFeedOptions - Options for fetching activity feed
|
||||
*/
|
||||
export interface ActivityFeedOptions {
|
||||
/** Maximum number of activities to return */
|
||||
limit?: number;
|
||||
/** Filter to specific project IDs */
|
||||
projectIds?: string[];
|
||||
/** Filter to specific activity types */
|
||||
types?: ActivityType[];
|
||||
/** Only return activities after this ISO timestamp */
|
||||
since?: string;
|
||||
/** Only return activities before this ISO timestamp */
|
||||
until?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Multi-Project Overview Response Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* MultiProjectOverview - Complete overview data for the dashboard
|
||||
*
|
||||
* Contains all data needed to render the multi-project overview page,
|
||||
* including individual project statuses, aggregate metrics, and recent activity.
|
||||
*/
|
||||
export interface MultiProjectOverview {
|
||||
/** Individual status for each project */
|
||||
projects: ProjectStatus[];
|
||||
/** Aggregate metrics across all projects */
|
||||
aggregate: AggregateStatus;
|
||||
/** Recent activity feed (sorted by timestamp, most recent first) */
|
||||
recentActivity: RecentActivity[];
|
||||
/** ISO timestamp when this overview was generated */
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ProjectOverviewError - Error response for overview requests
|
||||
*/
|
||||
export interface ProjectOverviewError {
|
||||
/** Error code for programmatic handling */
|
||||
code: 'PROJECTS_NOT_FOUND' | 'PERMISSION_DENIED' | 'INTERNAL_ERROR';
|
||||
/** Human-readable error message */
|
||||
message: string;
|
||||
/** Project IDs that failed to load, if applicable */
|
||||
failedProjectIds?: string[];
|
||||
}
|
||||
@@ -1,375 +0,0 @@
|
||||
/**
|
||||
* Provider Usage Types
|
||||
*
|
||||
* Unified type definitions for tracking usage across all AI providers.
|
||||
* Each provider can have different usage metrics, but all share a common
|
||||
* structure for display in the UI.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Common usage window structure - represents a time-bounded usage period
|
||||
* Used by Claude (session/weekly), Codex (rate limits), Cursor, Gemini, etc.
|
||||
*/
|
||||
export interface UsageWindow {
|
||||
/** Display name for this window (e.g., "5-hour Session", "Weekly Limit") */
|
||||
name: string;
|
||||
/** Percentage of quota used (0-100) */
|
||||
usedPercent: number;
|
||||
/** When this window resets (ISO date string) */
|
||||
resetsAt: string;
|
||||
/** Human-readable reset text (e.g., "Resets in 2h 15m") */
|
||||
resetText: string;
|
||||
/** Window duration in minutes (if applicable) */
|
||||
windowDurationMins?: number;
|
||||
/** Raw limit value (if available) */
|
||||
limit?: number;
|
||||
/** Raw used value (if available) */
|
||||
used?: number;
|
||||
/** Raw remaining value (if available) */
|
||||
remaining?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan/tier information for a provider
|
||||
*/
|
||||
export interface ProviderPlan {
|
||||
/** Plan type identifier (e.g., "free", "pro", "max", "team", "enterprise") */
|
||||
type: string;
|
||||
/** Display name for the plan */
|
||||
displayName: string;
|
||||
/** Whether this is a paid plan */
|
||||
isPaid?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cost/billing information (for pay-per-use providers)
|
||||
*/
|
||||
export interface UsageCost {
|
||||
/** Amount used in current billing period */
|
||||
used: number;
|
||||
/** Limit for current billing period (null if unlimited) */
|
||||
limit: number | null;
|
||||
/** Currency code (e.g., "USD") */
|
||||
currency: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider identifiers for usage tracking
|
||||
*/
|
||||
export type UsageProviderId =
|
||||
| 'claude'
|
||||
| 'codex'
|
||||
| 'cursor'
|
||||
| 'gemini'
|
||||
| 'copilot'
|
||||
| 'opencode'
|
||||
| 'minimax'
|
||||
| 'glm';
|
||||
|
||||
/**
|
||||
* Base interface for all provider usage data
|
||||
*/
|
||||
export interface BaseProviderUsage {
|
||||
/** Provider identifier */
|
||||
providerId: UsageProviderId;
|
||||
/** Provider display name */
|
||||
providerName: string;
|
||||
/** Whether this provider is available and authenticated */
|
||||
available: boolean;
|
||||
/** Primary usage window (most important metric) */
|
||||
primary?: UsageWindow;
|
||||
/** Secondary usage window (if applicable) */
|
||||
secondary?: UsageWindow;
|
||||
/** Additional usage windows (for providers with more than 2) */
|
||||
additional?: UsageWindow[];
|
||||
/** Plan/tier information */
|
||||
plan?: ProviderPlan;
|
||||
/** Cost/billing information */
|
||||
cost?: UsageCost;
|
||||
/** Last time usage was fetched (ISO date string) */
|
||||
lastUpdated: string;
|
||||
/** Error message if fetching failed */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude-specific usage data
|
||||
*/
|
||||
export interface ClaudeProviderUsage extends BaseProviderUsage {
|
||||
providerId: 'claude';
|
||||
/** Session (5-hour) usage window */
|
||||
sessionWindow?: UsageWindow;
|
||||
/** Weekly (all models) usage window */
|
||||
weeklyWindow?: UsageWindow;
|
||||
/** Sonnet-specific weekly usage window */
|
||||
sonnetWindow?: UsageWindow;
|
||||
/** User's timezone */
|
||||
userTimezone?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Codex-specific usage data
|
||||
*/
|
||||
export interface CodexProviderUsage extends BaseProviderUsage {
|
||||
providerId: 'codex';
|
||||
/** Plan type (free, plus, pro, team, enterprise, edu) */
|
||||
planType?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cursor-specific usage data
|
||||
*/
|
||||
export interface CursorProviderUsage extends BaseProviderUsage {
|
||||
providerId: 'cursor';
|
||||
/** Included plan usage (fast requests) */
|
||||
planUsage?: UsageWindow;
|
||||
/** On-demand/overage usage */
|
||||
onDemandUsage?: UsageWindow;
|
||||
/** On-demand cost in USD */
|
||||
onDemandCostUsd?: number;
|
||||
/** Billing cycle end date */
|
||||
billingCycleEnd?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemini-specific usage data
|
||||
*/
|
||||
export interface GeminiProviderUsage extends BaseProviderUsage {
|
||||
providerId: 'gemini';
|
||||
/** Quota remaining fraction (0-1) */
|
||||
remainingFraction?: number;
|
||||
/** Model ID for quota */
|
||||
modelId?: string;
|
||||
/** Tier type (standard, free, workspace, legacy) */
|
||||
tierType?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub Copilot-specific usage data
|
||||
*/
|
||||
export interface CopilotProviderUsage extends BaseProviderUsage {
|
||||
providerId: 'copilot';
|
||||
/** Premium interactions quota */
|
||||
premiumInteractions?: UsageWindow;
|
||||
/** Chat quota */
|
||||
chatQuota?: UsageWindow;
|
||||
/** Copilot plan type */
|
||||
copilotPlan?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenCode-specific usage data
|
||||
*/
|
||||
export interface OpenCodeProviderUsage extends BaseProviderUsage {
|
||||
providerId: 'opencode';
|
||||
/** Rolling 5-hour usage window */
|
||||
rollingWindow?: UsageWindow;
|
||||
/** Weekly usage window */
|
||||
weeklyWindow?: UsageWindow;
|
||||
/** Workspace ID */
|
||||
workspaceId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* MiniMax-specific usage data
|
||||
*/
|
||||
export interface MiniMaxProviderUsage extends BaseProviderUsage {
|
||||
providerId: 'minimax';
|
||||
/** Coding plan token remains */
|
||||
tokenRemains?: number;
|
||||
/** Total tokens in plan */
|
||||
totalTokens?: number;
|
||||
/** Plan start time */
|
||||
planStartTime?: string;
|
||||
/** Plan end time */
|
||||
planEndTime?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* GLM (z.AI)-specific usage data
|
||||
*/
|
||||
export interface GLMProviderUsage extends BaseProviderUsage {
|
||||
providerId: 'glm';
|
||||
/** Coding plan usage similar to MiniMax */
|
||||
tokenRemains?: number;
|
||||
totalTokens?: number;
|
||||
planStartTime?: string;
|
||||
planEndTime?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Union type of all provider usage types
|
||||
*/
|
||||
export type ProviderUsage =
|
||||
| ClaudeProviderUsage
|
||||
| CodexProviderUsage
|
||||
| CursorProviderUsage
|
||||
| GeminiProviderUsage
|
||||
| CopilotProviderUsage
|
||||
| OpenCodeProviderUsage
|
||||
| MiniMaxProviderUsage
|
||||
| GLMProviderUsage;
|
||||
|
||||
/**
|
||||
* Aggregated usage data from all providers
|
||||
*/
|
||||
export interface AllProvidersUsage {
|
||||
/** Usage data by provider ID */
|
||||
providers: Partial<Record<UsageProviderId, ProviderUsage>>;
|
||||
/** Last time any usage was fetched */
|
||||
lastUpdated: string;
|
||||
/** List of providers that are enabled but had errors */
|
||||
errors: Array<{ providerId: UsageProviderId; message: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response type for the unified usage endpoint
|
||||
*/
|
||||
export interface ProviderUsageResponse {
|
||||
success: boolean;
|
||||
data?: AllProvidersUsage;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request options for fetching provider usage
|
||||
*/
|
||||
export interface ProviderUsageOptions {
|
||||
/** Which providers to fetch usage for (empty = all enabled) */
|
||||
providers?: UsageProviderId[];
|
||||
/** Force refresh even if cached data is fresh */
|
||||
forceRefresh?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider display information for UI
|
||||
*/
|
||||
export interface ProviderDisplayInfo {
|
||||
id: UsageProviderId;
|
||||
name: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
statusUrl?: string;
|
||||
dashboardUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider display metadata
|
||||
*/
|
||||
export const PROVIDER_DISPLAY_INFO: Record<UsageProviderId, ProviderDisplayInfo> = {
|
||||
claude: {
|
||||
id: 'claude',
|
||||
name: 'Claude',
|
||||
icon: 'anthropic',
|
||||
color: '#D97706',
|
||||
statusUrl: 'https://status.claude.com',
|
||||
dashboardUrl: 'https://console.anthropic.com',
|
||||
},
|
||||
codex: {
|
||||
id: 'codex',
|
||||
name: 'Codex',
|
||||
icon: 'openai',
|
||||
color: '#10A37F',
|
||||
statusUrl: 'https://status.openai.com',
|
||||
dashboardUrl: 'https://platform.openai.com/usage',
|
||||
},
|
||||
cursor: {
|
||||
id: 'cursor',
|
||||
name: 'Cursor',
|
||||
icon: 'cursor',
|
||||
color: '#6366F1',
|
||||
dashboardUrl: 'https://cursor.com/settings',
|
||||
},
|
||||
gemini: {
|
||||
id: 'gemini',
|
||||
name: 'Gemini',
|
||||
icon: 'google',
|
||||
color: '#4285F4',
|
||||
dashboardUrl: 'https://aistudio.google.com',
|
||||
},
|
||||
copilot: {
|
||||
id: 'copilot',
|
||||
name: 'GitHub Copilot',
|
||||
icon: 'github',
|
||||
color: '#24292E',
|
||||
dashboardUrl: 'https://github.com/settings/copilot',
|
||||
},
|
||||
opencode: {
|
||||
id: 'opencode',
|
||||
name: 'OpenCode',
|
||||
icon: 'opencode',
|
||||
color: '#FF6B6B',
|
||||
dashboardUrl: 'https://opencode.ai',
|
||||
},
|
||||
minimax: {
|
||||
id: 'minimax',
|
||||
name: 'MiniMax',
|
||||
icon: 'minimax',
|
||||
color: '#FF4081',
|
||||
dashboardUrl: 'https://platform.minimax.io/user-center/payment/coding-plan',
|
||||
},
|
||||
glm: {
|
||||
id: 'glm',
|
||||
name: 'z.AI GLM',
|
||||
icon: 'glm',
|
||||
color: '#00BFA5',
|
||||
dashboardUrl: 'https://z.ai/account',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to calculate the maximum usage percentage across all windows
|
||||
*/
|
||||
export function getMaxUsagePercent(usage: ProviderUsage): number {
|
||||
let max = 0;
|
||||
if (usage.primary?.usedPercent !== undefined) {
|
||||
max = Math.max(max, usage.primary.usedPercent);
|
||||
}
|
||||
if (usage.secondary?.usedPercent !== undefined) {
|
||||
max = Math.max(max, usage.secondary.usedPercent);
|
||||
}
|
||||
if (usage.additional) {
|
||||
for (const window of usage.additional) {
|
||||
if (window.usedPercent !== undefined) {
|
||||
max = Math.max(max, window.usedPercent);
|
||||
}
|
||||
}
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get usage status color based on percentage
|
||||
*/
|
||||
export function getUsageStatusColor(percent: number): 'green' | 'yellow' | 'orange' | 'red' {
|
||||
if (percent >= 90) return 'red';
|
||||
if (percent >= 75) return 'orange';
|
||||
if (percent >= 50) return 'yellow';
|
||||
return 'green';
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to format reset time as human-readable string
|
||||
*/
|
||||
export function formatResetTime(resetAt: string | Date): string {
|
||||
const date = typeof resetAt === 'string' ? new Date(resetAt) : 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';
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { CURSOR_MODEL_MAP, LEGACY_CURSOR_MODEL_MAP } from './cursor-models.js';
|
||||
import { CLAUDE_MODEL_MAP, CODEX_MODEL_MAP } from './model.js';
|
||||
import { OPENCODE_MODEL_CONFIG_MAP, LEGACY_OPENCODE_MODEL_MAP } from './opencode-models.js';
|
||||
import { GEMINI_MODEL_MAP } from './gemini-models.js';
|
||||
import { COPILOT_MODEL_MAP } from './copilot-models.js';
|
||||
|
||||
/** Provider prefix constants */
|
||||
export const PROVIDER_PREFIXES = {
|
||||
@@ -18,6 +19,7 @@ export const PROVIDER_PREFIXES = {
|
||||
codex: 'codex-',
|
||||
opencode: 'opencode-',
|
||||
gemini: 'gemini-',
|
||||
copilot: 'copilot-',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
@@ -114,6 +116,28 @@ export function isGeminiModel(model: string | undefined | null): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a model string represents a GitHub Copilot model
|
||||
*
|
||||
* @param model - Model string to check (e.g., "copilot-gpt-4o", "copilot-claude-3.5-sonnet")
|
||||
* @returns true if the model is a Copilot model
|
||||
*/
|
||||
export function isCopilotModel(model: string | undefined | null): boolean {
|
||||
if (!model || typeof model !== 'string') return false;
|
||||
|
||||
// Canonical format: copilot- prefix (e.g., "copilot-gpt-4o")
|
||||
if (model.startsWith(PROVIDER_PREFIXES.copilot)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if it's a known Copilot model ID (map keys include copilot- prefix)
|
||||
if (model in COPILOT_MODEL_MAP) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a model string represents an OpenCode model
|
||||
*
|
||||
@@ -175,7 +199,11 @@ export function isOpencodeModel(model: string | undefined | null): boolean {
|
||||
* @returns The provider type, defaults to 'claude' for unknown models
|
||||
*/
|
||||
export function getModelProvider(model: string | undefined | null): ModelProvider {
|
||||
// Check Gemini first since it uses gemini- prefix
|
||||
// Check Copilot first since it has a unique prefix
|
||||
if (isCopilotModel(model)) {
|
||||
return 'copilot';
|
||||
}
|
||||
// Check Gemini since it uses gemini- prefix
|
||||
if (isGeminiModel(model)) {
|
||||
return 'gemini';
|
||||
}
|
||||
@@ -248,6 +276,10 @@ export function addProviderPrefix(model: string, provider: ModelProvider): strin
|
||||
if (!model.startsWith(PROVIDER_PREFIXES.gemini)) {
|
||||
return `${PROVIDER_PREFIXES.gemini}${model}`;
|
||||
}
|
||||
} else if (provider === 'copilot') {
|
||||
if (!model.startsWith(PROVIDER_PREFIXES.copilot)) {
|
||||
return `${PROVIDER_PREFIXES.copilot}${model}`;
|
||||
}
|
||||
}
|
||||
// Claude models don't use prefixes
|
||||
return model;
|
||||
@@ -284,6 +316,7 @@ export function normalizeModelString(model: string | undefined | null): string {
|
||||
model.startsWith(PROVIDER_PREFIXES.codex) ||
|
||||
model.startsWith(PROVIDER_PREFIXES.opencode) ||
|
||||
model.startsWith(PROVIDER_PREFIXES.gemini) ||
|
||||
model.startsWith(PROVIDER_PREFIXES.copilot) ||
|
||||
model.startsWith('claude-')
|
||||
) {
|
||||
return model;
|
||||
|
||||
@@ -11,6 +11,10 @@ import type { CursorModelId } from './cursor-models.js';
|
||||
import { CURSOR_MODEL_MAP, getAllCursorModelIds } from './cursor-models.js';
|
||||
import type { OpencodeModelId } from './opencode-models.js';
|
||||
import { getAllOpencodeModelIds, DEFAULT_OPENCODE_MODEL } from './opencode-models.js';
|
||||
import type { GeminiModelId } from './gemini-models.js';
|
||||
import { getAllGeminiModelIds, DEFAULT_GEMINI_MODEL } from './gemini-models.js';
|
||||
import type { CopilotModelId } from './copilot-models.js';
|
||||
import { getAllCopilotModelIds, DEFAULT_COPILOT_MODEL } from './copilot-models.js';
|
||||
import type { PromptCustomization } from './prompts.js';
|
||||
import type { CodexSandboxMode, CodexApprovalPolicy } from './codex.js';
|
||||
import type { ReasoningEffort } from './provider.js';
|
||||
@@ -99,7 +103,7 @@ export function getThinkingTokenBudget(level: ThinkingLevel | undefined): number
|
||||
}
|
||||
|
||||
/** ModelProvider - AI model provider for credentials and API key management */
|
||||
export type ModelProvider = 'claude' | 'cursor' | 'codex' | 'opencode' | 'gemini';
|
||||
export type ModelProvider = 'claude' | 'cursor' | 'codex' | 'opencode' | 'gemini' | 'copilot';
|
||||
|
||||
// ============================================================================
|
||||
// Claude-Compatible Providers - Configuration for Claude-compatible API endpoints
|
||||
@@ -895,6 +899,18 @@ export interface GlobalSettings {
|
||||
/** Which dynamic OpenCode models are enabled (empty = all discovered) */
|
||||
enabledDynamicModelIds?: string[];
|
||||
|
||||
// Gemini CLI Settings (global)
|
||||
/** Which Gemini models are available in feature modal (empty = all) */
|
||||
enabledGeminiModels?: GeminiModelId[];
|
||||
/** Default Gemini model selection when switching to Gemini CLI */
|
||||
geminiDefaultModel?: GeminiModelId;
|
||||
|
||||
// Copilot CLI Settings (global)
|
||||
/** Which Copilot models are available in feature modal (empty = all) */
|
||||
enabledCopilotModels?: CopilotModelId[];
|
||||
/** Default Copilot model selection when switching to Copilot CLI */
|
||||
copilotDefaultModel?: CopilotModelId;
|
||||
|
||||
// Provider Visibility Settings
|
||||
/** Providers that are disabled and should not appear in model dropdowns */
|
||||
disabledProviders?: ModelProvider[];
|
||||
@@ -1316,6 +1332,10 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
|
||||
enabledOpencodeModels: getAllOpencodeModelIds(), // Returns prefixed IDs
|
||||
opencodeDefaultModel: DEFAULT_OPENCODE_MODEL, // Already prefixed
|
||||
enabledDynamicModelIds: [],
|
||||
enabledGeminiModels: getAllGeminiModelIds(), // Returns prefixed IDs
|
||||
geminiDefaultModel: DEFAULT_GEMINI_MODEL, // Already prefixed
|
||||
enabledCopilotModels: getAllCopilotModelIds(), // Returns prefixed IDs
|
||||
copilotDefaultModel: DEFAULT_COPILOT_MODEL, // Already prefixed
|
||||
disabledProviders: [],
|
||||
keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS,
|
||||
projects: [],
|
||||
|
||||
145
package-lock.json
generated
145
package-lock.json
generated
@@ -43,6 +43,7 @@
|
||||
"@automaker/prompts": "1.0.0",
|
||||
"@automaker/types": "1.0.0",
|
||||
"@automaker/utils": "1.0.0",
|
||||
"@github/copilot-sdk": "^0.1.16",
|
||||
"@modelcontextprotocol/sdk": "1.25.2",
|
||||
"@openai/codex-sdk": "^0.77.0",
|
||||
"cookie-parser": "1.4.7",
|
||||
@@ -1547,7 +1548,7 @@
|
||||
},
|
||||
"node_modules/@electron/node-gyp": {
|
||||
"version": "10.2.0-electron.1",
|
||||
"resolved": "git+ssh://git@github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2",
|
||||
"resolved": "git+https://github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2",
|
||||
"integrity": "sha512-4MSBTT8y07YUDqf69/vSh80Hh791epYqGtWHO3zSKhYFwQg+gx9wi1PqbqP6YqC4WMsNxZ5l9oDmnWdK5pfCKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
@@ -3032,6 +3033,133 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@github/copilot": {
|
||||
"version": "0.0.389",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-0.0.389.tgz",
|
||||
"integrity": "sha512-XCHMCd8fu7g9WAp+ZepXBF1ud8vdfxDG4ajstGJqHfbdz0RxQktB35R5s/vKizpYXSZogFqwjxl41qX8DypY6g==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"copilot": "npm-loader.js"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@github/copilot-darwin-arm64": "0.0.389",
|
||||
"@github/copilot-darwin-x64": "0.0.389",
|
||||
"@github/copilot-linux-arm64": "0.0.389",
|
||||
"@github/copilot-linux-x64": "0.0.389",
|
||||
"@github/copilot-win32-arm64": "0.0.389",
|
||||
"@github/copilot-win32-x64": "0.0.389"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-darwin-arm64": {
|
||||
"version": "0.0.389",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-0.0.389.tgz",
|
||||
"integrity": "sha512-4Crm/C9//ZPsK+NP5E5BEjltAGuij9XkvRILvZ/mqlaiDXRncFvUtdOoV+/Of+i4Zva/1sWnc7CrS7PHGJDyFg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"bin": {
|
||||
"copilot-darwin-arm64": "copilot"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-darwin-x64": {
|
||||
"version": "0.0.389",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-0.0.389.tgz",
|
||||
"integrity": "sha512-w0LB+lw29UmRS9oW8ENyZhrf3S5LQ3Pz796dQY8LZybp7WxEGtQhvXN48mye9gGzOHNoHxQ2+10+OzsjC/mLUQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"bin": {
|
||||
"copilot-darwin-x64": "copilot"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-linux-arm64": {
|
||||
"version": "0.0.389",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-0.0.389.tgz",
|
||||
"integrity": "sha512-8QNvfs4r6nrbQrT4llu0CbJHcCJosyj+ZgLSpA+lqIiO/TiTQ48kV41uNjzTz1RmR6/qBKcz81FB7HcHXpT3xw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"bin": {
|
||||
"copilot-linux-arm64": "copilot"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-linux-x64": {
|
||||
"version": "0.0.389",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-0.0.389.tgz",
|
||||
"integrity": "sha512-ls42wSzspC7sLiweoqu2zT75mqMsLWs+IZBfCqcuH1BV+C/j/XSEHsSrJxAI3TPtIsOTolPbTAa8jye1nGDxeg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"bin": {
|
||||
"copilot-linux-x64": "copilot"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-sdk": {
|
||||
"version": "0.1.16",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-0.1.16.tgz",
|
||||
"integrity": "sha512-yEZrrUl9w6rvKmjJpzpqovL39GzFrHxnIXOSK/bQfFwk7Ak/drmBk2gOwJqDVJcbhUm2dsoeLIfok7vtyjAxTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@github/copilot": "^0.0.389",
|
||||
"vscode-jsonrpc": "^8.2.1",
|
||||
"zod": "^4.3.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-win32-arm64": {
|
||||
"version": "0.0.389",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-0.0.389.tgz",
|
||||
"integrity": "sha512-loniaCnrty9okQMl3EhxeeyDhnrJ/lJK0Q0r7wkLf1d/TM2swp3tsGZyIRlhDKx5lgcnCPm1m0BqauMo8Vs34g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"bin": {
|
||||
"copilot-win32-arm64": "copilot.exe"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-win32-x64": {
|
||||
"version": "0.0.389",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-0.0.389.tgz",
|
||||
"integrity": "sha512-L1ZzwV/vsxnrz0WO4qLDUlXXFQQ9fOFuBGKWy6TXS7aniaxI/7mdRQR1YjIEqy+AzRw9BaXR2UUUUDk0gb1+kw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"bin": {
|
||||
"copilot-win32-x64": "copilot.exe"
|
||||
}
|
||||
},
|
||||
"node_modules/@hono/node-server": {
|
||||
"version": "1.19.7",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.7.tgz",
|
||||
@@ -16410,6 +16538,15 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/vscode-jsonrpc": {
|
||||
"version": "8.2.1",
|
||||
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz",
|
||||
"integrity": "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-keyname": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
@@ -16646,9 +16783,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz",
|
||||
"integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==",
|
||||
"version": "4.3.6",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
|
||||
119
tests/e2e/multi-project-dashboard.spec.ts
Normal file
119
tests/e2e/multi-project-dashboard.spec.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Multi-Project Dashboard E2E Tests
|
||||
*
|
||||
* Verifies the unified dashboard showing status across all projects.
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Multi-Project Dashboard', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Navigate to the dashboard first
|
||||
await page.goto('/dashboard');
|
||||
await expect(page.getByTestId('dashboard-view')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should navigate to overview from dashboard when projects exist', async ({ page }) => {
|
||||
// Check if the overview button is visible (only shows when projects exist)
|
||||
const overviewButton = page.getByTestId('projects-overview-button');
|
||||
|
||||
// If there are projects, the button should be visible
|
||||
if (await overviewButton.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await overviewButton.click();
|
||||
|
||||
// Should navigate to overview page
|
||||
await expect(page).toHaveURL(/\/overview/);
|
||||
await expect(page.getByTestId('overview-view')).toBeVisible();
|
||||
} else {
|
||||
// No projects - overview button won't be shown
|
||||
test.info().annotations.push({
|
||||
type: 'info',
|
||||
description: 'No projects available - skipping overview navigation test',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('should display overview view with correct structure', async ({ page }) => {
|
||||
// Navigate directly to overview
|
||||
await page.goto('/overview');
|
||||
|
||||
// Wait for the overview view to load
|
||||
const overviewView = page.getByTestId('overview-view');
|
||||
|
||||
// The view should be visible (even if loading)
|
||||
await expect(overviewView).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Should have a back button to return to dashboard
|
||||
const backButton = page
|
||||
.locator('button')
|
||||
.filter({ has: page.locator('svg.lucide-arrow-left') });
|
||||
await expect(backButton).toBeVisible();
|
||||
|
||||
// Click back to return to dashboard
|
||||
await backButton.click();
|
||||
await expect(page).toHaveURL(/\/dashboard/);
|
||||
});
|
||||
|
||||
test('should show loading state and then content or empty state', async ({ page }) => {
|
||||
await page.goto('/overview');
|
||||
|
||||
// Should show the view
|
||||
const overviewView = page.getByTestId('overview-view');
|
||||
await expect(overviewView).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Wait for loading to complete (either shows content or error)
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// After loading, should show either:
|
||||
// 1. Project cards if projects exist
|
||||
// 2. Empty state message if no projects
|
||||
// 3. Error message if API failed
|
||||
const hasProjects = (await page.locator('[data-testid^="project-status-card-"]').count()) > 0;
|
||||
const hasEmptyState = await page
|
||||
.getByText('No projects yet')
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
const hasError = await page
|
||||
.getByText('Failed to load overview')
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
// At least one of these should be true
|
||||
expect(hasProjects || hasEmptyState || hasError).toBeTruthy();
|
||||
});
|
||||
|
||||
test('should have overview link in sidebar footer', async ({ page }) => {
|
||||
// First open a project to see the sidebar
|
||||
await page.goto('/overview');
|
||||
|
||||
// The overview link should be in the sidebar footer
|
||||
const sidebarOverviewLink = page.getByTestId('projects-overview-link');
|
||||
|
||||
if (await sidebarOverviewLink.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
// Should be clickable
|
||||
await sidebarOverviewLink.click();
|
||||
await expect(page).toHaveURL(/\/overview/);
|
||||
}
|
||||
});
|
||||
|
||||
test('should refresh data when refresh button is clicked', async ({ page }) => {
|
||||
await page.goto('/overview');
|
||||
|
||||
// Wait for initial load
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Find the refresh button
|
||||
const refreshButton = page.locator('button').filter({ hasText: 'Refresh' });
|
||||
|
||||
if (await refreshButton.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await refreshButton.click();
|
||||
|
||||
// The button should show loading state (spinner icon)
|
||||
// Wait a moment for the refresh to complete
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Page should still be on overview
|
||||
await expect(page).toHaveURL(/\/overview/);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user