diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 00000000..7cddcaef --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,108 @@ +name: Feature Request +description: Suggest a new feature or enhancement for Automaker +title: '[Feature]: ' +labels: ['enhancement'] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to suggest a feature! Please fill out the form below to help us understand your request. + + - type: dropdown + id: feature-area + attributes: + label: Feature Area + description: Which area of Automaker does this feature relate to? + options: + - UI/UX (User Interface) + - Agent/AI + - Kanban Board + - Git/Worktree Management + - Project Management + - Settings/Configuration + - Documentation + - Performance + - Other + default: 0 + validations: + required: true + + - type: dropdown + id: priority + attributes: + label: Priority + description: How important is this feature to your workflow? + options: + - Nice to have + - Would improve my workflow + - Critical for my use case + default: 0 + validations: + required: true + + - type: textarea + id: problem-statement + attributes: + label: Problem Statement + description: Is your feature request related to a problem? Please describe the problem you're trying to solve. + placeholder: A clear and concise description of what the problem is. Ex. I'm always frustrated when... + validations: + required: true + + - type: textarea + id: proposed-solution + attributes: + label: Proposed Solution + description: Describe the solution you'd like to see implemented. + placeholder: A clear and concise description of what you want to happen. + validations: + required: true + + - type: textarea + id: alternatives-considered + attributes: + label: Alternatives Considered + description: Describe any alternative solutions or workarounds you've considered. + placeholder: A clear and concise description of any alternative solutions or features you've considered. + validations: + required: false + + - type: textarea + id: use-cases + attributes: + label: Use Cases + description: Describe specific scenarios where this feature would be useful. + placeholder: | + 1. When working on... + 2. As a user who needs to... + 3. In situations where... + validations: + required: false + + - type: textarea + id: mockups + attributes: + label: Mockups/Screenshots + description: If applicable, add mockups, wireframes, or screenshots to help illustrate your feature request. + placeholder: Drag and drop images here or paste image URLs + validations: + required: false + + - type: textarea + id: additional-context + attributes: + label: Additional Context + description: Add any other context, references, or examples about the feature request here. + placeholder: Any additional information that might be helpful... + validations: + required: false + + - type: checkboxes + id: terms + attributes: + label: Checklist + options: + - label: I have searched existing issues to ensure this feature hasn't been requested already + required: true + - label: I have provided a clear description of the problem and proposed solution + required: true diff --git a/.husky/pre-commit b/.husky/pre-commit index 276c2fa0..f61fd35b 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -31,7 +31,12 @@ fi # Ensure common system paths are in PATH (for systems without nvm) # This helps find node/npm installed via Homebrew, system packages, etc. -export PATH="$PATH:/usr/local/bin:/opt/homebrew/bin:/usr/bin" +if [ -n "$WINDIR" ]; then + export PATH="$PATH:/c/Program Files/nodejs:/c/Program Files (x86)/nodejs" + export PATH="$PATH:$APPDATA/npm:$LOCALAPPDATA/Programs/nodejs" +else + export PATH="$PATH:/usr/local/bin:/opt/homebrew/bin:/usr/bin" +fi # Run lint-staged - works with or without nvm # Prefer npx, fallback to npm exec, both work with system-installed Node.js diff --git a/Dockerfile b/Dockerfile index f40b1287..e0afeb74 100644 --- a/Dockerfile +++ b/Dockerfile @@ -65,8 +65,16 @@ ARG UID=1001 ARG GID=1001 # Install git, curl, bash (for terminal), gosu (for user switching), and GitHub CLI (pinned version, multi-arch) +# Also install Playwright/Chromium system dependencies (aligns with playwright install-deps on Debian/Ubuntu) RUN apt-get update && apt-get install -y --no-install-recommends \ git curl bash gosu ca-certificates openssh-client \ + # Playwright/Chromium dependencies + libglib2.0-0 libnss3 libnspr4 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 \ + libcups2 libdrm2 libxkbcommon0 libatspi2.0-0 libxcomposite1 libxdamage1 \ + libxfixes3 libxrandr2 libgbm1 libasound2 libpango-1.0-0 libcairo2 \ + libx11-6 libx11-xcb1 libxcb1 libxext6 libxrender1 libxss1 libxtst6 \ + libxshmfence1 libgtk-3-0 libexpat1 libfontconfig1 fonts-liberation \ + xdg-utils libpangocairo-1.0-0 libpangoft2-1.0-0 libu2f-udev libvulkan1 \ && GH_VERSION="2.63.2" \ && ARCH=$(uname -m) \ && case "$ARCH" in \ diff --git a/Dockerfile.dev b/Dockerfile.dev index 1acd7742..60e445f2 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -8,9 +8,17 @@ FROM node:22-slim # Install build dependencies for native modules (node-pty) and runtime tools +# Also install Playwright/Chromium system dependencies (aligns with playwright install-deps on Debian/Ubuntu) RUN apt-get update && apt-get install -y --no-install-recommends \ python3 make g++ \ git curl bash gosu ca-certificates openssh-client \ + # Playwright/Chromium dependencies + libglib2.0-0 libnss3 libnspr4 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 \ + libcups2 libdrm2 libxkbcommon0 libatspi2.0-0 libxcomposite1 libxdamage1 \ + libxfixes3 libxrandr2 libgbm1 libasound2 libpango-1.0-0 libcairo2 \ + libx11-6 libx11-xcb1 libxcb1 libxext6 libxrender1 libxss1 libxtst6 \ + libxshmfence1 libgtk-3-0 libexpat1 libfontconfig1 fonts-liberation \ + xdg-utils libpangocairo-1.0-0 libpangoft2-1.0-0 libu2f-udev libvulkan1 \ && GH_VERSION="2.63.2" \ && ARCH=$(uname -m) \ && case "$ARCH" in \ diff --git a/apps/server/package.json b/apps/server/package.json index 23e6a2a9..3cc4fe18 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "@automaker/server", - "version": "0.10.0", + "version": "0.11.0", "description": "Backend server for Automaker - provides API for both web and Electron modes", "author": "AutoMaker Team", "license": "SEE LICENSE IN LICENSE", diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index f763c08d..609be945 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -67,6 +67,7 @@ import { createPipelineRoutes } from './routes/pipeline/index.js'; import { pipelineService } from './services/pipeline-service.js'; import { createIdeationRoutes } from './routes/ideation/index.js'; import { IdeationService } from './services/ideation-service.js'; +import { getDevServerService } from './services/dev-server-service.js'; // Load environment variables dotenv.config(); @@ -176,6 +177,10 @@ const codexUsageService = new CodexUsageService(codexAppServerService); const mcpTestService = new MCPTestService(settingsService); const ideationService = new IdeationService(events, settingsService, featureLoader); +// Initialize DevServerService with event emitter for real-time log streaming +const devServerService = getDevServerService(); +devServerService.setEventEmitter(events); + // Initialize services (async () => { await agentService.initialize(); @@ -217,7 +222,7 @@ app.use('/api/sessions', createSessionsRoutes(agentService)); app.use('/api/features', createFeaturesRoutes(featureLoader)); app.use('/api/auto-mode', createAutoModeRoutes(autoModeService)); app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService)); -app.use('/api/worktree', createWorktreeRoutes(events)); +app.use('/api/worktree', createWorktreeRoutes(events, settingsService)); app.use('/api/git', createGitRoutes()); app.use('/api/suggestions', createSuggestionsRoutes(events, settingsService)); app.use('/api/models', createModelsRoutes()); diff --git a/apps/server/src/middleware/validate-paths.ts b/apps/server/src/middleware/validate-paths.ts index 51b8ccb1..1f7f3876 100644 --- a/apps/server/src/middleware/validate-paths.ts +++ b/apps/server/src/middleware/validate-paths.ts @@ -8,12 +8,28 @@ import type { Request, Response, NextFunction } from 'express'; import { validatePath, PathNotAllowedError } from '@automaker/platform'; /** - * Creates a middleware that validates specified path parameters in req.body + * Helper to get parameter value from request (checks body first, then query) + */ +function getParamValue(req: Request, paramName: string): unknown { + // Check body first (for POST/PUT/PATCH requests) + if (req.body && req.body[paramName] !== undefined) { + return req.body[paramName]; + } + // Fall back to query params (for GET requests) + if (req.query && req.query[paramName] !== undefined) { + return req.query[paramName]; + } + return undefined; +} + +/** + * Creates a middleware that validates specified path parameters in req.body or req.query * @param paramNames - Names of parameters to validate (e.g., 'projectPath', 'worktreePath') * @example * router.post('/create', validatePathParams('projectPath'), handler); * router.post('/delete', validatePathParams('projectPath', 'worktreePath'), handler); * router.post('/send', validatePathParams('workingDirectory?', 'imagePaths[]'), handler); + * router.get('/logs', validatePathParams('worktreePath'), handler); // Works with query params too * * Special syntax: * - 'paramName?' - Optional parameter (only validated if present) @@ -26,8 +42,8 @@ export function validatePathParams(...paramNames: string[]) { // Handle optional parameters (paramName?) if (paramName.endsWith('?')) { const actualName = paramName.slice(0, -1); - const value = req.body[actualName]; - if (value) { + const value = getParamValue(req, actualName); + if (value && typeof value === 'string') { validatePath(value); } continue; @@ -36,18 +52,20 @@ export function validatePathParams(...paramNames: string[]) { // Handle array parameters (paramName[]) if (paramName.endsWith('[]')) { const actualName = paramName.slice(0, -2); - const values = req.body[actualName]; + const values = getParamValue(req, actualName); if (Array.isArray(values) && values.length > 0) { for (const value of values) { - validatePath(value); + if (typeof value === 'string') { + validatePath(value); + } } } continue; } // Handle regular parameters - const value = req.body[paramName]; - if (value) { + const value = getParamValue(req, paramName); + if (value && typeof value === 'string') { validatePath(value); } } diff --git a/apps/server/src/providers/claude-provider.ts b/apps/server/src/providers/claude-provider.ts index ecdd46af..f8a31d81 100644 --- a/apps/server/src/providers/claude-provider.ts +++ b/apps/server/src/providers/claude-provider.ts @@ -22,6 +22,8 @@ import type { // Only these vars are passed - nothing else from process.env leaks through. const ALLOWED_ENV_VARS = [ 'ANTHROPIC_API_KEY', + 'ANTHROPIC_BASE_URL', + 'ANTHROPIC_AUTH_TOKEN', 'PATH', 'HOME', 'SHELL', diff --git a/apps/server/src/providers/cli-provider.ts b/apps/server/src/providers/cli-provider.ts index 7e0599f9..8683f841 100644 --- a/apps/server/src/providers/cli-provider.ts +++ b/apps/server/src/providers/cli-provider.ts @@ -26,22 +26,22 @@ * ``` */ -import { execSync } from 'child_process'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; -import { BaseProvider } from './base-provider.js'; -import type { ProviderConfig, ExecuteOptions, ProviderMessage } from './types.js'; import { - spawnJSONLProcess, - type SubprocessOptions, - isWslAvailable, - findCliInWsl, createWslCommand, + findCliInWsl, + isWslAvailable, + spawnJSONLProcess, windowsToWslPath, + type SubprocessOptions, type WslCliResult, } from '@automaker/platform'; import { createLogger, isAbortError } from '@automaker/utils'; +import { execSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { BaseProvider } from './base-provider.js'; +import type { ExecuteOptions, ProviderConfig, ProviderMessage } from './types.js'; /** * Spawn strategy for CLI tools on Windows @@ -522,8 +522,13 @@ export abstract class CliProvider extends BaseProvider { throw new Error(`${this.getCliName()} CLI not found. ${this.getInstallInstructions()}`); } - const cliArgs = this.buildCliArgs(options); - const subprocessOptions = this.buildSubprocessOptions(options, cliArgs); + // Many CLI-based providers do not support a separate "system" message. + // If a systemPrompt is provided, embed it into the prompt so downstream models + // still receive critical formatting/schema instructions (e.g., JSON-only outputs). + const effectiveOptions = this.embedSystemPromptIntoPrompt(options); + + const cliArgs = this.buildCliArgs(effectiveOptions); + const subprocessOptions = this.buildSubprocessOptions(effectiveOptions, cliArgs); try { for await (const rawEvent of spawnJSONLProcess(subprocessOptions)) { @@ -555,4 +560,52 @@ export abstract class CliProvider extends BaseProvider { throw error; } } + + /** + * Embed system prompt text into the user prompt for CLI providers. + * + * Most CLI providers we integrate with only accept a single prompt via stdin/args. + * When upstream code supplies `options.systemPrompt`, we prepend it to the prompt + * content and clear `systemPrompt` to avoid any accidental double-injection by + * subclasses. + */ + protected embedSystemPromptIntoPrompt(options: ExecuteOptions): ExecuteOptions { + if (!options.systemPrompt) { + return options; + } + + // Only string system prompts can be reliably embedded for CLI providers. + // Presets are provider-specific (e.g., Claude SDK) and cannot be represented + // universally. If a preset is provided, we only embed its optional `append`. + const systemText = + typeof options.systemPrompt === 'string' + ? options.systemPrompt + : options.systemPrompt.append + ? options.systemPrompt.append + : ''; + + if (!systemText) { + return { ...options, systemPrompt: undefined }; + } + + // Preserve original prompt structure. + if (typeof options.prompt === 'string') { + return { + ...options, + prompt: `${systemText}\n\n---\n\n${options.prompt}`, + systemPrompt: undefined, + }; + } + + if (Array.isArray(options.prompt)) { + return { + ...options, + prompt: [{ type: 'text', text: systemText }, ...options.prompt], + systemPrompt: undefined, + }; + } + + // Should be unreachable due to ExecuteOptions typing, but keep safe. + return { ...options, systemPrompt: undefined }; + } } diff --git a/apps/server/src/providers/opencode-provider.ts b/apps/server/src/providers/opencode-provider.ts index a5b3bae2..6babb978 100644 --- a/apps/server/src/providers/opencode-provider.ts +++ b/apps/server/src/providers/opencode-provider.ts @@ -730,7 +730,7 @@ export class OpencodeProvider extends CliProvider { if (this.detectedStrategy === 'npx') { // NPX strategy: execute npx with opencode-ai package - command = 'npx'; + command = process.platform === 'win32' ? 'npx.cmd' : 'npx'; args = ['opencode-ai@latest', 'models']; opencodeLogger.debug(`Executing: ${command} ${args.join(' ')}`); } else if (this.useWsl && this.wslCliPath) { @@ -751,6 +751,8 @@ export class OpencodeProvider extends CliProvider { encoding: 'utf-8', timeout: 30000, windowsHide: true, + // Use shell on Windows for .cmd files + shell: process.platform === 'win32' && command.endsWith('.cmd'), }); opencodeLogger.debug( @@ -963,7 +965,7 @@ export class OpencodeProvider extends CliProvider { if (this.detectedStrategy === 'npx') { // NPX strategy - command = 'npx'; + command = process.platform === 'win32' ? 'npx.cmd' : 'npx'; args = ['opencode-ai@latest', 'auth', 'list']; opencodeLogger.debug(`Executing: ${command} ${args.join(' ')}`); } else if (this.useWsl && this.wslCliPath) { @@ -984,6 +986,8 @@ export class OpencodeProvider extends CliProvider { encoding: 'utf-8', timeout: 15000, windowsHide: true, + // Use shell on Windows for .cmd files + shell: process.platform === 'win32' && command.endsWith('.cmd'), }); opencodeLogger.debug( diff --git a/apps/server/src/routes/claude/index.ts b/apps/server/src/routes/claude/index.ts index 20816bbc..ec35ca1b 100644 --- a/apps/server/src/routes/claude/index.ts +++ b/apps/server/src/routes/claude/index.ts @@ -34,6 +34,13 @@ export function createClaudeRoutes(service: ClaudeUsageService): Router { error: 'Authentication required', message: "Please run 'claude login' to authenticate", }); + } else if (message.includes('TRUST_PROMPT_PENDING')) { + // Trust prompt appeared but couldn't be auto-approved + res.status(200).json({ + error: 'Trust prompt pending', + message: + 'Claude CLI needs folder permission. Please run "claude" in your terminal and approve access.', + }); } else if (message.includes('timed out')) { res.status(200).json({ error: 'Command timed out', diff --git a/apps/server/src/routes/worktree/index.ts b/apps/server/src/routes/worktree/index.ts index a00e0bfe..4b54ae9e 100644 --- a/apps/server/src/routes/worktree/index.ts +++ b/apps/server/src/routes/worktree/index.ts @@ -17,6 +17,7 @@ import { createDeleteHandler } from './routes/delete.js'; import { createCreatePRHandler } from './routes/create-pr.js'; import { createPRInfoHandler } from './routes/pr-info.js'; import { createCommitHandler } from './routes/commit.js'; +import { createGenerateCommitMessageHandler } from './routes/generate-commit-message.js'; import { createPushHandler } from './routes/push.js'; import { createPullHandler } from './routes/pull.js'; import { createCheckoutBranchHandler } from './routes/checkout-branch.js'; @@ -33,14 +34,19 @@ import { createMigrateHandler } from './routes/migrate.js'; import { createStartDevHandler } from './routes/start-dev.js'; import { createStopDevHandler } from './routes/stop-dev.js'; import { createListDevServersHandler } from './routes/list-dev-servers.js'; +import { createGetDevServerLogsHandler } from './routes/dev-server-logs.js'; import { createGetInitScriptHandler, createPutInitScriptHandler, createDeleteInitScriptHandler, createRunInitScriptHandler, } from './routes/init-script.js'; +import type { SettingsService } from '../../services/settings-service.js'; -export function createWorktreeRoutes(events: EventEmitter): Router { +export function createWorktreeRoutes( + events: EventEmitter, + settingsService?: SettingsService +): Router { const router = Router(); router.post('/info', validatePathParams('projectPath'), createInfoHandler()); @@ -64,6 +70,12 @@ export function createWorktreeRoutes(events: EventEmitter): Router { requireGitRepoOnly, createCommitHandler() ); + router.post( + '/generate-commit-message', + validatePathParams('worktreePath'), + requireGitRepoOnly, + createGenerateCommitMessageHandler(settingsService) + ); router.post( '/push', validatePathParams('worktreePath'), @@ -97,6 +109,11 @@ export function createWorktreeRoutes(events: EventEmitter): Router { ); router.post('/stop-dev', createStopDevHandler()); router.post('/list-dev-servers', createListDevServersHandler()); + router.get( + '/dev-server-logs', + validatePathParams('worktreePath'), + createGetDevServerLogsHandler() + ); // Init script routes router.get('/init-script', createGetInitScriptHandler()); diff --git a/apps/server/src/routes/worktree/routes/create-pr.ts b/apps/server/src/routes/worktree/routes/create-pr.ts index ec7ba4dd..1bde9448 100644 --- a/apps/server/src/routes/worktree/routes/create-pr.ts +++ b/apps/server/src/routes/worktree/routes/create-pr.ts @@ -70,9 +70,8 @@ export function createCreatePRHandler() { logger.debug(`Changed files:\n${status}`); } - // If there are changes, commit them + // If there are changes, commit them before creating the PR let commitHash: string | null = null; - let commitError: string | null = null; if (hasChanges) { const message = commitMessage || `Changes from ${branchName}`; logger.debug(`Committing changes with message: ${message}`); @@ -98,14 +97,13 @@ export function createCreatePRHandler() { logger.info(`Commit successful: ${commitHash}`); } catch (commitErr: unknown) { const err = commitErr as { stderr?: string; message?: string }; - commitError = err.stderr || err.message || 'Commit failed'; + const commitError = err.stderr || err.message || 'Commit failed'; logger.error(`Commit failed: ${commitError}`); // Return error immediately - don't proceed with push/PR if commit fails res.status(500).json({ success: false, error: `Failed to commit changes: ${commitError}`, - commitError, }); return; } @@ -381,9 +379,8 @@ export function createCreatePRHandler() { success: true, result: { branch: branchName, - committed: hasChanges && !commitError, + committed: hasChanges, commitHash, - commitError: commitError || undefined, pushed: true, prUrl, prNumber, diff --git a/apps/server/src/routes/worktree/routes/dev-server-logs.ts b/apps/server/src/routes/worktree/routes/dev-server-logs.ts new file mode 100644 index 00000000..66dfed92 --- /dev/null +++ b/apps/server/src/routes/worktree/routes/dev-server-logs.ts @@ -0,0 +1,52 @@ +/** + * GET /dev-server-logs endpoint - Get buffered logs for a worktree's dev server + * + * Returns the scrollback buffer containing historical log output for a running + * dev server. Used by clients to populate the log panel on initial connection + * before subscribing to real-time updates via WebSocket. + */ + +import type { Request, Response } from 'express'; +import { getDevServerService } from '../../../services/dev-server-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createGetDevServerLogsHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath } = req.query as { + worktreePath?: string; + }; + + if (!worktreePath) { + res.status(400).json({ + success: false, + error: 'worktreePath query parameter is required', + }); + return; + } + + const devServerService = getDevServerService(); + const result = devServerService.getServerLogs(worktreePath); + + if (result.success && result.result) { + res.json({ + success: true, + result: { + worktreePath: result.result.worktreePath, + port: result.result.port, + logs: result.result.logs, + startedAt: result.result.startedAt, + }, + }); + } else { + res.status(404).json({ + success: false, + error: result.error || 'Failed to get dev server logs', + }); + } + } catch (error) { + logError(error, 'Get dev server logs failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/worktree/routes/generate-commit-message.ts b/apps/server/src/routes/worktree/routes/generate-commit-message.ts new file mode 100644 index 00000000..a450659f --- /dev/null +++ b/apps/server/src/routes/worktree/routes/generate-commit-message.ts @@ -0,0 +1,275 @@ +/** + * POST /worktree/generate-commit-message endpoint - Generate an AI commit message from git diff + * + * Uses the configured model (via phaseModels.commitMessageModel) to generate a concise, + * conventional commit message from git changes. Defaults to Claude Haiku for speed. + */ + +import type { Request, Response } from 'express'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { existsSync } from 'fs'; +import { join } from 'path'; +import { query } from '@anthropic-ai/claude-agent-sdk'; +import { createLogger } from '@automaker/utils'; +import { DEFAULT_PHASE_MODELS, isCursorModel, stripProviderPrefix } from '@automaker/types'; +import { resolvePhaseModel } from '@automaker/model-resolver'; +import { mergeCommitMessagePrompts } from '@automaker/prompts'; +import { ProviderFactory } from '../../../providers/provider-factory.js'; +import type { SettingsService } from '../../../services/settings-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +const logger = createLogger('GenerateCommitMessage'); +const execAsync = promisify(exec); + +/** Timeout for AI provider calls in milliseconds (30 seconds) */ +const AI_TIMEOUT_MS = 30_000; + +/** + * Wraps an async generator with a timeout. + * If the generator takes longer than the timeout, it throws an error. + */ +async function* withTimeout( + generator: AsyncIterable, + timeoutMs: number +): AsyncGenerator { + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error(`AI provider timed out after ${timeoutMs}ms`)), timeoutMs); + }); + + const iterator = generator[Symbol.asyncIterator](); + let done = false; + + while (!done) { + const result = await Promise.race([iterator.next(), timeoutPromise]); + if (result.done) { + done = true; + } else { + yield result.value; + } + } +} + +/** + * Get the effective system prompt for commit message generation. + * Uses custom prompt from settings if enabled, otherwise falls back to default. + */ +async function getSystemPrompt(settingsService?: SettingsService): Promise { + const settings = await settingsService?.getGlobalSettings(); + const prompts = mergeCommitMessagePrompts(settings?.promptCustomization?.commitMessage); + return prompts.systemPrompt; +} + +interface GenerateCommitMessageRequestBody { + worktreePath: string; +} + +interface GenerateCommitMessageSuccessResponse { + success: true; + message: string; +} + +interface GenerateCommitMessageErrorResponse { + success: false; + error: string; +} + +async function extractTextFromStream( + stream: AsyncIterable<{ + type: string; + subtype?: string; + result?: string; + message?: { + content?: Array<{ type: string; text?: string }>; + }; + }> +): Promise { + let responseText = ''; + + for await (const msg of stream) { + if (msg.type === 'assistant' && msg.message?.content) { + for (const block of msg.message.content) { + if (block.type === 'text' && block.text) { + responseText += block.text; + } + } + } else if (msg.type === 'result' && msg.subtype === 'success') { + responseText = msg.result || responseText; + } + } + + return responseText; +} + +export function createGenerateCommitMessageHandler( + settingsService?: SettingsService +): (req: Request, res: Response) => Promise { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath } = req.body as GenerateCommitMessageRequestBody; + + if (!worktreePath || typeof worktreePath !== 'string') { + const response: GenerateCommitMessageErrorResponse = { + success: false, + error: 'worktreePath is required and must be a string', + }; + res.status(400).json(response); + return; + } + + // Validate that the directory exists + if (!existsSync(worktreePath)) { + const response: GenerateCommitMessageErrorResponse = { + success: false, + error: 'worktreePath does not exist', + }; + res.status(400).json(response); + return; + } + + // Validate that it's a git repository (check for .git folder or file for worktrees) + const gitPath = join(worktreePath, '.git'); + if (!existsSync(gitPath)) { + const response: GenerateCommitMessageErrorResponse = { + success: false, + error: 'worktreePath is not a git repository', + }; + res.status(400).json(response); + return; + } + + logger.info(`Generating commit message for worktree: ${worktreePath}`); + + // Get git diff of staged and unstaged changes + let diff = ''; + try { + // First try to get staged changes + const { stdout: stagedDiff } = await execAsync('git diff --cached', { + cwd: worktreePath, + maxBuffer: 1024 * 1024 * 5, // 5MB buffer + }); + + // If no staged changes, get unstaged changes + if (!stagedDiff.trim()) { + const { stdout: unstagedDiff } = await execAsync('git diff', { + cwd: worktreePath, + maxBuffer: 1024 * 1024 * 5, // 5MB buffer + }); + diff = unstagedDiff; + } else { + diff = stagedDiff; + } + } catch (error) { + logger.error('Failed to get git diff:', error); + const response: GenerateCommitMessageErrorResponse = { + success: false, + error: 'Failed to get git changes', + }; + res.status(500).json(response); + return; + } + + if (!diff.trim()) { + const response: GenerateCommitMessageErrorResponse = { + success: false, + error: 'No changes to commit', + }; + res.status(400).json(response); + return; + } + + // Truncate diff if too long (keep first 10000 characters to avoid token limits) + const truncatedDiff = + diff.length > 10000 ? diff.substring(0, 10000) + '\n\n[... diff truncated ...]' : diff; + + const userPrompt = `Generate a commit message for these changes:\n\n\`\`\`diff\n${truncatedDiff}\n\`\`\``; + + // Get model from phase settings + const settings = await settingsService?.getGlobalSettings(); + const phaseModelEntry = + settings?.phaseModels?.commitMessageModel || DEFAULT_PHASE_MODELS.commitMessageModel; + const { model } = resolvePhaseModel(phaseModelEntry); + + logger.info(`Using model for commit message: ${model}`); + + // Get the effective system prompt (custom or default) + const systemPrompt = await getSystemPrompt(settingsService); + + let message: string; + + // Route to appropriate provider based on model type + if (isCursorModel(model)) { + // Use Cursor provider for Cursor models + logger.info(`Using Cursor provider for model: ${model}`); + + const provider = ProviderFactory.getProviderForModel(model); + const bareModel = stripProviderPrefix(model); + + const cursorPrompt = `${systemPrompt}\n\n${userPrompt}`; + + let responseText = ''; + const cursorStream = provider.executeQuery({ + prompt: cursorPrompt, + model: bareModel, + cwd: worktreePath, + maxTurns: 1, + allowedTools: [], + readOnly: true, + }); + + // Wrap with timeout to prevent indefinite hangs + for await (const msg of withTimeout(cursorStream, AI_TIMEOUT_MS)) { + if (msg.type === 'assistant' && msg.message?.content) { + for (const block of msg.message.content) { + if (block.type === 'text' && block.text) { + responseText += block.text; + } + } + } + } + + message = responseText.trim(); + } else { + // Use Claude SDK for Claude models + const stream = query({ + prompt: userPrompt, + options: { + model, + systemPrompt, + maxTurns: 1, + allowedTools: [], + permissionMode: 'default', + }, + }); + + // Wrap with timeout to prevent indefinite hangs + message = await extractTextFromStream(withTimeout(stream, AI_TIMEOUT_MS)); + } + + if (!message || message.trim().length === 0) { + logger.warn('Received empty response from model'); + const response: GenerateCommitMessageErrorResponse = { + success: false, + error: 'Failed to generate commit message - empty response', + }; + res.status(500).json(response); + return; + } + + logger.info(`Generated commit message: ${message.trim().substring(0, 100)}...`); + + const response: GenerateCommitMessageSuccessResponse = { + success: true, + message: message.trim(), + }; + res.json(response); + } catch (error) { + logError(error, 'Generate commit message failed'); + const response: GenerateCommitMessageErrorResponse = { + success: false, + error: getErrorMessage(error), + }; + res.status(500).json(response); + } + }; +} diff --git a/apps/server/src/routes/worktree/routes/list-branches.ts b/apps/server/src/routes/worktree/routes/list-branches.ts index dc7d7d6c..c6db10fc 100644 --- a/apps/server/src/routes/worktree/routes/list-branches.ts +++ b/apps/server/src/routes/worktree/routes/list-branches.ts @@ -1,5 +1,5 @@ /** - * POST /list-branches endpoint - List all local branches + * POST /list-branches endpoint - List all local branches and optionally remote branches * * Note: Git repository validation (isGitRepo, hasCommits) is handled by * the requireValidWorktree middleware in index.ts @@ -21,8 +21,9 @@ interface BranchInfo { export function createListBranchesHandler() { return async (req: Request, res: Response): Promise => { try { - const { worktreePath } = req.body as { + const { worktreePath, includeRemote = false } = req.body as { worktreePath: string; + includeRemote?: boolean; }; if (!worktreePath) { @@ -60,6 +61,55 @@ export function createListBranchesHandler() { }; }); + // Fetch remote branches if requested + if (includeRemote) { + try { + // Fetch latest remote refs (silently, don't fail if offline) + try { + await execAsync('git fetch --all --quiet', { + cwd: worktreePath, + timeout: 10000, // 10 second timeout + }); + } catch { + // Ignore fetch errors - we'll use cached remote refs + } + + // List remote branches + const { stdout: remoteBranchesOutput } = await execAsync( + 'git branch -r --format="%(refname:short)"', + { cwd: worktreePath } + ); + + const localBranchNames = new Set(branches.map((b) => b.name)); + + remoteBranchesOutput + .trim() + .split('\n') + .filter((b) => b.trim()) + .forEach((name) => { + // Remove any surrounding quotes + const cleanName = name.trim().replace(/^['"]|['"]$/g, ''); + // Skip HEAD pointers like "origin/HEAD" + if (cleanName.includes('/HEAD')) return; + + // Only add remote branches if a branch with the exact same name isn't already + // in the list. This avoids duplicates if a local branch is named like a remote one. + // Note: We intentionally include remote branches even when a local branch with the + // same base name exists (e.g., show "origin/main" even if local "main" exists), + // since users need to select remote branches as PR base targets. + if (!localBranchNames.has(cleanName)) { + branches.push({ + name: cleanName, // Keep full name like "origin/main" + isCurrent: false, + isRemote: true, + }); + } + }); + } catch { + // Ignore errors fetching remote branches - return local branches only + } + } + // Get ahead/behind count for current branch let aheadCount = 0; let behindCount = 0; diff --git a/apps/server/src/routes/worktree/routes/list.ts b/apps/server/src/routes/worktree/routes/list.ts index bc70a341..a7c12f98 100644 --- a/apps/server/src/routes/worktree/routes/list.ts +++ b/apps/server/src/routes/worktree/routes/list.ts @@ -13,7 +13,7 @@ import { promisify } from 'util'; import path from 'path'; import * as secureFs from '../../../lib/secure-fs.js'; import { isGitRepo } from '@automaker/git-utils'; -import { getErrorMessage, logError, normalizePath } from '../common.js'; +import { getErrorMessage, logError, normalizePath, execEnv, isGhCliAvailable } from '../common.js'; import { readAllWorktreeMetadata, type WorktreePRInfo } from '../../../lib/worktree-metadata.js'; import { createLogger } from '@automaker/utils'; @@ -121,6 +121,52 @@ async function scanWorktreesDirectory( return discovered; } +/** + * Fetch open PRs from GitHub and create a map of branch name to PR info. + * This allows detecting PRs that were created outside the app. + */ +async function fetchGitHubPRs(projectPath: string): Promise> { + const prMap = new Map(); + + try { + // Check if gh CLI is available + const ghAvailable = await isGhCliAvailable(); + if (!ghAvailable) { + return prMap; + } + + // Fetch open PRs from GitHub + const { stdout } = await execAsync( + 'gh pr list --state open --json number,title,url,state,headRefName,createdAt --limit 1000', + { cwd: projectPath, env: execEnv, timeout: 15000 } + ); + + const prs = JSON.parse(stdout || '[]') as Array<{ + number: number; + title: string; + url: string; + state: string; + headRefName: string; + createdAt: string; + }>; + + for (const pr of prs) { + prMap.set(pr.headRefName, { + number: pr.number, + url: pr.url, + title: pr.title, + state: pr.state, + createdAt: pr.createdAt, + }); + } + } catch (error) { + // Silently fail - PR detection is optional + logger.warn(`Failed to fetch GitHub PRs: ${getErrorMessage(error)}`); + } + + return prMap; +} + export function createListHandler() { return async (req: Request, res: Response): Promise => { try { @@ -241,11 +287,23 @@ export function createListHandler() { } } - // Add PR info from metadata for each worktree + // Add PR info from metadata or GitHub for each worktree + // Only fetch GitHub PRs if includeDetails is requested (performance optimization) + const githubPRs = includeDetails + ? await fetchGitHubPRs(projectPath) + : new Map(); + for (const worktree of worktrees) { const metadata = allMetadata.get(worktree.branch); if (metadata?.pr) { + // Use stored metadata (more complete info) worktree.pr = metadata.pr; + } else if (includeDetails) { + // Fall back to GitHub PR detection only when includeDetails is requested + const githubPR = githubPRs.get(worktree.branch); + if (githubPR) { + worktree.pr = githubPR; + } } } diff --git a/apps/server/src/routes/worktree/routes/merge.ts b/apps/server/src/routes/worktree/routes/merge.ts index ab4e0c17..69f120b8 100644 --- a/apps/server/src/routes/worktree/routes/merge.ts +++ b/apps/server/src/routes/worktree/routes/merge.ts @@ -8,7 +8,6 @@ import type { Request, Response } from 'express'; import { exec } from 'child_process'; import { promisify } from 'util'; -import path from 'path'; import { getErrorMessage, logError } from '../common.js'; const execAsync = promisify(exec); @@ -16,28 +15,31 @@ const execAsync = promisify(exec); export function createMergeHandler() { return async (req: Request, res: Response): Promise => { try { - const { projectPath, featureId, options } = req.body as { + const { projectPath, branchName, worktreePath, options } = req.body as { projectPath: string; - featureId: string; + branchName: string; + worktreePath: string; options?: { squash?: boolean; message?: string }; }; - if (!projectPath || !featureId) { + if (!projectPath || !branchName || !worktreePath) { res.status(400).json({ success: false, - error: 'projectPath and featureId required', + error: 'projectPath, branchName, and worktreePath are required', }); return; } - const branchName = `feature/${featureId}`; - // Git worktrees are stored in project directory - const worktreePath = path.join(projectPath, '.worktrees', featureId); - - // Get current branch - const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { - cwd: projectPath, - }); + // Validate branch exists + try { + await execAsync(`git rev-parse --verify ${branchName}`, { cwd: projectPath }); + } catch { + res.status(400).json({ + success: false, + error: `Branch "${branchName}" does not exist`, + }); + return; + } // Merge the feature branch const mergeCmd = options?.squash diff --git a/apps/server/src/services/claude-usage-service.ts b/apps/server/src/services/claude-usage-service.ts index 64ace35d..64dceb6a 100644 --- a/apps/server/src/services/claude-usage-service.ts +++ b/apps/server/src/services/claude-usage-service.ts @@ -49,13 +49,11 @@ export class ClaudeUsageService { /** * Execute the claude /usage command and return the output - * Uses platform-specific PTY implementation + * Uses node-pty on all platforms for consistency */ private executeClaudeUsageCommand(): Promise { - if (this.isWindows || this.isLinux) { - return this.executeClaudeUsageCommandPty(); - } - return this.executeClaudeUsageCommandMac(); + // Use node-pty on all platforms - it's more reliable than expect on macOS + return this.executeClaudeUsageCommandPty(); } /** @@ -67,24 +65,36 @@ export class ClaudeUsageService { let stderr = ''; let settled = false; - // Use a simple working directory (home or tmp) - const workingDirectory = process.env.HOME || '/tmp'; + // Use current working directory - likely already trusted by Claude CLI + const workingDirectory = process.cwd(); // Use 'expect' with an inline script to run claude /usage with a PTY - // Wait for "Current session" header, then wait for full output before exiting + // Running from cwd which should already be trusted const expectScript = ` - set timeout 20 + set timeout 30 spawn claude /usage + + # Wait for usage data or handle trust prompt if needed expect { - "Current session" { - sleep 2 - send "\\x1b" + -re "Ready to code|permission to work|Do you want to work" { + # Trust prompt appeared - send Enter to approve + sleep 1 + send "\\r" + exp_continue } - "Esc to cancel" { + "Current session" { + # Usage data appeared - wait for full output, then exit sleep 3 send "\\x1b" } - timeout {} + "% left" { + # Usage percentage appeared + sleep 3 + send "\\x1b" + } + timeout { + send "\\x1b" + } eof {} } expect eof @@ -158,14 +168,18 @@ export class ClaudeUsageService { let output = ''; let settled = false; let hasSeenUsageData = false; + let hasSeenTrustPrompt = false; - const workingDirectory = this.isWindows - ? process.env.USERPROFILE || os.homedir() || 'C:\\' - : process.env.HOME || os.homedir() || '/tmp'; + // Use current working directory (project dir) - most likely already trusted by Claude CLI + const workingDirectory = process.cwd(); // Use platform-appropriate shell and command const shell = this.isWindows ? 'cmd.exe' : '/bin/sh'; - const args = this.isWindows ? ['/c', 'claude', '/usage'] : ['-c', 'claude /usage']; + // Use --add-dir to whitelist the current directory and bypass the trust prompt + // We don't pass /usage here, we'll type it into the REPL + const args = this.isWindows + ? ['/c', 'claude', '--add-dir', workingDirectory] + : ['-c', `claude --add-dir "${workingDirectory}"`]; let ptyProcess: any = null; @@ -181,8 +195,6 @@ export class ClaudeUsageService { } as Record, }); } catch (spawnError) { - // pty.spawn() can throw synchronously if the native module fails to load - // or if PTY is not available in the current environment (e.g., containers without /dev/pts) const errorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError); logger.error('[executeClaudeUsageCommandPty] Failed to spawn PTY:', errorMessage); @@ -204,17 +216,60 @@ export class ClaudeUsageService { // Don't fail if we have data - return it instead if (output.includes('Current session')) { resolve(output); + } else if (hasSeenTrustPrompt) { + // Trust prompt was shown but we couldn't auto-approve it + reject( + new Error( + 'TRUST_PROMPT_PENDING: Claude CLI is waiting for folder permission. Please run "claude" in your terminal and approve access to continue.' + ) + ); } else { - reject(new Error('Command timed out')); + reject( + new Error( + 'The Claude CLI took too long to respond. This can happen if the CLI is waiting for a trust prompt or is otherwise busy.' + ) + ); } } - }, this.timeout); + }, 45000); // 45 second timeout + + let hasSentCommand = false; + let hasApprovedTrust = false; ptyProcess.onData((data: string) => { output += data; - // Check if we've seen the usage data (look for "Current session") - if (!hasSeenUsageData && output.includes('Current session')) { + // Strip ANSI codes for easier matching + // eslint-disable-next-line no-control-regex + const cleanOutput = output.replace(/\x1B\[[0-9;]*[A-Za-z]/g, ''); + + // Check for specific authentication/permission errors + if ( + cleanOutput.includes('OAuth token does not meet scope requirement') || + cleanOutput.includes('permission_error') || + cleanOutput.includes('token_expired') || + cleanOutput.includes('authentication_error') + ) { + if (!settled) { + settled = true; + if (ptyProcess && !ptyProcess.killed) { + ptyProcess.kill(); + } + reject( + new Error( + "Claude CLI authentication issue. Please run 'claude logout' and then 'claude login' in your terminal to refresh permissions." + ) + ); + } + return; + } + + // Check if we've seen the usage data (look for "Current session" or the TUI Usage header) + if ( + !hasSeenUsageData && + (cleanOutput.includes('Current session') || + (cleanOutput.includes('Usage') && cleanOutput.includes('% left'))) + ) { hasSeenUsageData = true; // Wait for full output, then send escape to exit setTimeout(() => { @@ -228,16 +283,62 @@ export class ClaudeUsageService { } }, 2000); } - }, 2000); + }, 3000); + } + + // Handle Trust Dialog - multiple variants: + // - "Do you want to work in this folder?" + // - "Ready to code here?" / "I'll need permission to work with your files" + // Since we are running in cwd (project dir), it is safe to approve. + if ( + !hasApprovedTrust && + (cleanOutput.includes('Do you want to work in this folder?') || + cleanOutput.includes('Ready to code here') || + cleanOutput.includes('permission to work with your files')) + ) { + hasApprovedTrust = true; + hasSeenTrustPrompt = true; + // Wait a tiny bit to ensure prompt is ready, then send Enter + setTimeout(() => { + if (!settled && ptyProcess && !ptyProcess.killed) { + ptyProcess.write('\r'); + } + }, 1000); + } + + // Detect REPL prompt and send /usage command + if ( + !hasSentCommand && + (cleanOutput.includes('❯') || cleanOutput.includes('? for shortcuts')) + ) { + hasSentCommand = true; + // Wait for REPL to fully settle + setTimeout(() => { + if (!settled && ptyProcess && !ptyProcess.killed) { + // Send command with carriage return + ptyProcess.write('/usage\r'); + + // Send another enter after 1 second to confirm selection if autocomplete menu appeared + setTimeout(() => { + if (!settled && ptyProcess && !ptyProcess.killed) { + ptyProcess.write('\r'); + } + }, 1200); + } + }, 1500); } // Fallback: if we see "Esc to cancel" but haven't seen usage data yet - if (!hasSeenUsageData && output.includes('Esc to cancel')) { + if ( + !hasSeenUsageData && + cleanOutput.includes('Esc to cancel') && + !cleanOutput.includes('Do you want to work in this folder?') + ) { setTimeout(() => { if (!settled && ptyProcess && !ptyProcess.killed) { ptyProcess.write('\x1b'); // Send escape key } - }, 3000); + }, 5000); } }); @@ -246,8 +347,11 @@ export class ClaudeUsageService { if (settled) return; settled = true; - // Check for authentication errors in output - if (output.includes('token_expired') || output.includes('authentication_error')) { + if ( + output.includes('token_expired') || + output.includes('authentication_error') || + output.includes('permission_error') + ) { reject(new Error("Authentication required - please run 'claude login'")); return; } diff --git a/apps/server/src/services/dev-server-service.ts b/apps/server/src/services/dev-server-service.ts index cac27e92..5187c0c8 100644 --- a/apps/server/src/services/dev-server-service.ts +++ b/apps/server/src/services/dev-server-service.ts @@ -12,24 +12,123 @@ import * as secureFs from '../lib/secure-fs.js'; import path from 'path'; import net from 'net'; import { createLogger } from '@automaker/utils'; +import type { EventEmitter } from '../lib/events.js'; const logger = createLogger('DevServerService'); +// Maximum scrollback buffer size (characters) - matches TerminalService pattern +const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per dev server + +// Throttle output to prevent overwhelming WebSocket under heavy load +const OUTPUT_THROTTLE_MS = 4; // ~250fps max update rate for responsive feedback +const OUTPUT_BATCH_SIZE = 4096; // Smaller batches for lower latency + export interface DevServerInfo { worktreePath: string; port: number; url: string; process: ChildProcess | null; startedAt: Date; + // Scrollback buffer for log history (replay on reconnect) + scrollbackBuffer: string; + // Pending output to be flushed to subscribers + outputBuffer: string; + // Throttle timer for batching output + flushTimeout: NodeJS.Timeout | null; + // Flag to indicate server is stopping (prevents output after stop) + stopping: boolean; } // Port allocation starts at 3001 to avoid conflicts with common dev ports const BASE_PORT = 3001; const MAX_PORT = 3099; // Safety limit +// Common livereload ports that may need cleanup when stopping dev servers +const LIVERELOAD_PORTS = [35729, 35730, 35731] as const; + class DevServerService { private runningServers: Map = new Map(); private allocatedPorts: Set = new Set(); + private emitter: EventEmitter | null = null; + + /** + * Set the event emitter for streaming log events + * Called during service initialization with the global event emitter + */ + setEventEmitter(emitter: EventEmitter): void { + this.emitter = emitter; + } + + /** + * Append data to scrollback buffer with size limit enforcement + * Evicts oldest data when buffer exceeds MAX_SCROLLBACK_SIZE + */ + private appendToScrollback(server: DevServerInfo, data: string): void { + server.scrollbackBuffer += data; + if (server.scrollbackBuffer.length > MAX_SCROLLBACK_SIZE) { + server.scrollbackBuffer = server.scrollbackBuffer.slice(-MAX_SCROLLBACK_SIZE); + } + } + + /** + * Flush buffered output to WebSocket subscribers + * Sends batched output to prevent overwhelming clients under heavy load + */ + private flushOutput(server: DevServerInfo): void { + // Skip flush if server is stopping or buffer is empty + if (server.stopping || server.outputBuffer.length === 0) { + server.flushTimeout = null; + return; + } + + let dataToSend = server.outputBuffer; + if (dataToSend.length > OUTPUT_BATCH_SIZE) { + // Send in batches if buffer is large + dataToSend = server.outputBuffer.slice(0, OUTPUT_BATCH_SIZE); + server.outputBuffer = server.outputBuffer.slice(OUTPUT_BATCH_SIZE); + // Schedule another flush for remaining data + server.flushTimeout = setTimeout(() => this.flushOutput(server), OUTPUT_THROTTLE_MS); + } else { + server.outputBuffer = ''; + server.flushTimeout = null; + } + + // Emit output event for WebSocket streaming + if (this.emitter) { + this.emitter.emit('dev-server:output', { + worktreePath: server.worktreePath, + content: dataToSend, + timestamp: new Date().toISOString(), + }); + } + } + + /** + * Handle incoming stdout/stderr data from dev server process + * Buffers data for scrollback replay and schedules throttled emission + */ + private handleProcessOutput(server: DevServerInfo, data: Buffer): void { + // Skip output if server is stopping + if (server.stopping) { + return; + } + + const content = data.toString(); + + // Append to scrollback buffer for replay on reconnect + this.appendToScrollback(server, content); + + // Buffer output for throttled live delivery + server.outputBuffer += content; + + // Schedule flush if not already scheduled + if (!server.flushTimeout) { + server.flushTimeout = setTimeout(() => this.flushOutput(server), OUTPUT_THROTTLE_MS); + } + + // Also log for debugging (existing behavior) + logger.debug(`[Port${server.port}] ${content.trim()}`); + } /** * Check if a port is available (not in use by system or by us) @@ -244,10 +343,9 @@ class DevServerService { // Reserve the port (port was already force-killed in findAvailablePort) this.allocatedPorts.add(port); - // Also kill common related ports (livereload uses 35729 by default) + // Also kill common related ports (livereload, etc.) // Some dev servers use fixed ports for HMR/livereload regardless of main port - const commonRelatedPorts = [35729, 35730, 35731]; - for (const relatedPort of commonRelatedPorts) { + for (const relatedPort of LIVERELOAD_PORTS) { this.killProcessOnPort(relatedPort); } @@ -259,9 +357,14 @@ class DevServerService { logger.debug(`Command: ${devCommand.cmd} ${devCommand.args.join(' ')} with PORT=${port}`); // Spawn the dev process with PORT environment variable + // FORCE_COLOR enables colored output even when not running in a TTY const env = { ...process.env, PORT: String(port), + FORCE_COLOR: '1', + // Some tools use these additional env vars for color detection + COLORTERM: 'truecolor', + TERM: 'xterm-256color', }; const devProcess = spawn(devCommand.cmd, devCommand.args, { @@ -274,32 +377,66 @@ class DevServerService { // Track if process failed early using object to work around TypeScript narrowing const status = { error: null as string | null, exited: false }; - // Log output for debugging + // Create server info early so we can reference it in handlers + // We'll add it to runningServers after verifying the process started successfully + const serverInfo: DevServerInfo = { + worktreePath, + port, + url: `http://localhost:${port}`, + process: devProcess, + startedAt: new Date(), + scrollbackBuffer: '', + outputBuffer: '', + flushTimeout: null, + stopping: false, + }; + + // Capture stdout with buffer management and event emission if (devProcess.stdout) { devProcess.stdout.on('data', (data: Buffer) => { - logger.debug(`[Port${port}] ${data.toString().trim()}`); + this.handleProcessOutput(serverInfo, data); }); } + // Capture stderr with buffer management and event emission if (devProcess.stderr) { devProcess.stderr.on('data', (data: Buffer) => { - const msg = data.toString().trim(); - logger.debug(`[Port${port}] ${msg}`); + this.handleProcessOutput(serverInfo, data); }); } + // Helper to clean up resources and emit stop event + const cleanupAndEmitStop = (exitCode: number | null, errorMessage?: string) => { + if (serverInfo.flushTimeout) { + clearTimeout(serverInfo.flushTimeout); + serverInfo.flushTimeout = null; + } + + // Emit stopped event (only if not already stopping - prevents duplicate events) + if (this.emitter && !serverInfo.stopping) { + this.emitter.emit('dev-server:stopped', { + worktreePath, + port, + exitCode, + error: errorMessage, + timestamp: new Date().toISOString(), + }); + } + + this.allocatedPorts.delete(port); + this.runningServers.delete(worktreePath); + }; + devProcess.on('error', (error) => { logger.error(`Process error:`, error); status.error = error.message; - this.allocatedPorts.delete(port); - this.runningServers.delete(worktreePath); + cleanupAndEmitStop(null, error.message); }); devProcess.on('exit', (code) => { logger.info(`Process for ${worktreePath} exited with code ${code}`); status.exited = true; - this.allocatedPorts.delete(port); - this.runningServers.delete(worktreePath); + cleanupAndEmitStop(code); }); // Wait a moment to see if the process fails immediately @@ -319,16 +456,19 @@ class DevServerService { }; } - const serverInfo: DevServerInfo = { - worktreePath, - port, - url: `http://localhost:${port}`, - process: devProcess, - startedAt: new Date(), - }; - + // Server started successfully - add to running servers map this.runningServers.set(worktreePath, serverInfo); + // Emit started event for WebSocket subscribers + if (this.emitter) { + this.emitter.emit('dev-server:started', { + worktreePath, + port, + url: serverInfo.url, + timestamp: new Date().toISOString(), + }); + } + return { success: true, result: { @@ -365,6 +505,28 @@ class DevServerService { logger.info(`Stopping dev server for ${worktreePath}`); + // Mark as stopping to prevent further output events + server.stopping = true; + + // Clean up flush timeout to prevent memory leaks + if (server.flushTimeout) { + clearTimeout(server.flushTimeout); + server.flushTimeout = null; + } + + // Clear any pending output buffer + server.outputBuffer = ''; + + // Emit stopped event immediately so UI updates right away + if (this.emitter) { + this.emitter.emit('dev-server:stopped', { + worktreePath, + port: server.port, + exitCode: null, // Will be populated by exit handler if process exits normally + timestamp: new Date().toISOString(), + }); + } + // Kill the process if (server.process && !server.process.killed) { server.process.kill('SIGTERM'); @@ -422,6 +584,41 @@ class DevServerService { return this.runningServers.get(worktreePath); } + /** + * Get buffered logs for a worktree's dev server + * Returns the scrollback buffer containing historical log output + * Used by the API to serve logs to clients on initial connection + */ + getServerLogs(worktreePath: string): { + success: boolean; + result?: { + worktreePath: string; + port: number; + logs: string; + startedAt: string; + }; + error?: string; + } { + const server = this.runningServers.get(worktreePath); + + if (!server) { + return { + success: false, + error: `No dev server running for worktree: ${worktreePath}`, + }; + } + + return { + success: true, + result: { + worktreePath: server.worktreePath, + port: server.port, + logs: server.scrollbackBuffer, + startedAt: server.startedAt.toISOString(), + }, + }; + } + /** * Get all allocated ports */ diff --git a/apps/server/tests/unit/providers/claude-provider.test.ts b/apps/server/tests/unit/providers/claude-provider.test.ts index f107c4f4..b3d2df79 100644 --- a/apps/server/tests/unit/providers/claude-provider.test.ts +++ b/apps/server/tests/unit/providers/claude-provider.test.ts @@ -12,6 +12,8 @@ describe('claude-provider.ts', () => { vi.clearAllMocks(); provider = new ClaudeProvider(); delete process.env.ANTHROPIC_API_KEY; + delete process.env.ANTHROPIC_BASE_URL; + delete process.env.ANTHROPIC_AUTH_TOKEN; }); describe('getName', () => { @@ -267,6 +269,93 @@ describe('claude-provider.ts', () => { }); }); + describe('environment variable passthrough', () => { + afterEach(() => { + delete process.env.ANTHROPIC_BASE_URL; + delete process.env.ANTHROPIC_AUTH_TOKEN; + }); + + it('should pass ANTHROPIC_BASE_URL to SDK env', async () => { + process.env.ANTHROPIC_BASE_URL = 'https://custom.example.com/v1'; + + vi.mocked(sdk.query).mockReturnValue( + (async function* () { + yield { type: 'text', text: 'test' }; + })() + ); + + const generator = provider.executeQuery({ + prompt: 'Test', + cwd: '/test', + }); + + await collectAsyncGenerator(generator); + + expect(sdk.query).toHaveBeenCalledWith({ + prompt: 'Test', + options: expect.objectContaining({ + env: expect.objectContaining({ + ANTHROPIC_BASE_URL: 'https://custom.example.com/v1', + }), + }), + }); + }); + + it('should pass ANTHROPIC_AUTH_TOKEN to SDK env', async () => { + process.env.ANTHROPIC_AUTH_TOKEN = 'custom-auth-token'; + + vi.mocked(sdk.query).mockReturnValue( + (async function* () { + yield { type: 'text', text: 'test' }; + })() + ); + + const generator = provider.executeQuery({ + prompt: 'Test', + cwd: '/test', + }); + + await collectAsyncGenerator(generator); + + expect(sdk.query).toHaveBeenCalledWith({ + prompt: 'Test', + options: expect.objectContaining({ + env: expect.objectContaining({ + ANTHROPIC_AUTH_TOKEN: 'custom-auth-token', + }), + }), + }); + }); + + it('should pass both custom endpoint vars together', async () => { + process.env.ANTHROPIC_BASE_URL = 'https://gateway.example.com'; + process.env.ANTHROPIC_AUTH_TOKEN = 'gateway-token'; + + vi.mocked(sdk.query).mockReturnValue( + (async function* () { + yield { type: 'text', text: 'test' }; + })() + ); + + const generator = provider.executeQuery({ + prompt: 'Test', + cwd: '/test', + }); + + await collectAsyncGenerator(generator); + + expect(sdk.query).toHaveBeenCalledWith({ + prompt: 'Test', + options: expect.objectContaining({ + env: expect.objectContaining({ + ANTHROPIC_BASE_URL: 'https://gateway.example.com', + ANTHROPIC_AUTH_TOKEN: 'gateway-token', + }), + }), + }); + }); + }); + describe('getAvailableModels', () => { it('should return 4 Claude models', () => { const models = provider.getAvailableModels(); diff --git a/apps/server/tests/unit/services/claude-usage-service.test.ts b/apps/server/tests/unit/services/claude-usage-service.test.ts index d16802f6..4b3f3c94 100644 --- a/apps/server/tests/unit/services/claude-usage-service.test.ts +++ b/apps/server/tests/unit/services/claude-usage-service.test.ts @@ -551,7 +551,7 @@ Resets in 2h expect(result.sessionPercentage).toBe(35); expect(pty.spawn).toHaveBeenCalledWith( 'cmd.exe', - ['/c', 'claude', '/usage'], + ['/c', 'claude', '--add-dir', 'C:\\Users\\testuser'], expect.any(Object) ); }); @@ -582,8 +582,8 @@ Resets in 2h // Simulate seeing usage data dataCallback!(mockOutput); - // Advance time to trigger escape key sending - vi.advanceTimersByTime(2100); + // Advance time to trigger escape key sending (impl uses 3000ms delay) + vi.advanceTimersByTime(3100); expect(mockPty.write).toHaveBeenCalledWith('\x1b'); @@ -614,9 +614,10 @@ Resets in 2h const promise = windowsService.fetchUsageData(); dataCallback!('authentication_error'); - exitCallback!({ exitCode: 1 }); - await expect(promise).rejects.toThrow('Authentication required'); + await expect(promise).rejects.toThrow( + "Claude CLI authentication issue. Please run 'claude logout' and then 'claude login' in your terminal to refresh permissions." + ); }); it('should handle timeout with no data on Windows', async () => { @@ -628,14 +629,18 @@ Resets in 2h onExit: vi.fn(), write: vi.fn(), kill: vi.fn(), + killed: false, }; vi.mocked(pty.spawn).mockReturnValue(mockPty as any); const promise = windowsService.fetchUsageData(); - vi.advanceTimersByTime(31000); + // Advance time past timeout (45 seconds) + vi.advanceTimersByTime(46000); - await expect(promise).rejects.toThrow('Command timed out'); + await expect(promise).rejects.toThrow( + 'The Claude CLI took too long to respond. This can happen if the CLI is waiting for a trust prompt or is otherwise busy.' + ); expect(mockPty.kill).toHaveBeenCalled(); vi.useRealTimers(); @@ -654,6 +659,7 @@ Resets in 2h onExit: vi.fn(), write: vi.fn(), kill: vi.fn(), + killed: false, }; vi.mocked(pty.spawn).mockReturnValue(mockPty as any); @@ -662,8 +668,8 @@ Resets in 2h // Simulate receiving usage data dataCallback!('Current session\n65% left\nResets in 2h'); - // Advance time past timeout (30 seconds) - vi.advanceTimersByTime(31000); + // Advance time past timeout (45 seconds) + vi.advanceTimersByTime(46000); // Should resolve with data instead of rejecting const result = await promise; @@ -686,6 +692,7 @@ Resets in 2h onExit: vi.fn(), write: vi.fn(), kill: vi.fn(), + killed: false, }; vi.mocked(pty.spawn).mockReturnValue(mockPty as any); @@ -694,8 +701,8 @@ Resets in 2h // Simulate seeing usage data dataCallback!('Current session\n65% left'); - // Advance 2s to trigger ESC - vi.advanceTimersByTime(2100); + // Advance 3s to trigger ESC (impl uses 3000ms delay) + vi.advanceTimersByTime(3100); expect(mockPty.write).toHaveBeenCalledWith('\x1b'); // Advance another 2s to trigger SIGTERM fallback diff --git a/apps/ui/package.json b/apps/ui/package.json index 384dc581..b28ad8c7 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -1,6 +1,6 @@ { "name": "@automaker/ui", - "version": "0.10.0", + "version": "0.11.0", "description": "An autonomous AI development studio that helps you build software faster using AI-powered agents", "homepage": "https://github.com/AutoMaker-Org/automaker", "repository": { diff --git a/apps/ui/src/app.tsx b/apps/ui/src/app.tsx index 31a71e85..c27cd5e7 100644 --- a/apps/ui/src/app.tsx +++ b/apps/ui/src/app.tsx @@ -5,6 +5,7 @@ import { router } from './utils/router'; 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 './styles/global.css'; import './styles/theme-imports'; @@ -24,8 +25,11 @@ export default function App() { useEffect(() => { if (import.meta.env.DEV) { const clearPerfEntries = () => { - performance.clearMarks(); - performance.clearMeasures(); + // Check if window.performance is available before calling its methods + if (window.performance) { + window.performance.clearMarks(); + window.performance.clearMeasures(); + } }; const interval = setInterval(clearPerfEntries, 5000); return () => clearInterval(interval); @@ -45,6 +49,9 @@ export default function App() { // Initialize Cursor CLI status at startup useCursorStatusInit(); + // Initialize Provider auth status at startup (for Claude/Codex usage display) + useProviderAuthInit(); + const handleSplashComplete = useCallback(() => { sessionStorage.setItem('automaker-splash-shown', 'true'); setShowSplash(false); diff --git a/apps/ui/src/components/claude-usage-popover.tsx b/apps/ui/src/components/claude-usage-popover.tsx index 227f16e1..d51e316c 100644 --- a/apps/ui/src/components/claude-usage-popover.tsx +++ b/apps/ui/src/components/claude-usage-popover.tsx @@ -11,6 +11,7 @@ import { useSetupStore } from '@/store/setup-store'; const ERROR_CODES = { API_BRIDGE_UNAVAILABLE: 'API_BRIDGE_UNAVAILABLE', AUTH_ERROR: 'AUTH_ERROR', + TRUST_PROMPT: 'TRUST_PROMPT', UNKNOWN: 'UNKNOWN', } as const; @@ -55,8 +56,12 @@ export function ClaudeUsagePopover() { } const data = await api.claude.getUsage(); if ('error' in data) { + // Detect trust prompt error + const isTrustPrompt = + data.error === 'Trust prompt pending' || + (data.message && data.message.includes('folder permission')); setError({ - code: ERROR_CODES.AUTH_ERROR, + code: isTrustPrompt ? ERROR_CODES.TRUST_PROMPT : ERROR_CODES.AUTH_ERROR, message: data.message || data.error, }); return; @@ -257,6 +262,11 @@ export function ClaudeUsagePopover() {

{error.code === ERROR_CODES.API_BRIDGE_UNAVAILABLE ? ( 'Ensure the Electron bridge is running or restart the app' + ) : error.code === ERROR_CODES.TRUST_PROMPT ? ( + <> + Run claude in your + terminal and approve access to continue + ) : ( <> Make sure Claude CLI is installed and authenticated via{' '} diff --git a/apps/ui/src/components/layout/project-switcher/components/icon-picker.tsx b/apps/ui/src/components/layout/project-switcher/components/icon-picker.tsx index 10947a51..31ce1d3d 100644 --- a/apps/ui/src/components/layout/project-switcher/components/icon-picker.tsx +++ b/apps/ui/src/components/layout/project-switcher/components/icon-picker.tsx @@ -10,43 +10,434 @@ interface IconPickerProps { onSelectIcon: (icon: string | null) => void; } -// Popular project-related icons +// Comprehensive list of project-related icons from Lucide +// Organized by category for easier browsing const POPULAR_ICONS = [ + // Folders & Files 'Folder', 'FolderOpen', 'FolderCode', 'FolderGit', 'FolderKanban', - 'Package', - 'Box', - 'Boxes', + 'FolderTree', + 'FolderInput', + 'FolderOutput', + 'FolderPlus', + 'File', + 'FileCode', + 'FileText', + 'FileJson', + 'FileImage', + 'FileVideo', + 'FileAudio', + 'FileSpreadsheet', + 'Files', + 'Archive', + + // Code & Development 'Code', 'Code2', 'Braces', - 'FileCode', + 'Brackets', 'Terminal', - 'Globe', - 'Server', - 'Database', + 'TerminalSquare', + 'Command', + 'GitBranch', + 'GitCommit', + 'GitMerge', + 'GitPullRequest', + 'GitCompare', + 'GitFork', + 'GitHub', + 'Gitlab', + 'Bitbucket', + 'Vscode', + + // Packages & Containers + 'Package', + 'PackageSearch', + 'PackageCheck', + 'PackageX', + 'Box', + 'Boxes', + 'Container', + + // UI & Design 'Layout', + 'LayoutGrid', + 'LayoutList', + 'LayoutDashboard', + 'LayoutTemplate', 'Layers', + 'Layers2', + 'Layers3', 'Blocks', 'Component', - 'Puzzle', + 'Palette', + 'Paintbrush', + 'Brush', + 'PenTool', + 'Ruler', + 'Grid', + 'Grid3x3', + 'Square', + 'RectangleHorizontal', + 'RectangleVertical', + 'Circle', + + // Tools & Settings 'Cog', + 'Settings', + 'Settings2', 'Wrench', 'Hammer', + 'Screwdriver', + 'WrenchIcon', + 'Tool', + 'ScrewdriverWrench', + 'Sliders', + 'SlidersHorizontal', + 'Filter', + 'FilterX', + + // Technology & Infrastructure + 'Server', + 'ServerCrash', + 'ServerCog', + 'Database', + 'DatabaseBackup', + 'CloudUpload', + 'CloudDownload', + 'CloudOff', + 'Globe', + 'Globe2', + 'Network', + 'Wifi', + 'WifiOff', + 'Router', + 'Cpu', + 'MemoryStick', + 'HardDrive', + 'HardDriveIcon', + 'CircuitBoard', + 'Microchip', + 'Monitor', + 'MonitorSpeaker', + 'Laptop', + 'Smartphone', + 'Tablet', + 'Mouse', + 'Keyboard', + 'Headphones', + 'Printer', + 'Scanner', + + // Workflow & Process + 'Workflow', 'Zap', 'Rocket', - 'Sparkles', - 'Star', - 'Heart', + 'Flame', + 'Lightning', + 'Bolt', + 'Target', + 'Flag', + 'FlagTriangleRight', + 'CheckCircle', + 'CheckCircle2', + 'XCircle', + 'AlertCircle', + 'Info', + 'HelpCircle', + 'Clock', + 'Timer', + 'Stopwatch', + 'Calendar', + 'CalendarDays', + 'CalendarCheck', + 'CalendarClock', + + // Security & Access 'Shield', + 'ShieldCheck', + 'ShieldAlert', + 'ShieldOff', 'Lock', + 'Unlock', 'Key', - 'Cpu', - 'CircuitBoard', - 'Workflow', + 'KeyRound', + 'Eye', + 'EyeOff', + 'User', + 'Users', + 'UserCheck', + 'UserX', + 'UserPlus', + 'UserCog', + + // Business & Finance + 'Briefcase', + 'Building', + 'Building2', + 'Store', + 'ShoppingCart', + 'ShoppingBag', + 'CreditCard', + 'Wallet', + 'DollarSign', + 'Euro', + 'PoundSterling', + 'Yen', + 'Coins', + 'Receipt', + 'ChartBar', + 'ChartLine', + 'ChartPie', + 'TrendingUp', + 'TrendingDown', + 'Activity', + 'BarChart', + 'LineChart', + 'PieChart', + + // Communication & Media + 'MessageSquare', + 'MessageCircle', + 'Mail', + 'MailOpen', + 'Send', + 'Inbox', + 'Phone', + 'PhoneCall', + 'Video', + 'VideoOff', + 'Camera', + 'CameraOff', + 'Image', + 'ImageIcon', + 'Film', + 'Music', + 'Mic', + 'MicOff', + 'Volume', + 'Volume2', + 'VolumeX', + 'Radio', + 'Podcast', + + // Social & Community + 'Heart', + 'HeartHandshake', + 'Star', + 'StarOff', + 'ThumbsUp', + 'ThumbsDown', + 'Share', + 'Share2', + 'Link', + 'Link2', + 'ExternalLink', + 'AtSign', + 'Hash', + 'Hashtag', + 'Tag', + 'Tags', + + // Navigation & Location + 'Compass', + 'Map', + 'MapPin', + 'Navigation', + 'Navigation2', + 'Route', + 'Plane', + 'Car', + 'Bike', + 'Ship', + 'Train', + 'Bus', + + // Science & Education + 'FlaskConical', + 'FlaskRound', + 'Beaker', + 'TestTube', + 'TestTube2', + 'Microscope', + 'Atom', + 'Brain', + 'GraduationCap', + 'Book', + 'BookOpen', + 'BookMarked', + 'Library', + 'School', + 'University', + + // Food & Health + 'Coffee', + 'Utensils', + 'UtensilsCrossed', + 'Apple', + 'Cherry', + 'Cookie', + 'Cake', + 'Pizza', + 'Beer', + 'Wine', + 'HeartPulse', + 'Dumbbell', + 'Running', + + // Nature & Weather + 'Tree', + 'TreePine', + 'Leaf', + 'Flower', + 'Flower2', + 'Sun', + 'Moon', + 'CloudRain', + 'CloudSnow', + 'CloudLightning', + 'Droplet', + 'Wind', + 'Snowflake', + 'Umbrella', + + // Objects & Symbols + 'Puzzle', + 'PuzzleIcon', + 'Gamepad', + 'Gamepad2', + 'Dice', + 'Dice1', + 'Dice6', + 'Gem', + 'Crown', + 'Trophy', + 'Medal', + 'Award', + 'Gift', + 'GiftIcon', + 'Bell', + 'BellOff', + 'BellRing', + 'Home', + 'House', + 'DoorOpen', + 'DoorClosed', + 'Window', + 'Lightbulb', + 'LightbulbOff', + 'Candle', + 'Flashlight', + 'FlashlightOff', + 'Battery', + 'BatteryFull', + 'BatteryLow', + 'BatteryCharging', + 'Plug', + 'PlugZap', + 'Power', + 'PowerOff', + + // Arrows & Directions + 'ArrowRight', + 'ArrowLeft', + 'ArrowUp', + 'ArrowDown', + 'ArrowUpRight', + 'ArrowDownRight', + 'ArrowDownLeft', + 'ArrowUpLeft', + 'ChevronRight', + 'ChevronLeft', + 'ChevronUp', + 'ChevronDown', + 'Move', + 'MoveUp', + 'MoveDown', + 'MoveLeft', + 'MoveRight', + 'RotateCw', + 'RotateCcw', + 'RefreshCw', + 'RefreshCcw', + + // Shapes & Symbols + 'Diamond', + 'Pentagon', + 'Cross', + 'Plus', + 'Minus', + 'X', + 'Check', + 'Divide', + 'Equal', + 'Infinity', + 'Percent', + + // Miscellaneous + 'Bot', + 'Wand', + 'Wand2', + 'Magic', + 'Stars', + 'Comet', + 'Satellite', + 'SatelliteDish', + 'Radar', + 'RadarIcon', + 'Scan', + 'ScanLine', + 'QrCode', + 'Barcode', + 'ScanSearch', + 'Search', + 'SearchX', + 'ZoomIn', + 'ZoomOut', + 'Maximize', + 'Minimize', + 'Maximize2', + 'Minimize2', + 'Expand', + 'Shrink', + 'Copy', + 'CopyCheck', + 'Clipboard', + 'ClipboardCheck', + 'ClipboardCopy', + 'ClipboardList', + 'ClipboardPaste', + 'Scissors', + 'Cut', + 'FileEdit', + 'Pen', + 'Pencil', + 'Eraser', + 'Trash', + 'Trash2', + 'Delete', + 'ArchiveRestore', + 'Download', + 'Upload', + 'Save', + 'SaveAll', + 'FilePlus', + 'FileMinus', + 'FileX', + 'FileCheck', + 'FileQuestion', + 'FileWarning', + 'FileSearch', + 'FolderSearch', + 'FolderX', + 'FolderCheck', + 'FolderMinus', + 'FolderSync', + 'FolderUp', + 'FolderDown', ]; export function IconPicker({ selectedIcon, onSelectIcon }: IconPickerProps) { @@ -94,7 +485,7 @@ export function IconPicker({ selectedIcon, onSelectIcon }: IconPickerProps) { )} {/* Icons Grid */} - +

{filteredIcons.map((iconName) => { const IconComponent = getIconComponent(iconName); diff --git a/apps/ui/src/components/layout/project-switcher/components/project-context-menu.tsx b/apps/ui/src/components/layout/project-switcher/components/project-context-menu.tsx index 84b6ea9a..39a7b652 100644 --- a/apps/ui/src/components/layout/project-switcher/components/project-context-menu.tsx +++ b/apps/ui/src/components/layout/project-switcher/components/project-context-menu.tsx @@ -1,8 +1,105 @@ -import { useEffect, useRef } from 'react'; -import { Edit2, Trash2 } from 'lucide-react'; +import { useEffect, useRef, useState, memo } from 'react'; +import type { LucideIcon } from 'lucide-react'; +import { Edit2, Trash2, Palette, ChevronRight, Moon, Sun, Monitor } from 'lucide-react'; import { cn } from '@/lib/utils'; -import { useAppStore } from '@/store/app-store'; +import { type ThemeMode, useAppStore } from '@/store/app-store'; +import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import type { Project } from '@/lib/electron'; +import { PROJECT_DARK_THEMES, PROJECT_LIGHT_THEMES } from '@/components/layout/sidebar/constants'; +import { useThemePreview } from '@/components/layout/sidebar/hooks'; + +// Constants for z-index values +const Z_INDEX = { + CONTEXT_MENU: 100, + THEME_SUBMENU: 101, +} as const; + +// Theme option type - using ThemeMode for type safety +interface ThemeOption { + value: ThemeMode; + label: string; + icon: LucideIcon; + color: string; +} + +// Reusable theme button component to avoid duplication (DRY principle) +interface ThemeButtonProps { + option: ThemeOption; + isSelected: boolean; + onPointerEnter: () => void; + onPointerLeave: (e: React.PointerEvent) => void; + onClick: () => void; +} + +const ThemeButton = memo(function ThemeButton({ + option, + isSelected, + onPointerEnter, + onPointerLeave, + onClick, +}: ThemeButtonProps) { + const Icon = option.icon; + return ( + + ); +}); + +// Reusable theme column component +interface ThemeColumnProps { + title: string; + icon: LucideIcon; + themes: ThemeOption[]; + selectedTheme: ThemeMode | null; + onPreviewEnter: (value: ThemeMode) => void; + onPreviewLeave: (e: React.PointerEvent) => void; + onSelect: (value: ThemeMode) => void; +} + +const ThemeColumn = memo(function ThemeColumn({ + title, + icon: Icon, + themes, + selectedTheme, + onPreviewEnter, + onPreviewLeave, + onSelect, +}: ThemeColumnProps) { + return ( +
+
+ + {title} +
+
+ {themes.map((option) => ( + onPreviewEnter(option.value)} + onPointerLeave={onPreviewLeave} + onClick={() => onSelect(option.value)} + /> + ))} +
+
+ ); +}); interface ProjectContextMenuProps { project: Project; @@ -18,17 +115,30 @@ export function ProjectContextMenu({ onEdit, }: ProjectContextMenuProps) { const menuRef = useRef(null); - const { moveProjectToTrash } = useAppStore(); + const { + moveProjectToTrash, + theme: globalTheme, + setTheme, + setProjectTheme, + setPreviewTheme, + } = useAppStore(); + const [showRemoveDialog, setShowRemoveDialog] = useState(false); + const [showThemeSubmenu, setShowThemeSubmenu] = useState(false); + const themeSubmenuRef = useRef(null); + + const { handlePreviewEnter, handlePreviewLeave } = useThemePreview({ setPreviewTheme }); useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + setPreviewTheme(null); onClose(); } }; const handleEscape = (event: KeyboardEvent) => { if (event.key === 'Escape') { + setPreviewTheme(null); onClose(); } }; @@ -40,64 +150,184 @@ export function ProjectContextMenu({ document.removeEventListener('mousedown', handleClickOutside); document.removeEventListener('keydown', handleEscape); }; - }, [onClose]); + }, [onClose, setPreviewTheme]); const handleEdit = () => { onEdit(project); }; const handleRemove = () => { - if (confirm(`Remove "${project.name}" from the project list?`)) { - moveProjectToTrash(project.id); + setShowRemoveDialog(true); + }; + + const handleThemeSelect = (value: ThemeMode | '') => { + setPreviewTheme(null); + if (value !== '') { + setTheme(value); + } else { + setTheme(globalTheme); } + setProjectTheme(project.id, value === '' ? null : value); + setShowThemeSubmenu(false); + }; + + const handleConfirmRemove = () => { + moveProjectToTrash(project.id); onClose(); }; return ( -
-
- + <> +
+
+ - + {/* Theme Submenu Trigger */} +
setShowThemeSubmenu(true)} + onMouseLeave={() => { + setShowThemeSubmenu(false); + setPreviewTheme(null); + }} + > + + + {/* Theme Submenu */} + {showThemeSubmenu && ( +
+
+ {/* Use Global Option */} + + +
+ + {/* Two Column Layout - Using reusable ThemeColumn component */} +
+ + +
+
+
+ )} +
+ + +
-
+ + + ); } diff --git a/apps/ui/src/components/layout/project-switcher/project-switcher.tsx b/apps/ui/src/components/layout/project-switcher/project-switcher.tsx index e6080ab4..442413fd 100644 --- a/apps/ui/src/components/layout/project-switcher/project-switcher.tsx +++ b/apps/ui/src/components/layout/project-switcher/project-switcher.tsx @@ -1,8 +1,8 @@ import { useState, useCallback, useEffect } from 'react'; -import { Plus, Bug } from 'lucide-react'; +import { Plus, Bug, FolderOpen } from 'lucide-react'; import { useNavigate } from '@tanstack/react-router'; import { cn } from '@/lib/utils'; -import { useAppStore } from '@/store/app-store'; +import { useAppStore, type ThemeMode } from '@/store/app-store'; import { useOSDetection } from '@/hooks/use-os-detection'; import { ProjectSwitcherItem } from './components/project-switcher-item'; import { ProjectContextMenu } from './components/project-context-menu'; @@ -12,6 +12,9 @@ import { OnboardingDialog } from '@/components/layout/sidebar/dialogs'; import { useProjectCreation, useProjectTheme } from '@/components/layout/sidebar/hooks'; import type { Project } from '@/lib/electron'; import { getElectronAPI } from '@/lib/electron'; +import { initializeProject, hasAppSpec, hasAutomakerDir } from '@/lib/project-init'; +import { toast } from 'sonner'; +import { CreateSpecDialog } from '@/components/views/spec-view/dialogs'; function getOSAbbreviation(os: string): string { switch (os) { @@ -34,6 +37,8 @@ export function ProjectSwitcher() { setCurrentProject, trashedProjects, upsertAndSetCurrentProject, + specCreatingForProject, + setSpecCreatingForProject, } = useAppStore(); const [contextMenuProject, setContextMenuProject] = useState(null); const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>( @@ -41,6 +46,17 @@ export function ProjectSwitcher() { ); const [editDialogProject, setEditDialogProject] = useState(null); + // Setup dialog state for opening existing projects + const [showSetupDialog, setShowSetupDialog] = useState(false); + const [setupProjectPath, setSetupProjectPath] = useState(null); + const [projectOverview, setProjectOverview] = useState(''); + const [generateFeatures, setGenerateFeatures] = useState(true); + const [analyzeProject, setAnalyzeProject] = useState(true); + const [featureCount, setFeatureCount] = useState(5); + + // Derive isCreatingSpec from store state + const isCreatingSpec = specCreatingForProject !== null; + // Version info const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0'; const { os } = useOSDetection(); @@ -108,6 +124,109 @@ export function ProjectSwitcher() { api.openExternalLink('https://github.com/AutoMaker-Org/automaker/issues'); }, []); + /** + * Opens the system folder selection dialog and initializes the selected project. + */ + const handleOpenFolder = useCallback(async () => { + const api = getElectronAPI(); + const result = await api.openDirectory(); + + if (!result.canceled && result.filePaths[0]) { + const path = result.filePaths[0]; + // Extract folder name from path (works on both Windows and Mac/Linux) + const name = path.split(/[/\\]/).filter(Boolean).pop() || 'Untitled Project'; + + try { + // Check if this is a brand new project (no .automaker directory) + const hadAutomakerDir = await hasAutomakerDir(path); + + // Initialize the .automaker directory structure + const initResult = await initializeProject(path); + + if (!initResult.success) { + toast.error('Failed to initialize project', { + description: initResult.error || 'Unknown error occurred', + }); + return; + } + + // Upsert project and set as current (handles both create and update cases) + // Theme preservation is handled by the store action + const trashedProject = trashedProjects.find((p) => p.path === path); + const effectiveTheme = + (trashedProject?.theme as ThemeMode | undefined) || + (currentProject?.theme as ThemeMode | undefined) || + globalTheme; + upsertAndSetCurrentProject(path, name, effectiveTheme); + + // Check if app_spec.txt exists + const specExists = await hasAppSpec(path); + + if (!hadAutomakerDir && !specExists) { + // This is a brand new project - show setup dialog + setSetupProjectPath(path); + setShowSetupDialog(true); + toast.success('Project opened', { + description: `Opened ${name}. Let's set up your app specification!`, + }); + } else if (initResult.createdFiles && initResult.createdFiles.length > 0) { + toast.success(initResult.isNewProject ? 'Project initialized' : 'Project updated', { + description: `Set up ${initResult.createdFiles.length} file(s) in .automaker`, + }); + } else { + toast.success('Project opened', { + description: `Opened ${name}`, + }); + } + + // Navigate to board view + navigate({ to: '/board' }); + } catch (error) { + console.error('Failed to open project:', error); + toast.error('Failed to open project', { + description: error instanceof Error ? error.message : 'Unknown error', + }); + } + } + }, [trashedProjects, upsertAndSetCurrentProject, currentProject, globalTheme, navigate]); + + // Handler for creating initial spec from the setup dialog + const handleCreateInitialSpec = useCallback(async () => { + if (!setupProjectPath) return; + + setSpecCreatingForProject(setupProjectPath); + setShowSetupDialog(false); + + try { + const api = getElectronAPI(); + await api.generateAppSpec({ + projectPath: setupProjectPath, + projectOverview, + generateFeatures, + analyzeProject, + featureCount, + }); + } catch (error) { + console.error('Failed to generate spec:', error); + toast.error('Failed to generate spec', { + description: error instanceof Error ? error.message : 'Unknown error', + }); + setSpecCreatingForProject(null); + } + }, [ + setupProjectPath, + projectOverview, + generateFeatures, + analyzeProject, + featureCount, + setSpecCreatingForProject, + ]); + + const handleSkipSetup = useCallback(() => { + setShowSetupDialog(false); + setSetupProjectPath(null); + }, []); + // Keyboard shortcuts for project switching (1-9, 0) useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { @@ -204,7 +323,7 @@ export function ProjectSwitcher() {
{/* Projects List */} -
+
{projects.map((project, index) => ( 0 && ( <> -
+
+ )} {/* Add Project Button - when no projects, show without rule */} {projects.length === 0 && ( - + <> + + + )}
@@ -312,6 +461,26 @@ export function ProjectSwitcher() { onSkip={handleOnboardingSkip} onGenerateSpec={handleOnboardingSkip} /> + + {/* Setup Dialog for Open Project */} + ); } diff --git a/apps/ui/src/components/layout/sidebar.tsx b/apps/ui/src/components/layout/sidebar.tsx index 613b113f..92e804ce 100644 --- a/apps/ui/src/components/layout/sidebar.tsx +++ b/apps/ui/src/components/layout/sidebar.tsx @@ -253,26 +253,25 @@ export function Sidebar() { return ( <> - {/* Mobile overlay backdrop */} + {/* Mobile backdrop overlay */} {sidebarOpen && (