mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-03 21:03:08 +00:00
Compare commits
82 Commits
claude/add
...
906f471521
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
906f471521 | ||
|
|
a10ddadbde | ||
|
|
3399d48823 | ||
|
|
7f5c5e864d | ||
|
|
35d2d41821 | ||
|
|
7a5cb38a37 | ||
|
|
c9833b67a0 | ||
|
|
0f11ee2212 | ||
|
|
74b301c2d1 | ||
|
|
81ee2d1399 | ||
|
|
07f95ae13b | ||
|
|
8dd6ab2161 | ||
|
|
b5143f4b00 | ||
|
|
f5efa857ca | ||
|
|
c401bf4e63 | ||
|
|
43d5ec9aed | ||
|
|
f8108b1a6c | ||
|
|
a4c43b99a5 | ||
|
|
0f00180c50 | ||
|
|
22853c988a | ||
|
|
e52837cbe7 | ||
|
|
d12e0705f0 | ||
|
|
a3e536b8e6 | ||
|
|
43661e5a6e | ||
|
|
1b2bf0df3f | ||
|
|
db87e83aed | ||
|
|
92b1fb3725 | ||
|
|
d7f86d142a | ||
|
|
bbe669cdf2 | ||
|
|
8e13245aab | ||
|
|
ed92d4fd80 | ||
|
|
a6190f71b3 | ||
|
|
d04934359a | ||
|
|
7246debb69 | ||
|
|
066ffe5639 | ||
|
|
7bf02b64fa | ||
|
|
a3c62e8358 | ||
|
|
1ecb97b71c | ||
|
|
1e87b73dfd | ||
|
|
5a3dac1533 | ||
|
|
f3b16ad8ce | ||
|
|
140c444e6f | ||
|
|
907c1d65b3 | ||
|
|
92f2702f3b | ||
|
|
735786701f | ||
|
|
900bbb5e80 | ||
|
|
bc3e3dad1c | ||
|
|
d8fa5c4cd1 | ||
|
|
f005c30017 | ||
|
|
4012a2964a | ||
|
|
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 }}
|
||||
|
||||
@@ -25,6 +25,7 @@ COPY libs/types/package*.json ./libs/types/
|
||||
COPY libs/utils/package*.json ./libs/utils/
|
||||
COPY libs/prompts/package*.json ./libs/prompts/
|
||||
COPY libs/platform/package*.json ./libs/platform/
|
||||
COPY libs/spec-parser/package*.json ./libs/spec-parser/
|
||||
COPY libs/model-resolver/package*.json ./libs/model-resolver/
|
||||
COPY libs/dependency-resolver/package*.json ./libs/dependency-resolver/
|
||||
COPY libs/git-utils/package*.json ./libs/git-utils/
|
||||
|
||||
@@ -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,6 +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 { createProjectsRoutes } from './routes/projects/index.js';
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
@@ -347,6 +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/projects',
|
||||
createProjectsRoutes(featureLoader, autoModeService, settingsService, notificationService)
|
||||
);
|
||||
|
||||
// Create HTTP server
|
||||
const server = createServer(app);
|
||||
|
||||
@@ -98,9 +98,14 @@ const TEXT_ENCODING = 'utf-8';
|
||||
* This is the "no output" timeout - if the CLI doesn't produce any JSONL output
|
||||
* for this duration, the process is killed. For reasoning models with high
|
||||
* reasoning effort, this timeout is dynamically extended via calculateReasoningTimeout().
|
||||
*
|
||||
* For feature generation (which can generate 50+ features), we use a much longer
|
||||
* base timeout (5 minutes) since Codex models are slower at generating large JSON responses.
|
||||
*
|
||||
* @see calculateReasoningTimeout from @automaker/types
|
||||
*/
|
||||
const CODEX_CLI_TIMEOUT_MS = DEFAULT_TIMEOUT_MS;
|
||||
const CODEX_FEATURE_GENERATION_BASE_TIMEOUT_MS = 300000; // 5 minutes for feature generation
|
||||
const CONTEXT_WINDOW_256K = 256000;
|
||||
const MAX_OUTPUT_32K = 32000;
|
||||
const MAX_OUTPUT_16K = 16000;
|
||||
@@ -827,7 +832,14 @@ export class CodexProvider extends BaseProvider {
|
||||
// Higher reasoning effort (e.g., 'xhigh' for "xtra thinking" mode) requires more time
|
||||
// for the model to generate reasoning tokens before producing output.
|
||||
// This fixes GitHub issue #530 where features would get stuck with reasoning models.
|
||||
const timeout = calculateReasoningTimeout(options.reasoningEffort, CODEX_CLI_TIMEOUT_MS);
|
||||
//
|
||||
// For feature generation with 'xhigh', use the extended 5-minute base timeout
|
||||
// since generating 50+ features takes significantly longer than normal operations.
|
||||
const baseTimeout =
|
||||
options.reasoningEffort === 'xhigh'
|
||||
? CODEX_FEATURE_GENERATION_BASE_TIMEOUT_MS
|
||||
: CODEX_CLI_TIMEOUT_MS;
|
||||
const timeout = calculateReasoningTimeout(options.reasoningEffort, baseTimeout);
|
||||
|
||||
const stream = spawnJSONLProcess({
|
||||
command: commandPath,
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -8,10 +8,11 @@
|
||||
import * as secureFs from '../../lib/secure-fs.js';
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
|
||||
import { DEFAULT_PHASE_MODELS, supportsStructuredOutput, isCodexModel } from '@automaker/types';
|
||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||
import { streamingQuery } from '../../providers/simple-query-service.js';
|
||||
import { parseAndCreateFeatures } from './parse-and-create-features.js';
|
||||
import { extractJsonWithArray } from '../../lib/json-extractor.js';
|
||||
import { getAppSpecPath } from '@automaker/platform';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
import {
|
||||
@@ -25,6 +26,64 @@ const logger = createLogger('SpecRegeneration');
|
||||
|
||||
const DEFAULT_MAX_FEATURES = 50;
|
||||
|
||||
/**
|
||||
* Timeout for Codex models when generating features (5 minutes).
|
||||
* Codex models are slower and need more time to generate 50+ features.
|
||||
*/
|
||||
const CODEX_FEATURE_GENERATION_TIMEOUT_MS = 300000; // 5 minutes
|
||||
|
||||
/**
|
||||
* Type for extracted features JSON response
|
||||
*/
|
||||
interface FeaturesExtractionResult {
|
||||
features: Array<{
|
||||
id: string;
|
||||
category?: string;
|
||||
title: string;
|
||||
description: string;
|
||||
priority?: number;
|
||||
complexity?: 'simple' | 'moderate' | 'complex';
|
||||
dependencies?: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON schema for features output format (Claude/Codex structured output)
|
||||
*/
|
||||
const featuresOutputSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
features: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Unique feature identifier (kebab-case)' },
|
||||
category: { type: 'string', description: 'Feature category' },
|
||||
title: { type: 'string', description: 'Short, descriptive title' },
|
||||
description: { type: 'string', description: 'Detailed feature description' },
|
||||
priority: {
|
||||
type: 'number',
|
||||
description: 'Priority level: 1 (highest) to 5 (lowest)',
|
||||
},
|
||||
complexity: {
|
||||
type: 'string',
|
||||
enum: ['simple', 'moderate', 'complex'],
|
||||
description: 'Implementation complexity',
|
||||
},
|
||||
dependencies: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'IDs of features this depends on',
|
||||
},
|
||||
},
|
||||
required: ['id', 'title', 'description'],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['features'],
|
||||
} as const;
|
||||
|
||||
export async function generateFeaturesFromSpec(
|
||||
projectPath: string,
|
||||
events: EventEmitter,
|
||||
@@ -136,23 +195,80 @@ Generate ${featureCount} NEW features that build on each other logically. Rememb
|
||||
provider: undefined,
|
||||
credentials: undefined,
|
||||
};
|
||||
const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
|
||||
const { model, thinkingLevel, reasoningEffort } = resolvePhaseModel(phaseModelEntry);
|
||||
|
||||
logger.info('Using model:', model, provider ? `via provider: ${provider.name}` : 'direct API');
|
||||
|
||||
// Codex models need extended timeout for generating many features.
|
||||
// Use 'xhigh' reasoning effort to get 5-minute timeout (300s base * 1.0x = 300s).
|
||||
// The Codex provider has a special 5-minute base timeout for feature generation.
|
||||
const isCodex = isCodexModel(model);
|
||||
const effectiveReasoningEffort = isCodex ? 'xhigh' : reasoningEffort;
|
||||
|
||||
if (isCodex) {
|
||||
logger.info('Codex model detected - using extended timeout (5 minutes for feature generation)');
|
||||
}
|
||||
if (effectiveReasoningEffort) {
|
||||
logger.info('Reasoning effort:', effectiveReasoningEffort);
|
||||
}
|
||||
|
||||
// Determine if we should use structured output based on model type
|
||||
const useStructuredOutput = supportsStructuredOutput(model);
|
||||
logger.info(
|
||||
`Structured output mode: ${useStructuredOutput ? 'enabled (Claude/Codex)' : 'disabled (using JSON instructions)'}`
|
||||
);
|
||||
|
||||
// Build the final prompt - for non-Claude/Codex models, include explicit JSON instructions
|
||||
let finalPrompt = prompt;
|
||||
if (!useStructuredOutput) {
|
||||
finalPrompt = `${prompt}
|
||||
|
||||
CRITICAL INSTRUCTIONS:
|
||||
1. DO NOT write any files. Return the JSON in your response only.
|
||||
2. After analyzing the spec, respond with ONLY a JSON object - no explanations, no markdown, just raw JSON.
|
||||
3. The JSON must have this exact structure:
|
||||
{
|
||||
"features": [
|
||||
{
|
||||
"id": "unique-feature-id",
|
||||
"category": "Category Name",
|
||||
"title": "Short Feature Title",
|
||||
"description": "Detailed description of the feature",
|
||||
"priority": 1,
|
||||
"complexity": "simple|moderate|complex",
|
||||
"dependencies": ["other-feature-id"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
4. Feature IDs must be unique, lowercase, kebab-case (e.g., "user-authentication", "data-export")
|
||||
5. Priority ranges from 1 (highest) to 5 (lowest)
|
||||
6. Complexity must be one of: "simple", "moderate", "complex"
|
||||
7. Dependencies is an array of feature IDs that must be completed first (can be empty)
|
||||
|
||||
Your entire response should be valid JSON starting with { and ending with }. No text before or after.`;
|
||||
}
|
||||
|
||||
// Use streamingQuery with event callbacks
|
||||
const result = await streamingQuery({
|
||||
prompt,
|
||||
prompt: finalPrompt,
|
||||
model,
|
||||
cwd: projectPath,
|
||||
maxTurns: 250,
|
||||
allowedTools: ['Read', 'Glob', 'Grep'],
|
||||
abortController,
|
||||
thinkingLevel,
|
||||
reasoningEffort: effectiveReasoningEffort, // Extended timeout for Codex models
|
||||
readOnly: true, // Feature generation only reads code, doesn't write
|
||||
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
|
||||
claudeCompatibleProvider: provider, // Pass provider for alternative endpoint configuration
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
outputFormat: useStructuredOutput
|
||||
? {
|
||||
type: 'json_schema',
|
||||
schema: featuresOutputSchema,
|
||||
}
|
||||
: undefined,
|
||||
onText: (text) => {
|
||||
logger.debug(`Feature text block received (${text.length} chars)`);
|
||||
events.emit('spec-regeneration:event', {
|
||||
@@ -163,15 +279,51 @@ Generate ${featureCount} NEW features that build on each other logically. Rememb
|
||||
},
|
||||
});
|
||||
|
||||
const responseText = result.text;
|
||||
// Get response content - prefer structured output if available
|
||||
let contentForParsing: string;
|
||||
|
||||
logger.info(`Feature stream complete.`);
|
||||
logger.info(`Feature response length: ${responseText.length} chars`);
|
||||
logger.info('========== FULL RESPONSE TEXT ==========');
|
||||
logger.info(responseText);
|
||||
logger.info('========== END RESPONSE TEXT ==========');
|
||||
if (result.structured_output) {
|
||||
// Use structured output from Claude/Codex models
|
||||
logger.info('✅ Received structured output from model');
|
||||
contentForParsing = JSON.stringify(result.structured_output);
|
||||
logger.debug('Structured output:', contentForParsing);
|
||||
} else {
|
||||
// Use text response (for non-Claude/Codex models or fallback)
|
||||
// Pre-extract JSON to handle conversational text that may surround the JSON response
|
||||
// This follows the same pattern used in generate-spec.ts and validate-issue.ts
|
||||
const rawText = result.text;
|
||||
logger.info(`Feature stream complete.`);
|
||||
logger.info(`Feature response length: ${rawText.length} chars`);
|
||||
logger.info('========== FULL RESPONSE TEXT ==========');
|
||||
logger.info(rawText);
|
||||
logger.info('========== END RESPONSE TEXT ==========');
|
||||
|
||||
await parseAndCreateFeatures(projectPath, responseText, events);
|
||||
// Pre-extract JSON from response - handles conversational text around the JSON
|
||||
const extracted = extractJsonWithArray<FeaturesExtractionResult>(rawText, 'features', {
|
||||
logger,
|
||||
});
|
||||
if (extracted) {
|
||||
contentForParsing = JSON.stringify(extracted);
|
||||
logger.info('✅ Pre-extracted JSON from text response');
|
||||
} else {
|
||||
// If pre-extraction fails, we know the next step will also fail.
|
||||
// Throw an error here to avoid redundant parsing and make the failure point clearer.
|
||||
logger.error(
|
||||
'❌ Could not extract features JSON from model response. Full response text was:\n' +
|
||||
rawText
|
||||
);
|
||||
const errorMessage =
|
||||
'Failed to parse features from model response: No valid JSON with a "features" array found.';
|
||||
events.emit('spec-regeneration:event', {
|
||||
type: 'spec_regeneration_error',
|
||||
error: errorMessage,
|
||||
projectPath: projectPath,
|
||||
});
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
await parseAndCreateFeatures(projectPath, contentForParsing, events);
|
||||
|
||||
logger.debug('========== generateFeaturesFromSpec() completed ==========');
|
||||
}
|
||||
|
||||
@@ -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, supportsStructuredOutput } 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,10 +120,13 @@ ${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 based on model type
|
||||
const useStructuredOutput = supportsStructuredOutput(model);
|
||||
logger.info(
|
||||
`Structured output mode: ${useStructuredOutput ? 'enabled (Claude/Codex)' : 'disabled (using JSON instructions)'}`
|
||||
);
|
||||
|
||||
// Build the final prompt - for Cursor, include JSON schema instructions
|
||||
// Build the final prompt - for non-Claude/Codex models, include JSON schema instructions
|
||||
let finalPrompt = prompt;
|
||||
if (!useStructuredOutput) {
|
||||
finalPrompt = `${prompt}
|
||||
|
||||
@@ -10,9 +10,10 @@
|
||||
import * as secureFs from '../../lib/secure-fs.js';
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
|
||||
import { DEFAULT_PHASE_MODELS, supportsStructuredOutput } from '@automaker/types';
|
||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||
import { streamingQuery } from '../../providers/simple-query-service.js';
|
||||
import { extractJson } from '../../lib/json-extractor.js';
|
||||
import { getAppSpecPath } from '@automaker/platform';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
import {
|
||||
@@ -34,6 +35,28 @@ import { getNotificationService } from '../../services/notification-service.js';
|
||||
|
||||
const logger = createLogger('SpecSync');
|
||||
|
||||
/**
|
||||
* Type for extracted tech stack JSON response
|
||||
*/
|
||||
interface TechStackExtractionResult {
|
||||
technologies: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON schema for tech stack analysis output (Claude/Codex structured output)
|
||||
*/
|
||||
const techStackOutputSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
technologies: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'List of technologies detected in the project',
|
||||
},
|
||||
},
|
||||
required: ['technologies'],
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Result of a sync operation
|
||||
*/
|
||||
@@ -176,8 +199,14 @@ export async function syncSpec(
|
||||
|
||||
logger.info('Using model:', model, provider ? `via provider: ${provider.name}` : 'direct API');
|
||||
|
||||
// Determine if we should use structured output based on model type
|
||||
const useStructuredOutput = supportsStructuredOutput(model);
|
||||
logger.info(
|
||||
`Structured output mode: ${useStructuredOutput ? 'enabled (Claude/Codex)' : 'disabled (using JSON instructions)'}`
|
||||
);
|
||||
|
||||
// Use AI to analyze tech stack
|
||||
const techAnalysisPrompt = `Analyze this project and return ONLY a JSON object with the current technology stack.
|
||||
let techAnalysisPrompt = `Analyze this project and return ONLY a JSON object with the current technology stack.
|
||||
|
||||
Current known technologies: ${currentTechStack.join(', ')}
|
||||
|
||||
@@ -193,6 +222,16 @@ Return ONLY this JSON format, no other text:
|
||||
"technologies": ["Technology 1", "Technology 2", ...]
|
||||
}`;
|
||||
|
||||
// Add explicit JSON instructions for non-Claude/Codex models
|
||||
if (!useStructuredOutput) {
|
||||
techAnalysisPrompt = `${techAnalysisPrompt}
|
||||
|
||||
CRITICAL INSTRUCTIONS:
|
||||
1. DO NOT write any files. Return the JSON in your response only.
|
||||
2. Your entire response should be valid JSON starting with { and ending with }.
|
||||
3. No explanations, no markdown, no text before or after the JSON.`;
|
||||
}
|
||||
|
||||
try {
|
||||
const techResult = await streamingQuery({
|
||||
prompt: techAnalysisPrompt,
|
||||
@@ -206,44 +245,67 @@ Return ONLY this JSON format, no other text:
|
||||
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
|
||||
claudeCompatibleProvider: provider, // Pass provider for alternative endpoint configuration
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
outputFormat: useStructuredOutput
|
||||
? {
|
||||
type: 'json_schema',
|
||||
schema: techStackOutputSchema,
|
||||
}
|
||||
: undefined,
|
||||
onText: (text) => {
|
||||
logger.debug(`Tech analysis text: ${text.substring(0, 100)}`);
|
||||
},
|
||||
});
|
||||
|
||||
// Parse tech stack from response
|
||||
const jsonMatch = techResult.text.match(/\{[\s\S]*"technologies"[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
const parsed = JSON.parse(jsonMatch[0]);
|
||||
if (Array.isArray(parsed.technologies)) {
|
||||
const newTechStack = parsed.technologies as string[];
|
||||
// Parse tech stack from response - prefer structured output if available
|
||||
let parsedTechnologies: string[] | null = null;
|
||||
|
||||
// Calculate differences
|
||||
const currentSet = new Set(currentTechStack.map((t) => t.toLowerCase()));
|
||||
const newSet = new Set(newTechStack.map((t) => t.toLowerCase()));
|
||||
if (techResult.structured_output) {
|
||||
// Use structured output from Claude/Codex models
|
||||
const structured = techResult.structured_output as unknown as TechStackExtractionResult;
|
||||
if (Array.isArray(structured.technologies)) {
|
||||
parsedTechnologies = structured.technologies;
|
||||
logger.info('✅ Received structured output for tech analysis');
|
||||
}
|
||||
} else {
|
||||
// Fall back to text parsing for non-Claude/Codex models
|
||||
const extracted = extractJson<TechStackExtractionResult>(techResult.text, {
|
||||
logger,
|
||||
requiredKey: 'technologies',
|
||||
requireArray: true,
|
||||
});
|
||||
if (extracted && Array.isArray(extracted.technologies)) {
|
||||
parsedTechnologies = extracted.technologies;
|
||||
logger.info('✅ Extracted tech stack from text response');
|
||||
} else {
|
||||
logger.warn('⚠️ Failed to extract tech stack JSON from response');
|
||||
}
|
||||
}
|
||||
|
||||
for (const tech of newTechStack) {
|
||||
if (!currentSet.has(tech.toLowerCase())) {
|
||||
result.techStackUpdates.added.push(tech);
|
||||
}
|
||||
if (parsedTechnologies) {
|
||||
const newTechStack = parsedTechnologies;
|
||||
|
||||
// Calculate differences
|
||||
const currentSet = new Set(currentTechStack.map((t) => t.toLowerCase()));
|
||||
const newSet = new Set(newTechStack.map((t) => t.toLowerCase()));
|
||||
|
||||
for (const tech of newTechStack) {
|
||||
if (!currentSet.has(tech.toLowerCase())) {
|
||||
result.techStackUpdates.added.push(tech);
|
||||
}
|
||||
}
|
||||
|
||||
for (const tech of currentTechStack) {
|
||||
if (!newSet.has(tech.toLowerCase())) {
|
||||
result.techStackUpdates.removed.push(tech);
|
||||
}
|
||||
for (const tech of currentTechStack) {
|
||||
if (!newSet.has(tech.toLowerCase())) {
|
||||
result.techStackUpdates.removed.push(tech);
|
||||
}
|
||||
}
|
||||
|
||||
// Update spec with new tech stack if there are changes
|
||||
if (
|
||||
result.techStackUpdates.added.length > 0 ||
|
||||
result.techStackUpdates.removed.length > 0
|
||||
) {
|
||||
specContent = updateTechnologyStack(specContent, newTechStack);
|
||||
logger.info(
|
||||
`Updated tech stack: +${result.techStackUpdates.added.length}, -${result.techStackUpdates.removed.length}`
|
||||
);
|
||||
}
|
||||
// Update spec with new tech stack if there are changes
|
||||
if (result.techStackUpdates.added.length > 0 || result.techStackUpdates.removed.length > 0) {
|
||||
specContent = updateTechnologyStack(specContent, newTechStack);
|
||||
logger.info(
|
||||
`Updated tech stack: +${result.techStackUpdates.added.length}, -${result.techStackUpdates.removed.length}`
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
isCodexModel,
|
||||
isCursorModel,
|
||||
isOpencodeModel,
|
||||
supportsStructuredOutput,
|
||||
} from '@automaker/types';
|
||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||
import { extractJson } from '../../../lib/json-extractor.js';
|
||||
@@ -124,8 +125,9 @@ async function runValidation(
|
||||
const prompts = await getPromptCustomization(settingsService, '[ValidateIssue]');
|
||||
const issueValidationSystemPrompt = prompts.issueValidation.systemPrompt;
|
||||
|
||||
// Determine if we should use structured output (Claude/Codex support it, Cursor/OpenCode don't)
|
||||
const useStructuredOutput = isClaudeModel(model) || isCodexModel(model);
|
||||
// Determine if we should use structured output based on model type
|
||||
// Claude and Codex support it; Cursor, Gemini, OpenCode, Copilot don't
|
||||
const useStructuredOutput = supportsStructuredOutput(model);
|
||||
|
||||
// Build the final prompt - for Cursor, include system prompt and JSON schema instructions
|
||||
let finalPrompt = basePrompt;
|
||||
|
||||
@@ -4,15 +4,21 @@
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { IdeationService } from '../../../services/ideation-service.js';
|
||||
import type { IdeationContextSources } from '@automaker/types';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
const logger = createLogger('ideation:suggestions-generate');
|
||||
|
||||
/**
|
||||
* Creates an Express route handler for generating AI-powered ideation suggestions.
|
||||
* Accepts a prompt, category, and optional context sources configuration,
|
||||
* then returns structured suggestions that can be added to the board.
|
||||
*/
|
||||
export function createSuggestionsGenerateHandler(ideationService: IdeationService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, promptId, category, count } = req.body;
|
||||
const { projectPath, promptId, category, count, contextSources } = req.body;
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
@@ -38,7 +44,8 @@ export function createSuggestionsGenerateHandler(ideationService: IdeationServic
|
||||
projectPath,
|
||||
promptId,
|
||||
category,
|
||||
suggestionCount
|
||||
suggestionCount,
|
||||
contextSources as IdeationContextSources | undefined
|
||||
);
|
||||
|
||||
res.json({
|
||||
|
||||
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) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -39,8 +39,15 @@ interface GitHubRemoteCacheEntry {
|
||||
checkedAt: number;
|
||||
}
|
||||
|
||||
interface GitHubPRCacheEntry {
|
||||
prs: Map<string, WorktreePRInfo>;
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
const githubRemoteCache = new Map<string, GitHubRemoteCacheEntry>();
|
||||
const githubPRCache = new Map<string, GitHubPRCacheEntry>();
|
||||
const GITHUB_REMOTE_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
const GITHUB_PR_CACHE_TTL_MS = 2 * 60 * 1000; // 2 minutes - avoid hitting GitHub on every poll
|
||||
|
||||
interface WorktreeInfo {
|
||||
path: string;
|
||||
@@ -180,9 +187,21 @@ async function getGitHubRemoteStatus(projectPath: string): Promise<GitHubRemoteS
|
||||
* This also allows detecting PRs that were created outside the app.
|
||||
*
|
||||
* Uses cached GitHub remote status to avoid repeated warnings when the
|
||||
* project doesn't have a GitHub remote configured.
|
||||
* project doesn't have a GitHub remote configured. Results are cached
|
||||
* briefly to avoid hammering GitHub on frequent worktree polls.
|
||||
*/
|
||||
async function fetchGitHubPRs(projectPath: string): Promise<Map<string, WorktreePRInfo>> {
|
||||
async function fetchGitHubPRs(
|
||||
projectPath: string,
|
||||
forceRefresh = false
|
||||
): Promise<Map<string, WorktreePRInfo>> {
|
||||
const now = Date.now();
|
||||
const cached = githubPRCache.get(projectPath);
|
||||
|
||||
// Return cached result if valid and not forcing refresh
|
||||
if (!forceRefresh && cached && now - cached.fetchedAt < GITHUB_PR_CACHE_TTL_MS) {
|
||||
return cached.prs;
|
||||
}
|
||||
|
||||
const prMap = new Map<string, WorktreePRInfo>();
|
||||
|
||||
try {
|
||||
@@ -225,8 +244,22 @@ async function fetchGitHubPRs(projectPath: string): Promise<Map<string, Worktree
|
||||
createdAt: pr.createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
// Only update cache on successful fetch
|
||||
githubPRCache.set(projectPath, {
|
||||
prs: prMap,
|
||||
fetchedAt: Date.now(),
|
||||
});
|
||||
} catch (error) {
|
||||
// Silently fail - PR detection is optional
|
||||
// On fetch failure, return stale cached data if available to avoid
|
||||
// repeated API calls during GitHub API flakiness or temporary outages
|
||||
if (cached) {
|
||||
logger.warn(`Failed to fetch GitHub PRs, returning stale cache: ${getErrorMessage(error)}`);
|
||||
// Extend cache TTL to avoid repeated retries during outages
|
||||
githubPRCache.set(projectPath, { prs: cached.prs, fetchedAt: Date.now() });
|
||||
return cached.prs;
|
||||
}
|
||||
// No cache available, log warning and return empty map
|
||||
logger.warn(`Failed to fetch GitHub PRs: ${getErrorMessage(error)}`);
|
||||
}
|
||||
|
||||
@@ -364,7 +397,7 @@ export function createListHandler() {
|
||||
// Only fetch GitHub PRs if includeDetails is requested (performance optimization).
|
||||
// Uses --state all to detect merged/closed PRs, limited to 1000 recent PRs.
|
||||
const githubPRs = includeDetails
|
||||
? await fetchGitHubPRs(projectPath)
|
||||
? await fetchGitHubPRs(projectPath, forceRefreshGitHub)
|
||||
: new Map<string, WorktreePRInfo>();
|
||||
|
||||
for (const worktree of worktrees) {
|
||||
|
||||
@@ -233,6 +233,7 @@ interface RunningFeature {
|
||||
abortController: AbortController;
|
||||
isAutoMode: boolean;
|
||||
startTime: number;
|
||||
leaseCount: number;
|
||||
model?: string;
|
||||
provider?: ModelProvider;
|
||||
}
|
||||
@@ -334,6 +335,54 @@ export class AutoModeService {
|
||||
this.settingsService = settingsService ?? null;
|
||||
}
|
||||
|
||||
private acquireRunningFeature(params: {
|
||||
featureId: string;
|
||||
projectPath: string;
|
||||
isAutoMode: boolean;
|
||||
allowReuse?: boolean;
|
||||
abortController?: AbortController;
|
||||
}): RunningFeature {
|
||||
const existing = this.runningFeatures.get(params.featureId);
|
||||
if (existing) {
|
||||
if (!params.allowReuse) {
|
||||
throw new Error('already running');
|
||||
}
|
||||
existing.leaseCount = (existing.leaseCount ?? 1) + 1;
|
||||
return existing;
|
||||
}
|
||||
|
||||
const abortController = params.abortController ?? new AbortController();
|
||||
const entry: RunningFeature = {
|
||||
featureId: params.featureId,
|
||||
projectPath: params.projectPath,
|
||||
worktreePath: null,
|
||||
branchName: null,
|
||||
abortController,
|
||||
isAutoMode: params.isAutoMode,
|
||||
startTime: Date.now(),
|
||||
leaseCount: 1,
|
||||
};
|
||||
this.runningFeatures.set(params.featureId, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
private releaseRunningFeature(featureId: string, options?: { force?: boolean }): void {
|
||||
const entry = this.runningFeatures.get(featureId);
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (options?.force) {
|
||||
this.runningFeatures.delete(featureId);
|
||||
return;
|
||||
}
|
||||
|
||||
entry.leaseCount = (entry.leaseCount ?? 1) - 1;
|
||||
if (entry.leaseCount <= 0) {
|
||||
this.runningFeatures.delete(featureId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a failure and check if we should pause due to consecutive failures.
|
||||
* This handles cases where the SDK doesn't return useful error messages.
|
||||
@@ -1076,24 +1125,17 @@ export class AutoModeService {
|
||||
providedWorktreePath?: string,
|
||||
options?: {
|
||||
continuationPrompt?: string;
|
||||
/** Internal flag: set to true when called from a method that already tracks the feature */
|
||||
_calledInternally?: boolean;
|
||||
}
|
||||
): Promise<void> {
|
||||
if (this.runningFeatures.has(featureId)) {
|
||||
throw new Error('already running');
|
||||
}
|
||||
|
||||
// Add to running features immediately to prevent race conditions
|
||||
const abortController = new AbortController();
|
||||
const tempRunningFeature: RunningFeature = {
|
||||
const tempRunningFeature = this.acquireRunningFeature({
|
||||
featureId,
|
||||
projectPath,
|
||||
worktreePath: null,
|
||||
branchName: null,
|
||||
abortController,
|
||||
isAutoMode,
|
||||
startTime: Date.now(),
|
||||
};
|
||||
this.runningFeatures.set(featureId, tempRunningFeature);
|
||||
allowReuse: options?._calledInternally,
|
||||
});
|
||||
const abortController = tempRunningFeature.abortController;
|
||||
|
||||
// Save execution state when feature starts
|
||||
if (isAutoMode) {
|
||||
@@ -1130,9 +1172,8 @@ export class AutoModeService {
|
||||
continuationPrompt = continuationPrompt.replace(/\{\{approvedPlan\}\}/g, planContent);
|
||||
|
||||
// Recursively call executeFeature with the continuation prompt
|
||||
// Remove from running features temporarily, it will be added back
|
||||
this.runningFeatures.delete(featureId);
|
||||
return this.executeFeature(
|
||||
// Feature is already tracked, the recursive call will reuse the entry
|
||||
return await this.executeFeature(
|
||||
projectPath,
|
||||
featureId,
|
||||
useWorktrees,
|
||||
@@ -1140,6 +1181,7 @@ export class AutoModeService {
|
||||
providedWorktreePath,
|
||||
{
|
||||
continuationPrompt,
|
||||
_calledInternally: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1149,9 +1191,8 @@ export class AutoModeService {
|
||||
logger.info(
|
||||
`Feature ${featureId} has existing context, resuming instead of starting fresh`
|
||||
);
|
||||
// Remove from running features temporarily, resumeFeature will add it back
|
||||
this.runningFeatures.delete(featureId);
|
||||
return this.resumeFeature(projectPath, featureId, useWorktrees);
|
||||
// Feature is already tracked, resumeFeature will reuse the entry
|
||||
return await this.resumeFeature(projectPath, featureId, useWorktrees, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1401,7 +1442,7 @@ export class AutoModeService {
|
||||
logger.info(
|
||||
`Pending approvals at cleanup: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}`
|
||||
);
|
||||
this.runningFeatures.delete(featureId);
|
||||
this.releaseRunningFeature(featureId);
|
||||
|
||||
// Update execution state after feature completes
|
||||
if (this.autoLoopRunning && projectPath) {
|
||||
@@ -1581,7 +1622,7 @@ Complete the pipeline step instructions above. Review the previous work and appl
|
||||
|
||||
// Remove from running features immediately to allow resume
|
||||
// The abort signal will still propagate to stop any ongoing execution
|
||||
this.runningFeatures.delete(featureId);
|
||||
this.releaseRunningFeature(featureId, { force: true });
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -1589,50 +1630,67 @@ Complete the pipeline step instructions above. Review the previous work and appl
|
||||
/**
|
||||
* Resume a feature (continues from saved context)
|
||||
*/
|
||||
async resumeFeature(projectPath: string, featureId: string, useWorktrees = false): Promise<void> {
|
||||
if (this.runningFeatures.has(featureId)) {
|
||||
throw new Error('already running');
|
||||
}
|
||||
|
||||
// Load feature to check status
|
||||
const feature = await this.loadFeature(projectPath, featureId);
|
||||
if (!feature) {
|
||||
throw new Error(`Feature ${featureId} not found`);
|
||||
}
|
||||
|
||||
// Check if feature is stuck in a pipeline step
|
||||
const pipelineInfo = await this.detectPipelineStatus(
|
||||
projectPath,
|
||||
async resumeFeature(
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
useWorktrees = false,
|
||||
/** Internal flag: set to true when called from a method that already tracks the feature */
|
||||
_calledInternally = false
|
||||
): Promise<void> {
|
||||
this.acquireRunningFeature({
|
||||
featureId,
|
||||
(feature.status || '') as FeatureStatusWithPipeline
|
||||
);
|
||||
projectPath,
|
||||
isAutoMode: false,
|
||||
allowReuse: _calledInternally,
|
||||
});
|
||||
|
||||
if (pipelineInfo.isPipeline) {
|
||||
// Feature stuck in pipeline - use pipeline resume
|
||||
return this.resumePipelineFeature(projectPath, feature, useWorktrees, pipelineInfo);
|
||||
}
|
||||
|
||||
// Normal resume flow for non-pipeline features
|
||||
// Check if context exists in .automaker directory
|
||||
const featureDir = getFeatureDir(projectPath, featureId);
|
||||
const contextPath = path.join(featureDir, 'agent-output.md');
|
||||
|
||||
let hasContext = false;
|
||||
try {
|
||||
await secureFs.access(contextPath);
|
||||
hasContext = true;
|
||||
} catch {
|
||||
// No context
|
||||
}
|
||||
// Load feature to check status
|
||||
const feature = await this.loadFeature(projectPath, featureId);
|
||||
if (!feature) {
|
||||
throw new Error(`Feature ${featureId} not found`);
|
||||
}
|
||||
|
||||
if (hasContext) {
|
||||
// Load previous context and continue
|
||||
const context = (await secureFs.readFile(contextPath, 'utf-8')) as string;
|
||||
return this.executeFeatureWithContext(projectPath, featureId, context, useWorktrees);
|
||||
}
|
||||
// Check if feature is stuck in a pipeline step
|
||||
const pipelineInfo = await this.detectPipelineStatus(
|
||||
projectPath,
|
||||
featureId,
|
||||
(feature.status || '') as FeatureStatusWithPipeline
|
||||
);
|
||||
|
||||
// No context, start fresh - executeFeature will handle adding to runningFeatures
|
||||
return this.executeFeature(projectPath, featureId, useWorktrees, false);
|
||||
if (pipelineInfo.isPipeline) {
|
||||
// Feature stuck in pipeline - use pipeline resume
|
||||
// Pass _alreadyTracked to prevent double-tracking
|
||||
return await this.resumePipelineFeature(projectPath, feature, useWorktrees, pipelineInfo);
|
||||
}
|
||||
|
||||
// Normal resume flow for non-pipeline features
|
||||
// Check if context exists in .automaker directory
|
||||
const featureDir = getFeatureDir(projectPath, featureId);
|
||||
const contextPath = path.join(featureDir, 'agent-output.md');
|
||||
|
||||
let hasContext = false;
|
||||
try {
|
||||
await secureFs.access(contextPath);
|
||||
hasContext = true;
|
||||
} catch {
|
||||
// No context
|
||||
}
|
||||
|
||||
if (hasContext) {
|
||||
// Load previous context and continue
|
||||
// executeFeatureWithContext -> executeFeature will see feature is already tracked
|
||||
const context = (await secureFs.readFile(contextPath, 'utf-8')) as string;
|
||||
return await this.executeFeatureWithContext(projectPath, featureId, context, useWorktrees);
|
||||
}
|
||||
|
||||
// No context, start fresh - executeFeature will see feature is already tracked
|
||||
return await this.executeFeature(projectPath, featureId, useWorktrees, false, undefined, {
|
||||
_calledInternally: true,
|
||||
});
|
||||
} finally {
|
||||
this.releaseRunningFeature(featureId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1682,7 +1740,9 @@ Complete the pipeline step instructions above. Review the previous work and appl
|
||||
// Reset status to in_progress and start fresh
|
||||
await this.updateFeatureStatus(projectPath, featureId, 'in_progress');
|
||||
|
||||
return this.executeFeature(projectPath, featureId, useWorktrees, false);
|
||||
return this.executeFeature(projectPath, featureId, useWorktrees, false, undefined, {
|
||||
_calledInternally: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Edge Case 2: Step no longer exists in pipeline config
|
||||
@@ -1828,17 +1888,14 @@ Complete the pipeline step instructions above. Review the previous work and appl
|
||||
`[AutoMode] Resuming pipeline for feature ${featureId} from step ${startFromStepIndex + 1}/${sortedSteps.length}`
|
||||
);
|
||||
|
||||
// Add to running features immediately
|
||||
const abortController = new AbortController();
|
||||
this.runningFeatures.set(featureId, {
|
||||
const runningEntry = this.acquireRunningFeature({
|
||||
featureId,
|
||||
projectPath,
|
||||
worktreePath: null, // Will be set below
|
||||
branchName: feature.branchName ?? null,
|
||||
abortController,
|
||||
isAutoMode: false,
|
||||
startTime: Date.now(),
|
||||
allowReuse: true,
|
||||
});
|
||||
const abortController = runningEntry.abortController;
|
||||
runningEntry.branchName = feature.branchName ?? null;
|
||||
|
||||
try {
|
||||
// Validate project path
|
||||
@@ -1863,11 +1920,8 @@ Complete the pipeline step instructions above. Review the previous work and appl
|
||||
validateWorkingDirectory(workDir);
|
||||
|
||||
// Update running feature with worktree info
|
||||
const runningFeature = this.runningFeatures.get(featureId);
|
||||
if (runningFeature) {
|
||||
runningFeature.worktreePath = worktreePath;
|
||||
runningFeature.branchName = branchName ?? null;
|
||||
}
|
||||
runningEntry.worktreePath = worktreePath;
|
||||
runningEntry.branchName = branchName ?? null;
|
||||
|
||||
// Emit resume event
|
||||
this.emitAutoModeEvent('auto_mode_feature_start', {
|
||||
@@ -1945,7 +1999,7 @@ Complete the pipeline step instructions above. Review the previous work and appl
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
this.runningFeatures.delete(featureId);
|
||||
this.releaseRunningFeature(featureId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1962,11 +2016,12 @@ Complete the pipeline step instructions above. Review the previous work and appl
|
||||
// Validate project path early for fast failure
|
||||
validateWorkingDirectory(projectPath);
|
||||
|
||||
if (this.runningFeatures.has(featureId)) {
|
||||
throw new Error(`Feature ${featureId} is already running`);
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
const runningEntry = this.acquireRunningFeature({
|
||||
featureId,
|
||||
projectPath,
|
||||
isAutoMode: false,
|
||||
});
|
||||
const abortController = runningEntry.abortController;
|
||||
|
||||
// Load feature info for context FIRST to get branchName
|
||||
const feature = await this.loadFeature(projectPath, featureId);
|
||||
@@ -2048,17 +2103,10 @@ Address the follow-up instructions above. Review the previous work and make the
|
||||
const provider = ProviderFactory.getProviderNameForModel(model);
|
||||
logger.info(`Follow-up for feature ${featureId} using model: ${model}, provider: ${provider}`);
|
||||
|
||||
this.runningFeatures.set(featureId, {
|
||||
featureId,
|
||||
projectPath,
|
||||
worktreePath,
|
||||
branchName,
|
||||
abortController,
|
||||
isAutoMode: false,
|
||||
startTime: Date.now(),
|
||||
model,
|
||||
provider,
|
||||
});
|
||||
runningEntry.worktreePath = worktreePath;
|
||||
runningEntry.branchName = branchName;
|
||||
runningEntry.model = model;
|
||||
runningEntry.provider = provider;
|
||||
|
||||
try {
|
||||
// Update feature status to in_progress BEFORE emitting event
|
||||
@@ -2206,7 +2254,7 @@ Address the follow-up instructions above. Review the previous work and make the
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.runningFeatures.delete(featureId);
|
||||
this.releaseRunningFeature(featureId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4225,6 +4273,7 @@ After generating the revised spec, output:
|
||||
|
||||
return this.executeFeature(projectPath, featureId, useWorktrees, false, undefined, {
|
||||
continuationPrompt: prompt,
|
||||
_calledInternally: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,9 @@ import type {
|
||||
SendMessageOptions,
|
||||
PromptCategory,
|
||||
IdeationPrompt,
|
||||
IdeationContextSources,
|
||||
} from '@automaker/types';
|
||||
import { DEFAULT_IDEATION_CONTEXT_SOURCES } from '@automaker/types';
|
||||
import {
|
||||
getIdeationDir,
|
||||
getIdeasDir,
|
||||
@@ -32,8 +34,10 @@ import {
|
||||
getIdeationSessionsDir,
|
||||
getIdeationSessionPath,
|
||||
getIdeationAnalysisPath,
|
||||
getAppSpecPath,
|
||||
ensureIdeationDir,
|
||||
} from '@automaker/platform';
|
||||
import { extractXmlElements, extractImplementedFeatures } from '../lib/xml-extractor.js';
|
||||
import { createLogger, loadContextFiles, isAbortError } from '@automaker/utils';
|
||||
import { ProviderFactory } from '../providers/provider-factory.js';
|
||||
import type { SettingsService } from './settings-service.js';
|
||||
@@ -638,8 +642,12 @@ export class IdeationService {
|
||||
projectPath: string,
|
||||
promptId: string,
|
||||
category: IdeaCategory,
|
||||
count: number = 10
|
||||
count: number = 10,
|
||||
contextSources?: IdeationContextSources
|
||||
): Promise<AnalysisSuggestion[]> {
|
||||
const suggestionCount = Math.min(Math.max(Math.floor(count ?? 10), 1), 20);
|
||||
// Merge with defaults for backward compatibility
|
||||
const sources = { ...DEFAULT_IDEATION_CONTEXT_SOURCES, ...contextSources };
|
||||
validateWorkingDirectory(projectPath);
|
||||
|
||||
// Get the prompt
|
||||
@@ -656,16 +664,26 @@ export class IdeationService {
|
||||
});
|
||||
|
||||
try {
|
||||
// Load context files
|
||||
// Load context files (respecting toggle settings)
|
||||
const contextResult = await loadContextFiles({
|
||||
projectPath,
|
||||
fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'],
|
||||
includeContextFiles: sources.useContextFiles,
|
||||
includeMemory: sources.useMemoryFiles,
|
||||
});
|
||||
|
||||
// Build context from multiple sources
|
||||
let contextPrompt = contextResult.formattedPrompt;
|
||||
|
||||
// If no context files, try to gather basic project info
|
||||
// Add app spec context if enabled
|
||||
if (sources.useAppSpec) {
|
||||
const appSpecContext = await this.buildAppSpecContext(projectPath);
|
||||
if (appSpecContext) {
|
||||
contextPrompt = contextPrompt ? `${contextPrompt}\n\n${appSpecContext}` : appSpecContext;
|
||||
}
|
||||
}
|
||||
|
||||
// If no context was found, try to gather basic project info
|
||||
if (!contextPrompt) {
|
||||
const projectInfo = await this.gatherBasicProjectInfo(projectPath);
|
||||
if (projectInfo) {
|
||||
@@ -673,8 +691,11 @@ export class IdeationService {
|
||||
}
|
||||
}
|
||||
|
||||
// Gather existing features and ideas to prevent duplicates
|
||||
const existingWorkContext = await this.gatherExistingWorkContext(projectPath);
|
||||
// Gather existing features and ideas to prevent duplicates (respecting toggle settings)
|
||||
const existingWorkContext = await this.gatherExistingWorkContext(projectPath, {
|
||||
includeFeatures: sources.useExistingFeatures,
|
||||
includeIdeas: sources.useExistingIdeas,
|
||||
});
|
||||
|
||||
// Get customized prompts from settings
|
||||
const prompts = await getPromptCustomization(this.settingsService, '[IdeationService]');
|
||||
@@ -684,7 +705,7 @@ export class IdeationService {
|
||||
prompts.ideation.suggestionsSystemPrompt,
|
||||
contextPrompt,
|
||||
category,
|
||||
count,
|
||||
suggestionCount,
|
||||
existingWorkContext
|
||||
);
|
||||
|
||||
@@ -751,7 +772,11 @@ export class IdeationService {
|
||||
}
|
||||
|
||||
// Parse the response into structured suggestions
|
||||
const suggestions = this.parseSuggestionsFromResponse(responseText, category);
|
||||
const suggestions = this.parseSuggestionsFromResponse(
|
||||
responseText,
|
||||
category,
|
||||
suggestionCount
|
||||
);
|
||||
|
||||
// Emit complete event
|
||||
this.events.emit('ideation:suggestions', {
|
||||
@@ -814,40 +839,47 @@ ${contextSection}${existingWorkSection}`;
|
||||
*/
|
||||
private parseSuggestionsFromResponse(
|
||||
response: string,
|
||||
category: IdeaCategory
|
||||
category: IdeaCategory,
|
||||
count: number
|
||||
): AnalysisSuggestion[] {
|
||||
try {
|
||||
// Try to extract JSON from the response
|
||||
const jsonMatch = response.match(/\[[\s\S]*\]/);
|
||||
if (!jsonMatch) {
|
||||
logger.warn('No JSON array found in response, falling back to text parsing');
|
||||
return this.parseTextResponse(response, category);
|
||||
return this.parseTextResponse(response, category, count);
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(jsonMatch[0]);
|
||||
if (!Array.isArray(parsed)) {
|
||||
return this.parseTextResponse(response, category);
|
||||
return this.parseTextResponse(response, category, count);
|
||||
}
|
||||
|
||||
return parsed.map((item: any, index: number) => ({
|
||||
id: this.generateId('sug'),
|
||||
category,
|
||||
title: item.title || `Suggestion ${index + 1}`,
|
||||
description: item.description || '',
|
||||
rationale: item.rationale || '',
|
||||
priority: item.priority || 'medium',
|
||||
relatedFiles: item.relatedFiles || [],
|
||||
}));
|
||||
return parsed
|
||||
.map((item: any, index: number) => ({
|
||||
id: this.generateId('sug'),
|
||||
category,
|
||||
title: item.title || `Suggestion ${index + 1}`,
|
||||
description: item.description || '',
|
||||
rationale: item.rationale || '',
|
||||
priority: item.priority || 'medium',
|
||||
relatedFiles: item.relatedFiles || [],
|
||||
}))
|
||||
.slice(0, count);
|
||||
} catch (error) {
|
||||
logger.warn('Failed to parse JSON response:', error);
|
||||
return this.parseTextResponse(response, category);
|
||||
return this.parseTextResponse(response, category, count);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback: parse text response into suggestions
|
||||
*/
|
||||
private parseTextResponse(response: string, category: IdeaCategory): AnalysisSuggestion[] {
|
||||
private parseTextResponse(
|
||||
response: string,
|
||||
category: IdeaCategory,
|
||||
count: number
|
||||
): AnalysisSuggestion[] {
|
||||
const suggestions: AnalysisSuggestion[] = [];
|
||||
|
||||
// Try to find numbered items or headers
|
||||
@@ -907,7 +939,7 @@ ${contextSection}${existingWorkSection}`;
|
||||
});
|
||||
}
|
||||
|
||||
return suggestions.slice(0, 5); // Max 5 suggestions
|
||||
return suggestions.slice(0, count);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -1345,6 +1377,68 @@ ${contextSection}${existingWorkSection}`;
|
||||
return descriptions[category] || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build context from app_spec.txt for suggestion generation
|
||||
* Extracts project name, overview, capabilities, and implemented features
|
||||
*/
|
||||
private async buildAppSpecContext(projectPath: string): Promise<string> {
|
||||
try {
|
||||
const specPath = getAppSpecPath(projectPath);
|
||||
const specContent = (await secureFs.readFile(specPath, 'utf-8')) as string;
|
||||
|
||||
const parts: string[] = [];
|
||||
parts.push('## App Specification');
|
||||
|
||||
// Extract project name
|
||||
const projectNames = extractXmlElements(specContent, 'project_name');
|
||||
if (projectNames.length > 0 && projectNames[0]) {
|
||||
parts.push(`**Project:** ${projectNames[0]}`);
|
||||
}
|
||||
|
||||
// Extract overview
|
||||
const overviews = extractXmlElements(specContent, 'overview');
|
||||
if (overviews.length > 0 && overviews[0]) {
|
||||
parts.push(`**Overview:** ${overviews[0]}`);
|
||||
}
|
||||
|
||||
// Extract core capabilities
|
||||
const capabilities = extractXmlElements(specContent, 'capability');
|
||||
if (capabilities.length > 0) {
|
||||
parts.push('**Core Capabilities:**');
|
||||
for (const cap of capabilities) {
|
||||
parts.push(`- ${cap}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract implemented features
|
||||
const implementedFeatures = extractImplementedFeatures(specContent);
|
||||
if (implementedFeatures.length > 0) {
|
||||
parts.push('**Implemented Features:**');
|
||||
for (const feature of implementedFeatures) {
|
||||
if (feature.description) {
|
||||
parts.push(`- ${feature.name}: ${feature.description}`);
|
||||
} else {
|
||||
parts.push(`- ${feature.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only return content if we extracted something meaningful
|
||||
if (parts.length > 1) {
|
||||
return parts.join('\n');
|
||||
}
|
||||
return '';
|
||||
} catch (error) {
|
||||
// If file doesn't exist, return empty string silently
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return '';
|
||||
}
|
||||
// For other errors, log and return empty string
|
||||
logger.warn('Failed to build app spec context:', error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gather basic project information for context when no context files exist
|
||||
*/
|
||||
@@ -1440,11 +1534,15 @@ ${contextSection}${existingWorkSection}`;
|
||||
* Gather existing features and ideas to prevent duplicate suggestions
|
||||
* Returns a concise list of titles grouped by status to avoid polluting context
|
||||
*/
|
||||
private async gatherExistingWorkContext(projectPath: string): Promise<string> {
|
||||
private async gatherExistingWorkContext(
|
||||
projectPath: string,
|
||||
options?: { includeFeatures?: boolean; includeIdeas?: boolean }
|
||||
): Promise<string> {
|
||||
const { includeFeatures = true, includeIdeas = true } = options ?? {};
|
||||
const parts: string[] = [];
|
||||
|
||||
// Load existing features from the board
|
||||
if (this.featureLoader) {
|
||||
if (includeFeatures && this.featureLoader) {
|
||||
try {
|
||||
const features = await this.featureLoader.getAll(projectPath);
|
||||
if (features.length > 0) {
|
||||
@@ -1492,34 +1590,36 @@ ${contextSection}${existingWorkSection}`;
|
||||
}
|
||||
|
||||
// Load existing ideas
|
||||
try {
|
||||
const ideas = await this.getIdeas(projectPath);
|
||||
// Filter out archived ideas
|
||||
const activeIdeas = ideas.filter((idea) => idea.status !== 'archived');
|
||||
if (includeIdeas) {
|
||||
try {
|
||||
const ideas = await this.getIdeas(projectPath);
|
||||
// Filter out archived ideas
|
||||
const activeIdeas = ideas.filter((idea) => idea.status !== 'archived');
|
||||
|
||||
if (activeIdeas.length > 0) {
|
||||
parts.push('## Existing Ideas (Do NOT regenerate these)');
|
||||
parts.push(
|
||||
'The following ideas have already been captured. Do NOT suggest similar ideas:\n'
|
||||
);
|
||||
if (activeIdeas.length > 0) {
|
||||
parts.push('## Existing Ideas (Do NOT regenerate these)');
|
||||
parts.push(
|
||||
'The following ideas have already been captured. Do NOT suggest similar ideas:\n'
|
||||
);
|
||||
|
||||
// Group by category for organization
|
||||
const byCategory: Record<string, string[]> = {};
|
||||
for (const idea of activeIdeas) {
|
||||
const cat = idea.category || 'feature';
|
||||
if (!byCategory[cat]) {
|
||||
byCategory[cat] = [];
|
||||
// Group by category for organization
|
||||
const byCategory: Record<string, string[]> = {};
|
||||
for (const idea of activeIdeas) {
|
||||
const cat = idea.category || 'feature';
|
||||
if (!byCategory[cat]) {
|
||||
byCategory[cat] = [];
|
||||
}
|
||||
byCategory[cat].push(idea.title);
|
||||
}
|
||||
byCategory[cat].push(idea.title);
|
||||
}
|
||||
|
||||
for (const [category, titles] of Object.entries(byCategory)) {
|
||||
parts.push(`**${category}:** ${titles.join(', ')}`);
|
||||
for (const [category, titles] of Object.entries(byCategory)) {
|
||||
parts.push(`**${category}:** ${titles.join(', ')}`);
|
||||
}
|
||||
parts.push('');
|
||||
}
|
||||
parts.push('');
|
||||
} catch (error) {
|
||||
logger.warn('Failed to load existing ideas:', error);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to load existing ideas:', error);
|
||||
}
|
||||
|
||||
return parts.join('\n');
|
||||
|
||||
@@ -325,8 +325,12 @@ describe('codex-provider.ts', () => {
|
||||
);
|
||||
|
||||
const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0];
|
||||
// xhigh reasoning effort should have 4x the default timeout (120000ms)
|
||||
expect(call.timeout).toBe(DEFAULT_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.xhigh);
|
||||
// xhigh reasoning effort uses 5-minute base timeout (300000ms) for feature generation
|
||||
// then applies 4x multiplier: 300000 * 4.0 = 1200000ms (20 minutes)
|
||||
const CODEX_FEATURE_GENERATION_BASE_TIMEOUT_MS = 300000;
|
||||
expect(call.timeout).toBe(
|
||||
CODEX_FEATURE_GENERATION_BASE_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.xhigh
|
||||
);
|
||||
});
|
||||
|
||||
it('uses default timeout when no reasoning effort is specified', async () => {
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -15,7 +15,7 @@ import type {
|
||||
} from '@automaker/types';
|
||||
import { ProviderFactory } from '@/providers/provider-factory.js';
|
||||
|
||||
// Create a shared mock logger instance for assertions using vi.hoisted
|
||||
// Create shared mock instances for assertions using vi.hoisted
|
||||
const mockLogger = vi.hoisted(() => ({
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
@@ -23,6 +23,13 @@ const mockLogger = vi.hoisted(() => ({
|
||||
debug: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockCreateChatOptions = vi.hoisted(() =>
|
||||
vi.fn(() => ({
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
systemPrompt: 'test prompt',
|
||||
}))
|
||||
);
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/lib/secure-fs.js');
|
||||
vi.mock('@automaker/platform');
|
||||
@@ -37,10 +44,7 @@ vi.mock('@automaker/utils', async () => {
|
||||
});
|
||||
vi.mock('@/providers/provider-factory.js');
|
||||
vi.mock('@/lib/sdk-options.js', () => ({
|
||||
createChatOptions: vi.fn(() => ({
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
systemPrompt: 'test prompt',
|
||||
})),
|
||||
createChatOptions: mockCreateChatOptions,
|
||||
validateWorkingDirectory: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -786,6 +790,143 @@ describe('IdeationService', () => {
|
||||
service.generateSuggestions(testProjectPath, 'non-existent', 'features', 5)
|
||||
).rejects.toThrow('Prompt non-existent not found');
|
||||
});
|
||||
|
||||
it('should include app spec context when useAppSpec is enabled', async () => {
|
||||
const mockAppSpec = `
|
||||
<project_specification>
|
||||
<project_name>Test Project</project_name>
|
||||
<overview>A test application for unit testing</overview>
|
||||
<core_capabilities>
|
||||
<capability>User authentication</capability>
|
||||
<capability>Data visualization</capability>
|
||||
</core_capabilities>
|
||||
<implemented_features>
|
||||
<feature>
|
||||
<name>Login System</name>
|
||||
<description>Basic auth with email/password</description>
|
||||
</feature>
|
||||
</implemented_features>
|
||||
</project_specification>
|
||||
`;
|
||||
|
||||
vi.mocked(platform.getAppSpecPath).mockReturnValue('/test/project/.automaker/app_spec.txt');
|
||||
|
||||
// First call returns app spec, subsequent calls return empty JSON
|
||||
vi.mocked(secureFs.readFile)
|
||||
.mockResolvedValueOnce(mockAppSpec)
|
||||
.mockResolvedValue(JSON.stringify({}));
|
||||
|
||||
const mockProvider = {
|
||||
executeQuery: vi.fn().mockReturnValue({
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield {
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
result: JSON.stringify([{ title: 'Test', description: 'Test' }]),
|
||||
};
|
||||
},
|
||||
}),
|
||||
};
|
||||
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any);
|
||||
|
||||
const prompts = service.getAllPrompts();
|
||||
await service.generateSuggestions(testProjectPath, prompts[0].id, 'feature', 5, {
|
||||
useAppSpec: true,
|
||||
useContextFiles: false,
|
||||
useMemoryFiles: false,
|
||||
useExistingFeatures: false,
|
||||
useExistingIdeas: false,
|
||||
});
|
||||
|
||||
// Verify createChatOptions was called with systemPrompt containing app spec info
|
||||
expect(mockCreateChatOptions).toHaveBeenCalled();
|
||||
const chatOptionsCall = mockCreateChatOptions.mock.calls[0][0];
|
||||
expect(chatOptionsCall.systemPrompt).toContain('Test Project');
|
||||
expect(chatOptionsCall.systemPrompt).toContain('A test application for unit testing');
|
||||
expect(chatOptionsCall.systemPrompt).toContain('User authentication');
|
||||
expect(chatOptionsCall.systemPrompt).toContain('Login System');
|
||||
});
|
||||
|
||||
it('should exclude app spec context when useAppSpec is disabled', async () => {
|
||||
const mockAppSpec = `
|
||||
<project_specification>
|
||||
<project_name>Hidden Project</project_name>
|
||||
<overview>This should not appear</overview>
|
||||
</project_specification>
|
||||
`;
|
||||
|
||||
vi.mocked(platform.getAppSpecPath).mockReturnValue('/test/project/.automaker/app_spec.txt');
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(mockAppSpec);
|
||||
|
||||
const mockProvider = {
|
||||
executeQuery: vi.fn().mockReturnValue({
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield {
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
result: JSON.stringify([{ title: 'Test', description: 'Test' }]),
|
||||
};
|
||||
},
|
||||
}),
|
||||
};
|
||||
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any);
|
||||
|
||||
const prompts = service.getAllPrompts();
|
||||
await service.generateSuggestions(testProjectPath, prompts[0].id, 'feature', 5, {
|
||||
useAppSpec: false,
|
||||
useContextFiles: false,
|
||||
useMemoryFiles: false,
|
||||
useExistingFeatures: false,
|
||||
useExistingIdeas: false,
|
||||
});
|
||||
|
||||
// Verify createChatOptions was called with systemPrompt NOT containing app spec info
|
||||
expect(mockCreateChatOptions).toHaveBeenCalled();
|
||||
const chatOptionsCall = mockCreateChatOptions.mock.calls[0][0];
|
||||
expect(chatOptionsCall.systemPrompt).not.toContain('Hidden Project');
|
||||
expect(chatOptionsCall.systemPrompt).not.toContain('This should not appear');
|
||||
});
|
||||
|
||||
it('should handle missing app spec file gracefully', async () => {
|
||||
vi.mocked(platform.getAppSpecPath).mockReturnValue('/test/project/.automaker/app_spec.txt');
|
||||
|
||||
const enoentError = new Error('ENOENT: no such file or directory') as NodeJS.ErrnoException;
|
||||
enoentError.code = 'ENOENT';
|
||||
|
||||
// First call fails with ENOENT for app spec, subsequent calls return empty JSON
|
||||
vi.mocked(secureFs.readFile)
|
||||
.mockRejectedValueOnce(enoentError)
|
||||
.mockResolvedValue(JSON.stringify({}));
|
||||
|
||||
const mockProvider = {
|
||||
executeQuery: vi.fn().mockReturnValue({
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield {
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
result: JSON.stringify([{ title: 'Test', description: 'Test' }]),
|
||||
};
|
||||
},
|
||||
}),
|
||||
};
|
||||
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any);
|
||||
|
||||
const prompts = service.getAllPrompts();
|
||||
|
||||
// Should not throw
|
||||
await expect(
|
||||
service.generateSuggestions(testProjectPath, prompts[0].id, 'feature', 5, {
|
||||
useAppSpec: true,
|
||||
useContextFiles: false,
|
||||
useMemoryFiles: false,
|
||||
useExistingFeatures: false,
|
||||
useExistingIdeas: false,
|
||||
})
|
||||
).resolves.toBeDefined();
|
||||
|
||||
// Should not log warning for ENOENT
|
||||
expect(mockLogger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -107,6 +107,7 @@
|
||||
"sonner": "2.0.7",
|
||||
"tailwind-merge": "3.4.0",
|
||||
"usehooks-ts": "3.1.1",
|
||||
"zod": "^3.24.1 || ^4.0.0",
|
||||
"zustand": "5.0.9"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
|
||||
@@ -6,14 +6,25 @@ import { SplashScreen } from './components/splash-screen';
|
||||
import { useSettingsSync } from './hooks/use-settings-sync';
|
||||
import { useCursorStatusInit } from './hooks/use-cursor-status-init';
|
||||
import { useProviderAuthInit } from './hooks/use-provider-auth-init';
|
||||
import { useAppStore } from './store/app-store';
|
||||
import './styles/global.css';
|
||||
import './styles/theme-imports';
|
||||
import './styles/font-imports';
|
||||
|
||||
const logger = createLogger('App');
|
||||
|
||||
// Key for localStorage to persist splash screen preference
|
||||
const DISABLE_SPLASH_KEY = 'automaker-disable-splash';
|
||||
|
||||
export default function App() {
|
||||
const disableSplashScreen = useAppStore((state) => state.disableSplashScreen);
|
||||
|
||||
const [showSplash, setShowSplash] = useState(() => {
|
||||
// Check localStorage for user preference (available synchronously)
|
||||
const savedPreference = localStorage.getItem(DISABLE_SPLASH_KEY);
|
||||
if (savedPreference === 'true') {
|
||||
return false;
|
||||
}
|
||||
// Only show splash once per session
|
||||
if (sessionStorage.getItem('automaker-splash-shown')) {
|
||||
return false;
|
||||
@@ -21,6 +32,11 @@ export default function App() {
|
||||
return true;
|
||||
});
|
||||
|
||||
// Sync the disableSplashScreen setting to localStorage for fast access on next startup
|
||||
useEffect(() => {
|
||||
localStorage.setItem(DISABLE_SPLASH_KEY, String(disableSplashScreen));
|
||||
}, [disableSplashScreen]);
|
||||
|
||||
// Clear accumulated PerformanceMeasure entries to prevent memory leak in dev mode
|
||||
// React's internal scheduler creates performance marks/measures that accumulate without cleanup
|
||||
useEffect(() => {
|
||||
@@ -61,7 +77,7 @@ export default function App() {
|
||||
return (
|
||||
<>
|
||||
<RouterProvider router={router} />
|
||||
{showSplash && <SplashScreen onComplete={handleSplashComplete} />}
|
||||
{showSplash && !disableSplashScreen && <SplashScreen onComplete={handleSplashComplete} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import type { NavigateOptions } from '@tanstack/react-router';
|
||||
import { ChevronDown, Wrench, Github } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatShortcut } from '@/store/app-store';
|
||||
import { formatShortcut, useAppStore } from '@/store/app-store';
|
||||
import type { NavSection } from '../types';
|
||||
import type { Project } from '@/lib/electron';
|
||||
import type { SidebarStyle } from '@automaker/types';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -23,6 +24,7 @@ const sectionIcons: Record<string, React.ComponentType<{ className?: string }>>
|
||||
interface SidebarNavigationProps {
|
||||
currentProject: Project | null;
|
||||
sidebarOpen: boolean;
|
||||
sidebarStyle: SidebarStyle;
|
||||
navSections: NavSection[];
|
||||
isActiveRoute: (id: string) => boolean;
|
||||
navigate: (opts: NavigateOptions) => void;
|
||||
@@ -32,6 +34,7 @@ interface SidebarNavigationProps {
|
||||
export function SidebarNavigation({
|
||||
currentProject,
|
||||
sidebarOpen,
|
||||
sidebarStyle,
|
||||
navSections,
|
||||
isActiveRoute,
|
||||
navigate,
|
||||
@@ -39,21 +42,26 @@ export function SidebarNavigation({
|
||||
}: SidebarNavigationProps) {
|
||||
const navRef = useRef<HTMLElement>(null);
|
||||
|
||||
// Track collapsed state for each collapsible section
|
||||
const [collapsedSections, setCollapsedSections] = useState<Record<string, boolean>>({});
|
||||
// Get collapsed state from store (persisted across restarts)
|
||||
const { collapsedNavSections, setCollapsedNavSections, toggleNavSection } = useAppStore();
|
||||
|
||||
// Initialize collapsed state when sections change (e.g., GitHub section appears)
|
||||
// Only set defaults for sections that don't have a persisted state
|
||||
useEffect(() => {
|
||||
setCollapsedSections((prev) => {
|
||||
const updated = { ...prev };
|
||||
navSections.forEach((section) => {
|
||||
if (section.collapsible && section.label && !(section.label in updated)) {
|
||||
updated[section.label] = section.defaultCollapsed ?? false;
|
||||
}
|
||||
});
|
||||
return updated;
|
||||
let hasNewSections = false;
|
||||
const updated = { ...collapsedNavSections };
|
||||
|
||||
navSections.forEach((section) => {
|
||||
if (section.collapsible && section.label && !(section.label in updated)) {
|
||||
updated[section.label] = section.defaultCollapsed ?? false;
|
||||
hasNewSections = true;
|
||||
}
|
||||
});
|
||||
}, [navSections]);
|
||||
|
||||
if (hasNewSections) {
|
||||
setCollapsedNavSections(updated);
|
||||
}
|
||||
}, [navSections, collapsedNavSections, setCollapsedNavSections]);
|
||||
|
||||
// Check scroll state
|
||||
const checkScrollState = useCallback(() => {
|
||||
@@ -77,19 +85,12 @@ export function SidebarNavigation({
|
||||
nav.removeEventListener('scroll', checkScrollState);
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [checkScrollState, collapsedSections]);
|
||||
|
||||
const toggleSection = useCallback((label: string) => {
|
||||
setCollapsedSections((prev) => ({
|
||||
...prev,
|
||||
[label]: !prev[label],
|
||||
}));
|
||||
}, []);
|
||||
}, [checkScrollState, collapsedNavSections]);
|
||||
|
||||
// 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
|
||||
@@ -97,10 +98,17 @@ export function SidebarNavigation({
|
||||
});
|
||||
|
||||
return (
|
||||
<nav ref={navRef} className={cn('flex-1 overflow-y-auto scrollbar-hide px-3 pb-2 mt-1')}>
|
||||
<nav
|
||||
ref={navRef}
|
||||
className={cn(
|
||||
'flex-1 overflow-y-auto scrollbar-hide px-3 pb-2',
|
||||
// Add top padding in discord mode since there's no header
|
||||
sidebarStyle === 'discord' ? 'pt-3' : 'mt-1'
|
||||
)}
|
||||
>
|
||||
{/* Navigation sections */}
|
||||
{visibleSections.map((section, sectionIdx) => {
|
||||
const isCollapsed = section.label ? collapsedSections[section.label] : false;
|
||||
const isCollapsed = section.label ? collapsedNavSections[section.label] : false;
|
||||
const isCollapsible = section.collapsible && section.label && sidebarOpen;
|
||||
|
||||
const SectionIcon = section.label ? sectionIcons[section.label] : null;
|
||||
@@ -110,21 +118,37 @@ export function SidebarNavigation({
|
||||
{/* Section Label - clickable if collapsible (expanded sidebar) */}
|
||||
{section.label && sidebarOpen && (
|
||||
<button
|
||||
onClick={() => isCollapsible && toggleSection(section.label!)}
|
||||
onClick={() => isCollapsible && toggleNavSection(section.label!)}
|
||||
className={cn(
|
||||
'flex items-center w-full px-3 mb-1.5',
|
||||
isCollapsible && 'cursor-pointer hover:text-foreground'
|
||||
'group flex items-center w-full px-3 py-1.5 mb-1 rounded-md',
|
||||
'transition-all duration-200 ease-out',
|
||||
isCollapsible
|
||||
? [
|
||||
'cursor-pointer',
|
||||
'hover:bg-accent/50 hover:text-foreground',
|
||||
'border border-transparent hover:border-border/40',
|
||||
]
|
||||
: 'cursor-default'
|
||||
)}
|
||||
disabled={!isCollapsible}
|
||||
>
|
||||
<span className="text-[10px] font-semibold text-muted-foreground/70 uppercase tracking-widest">
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-semibold uppercase tracking-widest transition-colors duration-200',
|
||||
isCollapsible
|
||||
? 'text-muted-foreground/70 group-hover:text-foreground'
|
||||
: 'text-muted-foreground/70'
|
||||
)}
|
||||
>
|
||||
{section.label}
|
||||
</span>
|
||||
{isCollapsible && (
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'w-3 h-3 ml-auto text-muted-foreground/50 transition-transform duration-200',
|
||||
isCollapsed && '-rotate-90'
|
||||
'w-3 h-3 ml-auto transition-all duration-200',
|
||||
isCollapsed
|
||||
? '-rotate-90 text-muted-foreground/50 group-hover:text-muted-foreground'
|
||||
: 'text-muted-foreground/50 group-hover:text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -53,6 +53,7 @@ export function Sidebar() {
|
||||
trashedProjects,
|
||||
currentProject,
|
||||
sidebarOpen,
|
||||
sidebarStyle,
|
||||
mobileSidebarHidden,
|
||||
projectHistory,
|
||||
upsertAndSetCurrentProject,
|
||||
@@ -381,17 +382,21 @@ export function Sidebar() {
|
||||
)}
|
||||
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<SidebarHeader
|
||||
sidebarOpen={sidebarOpen}
|
||||
currentProject={currentProject}
|
||||
onNewProject={handleNewProject}
|
||||
onOpenFolder={handleOpenFolder}
|
||||
onProjectContextMenu={handleContextMenu}
|
||||
/>
|
||||
{/* Only show header in unified mode - in discord mode, ProjectSwitcher has the logo */}
|
||||
{sidebarStyle === 'unified' && (
|
||||
<SidebarHeader
|
||||
sidebarOpen={sidebarOpen}
|
||||
currentProject={currentProject}
|
||||
onNewProject={handleNewProject}
|
||||
onOpenFolder={handleOpenFolder}
|
||||
onProjectContextMenu={handleContextMenu}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SidebarNavigation
|
||||
currentProject={currentProject}
|
||||
sidebarOpen={sidebarOpen}
|
||||
sidebarStyle={sidebarStyle}
|
||||
navSections={navSections}
|
||||
isActiveRoute={isActiveRoute}
|
||||
navigate={navigate}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { Spinner, type SpinnerVariant } from '@/components/ui/spinner';
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all duration-200 cursor-pointer disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive active:scale-[0.98]",
|
||||
@@ -37,9 +37,19 @@ const buttonVariants = cva(
|
||||
}
|
||||
);
|
||||
|
||||
// Loading spinner component
|
||||
function ButtonSpinner({ className }: { className?: string }) {
|
||||
return <Spinner size="sm" className={className} />;
|
||||
/** Button variants that have colored backgrounds requiring foreground spinner color */
|
||||
const COLORED_BACKGROUND_VARIANTS = new Set<string>(['default', 'destructive']);
|
||||
|
||||
/** Get spinner variant based on button variant - use foreground for colored backgrounds */
|
||||
function getSpinnerVariant(
|
||||
buttonVariant: VariantProps<typeof buttonVariants>['variant']
|
||||
): SpinnerVariant {
|
||||
const variant = buttonVariant ?? 'default';
|
||||
if (COLORED_BACKGROUND_VARIANTS.has(variant)) {
|
||||
return 'foreground';
|
||||
}
|
||||
// outline, secondary, ghost, link, animated-outline use standard backgrounds
|
||||
return 'primary';
|
||||
}
|
||||
|
||||
function Button({
|
||||
@@ -57,6 +67,7 @@ function Button({
|
||||
loading?: boolean;
|
||||
}) {
|
||||
const isDisabled = disabled || loading;
|
||||
const spinnerVariant = getSpinnerVariant(variant);
|
||||
|
||||
// Special handling for animated-outline variant
|
||||
if (variant === 'animated-outline' && !asChild) {
|
||||
@@ -83,7 +94,7 @@ function Button({
|
||||
size === 'icon' && 'p-0 gap-0'
|
||||
)}
|
||||
>
|
||||
{loading && <ButtonSpinner />}
|
||||
{loading && <Spinner size="sm" variant={spinnerVariant} />}
|
||||
{children}
|
||||
</span>
|
||||
</button>
|
||||
@@ -99,7 +110,7 @@ function Button({
|
||||
disabled={isDisabled}
|
||||
{...props}
|
||||
>
|
||||
{loading && <ButtonSpinner />}
|
||||
{loading && <Spinner size="sm" variant={spinnerVariant} />}
|
||||
{children}
|
||||
</Comp>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type SpinnerSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
export type SpinnerSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
export type SpinnerVariant = 'primary' | 'foreground' | 'muted';
|
||||
|
||||
const sizeClasses: Record<SpinnerSize, string> = {
|
||||
xs: 'h-3 w-3',
|
||||
@@ -11,9 +12,17 @@ const sizeClasses: Record<SpinnerSize, string> = {
|
||||
xl: 'h-8 w-8',
|
||||
};
|
||||
|
||||
const variantClasses: Record<SpinnerVariant, string> = {
|
||||
primary: 'text-primary',
|
||||
foreground: 'text-primary-foreground',
|
||||
muted: 'text-muted-foreground',
|
||||
};
|
||||
|
||||
interface SpinnerProps {
|
||||
/** Size of the spinner */
|
||||
size?: SpinnerSize;
|
||||
/** Color variant - use 'foreground' when on primary backgrounds */
|
||||
variant?: SpinnerVariant;
|
||||
/** Additional class names */
|
||||
className?: string;
|
||||
}
|
||||
@@ -21,11 +30,12 @@ interface SpinnerProps {
|
||||
/**
|
||||
* Themed spinner component using the primary brand color.
|
||||
* Use this for all loading indicators throughout the app for consistency.
|
||||
* Use variant='foreground' when placing on primary-colored backgrounds.
|
||||
*/
|
||||
export function Spinner({ size = 'md', className }: SpinnerProps) {
|
||||
export function Spinner({ size = 'md', variant = 'primary', className }: SpinnerProps) {
|
||||
return (
|
||||
<Loader2
|
||||
className={cn(sizeClasses[size], 'animate-spin text-primary', className)}
|
||||
className={cn(sizeClasses[size], 'animate-spin', variantClasses[variant], className)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -261,7 +261,7 @@ export function TaskProgressPanel({
|
||||
)}
|
||||
>
|
||||
{isCompleted && <Check className="h-3.5 w-3.5" />}
|
||||
{isActive && <Spinner size="xs" />}
|
||||
{isActive && <Spinner size="xs" variant="foreground" />}
|
||||
{isPending && <Circle className="h-2 w-2 fill-current opacity-50" />}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -463,6 +463,16 @@ export function BoardView() {
|
||||
const selectedWorktreeBranch =
|
||||
currentWorktreeBranch || worktrees.find((w) => w.isMain)?.branch || 'main';
|
||||
|
||||
// Aggregate running auto tasks across all worktrees for this project
|
||||
const autoModeByWorktree = useAppStore((state) => state.autoModeByWorktree);
|
||||
const runningAutoTasksAllWorktrees = useMemo(() => {
|
||||
if (!currentProject?.id) return [];
|
||||
const prefix = `${currentProject.id}::`;
|
||||
return Object.entries(autoModeByWorktree)
|
||||
.filter(([key]) => key.startsWith(prefix))
|
||||
.flatMap(([, state]) => state.runningTasks ?? []);
|
||||
}, [autoModeByWorktree, currentProject?.id]);
|
||||
|
||||
// Get in-progress features for keyboard shortcuts (needed before actions hook)
|
||||
// Must be after runningAutoTasks is defined
|
||||
const inProgressFeaturesForShortcuts = useMemo(() => {
|
||||
@@ -1372,7 +1382,7 @@ export function BoardView() {
|
||||
setWorktreeRefreshKey((k) => k + 1);
|
||||
}}
|
||||
onRemovedWorktrees={handleRemovedWorktrees}
|
||||
runningFeatureIds={runningAutoTasks}
|
||||
runningFeatureIds={runningAutoTasksAllWorktrees}
|
||||
branchCardCounts={branchCardCounts}
|
||||
features={hookFeatures.map((f) => ({
|
||||
id: f.id,
|
||||
|
||||
@@ -78,7 +78,9 @@ export const KanbanColumn = memo(function KanbanColumn({
|
||||
)}
|
||||
>
|
||||
<div className={cn('w-2.5 h-2.5 rounded-full shrink-0', colorClass)} />
|
||||
<h3 className="font-semibold text-sm text-foreground/90 flex-1 tracking-tight">{title}</h3>
|
||||
<h3 className="font-semibold text-sm text-foreground/90 flex-1 tracking-tight whitespace-nowrap">
|
||||
{title}
|
||||
</h3>
|
||||
{headerAction}
|
||||
<span className="text-xs font-medium text-muted-foreground/80 bg-muted/50 px-2 py-0.5 rounded-md tabular-nums">
|
||||
{count}
|
||||
|
||||
@@ -132,7 +132,7 @@ const SortableColumnHeader = memo(function SortableColumnHeader({
|
||||
)}
|
||||
data-testid={`list-header-${column.id}`}
|
||||
>
|
||||
<span>{column.label}</span>
|
||||
<span className="whitespace-nowrap truncate">{column.label}</span>
|
||||
<SortIcon column={column.id} sortConfig={sortConfig} />
|
||||
</div>
|
||||
);
|
||||
@@ -156,7 +156,7 @@ const StaticColumnHeader = memo(function StaticColumnHeader({ column }: { column
|
||||
)}
|
||||
data-testid={`list-header-${column.id}`}
|
||||
>
|
||||
<span>{column.label}</span>
|
||||
<span className="whitespace-nowrap truncate">{column.label}</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -330,7 +330,7 @@ export function MergeWorktreeDialog({
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
<Spinner size="sm" variant="foreground" className="mr-2" />
|
||||
Merging...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
export const commitTemplate = {
|
||||
id: 'commit',
|
||||
name: 'Commit Changes',
|
||||
colorClass: 'bg-purple-500/20',
|
||||
instructions: `## Commit Changes Step
|
||||
|
||||
# ⚠️ CRITICAL REQUIREMENT: YOU MUST COMMIT ALL CHANGES USING CONVENTIONAL COMMIT FORMAT ⚠️
|
||||
|
||||
**THIS IS NOT OPTIONAL. YOU MUST CREATE AND EXECUTE A GIT COMMIT WITH ALL CHANGES.**
|
||||
|
||||
This step requires you to:
|
||||
1. **REVIEW** all changes made in this feature
|
||||
2. **CREATE** a conventional commit message
|
||||
3. **EXECUTE** the git commit command
|
||||
|
||||
**You cannot complete this step by only reviewing changes. You MUST execute the git commit command.**
|
||||
|
||||
---
|
||||
|
||||
### Phase 1: Review Phase
|
||||
Review all changes made in this feature:
|
||||
|
||||
- Review all modified files using \`git status\` and \`git diff\`
|
||||
- Identify the scope and nature of changes
|
||||
- Determine the appropriate conventional commit type
|
||||
- Identify any breaking changes that need to be documented
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Commit Phase - ⚠️ MANDATORY ACTION REQUIRED ⚠️
|
||||
|
||||
**YOU MUST NOW CREATE AND EXECUTE A GIT COMMIT WITH ALL CHANGES.**
|
||||
|
||||
**This is not optional. You must stage all changes and commit them using conventional commit format.**
|
||||
|
||||
#### Conventional Commit Format
|
||||
|
||||
Follow this format for your commit message:
|
||||
|
||||
\`\`\`
|
||||
<type>(<scope>): <subject>
|
||||
|
||||
<body>
|
||||
|
||||
<footer>
|
||||
\`\`\`
|
||||
|
||||
#### Commit Types (choose the most appropriate):
|
||||
|
||||
- **feat**: A new feature
|
||||
- **fix**: A bug fix
|
||||
- **docs**: Documentation only changes
|
||||
- **style**: Code style changes (formatting, missing semicolons, etc.)
|
||||
- **refactor**: Code refactoring without changing functionality
|
||||
- **perf**: Performance improvements
|
||||
- **test**: Adding or updating tests
|
||||
- **chore**: Changes to build process, dependencies, or tooling
|
||||
- **ci**: Changes to CI configuration
|
||||
- **build**: Changes to build system or dependencies
|
||||
|
||||
#### Scope (optional but recommended):
|
||||
- Component/module name (e.g., \`ui\`, \`server\`, \`auth\`)
|
||||
- Feature area (e.g., \`board\`, \`pipeline\`, \`agent\`)
|
||||
- Package name (e.g., \`@automaker/types\`)
|
||||
|
||||
#### Subject:
|
||||
- Use imperative mood: "add" not "added" or "adds"
|
||||
- First letter lowercase
|
||||
- No period at the end
|
||||
- Maximum 72 characters
|
||||
|
||||
#### Body (optional but recommended for significant changes):
|
||||
- Explain the "what" and "why" of the change
|
||||
- Reference related issues or PRs
|
||||
- Separate from subject with blank line
|
||||
- Wrap at 72 characters
|
||||
|
||||
#### Footer (optional):
|
||||
- Breaking changes: \`BREAKING CHANGE: <description>\`
|
||||
- Issue references: \`Closes #123\`, \`Fixes #456\`
|
||||
|
||||
#### Action Steps (You MUST complete these):
|
||||
|
||||
1. **Stage All Changes** - PREPARE FOR COMMIT:
|
||||
- ✅ Run \`git add .\` or \`git add -A\` to stage all changes
|
||||
- ✅ Verify staged changes with \`git status\`
|
||||
- ✅ Ensure all relevant changes are staged
|
||||
|
||||
2. **Create Commit Message** - FOLLOW CONVENTIONAL COMMIT FORMAT:
|
||||
- ✅ Determine the appropriate commit type based on changes
|
||||
- ✅ Identify the scope (component/module/feature)
|
||||
- ✅ Write a clear, imperative subject line
|
||||
- ✅ Add a body explaining the changes (if significant)
|
||||
- ✅ Include breaking changes in footer if applicable
|
||||
- ✅ Reference related issues if applicable
|
||||
|
||||
3. **Execute Commit** - COMMIT THE CHANGES:
|
||||
- ✅ Run \`git commit -m "<type>(<scope>): <subject>" -m "<body>"\` or use a multi-line commit message
|
||||
- ✅ Verify the commit was created with \`git log -1\`
|
||||
- ✅ **EXECUTE THE ACTUAL GIT COMMIT COMMAND**
|
||||
|
||||
#### Example Commit Messages:
|
||||
|
||||
\`\`\`
|
||||
feat(ui): add pipeline step commit template
|
||||
|
||||
Add a new pipeline step template for committing changes using
|
||||
conventional commit format. This ensures all commits follow
|
||||
a consistent pattern for better changelog generation.
|
||||
|
||||
Closes #123
|
||||
\`\`\`
|
||||
|
||||
\`\`\`
|
||||
fix(server): resolve agent session timeout issue
|
||||
|
||||
The agent session was timing out prematurely due to incorrect
|
||||
WebSocket heartbeat configuration. Updated heartbeat interval
|
||||
to match server expectations.
|
||||
|
||||
Fixes #456
|
||||
\`\`\`
|
||||
|
||||
\`\`\`
|
||||
refactor(pipeline): extract step template logic
|
||||
|
||||
Extract step template loading and validation into separate
|
||||
utility functions to improve code organization and testability.
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
### Summary Required
|
||||
After completing BOTH review AND commit phases, provide:
|
||||
- A summary of all changes that were committed
|
||||
- **The exact commit message that was used (this proves you executed the commit)**
|
||||
- The commit hash (if available)
|
||||
- Any notes about the commit (breaking changes, related issues, etc.)
|
||||
|
||||
---
|
||||
|
||||
# ⚠️ FINAL REMINDER ⚠️
|
||||
|
||||
**Reviewing changes without committing is INCOMPLETE and UNACCEPTABLE.**
|
||||
|
||||
**You MUST stage all changes and execute a git commit command.**
|
||||
**You MUST use conventional commit format for the commit message.**
|
||||
**You MUST show evidence of the commit execution in your summary.**
|
||||
**This step is only complete when changes have been committed to git.**`,
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import { uxReviewTemplate } from './ux-review';
|
||||
import { testingTemplate } from './testing';
|
||||
import { documentationTemplate } from './documentation';
|
||||
import { optimizationTemplate } from './optimization';
|
||||
import { commitTemplate } from './commit';
|
||||
|
||||
export interface PipelineStepTemplate {
|
||||
id: string;
|
||||
@@ -19,6 +20,7 @@ export const STEP_TEMPLATES: PipelineStepTemplate[] = [
|
||||
testingTemplate,
|
||||
documentationTemplate,
|
||||
optimizationTemplate,
|
||||
commitTemplate,
|
||||
];
|
||||
|
||||
// Helper to get template color class
|
||||
|
||||
@@ -210,7 +210,7 @@ export function PlanApprovalDialog({
|
||||
className="bg-green-600 hover:bg-green-700 text-white"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
<Spinner size="sm" variant="foreground" className="mr-2" />
|
||||
) : (
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
|
||||
@@ -12,7 +12,8 @@ import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { KanbanColumn, KanbanCard, EmptyStateCard } from './components';
|
||||
import { Feature, useAppStore, formatShortcut } from '@/store/app-store';
|
||||
import { Archive, Settings2, CheckSquare, GripVertical, Plus } from 'lucide-react';
|
||||
import { Archive, Settings2, CheckSquare, GripVertical, Plus, CheckCircle2 } from 'lucide-react';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useResponsiveKanban } from '@/hooks/use-responsive-kanban';
|
||||
import { getColumnsWithPipeline, type ColumnId } from './constants';
|
||||
import type { PipelineConfig } from '@automaker/types';
|
||||
@@ -357,35 +358,49 @@ export function KanbanBoard({
|
||||
contentClassName="perf-contain"
|
||||
headerAction={
|
||||
column.id === 'verified' ? (
|
||||
<div className="flex items-center gap-1">
|
||||
{columnFeatures.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={onArchiveAllVerified}
|
||||
data-testid="archive-all-verified-button"
|
||||
>
|
||||
<Archive className="w-3 h-3 mr-1" />
|
||||
Complete All
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 relative"
|
||||
onClick={onShowCompletedModal}
|
||||
title={`Completed Features (${completedCount})`}
|
||||
data-testid="completed-features-button"
|
||||
>
|
||||
<Archive className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
{completedCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-brand-500 text-white text-[8px] font-bold rounded-full w-3.5 h-3.5 flex items-center justify-center">
|
||||
{completedCount > 99 ? '99+' : completedCount}
|
||||
</span>
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center gap-1">
|
||||
{columnFeatures.length > 0 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={onArchiveAllVerified}
|
||||
data-testid="archive-all-verified-button"
|
||||
>
|
||||
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Complete All</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 relative"
|
||||
onClick={onShowCompletedModal}
|
||||
data-testid="completed-features-button"
|
||||
>
|
||||
<Archive className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
{completedCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-brand-500 text-white text-[8px] font-bold rounded-full w-3.5 h-3.5 flex items-center justify-center">
|
||||
{completedCount > 99 ? '99+' : completedCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Completed Features ({completedCount})</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
) : column.id === 'backlog' ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
export { BranchSwitchDropdown } from './branch-switch-dropdown';
|
||||
export { DevServerLogsPanel } from './dev-server-logs-panel';
|
||||
export { WorktreeActionsDropdown } from './worktree-actions-dropdown';
|
||||
export { WorktreeDropdown } from './worktree-dropdown';
|
||||
export type { WorktreeDropdownProps } from './worktree-dropdown';
|
||||
export { WorktreeDropdownItem } from './worktree-dropdown-item';
|
||||
export type { WorktreeDropdownItemProps } from './worktree-dropdown-item';
|
||||
export {
|
||||
truncateBranchName,
|
||||
getPRBadgeStyles,
|
||||
getChangesBadgeStyles,
|
||||
getTestStatusStyles,
|
||||
} from './worktree-indicator-utils';
|
||||
export type { TestStatus } from './worktree-indicator-utils';
|
||||
export { WorktreeMobileDropdown } from './worktree-mobile-dropdown';
|
||||
export { WorktreeTab } from './worktree-tab';
|
||||
|
||||
@@ -319,7 +319,7 @@ export function WorktreeActionsDropdown({
|
||||
<DropdownMenuItem onClick={() => onToggleAutoMode(worktree)} className="text-xs">
|
||||
<span className="flex items-center mr-2">
|
||||
<Zap className="w-3.5 h-3.5 text-yellow-500" />
|
||||
<span className="ml-0.5 h-2 w-2 rounded-full bg-green-500 animate-pulse" />
|
||||
<span className="ml-1.5 h-2 w-2 rounded-full bg-green-500 animate-pulse" />
|
||||
</span>
|
||||
Stop Auto Mode
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { DropdownMenuItem } from '@/components/ui/dropdown-menu';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Check, CircleDot, Globe, GitPullRequest, FlaskConical } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { WorktreeInfo, DevServerInfo, TestSessionInfo } from '../types';
|
||||
import {
|
||||
truncateBranchName,
|
||||
getPRBadgeStyles,
|
||||
getChangesBadgeStyles,
|
||||
getTestStatusStyles,
|
||||
} from './worktree-indicator-utils';
|
||||
|
||||
/**
|
||||
* Maximum characters for branch name before truncation in dropdown items.
|
||||
* Set to 28 to accommodate longer names in the wider dropdown menu while
|
||||
* still fitting comfortably with all status indicators.
|
||||
*/
|
||||
const MAX_ITEM_BRANCH_NAME_LENGTH = 28;
|
||||
|
||||
export interface WorktreeDropdownItemProps {
|
||||
/** The worktree to display */
|
||||
worktree: WorktreeInfo;
|
||||
/** Whether this worktree is currently selected */
|
||||
isSelected: boolean;
|
||||
/** Whether this worktree has running features/processes */
|
||||
isRunning: boolean;
|
||||
/** Number of cards associated with this worktree's branch */
|
||||
cardCount?: number;
|
||||
/** Whether the dev server is running for this worktree */
|
||||
devServerRunning?: boolean;
|
||||
/** Dev server information if running */
|
||||
devServerInfo?: DevServerInfo;
|
||||
/** Whether auto-mode is running for this worktree */
|
||||
isAutoModeRunning?: boolean;
|
||||
/** Whether tests are running for this worktree */
|
||||
isTestRunning?: boolean;
|
||||
/** Test session info for this worktree */
|
||||
testSessionInfo?: TestSessionInfo;
|
||||
/** Callback when the worktree is selected */
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A dropdown menu item component for displaying an individual worktree entry.
|
||||
*
|
||||
* Features:
|
||||
* - Selection indicator (checkmark when selected)
|
||||
* - Running status indicator (spinner)
|
||||
* - Branch name with tooltip for long names
|
||||
* - Main branch badge
|
||||
* - Dev server status indicator
|
||||
* - Auto mode indicator
|
||||
* - Test status indicator
|
||||
* - Card count badge
|
||||
* - Uncommitted changes indicator
|
||||
* - PR status badge
|
||||
*/
|
||||
export function WorktreeDropdownItem({
|
||||
worktree,
|
||||
isSelected,
|
||||
isRunning,
|
||||
cardCount,
|
||||
devServerRunning,
|
||||
devServerInfo,
|
||||
isAutoModeRunning = false,
|
||||
isTestRunning = false,
|
||||
testSessionInfo,
|
||||
onSelect,
|
||||
}: WorktreeDropdownItemProps) {
|
||||
const { hasChanges, changedFilesCount, pr } = worktree;
|
||||
|
||||
// Truncate long branch names using shared utility
|
||||
const { truncated: truncatedBranch, isTruncated: isBranchNameTruncated } = truncateBranchName(
|
||||
worktree.branch,
|
||||
MAX_ITEM_BRANCH_NAME_LENGTH
|
||||
);
|
||||
|
||||
const branchNameElement = (
|
||||
<span className={cn('font-mono text-xs truncate', isSelected && 'font-medium')}>
|
||||
{truncatedBranch}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onSelect={onSelect}
|
||||
className={cn('flex items-center gap-2 cursor-pointer pr-2', isSelected && 'bg-accent')}
|
||||
aria-current={isSelected ? 'true' : undefined}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{/* Selection indicator */}
|
||||
{isSelected ? (
|
||||
<Check className="w-3.5 h-3.5 shrink-0 text-primary" />
|
||||
) : (
|
||||
<div className="w-3.5 h-3.5 shrink-0" />
|
||||
)}
|
||||
|
||||
{/* Running indicator */}
|
||||
{isRunning && <Spinner size="xs" className="shrink-0" />}
|
||||
|
||||
{/* Branch name with optional tooltip */}
|
||||
{isBranchNameTruncated ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{branchNameElement}</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="font-mono text-xs">{worktree.branch}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
branchNameElement
|
||||
)}
|
||||
|
||||
{/* Main badge */}
|
||||
{worktree.isMain && (
|
||||
<span className="text-[10px] px-1 py-0.5 rounded bg-muted text-muted-foreground shrink-0">
|
||||
main
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right side indicators - ordered consistently with dropdown trigger */}
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{/* Card count badge */}
|
||||
{cardCount !== undefined && cardCount > 0 && (
|
||||
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
|
||||
{cardCount}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Uncommitted changes indicator */}
|
||||
{hasChanges && (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded border',
|
||||
getChangesBadgeStyles()
|
||||
)}
|
||||
title={`${changedFilesCount ?? 'Some'} uncommitted file${changedFilesCount !== 1 ? 's' : ''}`}
|
||||
>
|
||||
<CircleDot className="w-2.5 h-2.5 mr-0.5" />
|
||||
{changedFilesCount ?? '!'}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Dev server indicator */}
|
||||
{devServerRunning && (
|
||||
<span
|
||||
className="inline-flex items-center justify-center h-4 w-4 text-green-500"
|
||||
title={`Dev server running on port ${devServerInfo?.port}`}
|
||||
>
|
||||
<Globe className="w-3 h-3" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Test running indicator */}
|
||||
{isTestRunning && (
|
||||
<span
|
||||
className="inline-flex items-center justify-center h-4 w-4 text-blue-500"
|
||||
title="Tests Running"
|
||||
>
|
||||
<FlaskConical className="w-3 h-3 animate-pulse" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Last test result indicator (when not running) */}
|
||||
{!isTestRunning && testSessionInfo && (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center h-4 w-4',
|
||||
getTestStatusStyles(testSessionInfo.status)
|
||||
)}
|
||||
title={`Last test: ${testSessionInfo.status}`}
|
||||
>
|
||||
<FlaskConical className="w-3 h-3" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Auto mode indicator */}
|
||||
{isAutoModeRunning && (
|
||||
<span className="flex items-center justify-center h-4 px-0.5" title="Auto Mode Running">
|
||||
<span className="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* PR indicator */}
|
||||
{pr && (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-0.5 h-4 px-1 text-[10px] font-medium rounded border',
|
||||
getPRBadgeStyles(pr.state)
|
||||
)}
|
||||
title={`PR #${pr.number}: ${pr.title}`}
|
||||
>
|
||||
<GitPullRequest className="w-2.5 h-2.5" />#{pr.number}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuGroup,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import {
|
||||
GitBranch,
|
||||
ChevronDown,
|
||||
CircleDot,
|
||||
Globe,
|
||||
GitPullRequest,
|
||||
FlaskConical,
|
||||
} from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type {
|
||||
WorktreeInfo,
|
||||
BranchInfo,
|
||||
DevServerInfo,
|
||||
PRInfo,
|
||||
GitRepoStatus,
|
||||
TestSessionInfo,
|
||||
} from '../types';
|
||||
import { WorktreeDropdownItem } from './worktree-dropdown-item';
|
||||
import { BranchSwitchDropdown } from './branch-switch-dropdown';
|
||||
import { WorktreeActionsDropdown } from './worktree-actions-dropdown';
|
||||
import {
|
||||
truncateBranchName,
|
||||
getPRBadgeStyles,
|
||||
getChangesBadgeStyles,
|
||||
getTestStatusStyles,
|
||||
} from './worktree-indicator-utils';
|
||||
|
||||
export interface WorktreeDropdownProps {
|
||||
/** List of all worktrees to display in the dropdown */
|
||||
worktrees: WorktreeInfo[];
|
||||
/** Function to check if a worktree is currently selected */
|
||||
isWorktreeSelected: (worktree: WorktreeInfo) => boolean;
|
||||
/** Function to check if a worktree has running features/processes */
|
||||
hasRunningFeatures: (worktree: WorktreeInfo) => boolean;
|
||||
/** Whether worktree activation is in progress */
|
||||
isActivating: boolean;
|
||||
/** Map of branch names to card counts */
|
||||
branchCardCounts?: Record<string, number>;
|
||||
/** Function to check if dev server is running for a worktree */
|
||||
isDevServerRunning: (worktree: WorktreeInfo) => boolean;
|
||||
/** Function to get dev server info for a worktree */
|
||||
getDevServerInfo: (worktree: WorktreeInfo) => DevServerInfo | undefined;
|
||||
/** Function to check if auto-mode is running for a worktree */
|
||||
isAutoModeRunningForWorktree: (worktree: WorktreeInfo) => boolean;
|
||||
/** Function to check if tests are running for a worktree */
|
||||
isTestRunningForWorktree: (worktree: WorktreeInfo) => boolean;
|
||||
/** Function to get test session info for a worktree */
|
||||
getTestSessionInfo: (worktree: WorktreeInfo) => TestSessionInfo | undefined;
|
||||
/** Callback when a worktree is selected */
|
||||
onSelectWorktree: (worktree: WorktreeInfo) => void;
|
||||
|
||||
// Branch switching props
|
||||
branches: BranchInfo[];
|
||||
filteredBranches: BranchInfo[];
|
||||
branchFilter: string;
|
||||
isLoadingBranches: boolean;
|
||||
isSwitching: boolean;
|
||||
onBranchDropdownOpenChange: (worktree: WorktreeInfo) => (open: boolean) => void;
|
||||
onBranchFilterChange: (value: string) => void;
|
||||
onSwitchBranch: (worktree: WorktreeInfo, branchName: string) => void;
|
||||
onCreateBranch: (worktree: WorktreeInfo) => void;
|
||||
|
||||
// Action dropdown props
|
||||
isPulling: boolean;
|
||||
isPushing: boolean;
|
||||
isStartingDevServer: boolean;
|
||||
aheadCount: number;
|
||||
behindCount: number;
|
||||
hasRemoteBranch: boolean;
|
||||
gitRepoStatus: GitRepoStatus;
|
||||
hasTestCommand: boolean;
|
||||
isStartingTests: boolean;
|
||||
hasInitScript: boolean;
|
||||
onActionsDropdownOpenChange: (worktree: WorktreeInfo) => (open: boolean) => void;
|
||||
onPull: (worktree: WorktreeInfo) => void;
|
||||
onPush: (worktree: WorktreeInfo) => void;
|
||||
onPushNewBranch: (worktree: WorktreeInfo) => void;
|
||||
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
|
||||
onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void;
|
||||
onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void;
|
||||
onViewChanges: (worktree: WorktreeInfo) => void;
|
||||
onDiscardChanges: (worktree: WorktreeInfo) => void;
|
||||
onCommit: (worktree: WorktreeInfo) => void;
|
||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||
onResolveConflicts: (worktree: WorktreeInfo) => void;
|
||||
onMerge: (worktree: WorktreeInfo) => void;
|
||||
onDeleteWorktree: (worktree: WorktreeInfo) => void;
|
||||
onStartDevServer: (worktree: WorktreeInfo) => void;
|
||||
onStopDevServer: (worktree: WorktreeInfo) => void;
|
||||
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
|
||||
onViewDevServerLogs: (worktree: WorktreeInfo) => void;
|
||||
onRunInitScript: (worktree: WorktreeInfo) => void;
|
||||
onToggleAutoMode: (worktree: WorktreeInfo) => void;
|
||||
onStartTests: (worktree: WorktreeInfo) => void;
|
||||
onStopTests: (worktree: WorktreeInfo) => void;
|
||||
onViewTestLogs: (worktree: WorktreeInfo) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maximum characters for branch name before truncation in the dropdown trigger.
|
||||
* Set to 24 to keep the trigger compact while showing enough context for identification.
|
||||
*/
|
||||
const MAX_TRIGGER_BRANCH_NAME_LENGTH = 24;
|
||||
|
||||
/**
|
||||
* A dropdown component for displaying and switching between worktrees.
|
||||
* Used when there are 3+ worktrees to avoid horizontal tab wrapping.
|
||||
*
|
||||
* Features:
|
||||
* - Compact dropdown trigger showing current worktree with indicators
|
||||
* - Grouped display (main branch + worktrees)
|
||||
* - Full status indicators (PR, dev server, auto mode, changes)
|
||||
* - Branch switch dropdown integration
|
||||
* - Actions dropdown integration
|
||||
* - Tooltip for truncated branch names
|
||||
*/
|
||||
export function WorktreeDropdown({
|
||||
worktrees,
|
||||
isWorktreeSelected,
|
||||
hasRunningFeatures,
|
||||
isActivating,
|
||||
branchCardCounts,
|
||||
isDevServerRunning,
|
||||
getDevServerInfo,
|
||||
isAutoModeRunningForWorktree,
|
||||
isTestRunningForWorktree,
|
||||
getTestSessionInfo,
|
||||
onSelectWorktree,
|
||||
// Branch switching props
|
||||
branches,
|
||||
filteredBranches,
|
||||
branchFilter,
|
||||
isLoadingBranches,
|
||||
isSwitching,
|
||||
onBranchDropdownOpenChange,
|
||||
onBranchFilterChange,
|
||||
onSwitchBranch,
|
||||
onCreateBranch,
|
||||
// Action dropdown props
|
||||
isPulling,
|
||||
isPushing,
|
||||
isStartingDevServer,
|
||||
aheadCount,
|
||||
behindCount,
|
||||
hasRemoteBranch,
|
||||
gitRepoStatus,
|
||||
hasTestCommand,
|
||||
isStartingTests,
|
||||
hasInitScript,
|
||||
onActionsDropdownOpenChange,
|
||||
onPull,
|
||||
onPush,
|
||||
onPushNewBranch,
|
||||
onOpenInEditor,
|
||||
onOpenInIntegratedTerminal,
|
||||
onOpenInExternalTerminal,
|
||||
onViewChanges,
|
||||
onDiscardChanges,
|
||||
onCommit,
|
||||
onCreatePR,
|
||||
onAddressPRComments,
|
||||
onResolveConflicts,
|
||||
onMerge,
|
||||
onDeleteWorktree,
|
||||
onStartDevServer,
|
||||
onStopDevServer,
|
||||
onOpenDevServerUrl,
|
||||
onViewDevServerLogs,
|
||||
onRunInitScript,
|
||||
onToggleAutoMode,
|
||||
onStartTests,
|
||||
onStopTests,
|
||||
onViewTestLogs,
|
||||
}: WorktreeDropdownProps) {
|
||||
// Find the currently selected worktree to display in the trigger
|
||||
const selectedWorktree = worktrees.find((w) => isWorktreeSelected(w));
|
||||
const displayBranch = selectedWorktree?.branch || 'Select worktree';
|
||||
const { truncated: truncatedBranch, isTruncated: isBranchNameTruncated } = truncateBranchName(
|
||||
displayBranch,
|
||||
MAX_TRIGGER_BRANCH_NAME_LENGTH
|
||||
);
|
||||
|
||||
// Separate main worktree from others for grouping
|
||||
const mainWorktree = worktrees.find((w) => w.isMain);
|
||||
const otherWorktrees = worktrees.filter((w) => !w.isMain);
|
||||
|
||||
// Get status info for selected worktree - memoized to prevent unnecessary recalculations
|
||||
const selectedStatus = useMemo(() => {
|
||||
if (!selectedWorktree) {
|
||||
return {
|
||||
devServerRunning: false,
|
||||
devServerInfo: undefined,
|
||||
autoModeRunning: false,
|
||||
isRunning: false,
|
||||
testRunning: false,
|
||||
testSessionInfo: undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
devServerRunning: isDevServerRunning(selectedWorktree),
|
||||
devServerInfo: getDevServerInfo(selectedWorktree),
|
||||
autoModeRunning: isAutoModeRunningForWorktree(selectedWorktree),
|
||||
isRunning: hasRunningFeatures(selectedWorktree),
|
||||
testRunning: isTestRunningForWorktree(selectedWorktree),
|
||||
testSessionInfo: getTestSessionInfo(selectedWorktree),
|
||||
};
|
||||
}, [
|
||||
selectedWorktree,
|
||||
isDevServerRunning,
|
||||
getDevServerInfo,
|
||||
isAutoModeRunningForWorktree,
|
||||
hasRunningFeatures,
|
||||
isTestRunningForWorktree,
|
||||
getTestSessionInfo,
|
||||
]);
|
||||
|
||||
// Build trigger button with all indicators - memoized for performance
|
||||
const triggerButton = useMemo(
|
||||
() => (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(
|
||||
'h-7 px-3 gap-1.5 font-mono text-xs bg-secondary/50 hover:bg-secondary min-w-0 border-r-0 rounded-r-none'
|
||||
)}
|
||||
disabled={isActivating}
|
||||
>
|
||||
{/* Running/Activating indicator */}
|
||||
{(selectedStatus.isRunning || isActivating) && <Spinner size="xs" className="shrink-0" />}
|
||||
|
||||
{/* Branch icon */}
|
||||
<GitBranch className="w-3.5 h-3.5 shrink-0" />
|
||||
|
||||
{/* Branch name with optional tooltip */}
|
||||
<span className="truncate max-w-[150px]">{truncatedBranch}</span>
|
||||
|
||||
{/* Card count badge */}
|
||||
{selectedWorktree &&
|
||||
branchCardCounts?.[selectedWorktree.branch] !== undefined &&
|
||||
branchCardCounts[selectedWorktree.branch] > 0 && (
|
||||
<span className="inline-flex items-center justify-center h-4 min-w-4 px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border shrink-0">
|
||||
{branchCardCounts[selectedWorktree.branch]}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Uncommitted changes indicator */}
|
||||
{selectedWorktree?.hasChanges && (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center h-4 min-w-4 px-1 text-[10px] font-medium rounded border shrink-0',
|
||||
getChangesBadgeStyles()
|
||||
)}
|
||||
>
|
||||
<CircleDot className="w-2.5 h-2.5 mr-0.5" />
|
||||
{selectedWorktree.changedFilesCount ?? '!'}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Dev server indicator */}
|
||||
{selectedStatus.devServerRunning && (
|
||||
<span
|
||||
className="inline-flex items-center justify-center h-4 w-4 text-green-500 shrink-0"
|
||||
title={`Dev server running on port ${selectedStatus.devServerInfo?.port}`}
|
||||
>
|
||||
<Globe className="w-3 h-3" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Test running indicator */}
|
||||
{selectedStatus.testRunning && (
|
||||
<span
|
||||
className="inline-flex items-center justify-center h-4 w-4 text-blue-500 shrink-0"
|
||||
title="Tests Running"
|
||||
>
|
||||
<FlaskConical className="w-3 h-3 animate-pulse" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Last test result indicator (when not running) */}
|
||||
{!selectedStatus.testRunning && selectedStatus.testSessionInfo && (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center h-4 w-4 shrink-0',
|
||||
getTestStatusStyles(selectedStatus.testSessionInfo.status)
|
||||
)}
|
||||
title={`Last test: ${selectedStatus.testSessionInfo.status}`}
|
||||
>
|
||||
<FlaskConical className="w-3 h-3" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Auto mode indicator */}
|
||||
{selectedStatus.autoModeRunning && (
|
||||
<span
|
||||
className="flex items-center justify-center h-4 px-0.5 shrink-0"
|
||||
title="Auto Mode Running"
|
||||
>
|
||||
<span className="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* PR badge */}
|
||||
{selectedWorktree?.pr && (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-0.5 h-4 px-1 text-[10px] font-medium rounded border shrink-0',
|
||||
getPRBadgeStyles(selectedWorktree.pr.state)
|
||||
)}
|
||||
>
|
||||
<GitPullRequest className="w-2.5 h-2.5" />#{selectedWorktree.pr.number}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Dropdown chevron */}
|
||||
<ChevronDown className="w-3 h-3 shrink-0 ml-auto" />
|
||||
</Button>
|
||||
),
|
||||
[isActivating, selectedStatus, truncatedBranch, selectedWorktree, branchCardCounts]
|
||||
);
|
||||
|
||||
// Wrap trigger button with dropdown trigger first to ensure ref is passed correctly
|
||||
const dropdownTrigger = <DropdownMenuTrigger asChild>{triggerButton}</DropdownMenuTrigger>;
|
||||
|
||||
const triggerWithTooltip = isBranchNameTruncated ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{dropdownTrigger}</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="font-mono text-xs">{displayBranch}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
dropdownTrigger
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<DropdownMenu>
|
||||
{triggerWithTooltip}
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="w-80 max-h-96 overflow-y-auto"
|
||||
aria-label="Worktree selection"
|
||||
>
|
||||
{/* Main worktree section */}
|
||||
{mainWorktree && (
|
||||
<>
|
||||
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
|
||||
Main Branch
|
||||
</DropdownMenuLabel>
|
||||
<WorktreeDropdownItem
|
||||
worktree={mainWorktree}
|
||||
isSelected={isWorktreeSelected(mainWorktree)}
|
||||
isRunning={hasRunningFeatures(mainWorktree)}
|
||||
cardCount={branchCardCounts?.[mainWorktree.branch]}
|
||||
devServerRunning={isDevServerRunning(mainWorktree)}
|
||||
devServerInfo={getDevServerInfo(mainWorktree)}
|
||||
isAutoModeRunning={isAutoModeRunningForWorktree(mainWorktree)}
|
||||
isTestRunning={isTestRunningForWorktree(mainWorktree)}
|
||||
testSessionInfo={getTestSessionInfo(mainWorktree)}
|
||||
onSelect={() => onSelectWorktree(mainWorktree)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Other worktrees section */}
|
||||
{otherWorktrees.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
|
||||
Worktrees ({otherWorktrees.length})
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuGroup>
|
||||
{otherWorktrees.map((worktree) => (
|
||||
<WorktreeDropdownItem
|
||||
key={worktree.path}
|
||||
worktree={worktree}
|
||||
isSelected={isWorktreeSelected(worktree)}
|
||||
isRunning={hasRunningFeatures(worktree)}
|
||||
cardCount={branchCardCounts?.[worktree.branch]}
|
||||
devServerRunning={isDevServerRunning(worktree)}
|
||||
devServerInfo={getDevServerInfo(worktree)}
|
||||
isAutoModeRunning={isAutoModeRunningForWorktree(worktree)}
|
||||
isTestRunning={isTestRunningForWorktree(worktree)}
|
||||
testSessionInfo={getTestSessionInfo(worktree)}
|
||||
onSelect={() => onSelectWorktree(worktree)}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{worktrees.length === 0 && (
|
||||
<div className="px-2 py-4 text-center text-sm text-muted-foreground">
|
||||
No worktrees available
|
||||
</div>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Branch switch dropdown for main branch (only when main is selected) */}
|
||||
{selectedWorktree?.isMain && (
|
||||
<BranchSwitchDropdown
|
||||
worktree={selectedWorktree}
|
||||
isSelected={true}
|
||||
branches={branches}
|
||||
filteredBranches={filteredBranches}
|
||||
branchFilter={branchFilter}
|
||||
isLoadingBranches={isLoadingBranches}
|
||||
isSwitching={isSwitching}
|
||||
onOpenChange={onBranchDropdownOpenChange(selectedWorktree)}
|
||||
onFilterChange={onBranchFilterChange}
|
||||
onSwitchBranch={onSwitchBranch}
|
||||
onCreateBranch={onCreateBranch}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Actions dropdown for the selected worktree */}
|
||||
{selectedWorktree && (
|
||||
<WorktreeActionsDropdown
|
||||
worktree={selectedWorktree}
|
||||
isSelected={true}
|
||||
aheadCount={aheadCount}
|
||||
behindCount={behindCount}
|
||||
hasRemoteBranch={hasRemoteBranch}
|
||||
isPulling={isPulling}
|
||||
isPushing={isPushing}
|
||||
isStartingDevServer={isStartingDevServer}
|
||||
isDevServerRunning={isDevServerRunning(selectedWorktree)}
|
||||
devServerInfo={getDevServerInfo(selectedWorktree)}
|
||||
gitRepoStatus={gitRepoStatus}
|
||||
isAutoModeRunning={isAutoModeRunningForWorktree(selectedWorktree)}
|
||||
hasTestCommand={hasTestCommand}
|
||||
isStartingTests={isStartingTests}
|
||||
isTestRunning={isTestRunningForWorktree(selectedWorktree)}
|
||||
testSessionInfo={getTestSessionInfo(selectedWorktree)}
|
||||
onOpenChange={onActionsDropdownOpenChange(selectedWorktree)}
|
||||
onPull={onPull}
|
||||
onPush={onPush}
|
||||
onPushNewBranch={onPushNewBranch}
|
||||
onOpenInEditor={onOpenInEditor}
|
||||
onOpenInIntegratedTerminal={onOpenInIntegratedTerminal}
|
||||
onOpenInExternalTerminal={onOpenInExternalTerminal}
|
||||
onViewChanges={onViewChanges}
|
||||
onDiscardChanges={onDiscardChanges}
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
onAddressPRComments={onAddressPRComments}
|
||||
onResolveConflicts={onResolveConflicts}
|
||||
onMerge={onMerge}
|
||||
onDeleteWorktree={onDeleteWorktree}
|
||||
onStartDevServer={onStartDevServer}
|
||||
onStopDevServer={onStopDevServer}
|
||||
onOpenDevServerUrl={onOpenDevServerUrl}
|
||||
onViewDevServerLogs={onViewDevServerLogs}
|
||||
onRunInitScript={onRunInitScript}
|
||||
onToggleAutoMode={onToggleAutoMode}
|
||||
onStartTests={onStartTests}
|
||||
onStopTests={onStopTests}
|
||||
onViewTestLogs={onViewTestLogs}
|
||||
hasInitScript={hasInitScript}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Shared utility functions for worktree indicator styling and formatting.
|
||||
* These utilities ensure consistent appearance across WorktreeTab, WorktreeDropdown,
|
||||
* and WorktreeDropdownItem components.
|
||||
*/
|
||||
|
||||
import type { PRInfo } from '../types';
|
||||
|
||||
/**
|
||||
* Truncates a branch name if it exceeds the maximum length.
|
||||
* @param branchName - The full branch name
|
||||
* @param maxLength - Maximum characters before truncation
|
||||
* @returns Object with truncated name and whether truncation occurred
|
||||
*/
|
||||
export function truncateBranchName(
|
||||
branchName: string,
|
||||
maxLength: number
|
||||
): { truncated: string; isTruncated: boolean } {
|
||||
const isTruncated = branchName.length > maxLength;
|
||||
const truncated = isTruncated ? `${branchName.slice(0, maxLength)}...` : branchName;
|
||||
return { truncated, isTruncated };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the appropriate CSS classes for a PR badge based on PR state.
|
||||
* @param state - The PR state (OPEN, MERGED, or CLOSED)
|
||||
* @returns CSS class string for the badge
|
||||
*/
|
||||
export function getPRBadgeStyles(state: PRInfo['state']): string {
|
||||
switch (state) {
|
||||
case 'OPEN':
|
||||
return 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30';
|
||||
case 'MERGED':
|
||||
return 'bg-purple-500/15 text-purple-600 dark:text-purple-400 border-purple-500/30';
|
||||
case 'CLOSED':
|
||||
default:
|
||||
return 'bg-rose-500/15 text-rose-600 dark:text-rose-400 border-rose-500/30';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the CSS classes for the uncommitted changes badge.
|
||||
* This is a constant style used across all worktree components.
|
||||
*/
|
||||
export function getChangesBadgeStyles(): string {
|
||||
return 'bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30';
|
||||
}
|
||||
|
||||
/** Possible test session status values */
|
||||
export type TestStatus = 'pending' | 'running' | 'passed' | 'failed' | 'cancelled';
|
||||
|
||||
/**
|
||||
* Returns the CSS classes for a test status indicator based on test result.
|
||||
* @param status - The test session status
|
||||
* @returns CSS class string for the indicator color
|
||||
*/
|
||||
export function getTestStatusStyles(status: TestStatus): string {
|
||||
switch (status) {
|
||||
case 'passed':
|
||||
return 'text-green-500';
|
||||
case 'failed':
|
||||
return 'text-red-500';
|
||||
case 'running':
|
||||
return 'text-blue-500';
|
||||
case 'pending':
|
||||
case 'cancelled':
|
||||
default:
|
||||
return 'text-muted-foreground';
|
||||
}
|
||||
}
|
||||
@@ -260,8 +260,10 @@ export function WorktreeTab({
|
||||
aria-label={worktree.branch}
|
||||
data-testid={`worktree-branch-${worktree.branch}`}
|
||||
>
|
||||
{isRunning && <Spinner size="xs" />}
|
||||
{isActivating && !isRunning && <Spinner size="xs" />}
|
||||
{isRunning && <Spinner size="xs" variant={isSelected ? 'foreground' : 'primary'} />}
|
||||
{isActivating && !isRunning && (
|
||||
<Spinner size="xs" variant={isSelected ? 'foreground' : 'primary'} />
|
||||
)}
|
||||
{worktree.branch}
|
||||
{cardCount !== undefined && cardCount > 0 && (
|
||||
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
|
||||
@@ -327,8 +329,10 @@ export function WorktreeTab({
|
||||
: 'Click to switch to this branch'
|
||||
}
|
||||
>
|
||||
{isRunning && <Spinner size="xs" />}
|
||||
{isActivating && !isRunning && <Spinner size="xs" />}
|
||||
{isRunning && <Spinner size="xs" variant={isSelected ? 'foreground' : 'primary'} />}
|
||||
{isActivating && !isRunning && (
|
||||
<Spinner size="xs" variant={isSelected ? 'foreground' : 'primary'} />
|
||||
)}
|
||||
{worktree.branch}
|
||||
{cardCount !== undefined && cardCount > 0 && (
|
||||
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
|
||||
|
||||
@@ -95,12 +95,20 @@ export function useWorktrees({
|
||||
);
|
||||
|
||||
// fetchWorktrees for backward compatibility - now just triggers a refetch
|
||||
const fetchWorktrees = useCallback(async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.worktrees.all(projectPath),
|
||||
});
|
||||
return refetch();
|
||||
}, [projectPath, queryClient, refetch]);
|
||||
// The silent option is accepted but not used (React Query handles loading states)
|
||||
// Returns removed worktrees array if any were detected, undefined otherwise
|
||||
const fetchWorktrees = useCallback(
|
||||
async (_options?: {
|
||||
silent?: boolean;
|
||||
}): Promise<Array<{ path: string; branch: string }> | undefined> => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.worktrees.all(projectPath),
|
||||
});
|
||||
const result = await refetch();
|
||||
return result.data?.removedWorktrees;
|
||||
},
|
||||
[projectPath, queryClient, refetch]
|
||||
);
|
||||
|
||||
const currentWorktreePath = currentWorktree?.path ?? null;
|
||||
const selectedWorktree = currentWorktreePath
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
WorktreeMobileDropdown,
|
||||
WorktreeActionsDropdown,
|
||||
BranchSwitchDropdown,
|
||||
WorktreeDropdown,
|
||||
} from './components';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { ViewWorktreeChangesDialog, PushToRemoteDialog, MergeWorktreeDialog } from '../dialogs';
|
||||
@@ -36,6 +37,9 @@ import { TestLogsPanel } from '@/components/ui/test-logs-panel';
|
||||
import { Undo2 } from 'lucide-react';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
|
||||
/** Threshold for switching from tabs to dropdown layout (number of worktrees) */
|
||||
const WORKTREE_DROPDOWN_THRESHOLD = 3;
|
||||
|
||||
export function WorktreePanel({
|
||||
projectPath,
|
||||
onCreateWorktree,
|
||||
@@ -379,13 +383,13 @@ export function WorktreePanel({
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// Periodic interval check (5 seconds) to detect branch changes on disk
|
||||
// Reduced from 1s to 5s to minimize GPU/CPU usage from frequent re-renders
|
||||
// Periodic interval check (30 seconds) to detect branch changes on disk
|
||||
// Reduced polling to lessen repeated worktree list calls while keeping UI reasonably fresh
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
useEffect(() => {
|
||||
intervalRef.current = setInterval(() => {
|
||||
fetchWorktrees({ silent: true });
|
||||
}, 5000);
|
||||
}, 30000);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
@@ -712,30 +716,43 @@ export function WorktreePanel({
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop view: full tabs layout
|
||||
// Use dropdown layout when worktree count meets or exceeds the threshold
|
||||
const useDropdownLayout = worktrees.length >= WORKTREE_DROPDOWN_THRESHOLD;
|
||||
|
||||
// Desktop view: full tabs layout or dropdown layout depending on worktree count
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-glass/50 backdrop-blur-sm">
|
||||
<GitBranch className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground mr-2">Branch:</span>
|
||||
<span className="text-sm text-muted-foreground mr-2">
|
||||
{useDropdownLayout ? 'Worktree:' : 'Branch:'}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{mainWorktree && (
|
||||
<WorktreeTab
|
||||
key={mainWorktree.path}
|
||||
worktree={mainWorktree}
|
||||
cardCount={branchCardCounts?.[mainWorktree.branch]}
|
||||
hasChanges={mainWorktree.hasChanges}
|
||||
changedFilesCount={mainWorktree.changedFilesCount}
|
||||
isSelected={isWorktreeSelected(mainWorktree)}
|
||||
isRunning={hasRunningFeatures(mainWorktree)}
|
||||
{/* Dropdown layout for 3+ worktrees */}
|
||||
{useDropdownLayout ? (
|
||||
<>
|
||||
<WorktreeDropdown
|
||||
worktrees={worktrees}
|
||||
isWorktreeSelected={isWorktreeSelected}
|
||||
hasRunningFeatures={hasRunningFeatures}
|
||||
isActivating={isActivating}
|
||||
isDevServerRunning={isDevServerRunning(mainWorktree)}
|
||||
devServerInfo={getDevServerInfo(mainWorktree)}
|
||||
branchCardCounts={branchCardCounts}
|
||||
isDevServerRunning={isDevServerRunning}
|
||||
getDevServerInfo={getDevServerInfo}
|
||||
isAutoModeRunningForWorktree={isAutoModeRunningForWorktree}
|
||||
isTestRunningForWorktree={isTestRunningForWorktree}
|
||||
getTestSessionInfo={getTestSessionInfo}
|
||||
onSelectWorktree={handleSelectWorktree}
|
||||
// Branch switching props
|
||||
branches={branches}
|
||||
filteredBranches={filteredBranches}
|
||||
branchFilter={branchFilter}
|
||||
isLoadingBranches={isLoadingBranches}
|
||||
isSwitching={isSwitching}
|
||||
onBranchDropdownOpenChange={handleBranchDropdownOpenChange}
|
||||
onBranchFilterChange={setBranchFilter}
|
||||
onSwitchBranch={handleSwitchBranch}
|
||||
onCreateBranch={onCreateBranch}
|
||||
// Action dropdown props
|
||||
isPulling={isPulling}
|
||||
isPushing={isPushing}
|
||||
isStartingDevServer={isStartingDevServer}
|
||||
@@ -743,16 +760,10 @@ export function WorktreePanel({
|
||||
behindCount={behindCount}
|
||||
hasRemoteBranch={hasRemoteBranch}
|
||||
gitRepoStatus={gitRepoStatus}
|
||||
isAutoModeRunning={isAutoModeRunningForWorktree(mainWorktree)}
|
||||
hasTestCommand={hasTestCommand}
|
||||
isStartingTests={isStartingTests}
|
||||
isTestRunning={isTestRunningForWorktree(mainWorktree)}
|
||||
testSessionInfo={getTestSessionInfo(mainWorktree)}
|
||||
onSelectWorktree={handleSelectWorktree}
|
||||
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(mainWorktree)}
|
||||
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(mainWorktree)}
|
||||
onBranchFilterChange={setBranchFilter}
|
||||
onSwitchBranch={handleSwitchBranch}
|
||||
onCreateBranch={onCreateBranch}
|
||||
hasInitScript={hasInitScript}
|
||||
onActionsDropdownOpenChange={handleActionsDropdownOpenChange}
|
||||
onPull={handlePull}
|
||||
onPush={handlePush}
|
||||
onPushNewBranch={handlePushNewBranch}
|
||||
@@ -776,111 +787,206 @@ export function WorktreePanel({
|
||||
onStartTests={handleStartTests}
|
||||
onStopTests={handleStopTests}
|
||||
onViewTestLogs={handleViewTestLogs}
|
||||
hasInitScript={hasInitScript}
|
||||
hasTestCommand={hasTestCommand}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Worktrees section - only show if enabled */}
|
||||
{useWorktreesEnabled && (
|
||||
{useWorktreesEnabled && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={onCreateWorktree}
|
||||
title="Create new worktree"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={async () => {
|
||||
const removedWorktrees = await fetchWorktrees();
|
||||
if (removedWorktrees && removedWorktrees.length > 0 && onRemovedWorktrees) {
|
||||
onRemovedWorktrees(removedWorktrees);
|
||||
}
|
||||
}}
|
||||
disabled={isLoading}
|
||||
title="Refresh worktrees"
|
||||
>
|
||||
{isLoading ? <Spinner size="xs" /> : <RefreshCw className="w-3.5 h-3.5" />}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
/* Standard tabs layout for 1-2 worktrees */
|
||||
<>
|
||||
<div className="w-px h-5 bg-border mx-2" />
|
||||
<GitBranch className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground mr-2">Worktrees:</span>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{nonMainWorktrees.map((worktree) => {
|
||||
const cardCount = branchCardCounts?.[worktree.branch];
|
||||
return (
|
||||
<WorktreeTab
|
||||
key={worktree.path}
|
||||
worktree={worktree}
|
||||
cardCount={cardCount}
|
||||
hasChanges={worktree.hasChanges}
|
||||
changedFilesCount={worktree.changedFilesCount}
|
||||
isSelected={isWorktreeSelected(worktree)}
|
||||
isRunning={hasRunningFeatures(worktree)}
|
||||
isActivating={isActivating}
|
||||
isDevServerRunning={isDevServerRunning(worktree)}
|
||||
devServerInfo={getDevServerInfo(worktree)}
|
||||
branches={branches}
|
||||
filteredBranches={filteredBranches}
|
||||
branchFilter={branchFilter}
|
||||
isLoadingBranches={isLoadingBranches}
|
||||
isSwitching={isSwitching}
|
||||
isPulling={isPulling}
|
||||
isPushing={isPushing}
|
||||
isStartingDevServer={isStartingDevServer}
|
||||
aheadCount={aheadCount}
|
||||
behindCount={behindCount}
|
||||
hasRemoteBranch={hasRemoteBranch}
|
||||
gitRepoStatus={gitRepoStatus}
|
||||
isAutoModeRunning={isAutoModeRunningForWorktree(worktree)}
|
||||
isStartingTests={isStartingTests}
|
||||
isTestRunning={isTestRunningForWorktree(worktree)}
|
||||
testSessionInfo={getTestSessionInfo(worktree)}
|
||||
onSelectWorktree={handleSelectWorktree}
|
||||
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)}
|
||||
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)}
|
||||
onBranchFilterChange={setBranchFilter}
|
||||
onSwitchBranch={handleSwitchBranch}
|
||||
onCreateBranch={onCreateBranch}
|
||||
onPull={handlePull}
|
||||
onPush={handlePush}
|
||||
onPushNewBranch={handlePushNewBranch}
|
||||
onOpenInEditor={handleOpenInEditor}
|
||||
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
||||
onOpenInExternalTerminal={handleOpenInExternalTerminal}
|
||||
onViewChanges={handleViewChanges}
|
||||
onDiscardChanges={handleDiscardChanges}
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
onAddressPRComments={onAddressPRComments}
|
||||
onResolveConflicts={onResolveConflicts}
|
||||
onMerge={handleMerge}
|
||||
onDeleteWorktree={onDeleteWorktree}
|
||||
onStartDevServer={handleStartDevServer}
|
||||
onStopDevServer={handleStopDevServer}
|
||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||
onViewDevServerLogs={handleViewDevServerLogs}
|
||||
onRunInitScript={handleRunInitScript}
|
||||
onToggleAutoMode={handleToggleAutoMode}
|
||||
onStartTests={handleStartTests}
|
||||
onStopTests={handleStopTests}
|
||||
onViewTestLogs={handleViewTestLogs}
|
||||
hasInitScript={hasInitScript}
|
||||
hasTestCommand={hasTestCommand}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={onCreateWorktree}
|
||||
title="Create new worktree"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={async () => {
|
||||
const removedWorktrees = await fetchWorktrees();
|
||||
if (removedWorktrees && removedWorktrees.length > 0 && onRemovedWorktrees) {
|
||||
onRemovedWorktrees(removedWorktrees);
|
||||
}
|
||||
}}
|
||||
disabled={isLoading}
|
||||
title="Refresh worktrees"
|
||||
>
|
||||
{isLoading ? <Spinner size="xs" /> : <RefreshCw className="w-3.5 h-3.5" />}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{mainWorktree && (
|
||||
<WorktreeTab
|
||||
key={mainWorktree.path}
|
||||
worktree={mainWorktree}
|
||||
cardCount={branchCardCounts?.[mainWorktree.branch]}
|
||||
hasChanges={mainWorktree.hasChanges}
|
||||
changedFilesCount={mainWorktree.changedFilesCount}
|
||||
isSelected={isWorktreeSelected(mainWorktree)}
|
||||
isRunning={hasRunningFeatures(mainWorktree)}
|
||||
isActivating={isActivating}
|
||||
isDevServerRunning={isDevServerRunning(mainWorktree)}
|
||||
devServerInfo={getDevServerInfo(mainWorktree)}
|
||||
branches={branches}
|
||||
filteredBranches={filteredBranches}
|
||||
branchFilter={branchFilter}
|
||||
isLoadingBranches={isLoadingBranches}
|
||||
isSwitching={isSwitching}
|
||||
isPulling={isPulling}
|
||||
isPushing={isPushing}
|
||||
isStartingDevServer={isStartingDevServer}
|
||||
aheadCount={aheadCount}
|
||||
behindCount={behindCount}
|
||||
hasRemoteBranch={hasRemoteBranch}
|
||||
gitRepoStatus={gitRepoStatus}
|
||||
isAutoModeRunning={isAutoModeRunningForWorktree(mainWorktree)}
|
||||
isStartingTests={isStartingTests}
|
||||
isTestRunning={isTestRunningForWorktree(mainWorktree)}
|
||||
testSessionInfo={getTestSessionInfo(mainWorktree)}
|
||||
onSelectWorktree={handleSelectWorktree}
|
||||
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(mainWorktree)}
|
||||
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(mainWorktree)}
|
||||
onBranchFilterChange={setBranchFilter}
|
||||
onSwitchBranch={handleSwitchBranch}
|
||||
onCreateBranch={onCreateBranch}
|
||||
onPull={handlePull}
|
||||
onPush={handlePush}
|
||||
onPushNewBranch={handlePushNewBranch}
|
||||
onOpenInEditor={handleOpenInEditor}
|
||||
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
||||
onOpenInExternalTerminal={handleOpenInExternalTerminal}
|
||||
onViewChanges={handleViewChanges}
|
||||
onDiscardChanges={handleDiscardChanges}
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
onAddressPRComments={onAddressPRComments}
|
||||
onResolveConflicts={onResolveConflicts}
|
||||
onMerge={handleMerge}
|
||||
onDeleteWorktree={onDeleteWorktree}
|
||||
onStartDevServer={handleStartDevServer}
|
||||
onStopDevServer={handleStopDevServer}
|
||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||
onViewDevServerLogs={handleViewDevServerLogs}
|
||||
onRunInitScript={handleRunInitScript}
|
||||
onToggleAutoMode={handleToggleAutoMode}
|
||||
onStartTests={handleStartTests}
|
||||
onStopTests={handleStopTests}
|
||||
onViewTestLogs={handleViewTestLogs}
|
||||
hasInitScript={hasInitScript}
|
||||
hasTestCommand={hasTestCommand}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Worktrees section - only show if enabled and not using dropdown layout */}
|
||||
{useWorktreesEnabled && (
|
||||
<>
|
||||
<div className="w-px h-5 bg-border mx-2" />
|
||||
<GitBranch className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground mr-2">Worktrees:</span>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{nonMainWorktrees.map((worktree) => {
|
||||
const cardCount = branchCardCounts?.[worktree.branch];
|
||||
return (
|
||||
<WorktreeTab
|
||||
key={worktree.path}
|
||||
worktree={worktree}
|
||||
cardCount={cardCount}
|
||||
hasChanges={worktree.hasChanges}
|
||||
changedFilesCount={worktree.changedFilesCount}
|
||||
isSelected={isWorktreeSelected(worktree)}
|
||||
isRunning={hasRunningFeatures(worktree)}
|
||||
isActivating={isActivating}
|
||||
isDevServerRunning={isDevServerRunning(worktree)}
|
||||
devServerInfo={getDevServerInfo(worktree)}
|
||||
branches={branches}
|
||||
filteredBranches={filteredBranches}
|
||||
branchFilter={branchFilter}
|
||||
isLoadingBranches={isLoadingBranches}
|
||||
isSwitching={isSwitching}
|
||||
isPulling={isPulling}
|
||||
isPushing={isPushing}
|
||||
isStartingDevServer={isStartingDevServer}
|
||||
aheadCount={aheadCount}
|
||||
behindCount={behindCount}
|
||||
hasRemoteBranch={hasRemoteBranch}
|
||||
gitRepoStatus={gitRepoStatus}
|
||||
isAutoModeRunning={isAutoModeRunningForWorktree(worktree)}
|
||||
isStartingTests={isStartingTests}
|
||||
isTestRunning={isTestRunningForWorktree(worktree)}
|
||||
testSessionInfo={getTestSessionInfo(worktree)}
|
||||
onSelectWorktree={handleSelectWorktree}
|
||||
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)}
|
||||
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)}
|
||||
onBranchFilterChange={setBranchFilter}
|
||||
onSwitchBranch={handleSwitchBranch}
|
||||
onCreateBranch={onCreateBranch}
|
||||
onPull={handlePull}
|
||||
onPush={handlePush}
|
||||
onPushNewBranch={handlePushNewBranch}
|
||||
onOpenInEditor={handleOpenInEditor}
|
||||
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
||||
onOpenInExternalTerminal={handleOpenInExternalTerminal}
|
||||
onViewChanges={handleViewChanges}
|
||||
onDiscardChanges={handleDiscardChanges}
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
onAddressPRComments={onAddressPRComments}
|
||||
onResolveConflicts={onResolveConflicts}
|
||||
onMerge={handleMerge}
|
||||
onDeleteWorktree={onDeleteWorktree}
|
||||
onStartDevServer={handleStartDevServer}
|
||||
onStopDevServer={handleStopDevServer}
|
||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||
onViewDevServerLogs={handleViewDevServerLogs}
|
||||
onRunInitScript={handleRunInitScript}
|
||||
onToggleAutoMode={handleToggleAutoMode}
|
||||
onStartTests={handleStartTests}
|
||||
onStopTests={handleStopTests}
|
||||
onViewTestLogs={handleViewTestLogs}
|
||||
hasInitScript={hasInitScript}
|
||||
hasTestCommand={hasTestCommand}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={onCreateWorktree}
|
||||
title="Create new worktree"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={async () => {
|
||||
const removedWorktrees = await fetchWorktrees();
|
||||
if (removedWorktrees && removedWorktrees.length > 0 && onRemovedWorktrees) {
|
||||
onRemovedWorktrees(removedWorktrees);
|
||||
}
|
||||
}}
|
||||
disabled={isLoading}
|
||||
title="Refresh worktrees"
|
||||
>
|
||||
{isLoading ? <Spinner size="xs" /> : <RefreshCw className="w-3.5 h-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* IdeationSettingsPopover - Configure context sources for idea generation
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Settings2, FileText, Brain, LayoutGrid, Lightbulb, ScrollText } from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { useIdeationStore } from '@/store/ideation-store';
|
||||
import { DEFAULT_IDEATION_CONTEXT_SOURCES, type IdeationContextSources } from '@automaker/types';
|
||||
|
||||
interface IdeationSettingsPopoverProps {
|
||||
projectPath: string;
|
||||
}
|
||||
|
||||
const IDEATION_CONTEXT_OPTIONS: Array<{
|
||||
key: keyof IdeationContextSources;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: typeof FileText;
|
||||
}> = [
|
||||
{
|
||||
key: 'useAppSpec',
|
||||
label: 'App Specification',
|
||||
description: 'Overview, capabilities, features',
|
||||
icon: ScrollText,
|
||||
},
|
||||
{
|
||||
key: 'useContextFiles',
|
||||
label: 'Context Files',
|
||||
description: '.automaker/context/*.md|.txt',
|
||||
icon: FileText,
|
||||
},
|
||||
{
|
||||
key: 'useMemoryFiles',
|
||||
label: 'Memory Files',
|
||||
description: '.automaker/memory/*.md',
|
||||
icon: Brain,
|
||||
},
|
||||
{
|
||||
key: 'useExistingFeatures',
|
||||
label: 'Existing Features',
|
||||
description: 'Board features list',
|
||||
icon: LayoutGrid,
|
||||
},
|
||||
{
|
||||
key: 'useExistingIdeas',
|
||||
label: 'Existing Ideas',
|
||||
description: 'Ideation ideas list',
|
||||
icon: Lightbulb,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Renders a settings popover to toggle per-project ideation context sources.
|
||||
* Merges defaults with stored overrides and persists changes via the ideation store.
|
||||
*/
|
||||
export function IdeationSettingsPopover({ projectPath }: IdeationSettingsPopoverProps) {
|
||||
const { projectOverrides, setContextSource } = useIdeationStore(
|
||||
useShallow((state) => ({
|
||||
projectOverrides: state.contextSourcesByProject[projectPath],
|
||||
setContextSource: state.setContextSource,
|
||||
}))
|
||||
);
|
||||
const contextSources = useMemo(
|
||||
() => ({ ...DEFAULT_IDEATION_CONTEXT_SOURCES, ...projectOverrides }),
|
||||
[projectOverrides]
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 border rounded hover:bg-accent/50 transition-colors"
|
||||
title="Generation Settings"
|
||||
aria-label="Generation settings"
|
||||
data-testid="ideation-context-settings-button"
|
||||
>
|
||||
<Settings2 className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80" align="end" sideOffset={8}>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h4 className="font-medium text-sm mb-1">Generation Settings</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Configure which context sources are included when generating ideas.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{IDEATION_CONTEXT_OPTIONS.map((option) => {
|
||||
const Icon = option.icon;
|
||||
return (
|
||||
<div
|
||||
key={option.key}
|
||||
className="flex items-center justify-between gap-3 p-2 rounded-md bg-secondary/50"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<Icon className="w-4 h-4 text-brand-500 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<Label
|
||||
htmlFor={`ideation-context-toggle-${option.key}`}
|
||||
className="text-xs font-medium cursor-pointer block"
|
||||
>
|
||||
{option.label}
|
||||
</Label>
|
||||
<span className="text-[10px] text-muted-foreground truncate block">
|
||||
{option.description}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
id={`ideation-context-toggle-${option.key}`}
|
||||
checked={contextSources[option.key]}
|
||||
onCheckedChange={(checked) =>
|
||||
setContextSource(projectPath, option.key, checked)
|
||||
}
|
||||
data-testid={`ideation-context-toggle-${option.key}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-muted-foreground leading-relaxed">
|
||||
Disable sources to generate more focused ideas or reduce context size.
|
||||
</p>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { useGuidedPrompts } from '@/hooks/use-guided-prompts';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ArrowLeft, ChevronRight, Lightbulb, CheckCheck, Trash2 } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { IdeationSettingsPopover } from './components/ideation-settings-popover';
|
||||
import type { IdeaCategory } from '@automaker/types';
|
||||
import type { IdeationMode } from '@/store/ideation-store';
|
||||
|
||||
@@ -61,7 +62,10 @@ function IdeationBreadcrumbs({
|
||||
);
|
||||
}
|
||||
|
||||
// Header shown on all pages - matches other view headers
|
||||
/**
|
||||
* Header component for the ideation view with navigation, bulk actions, and settings.
|
||||
* Displays breadcrumbs, accept/discard all buttons, and the generate ideas button with settings popover.
|
||||
*/
|
||||
function IdeationHeader({
|
||||
currentMode,
|
||||
selectedCategory,
|
||||
@@ -75,6 +79,7 @@ function IdeationHeader({
|
||||
discardAllReady,
|
||||
discardAllCount,
|
||||
onDiscardAll,
|
||||
projectPath,
|
||||
}: {
|
||||
currentMode: IdeationMode;
|
||||
selectedCategory: IdeaCategory | null;
|
||||
@@ -88,6 +93,7 @@ function IdeationHeader({
|
||||
discardAllReady: boolean;
|
||||
discardAllCount: number;
|
||||
onDiscardAll: () => void;
|
||||
projectPath: string;
|
||||
}) {
|
||||
const { getCategoryById } = useGuidedPrompts();
|
||||
const showBackButton = currentMode === 'prompts';
|
||||
@@ -157,15 +163,23 @@ function IdeationHeader({
|
||||
Accept All ({acceptAllCount})
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={onGenerateIdeas} className="gap-2">
|
||||
<Lightbulb className="w-4 h-4" />
|
||||
Generate Ideas
|
||||
</Button>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button onClick={onGenerateIdeas} className="gap-2">
|
||||
<Lightbulb className="w-4 h-4" />
|
||||
Generate Ideas
|
||||
</Button>
|
||||
<IdeationSettingsPopover projectPath={projectPath} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Main view for brainstorming and idea management.
|
||||
* Provides a dashboard for reviewing generated ideas and a prompt selection flow
|
||||
* for generating new ideas using AI-powered suggestions.
|
||||
*/
|
||||
export function IdeationView() {
|
||||
const currentProject = useAppStore((s) => s.currentProject);
|
||||
const { currentMode, selectedCategory, setMode, setCategory } = useIdeationStore();
|
||||
@@ -282,6 +296,7 @@ export function IdeationView() {
|
||||
discardAllReady={discardAllReady}
|
||||
discardAllCount={discardAllCount}
|
||||
onDiscardAll={handleDiscardAll}
|
||||
projectPath={currentProject.path}
|
||||
/>
|
||||
|
||||
{/* Dashboard - main view */}
|
||||
|
||||
@@ -572,7 +572,7 @@ export function InterviewView() {
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
<Spinner size="sm" variant="foreground" className="mr-2" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -448,7 +448,7 @@ export function LoginView() {
|
||||
>
|
||||
{isLoggingIn ? (
|
||||
<>
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
<Spinner size="sm" variant="foreground" className="mr-2" />
|
||||
Authenticating...
|
||||
</>
|
||||
) : (
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -104,7 +104,10 @@ function FeatureDefaultModelOverrideSection({ project }: { project: Project }) {
|
||||
const hasOverride = !!projectOverride;
|
||||
const effectiveValue = projectOverride || globalValue;
|
||||
|
||||
// Get display name for a model
|
||||
/**
|
||||
* Formats a user-friendly model label using provider metadata when available,
|
||||
* falling back to known Claude aliases or the raw model id.
|
||||
*/
|
||||
const getModelDisplayName = (entry: PhaseModelEntry): string => {
|
||||
if (entry.providerId) {
|
||||
const provider = (claudeCompatibleProviders || []).find((p) => p.id === entry.providerId);
|
||||
@@ -127,10 +130,16 @@ function FeatureDefaultModelOverrideSection({ project }: { project: Project }) {
|
||||
return modelMap[entry.model] || entry.model;
|
||||
};
|
||||
|
||||
/**
|
||||
* Clears the project-level model override for this scope.
|
||||
*/
|
||||
const handleClearOverride = () => {
|
||||
setProjectDefaultFeatureModel(project.id, null);
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets the project-level model override for this scope.
|
||||
*/
|
||||
const handleSetOverride = (entry: PhaseModelEntry) => {
|
||||
setProjectDefaultFeatureModel(project.id, entry);
|
||||
};
|
||||
@@ -209,6 +218,10 @@ function FeatureDefaultModelOverrideSection({ project }: { project: Project }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single phase override row, showing the effective model
|
||||
* (project override or global default) and wiring selector/reset actions.
|
||||
*/
|
||||
function PhaseOverrideItem({
|
||||
phase,
|
||||
project,
|
||||
@@ -225,7 +238,10 @@ function PhaseOverrideItem({
|
||||
const hasOverride = !!projectOverride;
|
||||
const effectiveValue = projectOverride || globalValue;
|
||||
|
||||
// Get display name for a model
|
||||
/**
|
||||
* Formats a user-friendly model label using provider metadata when available,
|
||||
* falling back to known Claude aliases or the raw model id.
|
||||
*/
|
||||
const getModelDisplayName = (entry: PhaseModelEntry): string => {
|
||||
if (entry.providerId) {
|
||||
const provider = (claudeCompatibleProviders || []).find((p) => p.id === entry.providerId);
|
||||
@@ -248,10 +264,16 @@ function PhaseOverrideItem({
|
||||
return modelMap[entry.model] || entry.model;
|
||||
};
|
||||
|
||||
/**
|
||||
* Clears the project-level model override for this scope.
|
||||
*/
|
||||
const handleClearOverride = () => {
|
||||
setProjectPhaseModelOverride(project.id, phase.key, null);
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets the project-level model override for this scope.
|
||||
*/
|
||||
const handleSetOverride = (entry: PhaseModelEntry) => {
|
||||
setProjectPhaseModelOverride(project.id, phase.key, entry);
|
||||
};
|
||||
@@ -315,6 +337,10 @@ function PhaseOverrideItem({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a titled group of phase override rows and resolves each phase's
|
||||
* global default model with a fallback to DEFAULT_PHASE_MODELS.
|
||||
*/
|
||||
function PhaseGroup({
|
||||
title,
|
||||
subtitle,
|
||||
@@ -350,9 +376,11 @@ function PhaseGroup({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the per-project model overrides UI for all phase models.
|
||||
*/
|
||||
export function ProjectModelsSection({ project }: ProjectModelsSectionProps) {
|
||||
const { clearAllProjectPhaseModelOverrides, disabledProviders, claudeCompatibleProviders } =
|
||||
useAppStore();
|
||||
const { clearAllProjectPhaseModelOverrides, claudeCompatibleProviders } = useAppStore();
|
||||
const [showBulkReplace, setShowBulkReplace] = useState(false);
|
||||
|
||||
// Count how many overrides are set (including defaultFeatureModel)
|
||||
@@ -360,25 +388,13 @@ export function ProjectModelsSection({ project }: ProjectModelsSectionProps) {
|
||||
const hasDefaultFeatureModelOverride = !!project.defaultFeatureModel;
|
||||
const overrideCount = phaseOverrideCount + (hasDefaultFeatureModelOverride ? 1 : 0);
|
||||
|
||||
// Check if Claude is available
|
||||
const isClaudeDisabled = disabledProviders.includes('claude');
|
||||
|
||||
// Check if there are any enabled ClaudeCompatibleProviders
|
||||
const hasEnabledProviders =
|
||||
claudeCompatibleProviders && claudeCompatibleProviders.some((p) => p.enabled !== false);
|
||||
|
||||
if (isClaudeDisabled) {
|
||||
return (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<Workflow className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p className="text-sm">Claude not configured</p>
|
||||
<p className="text-xs mt-1">
|
||||
Enable Claude in global settings to configure per-project model overrides.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all project-level phase model overrides for this project.
|
||||
*/
|
||||
const handleClearAll = () => {
|
||||
clearAllProjectPhaseModelOverrides(project.id);
|
||||
};
|
||||
|
||||
@@ -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 />;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Palette, Moon, Sun, Type } from 'lucide-react';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Palette, Moon, Sun, Type, Sparkles, PanelLeft, Columns2 } from 'lucide-react';
|
||||
import { darkThemes, lightThemes } from '@/config/theme-options';
|
||||
import {
|
||||
UI_SANS_FONT_OPTIONS,
|
||||
@@ -11,6 +12,7 @@ import { cn } from '@/lib/utils';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { FontSelector } from '@/components/shared';
|
||||
import type { Theme } from '../shared/types';
|
||||
import type { SidebarStyle } from '@automaker/types';
|
||||
|
||||
interface AppearanceSectionProps {
|
||||
effectiveTheme: Theme;
|
||||
@@ -18,7 +20,16 @@ interface AppearanceSectionProps {
|
||||
}
|
||||
|
||||
export function AppearanceSection({ effectiveTheme, onThemeChange }: AppearanceSectionProps) {
|
||||
const { fontFamilySans, fontFamilyMono, setFontSans, setFontMono } = useAppStore();
|
||||
const {
|
||||
fontFamilySans,
|
||||
fontFamilyMono,
|
||||
setFontSans,
|
||||
setFontMono,
|
||||
disableSplashScreen,
|
||||
setDisableSplashScreen,
|
||||
sidebarStyle,
|
||||
setSidebarStyle,
|
||||
} = useAppStore();
|
||||
|
||||
// Determine if current theme is light or dark
|
||||
const isLightTheme = lightThemes.some((t) => t.value === effectiveTheme);
|
||||
@@ -189,6 +200,118 @@ export function AppearanceSection({ effectiveTheme, onThemeChange }: AppearanceS
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Splash Screen Section */}
|
||||
<div className="space-y-4 pt-6 border-t border-border/50">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Sparkles className="w-4 h-4 text-muted-foreground" />
|
||||
<Label className="text-foreground font-medium">Startup</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="disable-splash-screen" className="text-sm">
|
||||
Disable Splash Screen
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Skip the animated splash screen when the app starts
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="disable-splash-screen"
|
||||
checked={disableSplashScreen}
|
||||
onCheckedChange={setDisableSplashScreen}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar Style Section */}
|
||||
<div className="space-y-4 pt-6 border-t border-border/50">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<PanelLeft className="w-4 h-4 text-muted-foreground" />
|
||||
<Label className="text-foreground font-medium">Sidebar Layout</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground -mt-2 mb-4">
|
||||
Choose between a modern unified sidebar or classic Discord-style layout with a separate
|
||||
project switcher.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Unified Sidebar Option */}
|
||||
<button
|
||||
onClick={() => setSidebarStyle('unified')}
|
||||
className={cn(
|
||||
'group flex flex-col items-center gap-3 p-4 rounded-xl',
|
||||
'text-sm font-medium transition-all duration-200 ease-out',
|
||||
sidebarStyle === 'unified'
|
||||
? [
|
||||
'bg-gradient-to-br from-brand-500/15 to-brand-600/10',
|
||||
'border-2 border-brand-500/40',
|
||||
'text-foreground',
|
||||
'shadow-md shadow-brand-500/10',
|
||||
]
|
||||
: [
|
||||
'bg-accent/30 hover:bg-accent/50',
|
||||
'border border-border/50 hover:border-border',
|
||||
'text-muted-foreground hover:text-foreground',
|
||||
'hover:shadow-sm',
|
||||
],
|
||||
'hover:scale-[1.02] active:scale-[0.98]'
|
||||
)}
|
||||
data-testid="sidebar-style-unified"
|
||||
>
|
||||
<PanelLeft
|
||||
className={cn(
|
||||
'w-8 h-8 transition-all duration-200',
|
||||
sidebarStyle === 'unified' ? 'text-brand-500' : 'text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
<div className="text-center">
|
||||
<div className="font-medium">Unified</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
Single sidebar with project dropdown
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Discord-style Sidebar Option */}
|
||||
<button
|
||||
onClick={() => setSidebarStyle('discord')}
|
||||
className={cn(
|
||||
'group flex flex-col items-center gap-3 p-4 rounded-xl',
|
||||
'text-sm font-medium transition-all duration-200 ease-out',
|
||||
sidebarStyle === 'discord'
|
||||
? [
|
||||
'bg-gradient-to-br from-brand-500/15 to-brand-600/10',
|
||||
'border-2 border-brand-500/40',
|
||||
'text-foreground',
|
||||
'shadow-md shadow-brand-500/10',
|
||||
]
|
||||
: [
|
||||
'bg-accent/30 hover:bg-accent/50',
|
||||
'border border-border/50 hover:border-border',
|
||||
'text-muted-foreground hover:text-foreground',
|
||||
'hover:shadow-sm',
|
||||
],
|
||||
'hover:scale-[1.02] active:scale-[0.98]'
|
||||
)}
|
||||
data-testid="sidebar-style-discord"
|
||||
>
|
||||
<Columns2
|
||||
className={cn(
|
||||
'w-8 h-8 transition-all duration-200',
|
||||
sidebarStyle === 'discord' ? 'text-brand-500' : 'text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
<div className="text-center">
|
||||
<div className="font-medium">Classic</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
Separate project switcher + sidebar
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -60,7 +60,7 @@ export function CliInstallationCard({
|
||||
>
|
||||
{isInstalling ? (
|
||||
<>
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
<Spinner size="sm" variant="foreground" className="mr-2" />
|
||||
Installing...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -412,7 +412,7 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps
|
||||
>
|
||||
{isInstalling ? (
|
||||
<>
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
<Spinner size="sm" variant="foreground" className="mr-2" />
|
||||
Installing...
|
||||
</>
|
||||
) : (
|
||||
@@ -574,7 +574,7 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps
|
||||
>
|
||||
{isSavingApiKey ? (
|
||||
<>
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
<Spinner size="sm" variant="foreground" className="mr-2" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -408,7 +408,7 @@ export function CliSetupStep({ config, state, onNext, onBack, onSkip }: CliSetup
|
||||
>
|
||||
{isInstalling ? (
|
||||
<>
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
<Spinner size="sm" variant="foreground" className="mr-2" />
|
||||
Installing...
|
||||
</>
|
||||
) : (
|
||||
@@ -681,7 +681,7 @@ export function CliSetupStep({ config, state, onNext, onBack, onSkip }: CliSetup
|
||||
>
|
||||
{isSavingApiKey ? (
|
||||
<>
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
<Spinner size="sm" variant="foreground" className="mr-2" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -318,7 +318,7 @@ export function CursorSetupStep({ onNext, onBack, onSkip }: CursorSetupStepProps
|
||||
>
|
||||
{isLoggingIn ? (
|
||||
<>
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
<Spinner size="sm" variant="foreground" className="mr-2" />
|
||||
Waiting for login...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -316,7 +316,7 @@ export function OpencodeSetupStep({ onNext, onBack, onSkip }: OpencodeSetupStepP
|
||||
>
|
||||
{isLoggingIn ? (
|
||||
<>
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
<Spinner size="sm" variant="foreground" className="mr-2" />
|
||||
Waiting for login...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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
|
||||
@@ -328,7 +329,7 @@ function ClaudeContent() {
|
||||
>
|
||||
{isInstalling ? (
|
||||
<>
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
<Spinner size="sm" variant="foreground" className="mr-2" />
|
||||
Installing...
|
||||
</>
|
||||
) : (
|
||||
@@ -423,7 +424,11 @@ function ClaudeContent() {
|
||||
disabled={isSavingApiKey || !apiKey.trim()}
|
||||
className="flex-1 bg-brand-500 hover:bg-brand-600 text-white"
|
||||
>
|
||||
{isSavingApiKey ? <Spinner size="sm" /> : 'Save API Key'}
|
||||
{isSavingApiKey ? (
|
||||
<Spinner size="sm" variant="foreground" />
|
||||
) : (
|
||||
'Save API Key'
|
||||
)}
|
||||
</Button>
|
||||
{hasApiKey && (
|
||||
<Button
|
||||
@@ -660,7 +665,7 @@ function CursorContent() {
|
||||
>
|
||||
{isLoggingIn ? (
|
||||
<>
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
<Spinner size="sm" variant="foreground" className="mr-2" />
|
||||
Waiting for login...
|
||||
</>
|
||||
) : (
|
||||
@@ -917,7 +922,7 @@ function CodexContent() {
|
||||
>
|
||||
{isLoggingIn ? (
|
||||
<>
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
<Spinner size="sm" variant="foreground" className="mr-2" />
|
||||
Waiting for login...
|
||||
</>
|
||||
) : (
|
||||
@@ -960,7 +965,7 @@ function CodexContent() {
|
||||
disabled={isSaving || !apiKey.trim()}
|
||||
className="w-full bg-brand-500 hover:bg-brand-600 text-white"
|
||||
>
|
||||
{isSaving ? <Spinner size="sm" /> : 'Save API Key'}
|
||||
{isSaving ? <Spinner size="sm" variant="foreground" /> : 'Save API Key'}
|
||||
</Button>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
@@ -1193,7 +1198,7 @@ function OpencodeContent() {
|
||||
>
|
||||
{isLoggingIn ? (
|
||||
<>
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
<Spinner size="sm" variant="foreground" className="mr-2" />
|
||||
Waiting for login...
|
||||
</>
|
||||
) : (
|
||||
@@ -1465,7 +1470,7 @@ function GeminiContent() {
|
||||
>
|
||||
{isLoggingIn ? (
|
||||
<>
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
<Spinner size="sm" variant="foreground" className="mr-2" />
|
||||
Waiting for login...
|
||||
</>
|
||||
) : (
|
||||
@@ -1508,7 +1513,7 @@ function GeminiContent() {
|
||||
disabled={isSaving || !apiKey.trim()}
|
||||
className="w-full bg-brand-500 hover:bg-brand-600 text-white"
|
||||
>
|
||||
{isSaving ? <Spinner size="sm" /> : 'Save API Key'}
|
||||
{isSaving ? <Spinner size="sm" variant="foreground" /> : 'Save API Key'}
|
||||
</Button>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
@@ -1527,6 +1532,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" variant="foreground" 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 +1788,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 +1905,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 +1942,7 @@ export function ProvidersSetupStep({ onNext, onBack }: ProvidersSetupStepProps)
|
||||
setCodexAuthStatus,
|
||||
setOpencodeCliStatus,
|
||||
setGeminiCliStatus,
|
||||
setCopilotCliStatus,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1698,12 +1972,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 +2032,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 +2075,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 +2124,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>
|
||||
|
||||
|
||||
@@ -68,7 +68,16 @@ export function useGenerateIdeationSuggestions(projectPath: string) {
|
||||
throw new Error('Ideation API not available');
|
||||
}
|
||||
|
||||
const result = await api.ideation.generateSuggestions(projectPath, promptId, category);
|
||||
// Get context sources from store
|
||||
const contextSources = useIdeationStore.getState().getContextSources(projectPath);
|
||||
|
||||
const result = await api.ideation.generateSuggestions(
|
||||
projectPath,
|
||||
promptId,
|
||||
category,
|
||||
undefined, // count - use default
|
||||
contextSources
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to generate suggestions');
|
||||
|
||||
@@ -64,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
|
||||
*
|
||||
|
||||
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,6 +22,57 @@ import { useEventRecencyStore } from './use-event-recency';
|
||||
const PROGRESS_DEBOUNCE_WAIT = 150;
|
||||
const PROGRESS_DEBOUNCE_MAX_WAIT = 2000;
|
||||
|
||||
/**
|
||||
* Events that should invalidate the feature list (features.all query)
|
||||
* Note: pipeline_step_started is included to ensure Kanban board immediately reflects
|
||||
* feature moving to custom pipeline columns (fixes GitHub issue #668)
|
||||
*/
|
||||
const FEATURE_LIST_INVALIDATION_EVENTS: AutoModeEvent['type'][] = [
|
||||
'auto_mode_feature_complete',
|
||||
'auto_mode_error',
|
||||
'plan_approval_required',
|
||||
'plan_approved',
|
||||
'plan_rejected',
|
||||
'pipeline_step_started',
|
||||
'pipeline_step_complete',
|
||||
];
|
||||
|
||||
/**
|
||||
* Events that should invalidate a specific feature (features.single query)
|
||||
* Note: pipeline_step_started is NOT included here because it already invalidates
|
||||
* features.all() above, which also invalidates child queries (features.single)
|
||||
*/
|
||||
const SINGLE_FEATURE_INVALIDATION_EVENTS: AutoModeEvent['type'][] = [
|
||||
'auto_mode_feature_start',
|
||||
'auto_mode_phase',
|
||||
'auto_mode_phase_complete',
|
||||
];
|
||||
|
||||
/**
|
||||
* Events that should invalidate running agents status
|
||||
*/
|
||||
const RUNNING_AGENTS_INVALIDATION_EVENTS: AutoModeEvent['type'][] = [
|
||||
'auto_mode_feature_start',
|
||||
'auto_mode_feature_complete',
|
||||
'auto_mode_error',
|
||||
'auto_mode_resuming_features',
|
||||
];
|
||||
|
||||
/**
|
||||
* Events that signal a feature is done and debounce cleanup should occur
|
||||
*/
|
||||
const FEATURE_CLEANUP_EVENTS: AutoModeEvent['type'][] = [
|
||||
'auto_mode_feature_complete',
|
||||
'auto_mode_error',
|
||||
];
|
||||
|
||||
/**
|
||||
* Type guard to check if an event has a featureId property
|
||||
*/
|
||||
function hasFeatureId(event: AutoModeEvent): event is AutoModeEvent & { featureId: string } {
|
||||
return 'featureId' in event && typeof event.featureId === 'string';
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a unique key for per-feature debounce tracking
|
||||
*/
|
||||
@@ -115,40 +166,22 @@ export function useAutoModeQueryInvalidation(projectPath: string | undefined) {
|
||||
// This allows polling to be disabled when WebSocket events are flowing
|
||||
recordGlobalEvent();
|
||||
|
||||
// Invalidate features when agent completes, errors, or receives plan approval
|
||||
if (
|
||||
event.type === 'auto_mode_feature_complete' ||
|
||||
event.type === 'auto_mode_error' ||
|
||||
event.type === 'plan_approval_required' ||
|
||||
event.type === 'plan_approved' ||
|
||||
event.type === 'plan_rejected' ||
|
||||
event.type === 'pipeline_step_complete'
|
||||
) {
|
||||
// Invalidate feature list for lifecycle events
|
||||
if (FEATURE_LIST_INVALIDATION_EVENTS.includes(event.type)) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.features.all(currentProjectPath),
|
||||
});
|
||||
}
|
||||
|
||||
// Invalidate running agents on any status change
|
||||
if (
|
||||
event.type === 'auto_mode_feature_start' ||
|
||||
event.type === 'auto_mode_feature_complete' ||
|
||||
event.type === 'auto_mode_error' ||
|
||||
event.type === 'auto_mode_resuming_features'
|
||||
) {
|
||||
// Invalidate running agents on status changes
|
||||
if (RUNNING_AGENTS_INVALIDATION_EVENTS.includes(event.type)) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.runningAgents.all(),
|
||||
});
|
||||
}
|
||||
|
||||
// Invalidate specific feature when it starts or has phase changes
|
||||
if (
|
||||
(event.type === 'auto_mode_feature_start' ||
|
||||
event.type === 'auto_mode_phase' ||
|
||||
event.type === 'auto_mode_phase_complete' ||
|
||||
event.type === 'pipeline_step_started') &&
|
||||
'featureId' in event
|
||||
) {
|
||||
// Invalidate specific feature for phase changes
|
||||
if (SINGLE_FEATURE_INVALIDATION_EVENTS.includes(event.type) && hasFeatureId(event)) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.features.single(currentProjectPath, event.featureId),
|
||||
});
|
||||
@@ -156,23 +189,19 @@ export function useAutoModeQueryInvalidation(projectPath: string | undefined) {
|
||||
|
||||
// Invalidate agent output during progress updates (DEBOUNCED)
|
||||
// Uses per-feature debouncing to batch rapid progress events during streaming
|
||||
if (event.type === 'auto_mode_progress' && 'featureId' in event) {
|
||||
if (event.type === 'auto_mode_progress' && hasFeatureId(event)) {
|
||||
const debouncedInvalidation = getDebouncedInvalidation(event.featureId);
|
||||
debouncedInvalidation();
|
||||
}
|
||||
|
||||
// Clean up debounced functions when feature completes or errors
|
||||
// This ensures we flush any pending invalidations and free memory
|
||||
if (
|
||||
(event.type === 'auto_mode_feature_complete' || event.type === 'auto_mode_error') &&
|
||||
'featureId' in event &&
|
||||
event.featureId
|
||||
) {
|
||||
if (FEATURE_CLEANUP_EVENTS.includes(event.type) && hasFeatureId(event)) {
|
||||
cleanupFeatureDebounce(event.featureId);
|
||||
}
|
||||
|
||||
// Invalidate worktree queries when feature completes (may have created worktree)
|
||||
if (event.type === 'auto_mode_feature_complete' && 'featureId' in event) {
|
||||
if (event.type === 'auto_mode_feature_complete' && hasFeatureId(event)) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.worktrees.all(currentProjectPath),
|
||||
});
|
||||
|
||||
@@ -14,8 +14,8 @@ export interface ResponsiveKanbanConfig {
|
||||
* Default configuration for responsive Kanban columns
|
||||
*/
|
||||
const DEFAULT_CONFIG: ResponsiveKanbanConfig = {
|
||||
columnWidth: 288, // 18rem = 288px (w-72)
|
||||
columnMinWidth: 280, // Minimum column width - ensures usability
|
||||
columnWidth: 320, // Increased from 288px to accommodate longer column titles
|
||||
columnMinWidth: 320, // Increased from 280px to prevent title overflow
|
||||
columnMaxWidth: Infinity, // No max width - columns scale evenly to fill viewport
|
||||
gap: 20, // gap-5 = 20px
|
||||
padding: 40, // px-5 on both sides = 40px (matches gap between columns)
|
||||
|
||||
@@ -181,6 +181,7 @@ export function parseLocalStorageSettings(): Partial<GlobalSettings> | null {
|
||||
defaultPlanningMode: state.defaultPlanningMode as GlobalSettings['defaultPlanningMode'],
|
||||
defaultRequirePlanApproval: state.defaultRequirePlanApproval as boolean,
|
||||
muteDoneSound: state.muteDoneSound as boolean,
|
||||
disableSplashScreen: state.disableSplashScreen as boolean,
|
||||
enhancementModel: state.enhancementModel as GlobalSettings['enhancementModel'],
|
||||
validationModel: state.validationModel as GlobalSettings['validationModel'],
|
||||
phaseModels: state.phaseModels as GlobalSettings['phaseModels'],
|
||||
@@ -698,6 +699,8 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
|
||||
fontFamilySans: settings.fontFamilySans ?? null,
|
||||
fontFamilyMono: settings.fontFamilyMono ?? null,
|
||||
sidebarOpen: settings.sidebarOpen ?? true,
|
||||
sidebarStyle: settings.sidebarStyle ?? 'unified',
|
||||
collapsedNavSections: settings.collapsedNavSections ?? {},
|
||||
chatHistoryOpen: settings.chatHistoryOpen ?? false,
|
||||
maxConcurrency: settings.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY,
|
||||
autoModeByWorktree: restoredAutoModeByWorktree,
|
||||
@@ -711,6 +714,7 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
|
||||
model: 'claude-opus',
|
||||
},
|
||||
muteDoneSound: settings.muteDoneSound ?? false,
|
||||
disableSplashScreen: settings.disableSplashScreen ?? false,
|
||||
serverLogLevel: settings.serverLogLevel ?? 'info',
|
||||
enableRequestLogging: settings.enableRequestLogging ?? true,
|
||||
showQueryDevtools: settings.showQueryDevtools ?? true,
|
||||
@@ -798,6 +802,7 @@ function buildSettingsUpdateFromStore(): Record<string, unknown> {
|
||||
defaultPlanningMode: state.defaultPlanningMode,
|
||||
defaultRequirePlanApproval: state.defaultRequirePlanApproval,
|
||||
muteDoneSound: state.muteDoneSound,
|
||||
disableSplashScreen: state.disableSplashScreen,
|
||||
serverLogLevel: state.serverLogLevel,
|
||||
enableRequestLogging: state.enableRequestLogging,
|
||||
enhancementModel: state.enhancementModel,
|
||||
|
||||
@@ -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');
|
||||
@@ -50,6 +53,8 @@ const SETTINGS_FIELDS_TO_SYNC = [
|
||||
'terminalFontFamily', // Maps to terminalState.fontFamily
|
||||
'openTerminalMode', // Maps to terminalState.openTerminalMode
|
||||
'sidebarOpen',
|
||||
'sidebarStyle',
|
||||
'collapsedNavSections',
|
||||
'chatHistoryOpen',
|
||||
'maxConcurrency',
|
||||
'autoModeByWorktree', // Per-worktree auto mode settings (only maxConcurrency is persisted)
|
||||
@@ -61,6 +66,7 @@ const SETTINGS_FIELDS_TO_SYNC = [
|
||||
'defaultRequirePlanApproval',
|
||||
'defaultFeatureModel',
|
||||
'muteDoneSound',
|
||||
'disableSplashScreen',
|
||||
'serverLogLevel',
|
||||
'enableRequestLogging',
|
||||
'showQueryDevtools',
|
||||
@@ -75,6 +81,8 @@ const SETTINGS_FIELDS_TO_SYNC = [
|
||||
'codexDefaultModel',
|
||||
'enabledGeminiModels',
|
||||
'geminiDefaultModel',
|
||||
'enabledCopilotModels',
|
||||
'copilotDefaultModel',
|
||||
'enabledDynamicModelIds',
|
||||
'disabledProviders',
|
||||
'autoLoadClaudeMd',
|
||||
@@ -607,6 +615,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(
|
||||
@@ -677,6 +700,8 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
||||
useAppStore.setState({
|
||||
theme: serverSettings.theme as unknown as ThemeMode,
|
||||
sidebarOpen: serverSettings.sidebarOpen,
|
||||
sidebarStyle: serverSettings.sidebarStyle ?? 'unified',
|
||||
collapsedNavSections: serverSettings.collapsedNavSections ?? {},
|
||||
chatHistoryOpen: serverSettings.chatHistoryOpen,
|
||||
maxConcurrency: serverSettings.maxConcurrency,
|
||||
autoModeByWorktree: restoredAutoModeByWorktree,
|
||||
@@ -690,6 +715,7 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
||||
? migratePhaseModelEntry(serverSettings.defaultFeatureModel)
|
||||
: { model: 'claude-opus' },
|
||||
muteDoneSound: serverSettings.muteDoneSound,
|
||||
disableSplashScreen: serverSettings.disableSplashScreen ?? false,
|
||||
serverLogLevel: serverSettings.serverLogLevel ?? 'info',
|
||||
enableRequestLogging: serverSettings.enableRequestLogging ?? true,
|
||||
enhancementModel: serverSettings.enhancementModel,
|
||||
@@ -703,6 +729,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,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user