diff --git a/.gitignore b/.gitignore index be8843e0..7d6c7b0e 100644 --- a/.gitignore +++ b/.gitignore @@ -90,9 +90,8 @@ pnpm-lock.yaml yarn.lock # Fork-specific workflow files (should never be committed) -DEVELOPMENT_WORKFLOW.md -check-sync.sh # API key files data/.api-key data/credentials.json data/ +.codex/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a3fed705..61ad83f4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,6 +24,7 @@ For complete details on contribution terms and rights assignment, please review - [Development Setup](#development-setup) - [Project Structure](#project-structure) - [Pull Request Process](#pull-request-process) + - [Branching Strategy (RC Branches)](#branching-strategy-rc-branches) - [Branch Naming Convention](#branch-naming-convention) - [Commit Message Format](#commit-message-format) - [Submitting a Pull Request](#submitting-a-pull-request) @@ -186,6 +187,59 @@ automaker/ This section covers everything you need to know about contributing changes through pull requests, from creating your branch to getting your code merged. +### Branching Strategy (RC Branches) + +Automaker uses **Release Candidate (RC) branches** for all development work. Understanding this workflow is essential before contributing. + +**How it works:** + +1. **All development happens on RC branches** - We maintain version-specific RC branches (e.g., `v0.10.0rc`, `v0.11.0rc`) where all active development occurs +2. **RC branches are eventually merged to main** - Once an RC branch is stable and ready for release, it gets merged into `main` +3. **Main branch is for releases only** - The `main` branch contains only released, stable code + +**Before creating a PR:** + +1. **Check for the latest RC branch** - Before starting work, check the repository for the current RC branch: + + ```bash + git fetch upstream + git branch -r | grep rc + ``` + +2. **Base your work on the RC branch** - Create your feature branch from the latest RC branch, not from `main`: + + ```bash + # Find the latest RC branch (e.g., v0.11.0rc) + git checkout upstream/v0.11.0rc + git checkout -b feature/your-feature-name + ``` + +3. **Target the RC branch in your PR** - When opening your pull request, set the base branch to the current RC branch, not `main` + +**Example workflow:** + +```bash +# 1. Fetch latest changes +git fetch upstream + +# 2. Check for RC branches +git branch -r | grep rc +# Output: upstream/v0.11.0rc + +# 3. Create your branch from the RC +git checkout -b feature/add-dark-mode upstream/v0.11.0rc + +# 4. Make your changes and commit +git commit -m "feat: Add dark mode support" + +# 5. Push to your fork +git push origin feature/add-dark-mode + +# 6. Open PR targeting the RC branch (v0.11.0rc), NOT main +``` + +**Important:** PRs opened directly against `main` will be asked to retarget to the current RC branch. + ### Branch Naming Convention We use a consistent branch naming pattern to keep our repository organized: @@ -275,14 +329,14 @@ Follow these steps to submit your contribution: #### 1. Prepare Your Changes -Ensure you've synced with the latest upstream changes: +Ensure you've synced with the latest upstream changes from the RC branch: ```bash # Fetch latest changes from upstream git fetch upstream -# Rebase your branch on main (if needed) -git rebase upstream/main +# Rebase your branch on the current RC branch (if needed) +git rebase upstream/v0.11.0rc # Use the current RC branch name ``` #### 2. Run Pre-submission Checks @@ -314,18 +368,19 @@ git push origin feature/your-feature-name 1. Go to your fork on GitHub 2. Click "Compare & pull request" for your branch -3. Ensure the base repository is `AutoMaker-Org/automaker` and base branch is `main` +3. **Important:** Set the base repository to `AutoMaker-Org/automaker` and the base branch to the **current RC branch** (e.g., `v0.11.0rc`), not `main` 4. Fill out the PR template completely #### PR Requirements Checklist Your PR should include: +- [ ] **Targets the current RC branch** (not `main`) - see [Branching Strategy](#branching-strategy-rc-branches) - [ ] **Clear title** describing the change (use conventional commit format) - [ ] **Description** explaining what changed and why - [ ] **Link to related issue** (if applicable): `Closes #123` or `Fixes #456` - [ ] **All CI checks passing** (format, lint, build, tests) -- [ ] **No merge conflicts** with main branch +- [ ] **No merge conflicts** with the RC branch - [ ] **Tests included** for new functionality - [ ] **Documentation updated** if adding/changing public APIs diff --git a/DEVELOPMENT_WORKFLOW.md b/DEVELOPMENT_WORKFLOW.md new file mode 100644 index 00000000..0ce198ce --- /dev/null +++ b/DEVELOPMENT_WORKFLOW.md @@ -0,0 +1,253 @@ +# Development Workflow + +This document defines the standard workflow for keeping a branch in sync with the upstream +release candidate (RC) and for shipping feature work. It is paired with `check-sync.sh`. + +## Quick Decision Rule + +1. Ask the user to select a workflow: + - **Sync Workflow** → you are maintaining the current RC branch with fixes/improvements + and will push the same fixes to both origin and upstream RC when you have local + commits to publish. + - **PR Workflow** → you are starting new feature work on a new branch; upstream updates + happen via PR only. +2. After the user selects, run: + ```bash + ./check-sync.sh + ``` +3. Use the status output to confirm alignment. If it reports **diverged**, default to + merging `upstream/` into the current branch and preserving local commits. + For Sync Workflow, when the working tree is clean and you are behind upstream RC, + proceed with the fetch + merge without asking for additional confirmation. + +## Target RC Resolution + +The target RC is resolved dynamically so the workflow stays current as the RC changes. + +Resolution order: + +1. Latest `upstream/v*rc` branch (auto-detected) +2. `upstream/HEAD` (fallback) +3. If neither is available, you must pass `--rc ` + +Override for a single run: + +```bash +./check-sync.sh --rc +``` + +## Pre-Flight Checklist + +1. Confirm a clean working tree: + ```bash + git status + ``` +2. Confirm the current branch: + ```bash + git branch --show-current + ``` +3. Ensure remotes exist (origin + upstream): + ```bash + git remote -v + ``` + +## Sync Workflow (Upstream Sync) + +Use this flow when you are updating the current branch with fixes or improvements and +intend to keep origin and upstream RC in lockstep. + +1. **Check sync status** + ```bash + ./check-sync.sh + ``` +2. **Update from upstream RC before editing (no pulls)** + - **Behind upstream RC** → fetch and merge RC into your branch: + ```bash + git fetch upstream + git merge upstream/ --no-edit + ``` + When the working tree is clean and the user selected Sync Workflow, proceed without + an extra confirmation prompt. + - **Diverged** → stop and resolve manually. +3. **Resolve conflicts if needed** + - Handle conflicts intelligently: preserve upstream behavior and your local intent. +4. **Make changes and commit (if you are delivering fixes)** + ```bash + git add -A + git commit -m "type: description" + ``` +5. **Build to verify** + ```bash + npm run build:packages + npm run build + ``` +6. **Push after a successful merge to keep remotes aligned** + - If you only merged upstream RC changes, push **origin only** to sync your fork: + ```bash + git push origin + ``` + - If you have local fixes to publish, push **origin + upstream**: + ```bash + git push origin + git push upstream : + ``` + - Always ask the user which push to perform. + - Origin (origin-only sync): + ```bash + git push origin + ``` + - Upstream RC (publish the same fixes when you have local commits): + ```bash + git push upstream : + ``` +7. **Re-check sync** + ```bash + ./check-sync.sh + ``` + +## PR Workflow (Feature Work) + +Use this flow only for new feature work on a new branch. Do not push to upstream RC. + +1. **Create or switch to a feature branch** + ```bash + git checkout -b + ``` +2. **Make changes and commit** + ```bash + git add -A + git commit -m "type: description" + ``` +3. **Merge upstream RC before shipping** + ```bash + git merge upstream/ --no-edit + ``` +4. **Build and/or test** + ```bash + npm run build:packages + npm run build + ``` +5. **Push to origin** + ```bash + git push -u origin + ``` +6. **Create or update the PR** + - Use `gh pr create` or the GitHub UI. +7. **Review and follow-up** + +- Apply feedback, commit changes, and push again. +- Re-run `./check-sync.sh` if additional upstream sync is needed. + +## Conflict Resolution Checklist + +1. Identify which changes are from upstream vs. local. +2. Preserve both behaviors where possible; avoid dropping either side. +3. Prefer minimal, safe integrations over refactors. +4. Re-run build commands after resolving conflicts. +5. Re-run `./check-sync.sh` to confirm status. + +## Build/Test Matrix + +- **Sync Workflow**: `npm run build:packages` and `npm run build`. +- **PR Workflow**: `npm run build:packages` and `npm run build` (plus relevant tests). + +## Post-Sync Verification + +1. `git status` should be clean. +2. `./check-sync.sh` should show expected alignment. +3. Verify recent commits with: + ```bash + git log --oneline -5 + ``` + +## check-sync.sh Usage + +- Uses dynamic Target RC resolution (see above). +- Override target RC: + ```bash + ./check-sync.sh --rc + ``` +- Optional preview limit: + ```bash + ./check-sync.sh --preview 10 + ``` +- The script prints sync status for both origin and upstream and previews recent commits + when you are behind. + +## Stop Conditions + +Stop and ask for guidance if any of the following are true: + +- The working tree is dirty and you are about to merge or push. +- `./check-sync.sh` reports **diverged** during PR Workflow, or a merge cannot be completed. +- The script cannot resolve a target RC and requests `--rc`. +- A build fails after sync or conflict resolution. + +## AI Agent Guardrails + +- Always run `./check-sync.sh` before merges or pushes. +- Always ask for explicit user approval before any push command. +- Do not ask for additional confirmation before a Sync Workflow fetch + merge when the + working tree is clean and the user has already selected the Sync Workflow. +- Choose Sync vs PR workflow based on intent (RC maintenance vs new feature work), not + on the script's workflow hint. +- Only use force push when the user explicitly requests a history rewrite. +- Ask for explicit approval before dependency installs, branch deletion, or destructive operations. +- When resolving merge conflicts, preserve both upstream changes and local intent where possible. +- Do not create or switch to new branches unless the user explicitly requests it. + +## AI Agent Decision Guidance + +Agents should provide concrete, task-specific suggestions instead of repeatedly asking +open-ended questions. Use the user's stated goal and the `./check-sync.sh` status to +propose a default path plus one or two alternatives, and only ask for confirmation when +an action requires explicit approval. + +Default behavior: + +- If the intent is RC maintenance, recommend the Sync Workflow and proceed with + safe preparation steps (status checks, previews). If the branch is behind upstream RC, + fetch and merge without additional confirmation when the working tree is clean, then + push to origin to keep the fork aligned. Push upstream only when there are local fixes + to publish. +- If the intent is new feature work, recommend the PR Workflow and proceed with safe + preparation steps (status checks, identifying scope). Ask for approval before merges, + pushes, or dependency installs. +- If `./check-sync.sh` reports **diverged** during Sync Workflow, merge + `upstream/` into the current branch and preserve local commits. +- If `./check-sync.sh` reports **diverged** during PR Workflow, stop and ask for guidance + with a short explanation of the divergence and the minimal options to resolve it. + If the user's intent is RC maintenance, prefer the Sync Workflow regardless of the + script hint. When the intent is new feature work, use the PR Workflow and avoid upstream + RC pushes. + +Suggestion format (keep it short): + +- **Recommended**: one sentence with the default path and why it fits the task. +- **Alternatives**: one or two options with the tradeoff or prerequisite. +- **Approval points**: mention any upcoming actions that need explicit approval (exclude sync + workflow pushes and merges). + +## Failure Modes and How to Avoid Them + +Sync Workflow: + +- Wrong RC target: verify the auto-detected RC in `./check-sync.sh` output before merging. +- Diverged from upstream RC: stop and resolve manually before any merge or push. +- Dirty working tree: commit or stash before syncing to avoid accidental merges. +- Missing remotes: ensure both `origin` and `upstream` are configured before syncing. +- Build breaks after sync: run `npm run build:packages` and `npm run build` before pushing. + +PR Workflow: + +- Branch not synced to current RC: re-run `./check-sync.sh` and merge RC before shipping. +- Pushing the wrong branch: confirm `git branch --show-current` before pushing. +- Unreviewed changes: always commit and push to origin before opening or updating a PR. +- Skipped tests/builds: run the build commands before declaring the PR ready. + +## Notes + +- Avoid merging with uncommitted changes; commit or stash first. +- Prefer merge over rebase for PR branches; rebases rewrite history and often require a force push, + which should only be done with an explicit user request. +- Use clear, conventional commit messages and split unrelated changes into separate commits. diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index ce66ac9e..609be945 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -222,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/providers/codex-provider.ts b/apps/server/src/providers/codex-provider.ts index 2e3962a0..e0f38ee9 100644 --- a/apps/server/src/providers/codex-provider.ts +++ b/apps/server/src/providers/codex-provider.ts @@ -45,6 +45,7 @@ import { getCodexTodoToolName, } from './codex-tool-mapping.js'; import { SettingsService } from '../services/settings-service.js'; +import { createTempEnvOverride } from '../lib/auth-utils.js'; import { checkSandboxCompatibility } from '../lib/sdk-options.js'; import { CODEX_MODELS } from './codex-models.js'; @@ -142,6 +143,7 @@ type CodexExecutionMode = typeof CODEX_EXECUTION_MODE_CLI | typeof CODEX_EXECUTI type CodexExecutionPlan = { mode: CodexExecutionMode; cliPath: string | null; + openAiApiKey?: string | null; }; const ALLOWED_ENV_VARS = [ @@ -166,6 +168,22 @@ function buildEnv(): Record { return env; } +async function resolveOpenAiApiKey(): Promise { + const envKey = process.env[OPENAI_API_KEY_ENV]; + if (envKey) { + return envKey; + } + + try { + const settingsService = new SettingsService(getCodexSettingsDir()); + const credentials = await settingsService.getCredentials(); + const storedKey = credentials.apiKeys.openai?.trim(); + return storedKey ? storedKey : null; + } catch { + return null; + } +} + function hasMcpServersConfigured(options: ExecuteOptions): boolean { return Boolean(options.mcpServers && Object.keys(options.mcpServers).length > 0); } @@ -181,18 +199,21 @@ function isSdkEligible(options: ExecuteOptions): boolean { async function resolveCodexExecutionPlan(options: ExecuteOptions): Promise { const cliPath = await findCodexCliPath(); const authIndicators = await getCodexAuthIndicators(); - const hasApiKey = Boolean(process.env[OPENAI_API_KEY_ENV]); + const openAiApiKey = await resolveOpenAiApiKey(); + const hasApiKey = Boolean(openAiApiKey); const cliAuthenticated = authIndicators.hasOAuthToken || authIndicators.hasApiKey || hasApiKey; const sdkEligible = isSdkEligible(options); const cliAvailable = Boolean(cliPath); + if (hasApiKey) { + return { + mode: CODEX_EXECUTION_MODE_SDK, + cliPath, + openAiApiKey, + }; + } + if (sdkEligible) { - if (hasApiKey) { - return { - mode: CODEX_EXECUTION_MODE_SDK, - cliPath, - }; - } if (!cliAvailable) { throw new Error(ERROR_CODEX_SDK_AUTH_REQUIRED); } @@ -209,6 +230,7 @@ async function resolveCodexExecutionPlan(options: ExecuteOptions): Promise { const cliPath = await findCodexCliPath(); - const hasApiKey = !!process.env[OPENAI_API_KEY_ENV]; + const hasApiKey = Boolean(await resolveOpenAiApiKey()); const authIndicators = await getCodexAuthIndicators(); const installed = !!cliPath; @@ -1013,7 +1047,7 @@ export class CodexProvider extends BaseProvider { */ async checkAuth(): Promise { const cliPath = await findCodexCliPath(); - const hasApiKey = !!process.env[OPENAI_API_KEY_ENV]; + const hasApiKey = Boolean(await resolveOpenAiApiKey()); const authIndicators = await getCodexAuthIndicators(); // Check for API key in environment diff --git a/apps/server/src/providers/simple-query-service.ts b/apps/server/src/providers/simple-query-service.ts index b37ef732..5882b96f 100644 --- a/apps/server/src/providers/simple-query-service.ts +++ b/apps/server/src/providers/simple-query-service.ts @@ -15,7 +15,13 @@ */ import { ProviderFactory } from './provider-factory.js'; -import type { ProviderMessage, ContentBlock, ThinkingLevel } from '@automaker/types'; +import type { + ProviderMessage, + ContentBlock, + ThinkingLevel, + ReasoningEffort, +} from '@automaker/types'; +import { stripProviderPrefix } from '@automaker/types'; /** * Options for simple query execution @@ -42,6 +48,8 @@ export interface SimpleQueryOptions { }; /** Thinking level for Claude models */ thinkingLevel?: ThinkingLevel; + /** Reasoning effort for Codex/OpenAI models */ + reasoningEffort?: ReasoningEffort; /** If true, runs in read-only mode (no file writes) */ readOnly?: boolean; /** Setting sources for CLAUDE.md loading */ @@ -97,6 +105,7 @@ const DEFAULT_MODEL = 'claude-sonnet-4-20250514'; export async function simpleQuery(options: SimpleQueryOptions): Promise { const model = options.model || DEFAULT_MODEL; const provider = ProviderFactory.getProviderForModel(model); + const bareModel = stripProviderPrefix(model); let responseText = ''; let structuredOutput: Record | undefined; @@ -104,7 +113,8 @@ export async function simpleQuery(options: SimpleQueryOptions): Promise { const model = options.model || DEFAULT_MODEL; const provider = ProviderFactory.getProviderForModel(model); + const bareModel = stripProviderPrefix(model); let responseText = ''; let structuredOutput: Record | undefined; @@ -183,7 +195,8 @@ export async function streamingQuery(options: StreamingQueryOptions): Promise { + try { + const { stdout } = await execAsync(GH_REPO_VIEW_COMMAND, { + cwd: projectPath, + env: execEnv, + }); + + const data = JSON.parse(stdout) as GhRepoViewResponse; + const owner = typeof data.owner?.login === 'string' ? data.owner.login : null; + const repo = typeof data.name === 'string' ? data.name : null; + + if (!owner || !repo) { + return null; + } + + return { owner, repo }; + } catch { + return null; + } +} + export interface GitHubRemoteStatus { hasGitHubRemote: boolean; remoteUrl: string | null; @@ -21,19 +58,38 @@ export async function checkGitHubRemote(projectPath: string): Promise; @@ -45,6 +50,7 @@ interface GraphQLResponse { /** Timeout for GitHub API requests in milliseconds */ const GITHUB_API_TIMEOUT_MS = 30000; +const COMMENTS_PAGE_SIZE = 50; /** * Validate cursor format (GraphQL cursors are typically base64 strings) @@ -54,7 +60,7 @@ function isValidCursor(cursor: string): boolean { } /** - * Fetch comments for a specific issue using GitHub GraphQL API + * Fetch comments for a specific issue or pull request using GitHub GraphQL API */ async function fetchIssueComments( projectPath: string, @@ -70,24 +76,52 @@ async function fetchIssueComments( // Use GraphQL variables instead of string interpolation for safety const query = ` - query GetIssueComments($owner: String!, $repo: String!, $issueNumber: Int!, $cursor: String) { + query GetIssueComments( + $owner: String! + $repo: String! + $issueNumber: Int! + $cursor: String + $pageSize: Int! + ) { repository(owner: $owner, name: $repo) { - issue(number: $issueNumber) { - comments(first: 50, after: $cursor) { - totalCount - pageInfo { - hasNextPage - endCursor - } - nodes { - id - author { - login - avatarUrl + issueOrPullRequest(number: $issueNumber) { + __typename + ... on Issue { + comments(first: $pageSize, after: $cursor) { + totalCount + pageInfo { + hasNextPage + endCursor + } + nodes { + id + author { + login + avatarUrl + } + body + createdAt + updatedAt + } + } + } + ... on PullRequest { + comments(first: $pageSize, after: $cursor) { + totalCount + pageInfo { + hasNextPage + endCursor + } + nodes { + id + author { + login + avatarUrl + } + body + createdAt + updatedAt } - body - createdAt - updatedAt } } } @@ -99,6 +133,7 @@ async function fetchIssueComments( repo, issueNumber, cursor: cursor || null, + pageSize: COMMENTS_PAGE_SIZE, }; const requestBody = JSON.stringify({ query, variables }); @@ -140,10 +175,10 @@ async function fetchIssueComments( throw new Error(response.errors[0].message); } - const commentsData = response.data?.repository?.issue?.comments; + const commentsData = response.data?.repository?.issueOrPullRequest?.comments; if (!commentsData) { - throw new Error('Issue not found or no comments data available'); + throw new Error('Issue or pull request not found or no comments data available'); } const comments: GitHubComment[] = commentsData.nodes.map((node) => ({ diff --git a/apps/server/src/routes/github/routes/list-issues.ts b/apps/server/src/routes/github/routes/list-issues.ts index 9c0f8933..96c3c202 100644 --- a/apps/server/src/routes/github/routes/list-issues.ts +++ b/apps/server/src/routes/github/routes/list-issues.ts @@ -9,6 +9,17 @@ import { checkGitHubRemote } from './check-github-remote.js'; import { createLogger } from '@automaker/utils'; const logger = createLogger('ListIssues'); +const OPEN_ISSUES_LIMIT = 100; +const CLOSED_ISSUES_LIMIT = 50; +const ISSUE_LIST_FIELDS = 'number,title,state,author,createdAt,labels,url,body,assignees'; +const ISSUE_STATE_OPEN = 'open'; +const ISSUE_STATE_CLOSED = 'closed'; +const GH_ISSUE_LIST_COMMAND = 'gh issue list'; +const GH_STATE_FLAG = '--state'; +const GH_JSON_FLAG = '--json'; +const GH_LIMIT_FLAG = '--limit'; +const LINKED_PRS_BATCH_SIZE = 20; +const LINKED_PRS_TIMELINE_ITEMS = 10; export interface GitHubLabel { name: string; @@ -69,34 +80,68 @@ async function fetchLinkedPRs( // Build GraphQL query for batch fetching linked PRs // We fetch up to 20 issues at a time to avoid query limits - const batchSize = 20; - for (let i = 0; i < issueNumbers.length; i += batchSize) { - const batch = issueNumbers.slice(i, i + batchSize); + for (let i = 0; i < issueNumbers.length; i += LINKED_PRS_BATCH_SIZE) { + const batch = issueNumbers.slice(i, i + LINKED_PRS_BATCH_SIZE); const issueQueries = batch .map( (num, idx) => ` - issue${idx}: issue(number: ${num}) { - number - timelineItems(first: 10, itemTypes: [CROSS_REFERENCED_EVENT, CONNECTED_EVENT]) { - nodes { - ... on CrossReferencedEvent { - source { - ... on PullRequest { - number - title - state - url + issue${idx}: issueOrPullRequest(number: ${num}) { + ... on Issue { + number + timelineItems( + first: ${LINKED_PRS_TIMELINE_ITEMS} + itemTypes: [CROSS_REFERENCED_EVENT, CONNECTED_EVENT] + ) { + nodes { + ... on CrossReferencedEvent { + source { + ... on PullRequest { + number + title + state + url + } + } + } + ... on ConnectedEvent { + subject { + ... on PullRequest { + number + title + state + url + } } } } - ... on ConnectedEvent { - subject { - ... on PullRequest { - number - title - state - url + } + } + ... on PullRequest { + number + timelineItems( + first: ${LINKED_PRS_TIMELINE_ITEMS} + itemTypes: [CROSS_REFERENCED_EVENT, CONNECTED_EVENT] + ) { + nodes { + ... on CrossReferencedEvent { + source { + ... on PullRequest { + number + title + state + url + } + } + } + ... on ConnectedEvent { + subject { + ... on PullRequest { + number + title + state + url + } } } } @@ -213,16 +258,35 @@ export function createListIssuesHandler() { } // Fetch open and closed issues in parallel (now including assignees) + const repoQualifier = + remoteStatus.owner && remoteStatus.repo ? `${remoteStatus.owner}/${remoteStatus.repo}` : ''; + const repoFlag = repoQualifier ? `-R ${repoQualifier}` : ''; const [openResult, closedResult] = await Promise.all([ execAsync( - 'gh issue list --state open --json number,title,state,author,createdAt,labels,url,body,assignees --limit 100', + [ + GH_ISSUE_LIST_COMMAND, + repoFlag, + `${GH_STATE_FLAG} ${ISSUE_STATE_OPEN}`, + `${GH_JSON_FLAG} ${ISSUE_LIST_FIELDS}`, + `${GH_LIMIT_FLAG} ${OPEN_ISSUES_LIMIT}`, + ] + .filter(Boolean) + .join(' '), { cwd: projectPath, env: execEnv, } ), execAsync( - 'gh issue list --state closed --json number,title,state,author,createdAt,labels,url,body,assignees --limit 50', + [ + GH_ISSUE_LIST_COMMAND, + repoFlag, + `${GH_STATE_FLAG} ${ISSUE_STATE_CLOSED}`, + `${GH_JSON_FLAG} ${ISSUE_LIST_FIELDS}`, + `${GH_LIMIT_FLAG} ${CLOSED_ISSUES_LIMIT}`, + ] + .filter(Boolean) + .join(' '), { cwd: projectPath, env: execEnv, diff --git a/apps/server/src/routes/github/routes/list-prs.ts b/apps/server/src/routes/github/routes/list-prs.ts index 87f42a38..b273fc0a 100644 --- a/apps/server/src/routes/github/routes/list-prs.ts +++ b/apps/server/src/routes/github/routes/list-prs.ts @@ -6,6 +6,17 @@ import type { Request, Response } from 'express'; import { execAsync, execEnv, getErrorMessage, logError } from './common.js'; import { checkGitHubRemote } from './check-github-remote.js'; +const OPEN_PRS_LIMIT = 100; +const MERGED_PRS_LIMIT = 50; +const PR_LIST_FIELDS = + 'number,title,state,author,createdAt,labels,url,isDraft,headRefName,reviewDecision,mergeable,body'; +const PR_STATE_OPEN = 'open'; +const PR_STATE_MERGED = 'merged'; +const GH_PR_LIST_COMMAND = 'gh pr list'; +const GH_STATE_FLAG = '--state'; +const GH_JSON_FLAG = '--json'; +const GH_LIMIT_FLAG = '--limit'; + export interface GitHubLabel { name: string; color: string; @@ -57,16 +68,36 @@ export function createListPRsHandler() { return; } + const repoQualifier = + remoteStatus.owner && remoteStatus.repo ? `${remoteStatus.owner}/${remoteStatus.repo}` : ''; + const repoFlag = repoQualifier ? `-R ${repoQualifier}` : ''; + const [openResult, mergedResult] = await Promise.all([ execAsync( - 'gh pr list --state open --json number,title,state,author,createdAt,labels,url,isDraft,headRefName,reviewDecision,mergeable,body --limit 100', + [ + GH_PR_LIST_COMMAND, + repoFlag, + `${GH_STATE_FLAG} ${PR_STATE_OPEN}`, + `${GH_JSON_FLAG} ${PR_LIST_FIELDS}`, + `${GH_LIMIT_FLAG} ${OPEN_PRS_LIMIT}`, + ] + .filter(Boolean) + .join(' '), { cwd: projectPath, env: execEnv, } ), execAsync( - 'gh pr list --state merged --json number,title,state,author,createdAt,labels,url,isDraft,headRefName,reviewDecision,mergeable,body --limit 50', + [ + GH_PR_LIST_COMMAND, + repoFlag, + `${GH_STATE_FLAG} ${PR_STATE_MERGED}`, + `${GH_JSON_FLAG} ${PR_LIST_FIELDS}`, + `${GH_LIMIT_FLAG} ${MERGED_PRS_LIMIT}`, + ] + .filter(Boolean) + .join(' '), { cwd: projectPath, env: execEnv, diff --git a/apps/server/src/routes/github/routes/validate-issue.ts b/apps/server/src/routes/github/routes/validate-issue.ts index aaa83c9a..14de437b 100644 --- a/apps/server/src/routes/github/routes/validate-issue.ts +++ b/apps/server/src/routes/github/routes/validate-issue.ts @@ -3,7 +3,7 @@ * * Scans the codebase to determine if an issue is valid, invalid, or needs clarification. * Runs asynchronously and emits events for progress and completion. - * Supports both Claude models and Cursor models. + * Supports Claude, Codex, Cursor, and OpenCode models. */ import type { Request, Response } from 'express'; @@ -11,13 +11,19 @@ import type { EventEmitter } from '../../../lib/events.js'; import type { IssueValidationResult, IssueValidationEvent, - ModelAlias, - CursorModelId, + ModelId, GitHubComment, LinkedPRInfo, ThinkingLevel, + ReasoningEffort, +} from '@automaker/types'; +import { + DEFAULT_PHASE_MODELS, + isClaudeModel, + isCodexModel, + isCursorModel, + isOpencodeModel, } from '@automaker/types'; -import { isCursorModel, DEFAULT_PHASE_MODELS } from '@automaker/types'; import { resolvePhaseModel } from '@automaker/model-resolver'; import { extractJson } from '../../../lib/json-extractor.js'; import { writeValidation } from '../../../lib/validation-storage.js'; @@ -39,9 +45,6 @@ import { import type { SettingsService } from '../../../services/settings-service.js'; import { getAutoLoadClaudeMdSetting } from '../../../lib/settings-helpers.js'; -/** Valid Claude model values for validation */ -const VALID_CLAUDE_MODELS: readonly ModelAlias[] = ['opus', 'sonnet', 'haiku'] as const; - /** * Request body for issue validation */ @@ -51,10 +54,12 @@ interface ValidateIssueRequestBody { issueTitle: string; issueBody: string; issueLabels?: string[]; - /** Model to use for validation (opus, sonnet, haiku, or cursor model IDs) */ - model?: ModelAlias | CursorModelId; - /** Thinking level for Claude models (ignored for Cursor models) */ + /** Model to use for validation (Claude alias or provider model ID) */ + model?: ModelId; + /** Thinking level for Claude models (ignored for non-Claude models) */ thinkingLevel?: ThinkingLevel; + /** Reasoning effort for Codex models (ignored for non-Codex models) */ + reasoningEffort?: ReasoningEffort; /** Comments to include in validation analysis */ comments?: GitHubComment[]; /** Linked pull requests for this issue */ @@ -66,7 +71,7 @@ interface ValidateIssueRequestBody { * * Emits events for start, progress, complete, and error. * Stores result on completion. - * Supports both Claude models (with structured output) and Cursor models (with JSON parsing). + * Supports Claude/Codex models (structured output) and Cursor/OpenCode models (JSON parsing). */ async function runValidation( projectPath: string, @@ -74,13 +79,14 @@ async function runValidation( issueTitle: string, issueBody: string, issueLabels: string[] | undefined, - model: ModelAlias | CursorModelId, + model: ModelId, events: EventEmitter, abortController: AbortController, settingsService?: SettingsService, comments?: ValidationComment[], linkedPRs?: ValidationLinkedPR[], - thinkingLevel?: ThinkingLevel + thinkingLevel?: ThinkingLevel, + reasoningEffort?: ReasoningEffort ): Promise { // Emit start event const startEvent: IssueValidationEvent = { @@ -111,8 +117,8 @@ async function runValidation( let responseText = ''; - // Determine if we should use structured output (Claude supports it, Cursor doesn't) - const useStructuredOutput = !isCursorModel(model); + // Determine if we should use structured output (Claude/Codex support it, Cursor/OpenCode don't) + const useStructuredOutput = isClaudeModel(model) || isCodexModel(model); // Build the final prompt - for Cursor, include system prompt and JSON schema instructions let finalPrompt = basePrompt; @@ -138,14 +144,20 @@ ${basePrompt}`; '[ValidateIssue]' ); - // Use thinkingLevel from request if provided, otherwise fall back to settings + // Use request overrides if provided, otherwise fall back to settings let effectiveThinkingLevel: ThinkingLevel | undefined = thinkingLevel; - if (!effectiveThinkingLevel) { + let effectiveReasoningEffort: ReasoningEffort | undefined = reasoningEffort; + if (!effectiveThinkingLevel || !effectiveReasoningEffort) { const settings = await settingsService?.getGlobalSettings(); const phaseModelEntry = settings?.phaseModels?.validationModel || DEFAULT_PHASE_MODELS.validationModel; const resolved = resolvePhaseModel(phaseModelEntry); - effectiveThinkingLevel = resolved.thinkingLevel; + if (!effectiveThinkingLevel) { + effectiveThinkingLevel = resolved.thinkingLevel; + } + if (!effectiveReasoningEffort && typeof phaseModelEntry !== 'string') { + effectiveReasoningEffort = phaseModelEntry.reasoningEffort; + } } logger.info(`Using model: ${model}`); @@ -158,6 +170,7 @@ ${basePrompt}`; systemPrompt: useStructuredOutput ? ISSUE_VALIDATION_SYSTEM_PROMPT : undefined, abortController, thinkingLevel: effectiveThinkingLevel, + reasoningEffort: effectiveReasoningEffort, readOnly: true, // Issue validation only reads code, doesn't write settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined, outputFormat: useStructuredOutput @@ -262,6 +275,7 @@ export function createValidateIssueHandler( issueLabels, model = 'opus', thinkingLevel, + reasoningEffort, comments: rawComments, linkedPRs: rawLinkedPRs, } = req.body as ValidateIssueRequestBody; @@ -309,14 +323,17 @@ export function createValidateIssueHandler( return; } - // Validate model parameter at runtime - accept Claude models or Cursor models - const isValidClaudeModel = VALID_CLAUDE_MODELS.includes(model as ModelAlias); - const isValidCursorModel = isCursorModel(model); + // Validate model parameter at runtime - accept any supported provider model + const isValidModel = + isClaudeModel(model) || + isCursorModel(model) || + isCodexModel(model) || + isOpencodeModel(model); - if (!isValidClaudeModel && !isValidCursorModel) { + if (!isValidModel) { res.status(400).json({ success: false, - error: `Invalid model. Must be one of: ${VALID_CLAUDE_MODELS.join(', ')}, or a Cursor model ID`, + error: 'Invalid model. Must be a Claude, Cursor, Codex, or OpenCode model ID (or alias).', }); return; } @@ -347,7 +364,8 @@ export function createValidateIssueHandler( settingsService, validationComments, validationLinkedPRs, - thinkingLevel + thinkingLevel, + reasoningEffort ) .catch(() => { // Error is already handled inside runValidation (event emitted) diff --git a/apps/server/src/routes/settings/routes/get-credentials.ts b/apps/server/src/routes/settings/routes/get-credentials.ts index be15b04b..140ccce4 100644 --- a/apps/server/src/routes/settings/routes/get-credentials.ts +++ b/apps/server/src/routes/settings/routes/get-credentials.ts @@ -5,7 +5,7 @@ * Each provider shows: `{ configured: boolean, masked: string }` * Masked shows first 4 and last 4 characters for verification. * - * Response: `{ "success": true, "credentials": { anthropic } }` + * Response: `{ "success": true, "credentials": { anthropic, google, openai } }` */ import type { Request, Response } from 'express'; diff --git a/apps/server/src/routes/settings/routes/update-credentials.ts b/apps/server/src/routes/settings/routes/update-credentials.ts index c08b2445..2b415830 100644 --- a/apps/server/src/routes/settings/routes/update-credentials.ts +++ b/apps/server/src/routes/settings/routes/update-credentials.ts @@ -1,7 +1,7 @@ /** * PUT /api/settings/credentials - Update API credentials * - * Updates API keys for Anthropic. Partial updates supported. + * Updates API keys for supported providers. Partial updates supported. * Returns masked credentials for verification without exposing full keys. * * Request body: `Partial` (usually just apiKeys) diff --git a/apps/server/src/routes/setup/routes/api-keys.ts b/apps/server/src/routes/setup/routes/api-keys.ts index 047b6455..ec870e7b 100644 --- a/apps/server/src/routes/setup/routes/api-keys.ts +++ b/apps/server/src/routes/setup/routes/api-keys.ts @@ -11,6 +11,7 @@ export function createApiKeysHandler() { res.json({ success: true, hasAnthropicKey: !!getApiKey('anthropic') || !!process.env.ANTHROPIC_API_KEY, + hasGoogleKey: !!getApiKey('google'), hasOpenaiKey: !!getApiKey('openai') || !!process.env.OPENAI_API_KEY, }); } catch (error) { diff --git a/apps/server/src/routes/setup/routes/store-api-key.ts b/apps/server/src/routes/setup/routes/store-api-key.ts index e77a697e..eae2e430 100644 --- a/apps/server/src/routes/setup/routes/store-api-key.ts +++ b/apps/server/src/routes/setup/routes/store-api-key.ts @@ -21,22 +21,25 @@ export function createStoreApiKeyHandler() { return; } - setApiKey(provider, apiKey); - - // Also set as environment variable and persist to .env - if (provider === 'anthropic' || provider === 'anthropic_oauth_token') { - // Both API key and OAuth token use ANTHROPIC_API_KEY - process.env.ANTHROPIC_API_KEY = apiKey; - await persistApiKeyToEnv('ANTHROPIC_API_KEY', apiKey); - logger.info('[Setup] Stored API key as ANTHROPIC_API_KEY'); - } else { + const providerEnvMap: Record = { + anthropic: 'ANTHROPIC_API_KEY', + anthropic_oauth_token: 'ANTHROPIC_API_KEY', + openai: 'OPENAI_API_KEY', + }; + const envKey = providerEnvMap[provider]; + if (!envKey) { res.status(400).json({ success: false, - error: `Unsupported provider: ${provider}. Only anthropic is supported.`, + error: `Unsupported provider: ${provider}. Only anthropic and openai are supported.`, }); return; } + setApiKey(provider, apiKey); + process.env[envKey] = apiKey; + await persistApiKeyToEnv(envKey, apiKey); + logger.info(`[Setup] Stored API key as ${envKey}`); + res.json({ success: true }); } catch (error) { logError(error, 'Store API key failed'); diff --git a/apps/server/src/routes/worktree/index.ts b/apps/server/src/routes/worktree/index.ts index 525c3a96..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'; @@ -40,8 +41,12 @@ import { 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()); @@ -65,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'), 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/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index a587e1eb..29caa4e1 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -21,7 +21,7 @@ import type { ThinkingLevel, PlanningMode, } from '@automaker/types'; -import { DEFAULT_PHASE_MODELS, stripProviderPrefix } from '@automaker/types'; +import { DEFAULT_PHASE_MODELS, isClaudeModel, stripProviderPrefix } from '@automaker/types'; import { buildPromptWithImages, classifyError, @@ -3586,10 +3586,29 @@ If nothing notable: {"learnings": []}`; const phaseModelEntry = settings?.phaseModels?.memoryExtractionModel || DEFAULT_PHASE_MODELS.memoryExtractionModel; const { model } = resolvePhaseModel(phaseModelEntry); + const hasClaudeKey = Boolean(process.env.ANTHROPIC_API_KEY); + let resolvedModel = model; + + if (isClaudeModel(model) && !hasClaudeKey) { + const fallbackModel = feature.model + ? resolveModelString(feature.model, DEFAULT_MODELS.claude) + : null; + if (fallbackModel && !isClaudeModel(fallbackModel)) { + console.log( + `[AutoMode] Claude not configured for memory extraction; using feature model "${fallbackModel}".` + ); + resolvedModel = fallbackModel; + } else { + console.log( + '[AutoMode] Claude not configured for memory extraction; skipping learning extraction.' + ); + return; + } + } const result = await simpleQuery({ prompt: userPrompt, - model, + model: resolvedModel, cwd: projectPath, maxTurns: 1, allowedTools: [], diff --git a/apps/server/src/services/claude-usage-service.ts b/apps/server/src/services/claude-usage-service.ts index 64ace35d..c9000582 100644 --- a/apps/server/src/services/claude-usage-service.ts +++ b/apps/server/src/services/claude-usage-service.ts @@ -161,11 +161,15 @@ export class ClaudeUsageService { const workingDirectory = this.isWindows ? process.env.USERPROFILE || os.homedir() || 'C:\\' - : process.env.HOME || os.homedir() || '/tmp'; + : os.tmpdir(); // 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 +185,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); @@ -205,16 +207,52 @@ export class ClaudeUsageService { if (output.includes('Current session')) { resolve(output); } 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 +266,54 @@ export class ClaudeUsageService { } }, 2000); } - }, 2000); + }, 3000); + } + + // Handle Trust Dialog: "Do you want to work in this folder?" + // Since we are running in os.tmpdir(), it is safe to approve. + if (!hasApprovedTrust && cleanOutput.includes('Do you want to work in this folder?')) { + hasApprovedTrust = 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 +322,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/settings-service.ts b/apps/server/src/services/settings-service.ts index f1dfd45c..5f57ad83 100644 --- a/apps/server/src/services/settings-service.ts +++ b/apps/server/src/services/settings-service.ts @@ -431,6 +431,8 @@ export class SettingsService { */ async getMaskedCredentials(): Promise<{ anthropic: { configured: boolean; masked: string }; + google: { configured: boolean; masked: string }; + openai: { configured: boolean; masked: string }; }> { const credentials = await this.getCredentials(); @@ -444,6 +446,14 @@ export class SettingsService { configured: !!credentials.apiKeys.anthropic, masked: maskKey(credentials.apiKeys.anthropic), }, + google: { + configured: !!credentials.apiKeys.google, + masked: maskKey(credentials.apiKeys.google), + }, + openai: { + configured: !!credentials.apiKeys.openai, + masked: maskKey(credentials.apiKeys.openai), + }, }; } diff --git a/apps/server/tests/unit/providers/codex-provider.test.ts b/apps/server/tests/unit/providers/codex-provider.test.ts index ada1aae1..6ca69d86 100644 --- a/apps/server/tests/unit/providers/codex-provider.test.ts +++ b/apps/server/tests/unit/providers/codex-provider.test.ts @@ -257,7 +257,7 @@ describe('codex-provider.ts', () => { expect(results[1].result).toBe('Hello from SDK'); }); - it('uses the CLI when tools are requested even if an API key is present', async () => { + it('uses the SDK when API key is present, even for tool requests (to avoid OAuth issues)', async () => { process.env[OPENAI_API_KEY_ENV] = 'sk-test'; vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})()); @@ -270,8 +270,8 @@ describe('codex-provider.ts', () => { }) ); - expect(codexRunMock).not.toHaveBeenCalled(); - expect(spawnJSONLProcess).toHaveBeenCalled(); + expect(codexRunMock).toHaveBeenCalled(); + expect(spawnJSONLProcess).not.toHaveBeenCalled(); }); it('falls back to CLI when no tools are requested and no API key is available', async () => { 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/eslint.config.mjs b/apps/ui/eslint.config.mjs index d7bc54d4..6db837e3 100644 --- a/apps/ui/eslint.config.mjs +++ b/apps/ui/eslint.config.mjs @@ -70,6 +70,8 @@ const eslintConfig = defineConfig([ AbortSignal: 'readonly', Audio: 'readonly', ScrollBehavior: 'readonly', + URL: 'readonly', + URLSearchParams: 'readonly', // Timers setTimeout: 'readonly', setInterval: 'readonly', diff --git a/apps/ui/package.json b/apps/ui/package.json index 61bd5ae8..384dc581 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -56,6 +56,7 @@ "@radix-ui/react-label": "2.1.8", "@radix-ui/react-popover": "1.1.15", "@radix-ui/react-radio-group": "1.3.8", + "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "2.2.6", "@radix-ui/react-slider": "1.3.6", "@radix-ui/react-slot": "1.2.4", 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/layout/project-switcher/components/edit-project-dialog.tsx b/apps/ui/src/components/layout/project-switcher/components/edit-project-dialog.tsx new file mode 100644 index 00000000..31e39367 --- /dev/null +++ b/apps/ui/src/components/layout/project-switcher/components/edit-project-dialog.tsx @@ -0,0 +1,187 @@ +import { useState, useRef } from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Upload, X, ImageIcon } from 'lucide-react'; +import { useAppStore } from '@/store/app-store'; +import { getAuthenticatedImageUrl } from '@/lib/api-fetch'; +import { getHttpApiClient } from '@/lib/http-api-client'; +import type { Project } from '@/lib/electron'; +import { IconPicker } from './icon-picker'; + +interface EditProjectDialogProps { + project: Project; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function EditProjectDialog({ project, open, onOpenChange }: EditProjectDialogProps) { + const { setProjectName, setProjectIcon, setProjectCustomIcon } = useAppStore(); + const [name, setName] = useState(project.name); + const [icon, setIcon] = useState((project as any).icon || null); + const [customIconPath, setCustomIconPath] = useState( + (project as any).customIconPath || null + ); + const [isUploadingIcon, setIsUploadingIcon] = useState(false); + const fileInputRef = useRef(null); + + const handleSave = () => { + if (name.trim() !== project.name) { + setProjectName(project.id, name.trim()); + } + if (icon !== (project as any).icon) { + setProjectIcon(project.id, icon); + } + if (customIconPath !== (project as any).customIconPath) { + setProjectCustomIcon(project.id, customIconPath); + } + onOpenChange(false); + }; + + const handleCustomIconUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + // Validate file type + const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; + if (!validTypes.includes(file.type)) { + return; + } + + // Validate file size (max 2MB for icons) + if (file.size > 2 * 1024 * 1024) { + return; + } + + setIsUploadingIcon(true); + try { + // Convert to base64 + const reader = new FileReader(); + reader.onload = async () => { + const base64Data = reader.result as string; + const result = await getHttpApiClient().saveImageToTemp( + base64Data, + `project-icon-${file.name}`, + file.type, + project.path + ); + if (result.success && result.path) { + setCustomIconPath(result.path); + // Clear the Lucide icon when custom icon is set + setIcon(null); + } + setIsUploadingIcon(false); + }; + reader.readAsDataURL(file); + } catch { + setIsUploadingIcon(false); + } + }; + + const handleRemoveCustomIcon = () => { + setCustomIconPath(null); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + + return ( + + + + Edit Project + + +
+ {/* Project Name */} +
+ + setName(e.target.value)} + placeholder="Enter project name" + /> +
+ + {/* Icon Picker */} +
+ +

+ Choose a preset icon or upload a custom image +

+ + {/* Custom Icon Upload */} +
+
+ {customIconPath ? ( +
+ Custom project icon + +
+ ) : ( +
+ +
+ )} +
+ + +

+ PNG, JPG, GIF or WebP. Max 2MB. +

+
+
+
+ + {/* Preset Icon Picker - only show if no custom icon */} + {!customIconPath && } +
+
+ + + + + +
+
+ ); +} 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 new file mode 100644 index 00000000..10947a51 --- /dev/null +++ b/apps/ui/src/components/layout/project-switcher/components/icon-picker.tsx @@ -0,0 +1,129 @@ +import { useState } from 'react'; +import { X, Search } from 'lucide-react'; +import * as LucideIcons from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Input } from '@/components/ui/input'; +import { ScrollArea } from '@/components/ui/scroll-area'; + +interface IconPickerProps { + selectedIcon: string | null; + onSelectIcon: (icon: string | null) => void; +} + +// Popular project-related icons +const POPULAR_ICONS = [ + 'Folder', + 'FolderOpen', + 'FolderCode', + 'FolderGit', + 'FolderKanban', + 'Package', + 'Box', + 'Boxes', + 'Code', + 'Code2', + 'Braces', + 'FileCode', + 'Terminal', + 'Globe', + 'Server', + 'Database', + 'Layout', + 'Layers', + 'Blocks', + 'Component', + 'Puzzle', + 'Cog', + 'Wrench', + 'Hammer', + 'Zap', + 'Rocket', + 'Sparkles', + 'Star', + 'Heart', + 'Shield', + 'Lock', + 'Key', + 'Cpu', + 'CircuitBoard', + 'Workflow', +]; + +export function IconPicker({ selectedIcon, onSelectIcon }: IconPickerProps) { + const [search, setSearch] = useState(''); + + const filteredIcons = POPULAR_ICONS.filter((icon) => + icon.toLowerCase().includes(search.toLowerCase()) + ); + + const getIconComponent = (iconName: string) => { + return (LucideIcons as Record>)[iconName]; + }; + + return ( +
+ {/* Search */} +
+ + setSearch(e.target.value)} + placeholder="Search icons..." + className="pl-9" + /> +
+ + {/* Selected Icon Display */} + {selectedIcon && ( +
+
+ {(() => { + const IconComponent = getIconComponent(selectedIcon); + return IconComponent ? : null; + })()} + {selectedIcon} +
+ +
+ )} + + {/* Icons Grid */} + +
+ {filteredIcons.map((iconName) => { + const IconComponent = getIconComponent(iconName); + if (!IconComponent) return null; + + const isSelected = selectedIcon === iconName; + + return ( + + ); + })} +
+
+
+ ); +} diff --git a/apps/ui/src/components/layout/project-switcher/components/index.ts b/apps/ui/src/components/layout/project-switcher/components/index.ts new file mode 100644 index 00000000..86073ca1 --- /dev/null +++ b/apps/ui/src/components/layout/project-switcher/components/index.ts @@ -0,0 +1,4 @@ +export { ProjectSwitcherItem } from './project-switcher-item'; +export { ProjectContextMenu } from './project-context-menu'; +export { EditProjectDialog } from './edit-project-dialog'; +export { IconPicker } from './icon-picker'; 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 new file mode 100644 index 00000000..84b6ea9a --- /dev/null +++ b/apps/ui/src/components/layout/project-switcher/components/project-context-menu.tsx @@ -0,0 +1,103 @@ +import { useEffect, useRef } from 'react'; +import { Edit2, Trash2 } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { useAppStore } from '@/store/app-store'; +import type { Project } from '@/lib/electron'; + +interface ProjectContextMenuProps { + project: Project; + position: { x: number; y: number }; + onClose: () => void; + onEdit: (project: Project) => void; +} + +export function ProjectContextMenu({ + project, + position, + onClose, + onEdit, +}: ProjectContextMenuProps) { + const menuRef = useRef(null); + const { moveProjectToTrash } = useAppStore(); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + onClose(); + } + }; + + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClose(); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('keydown', handleEscape); + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('keydown', handleEscape); + }; + }, [onClose]); + + const handleEdit = () => { + onEdit(project); + }; + + const handleRemove = () => { + if (confirm(`Remove "${project.name}" from the project list?`)) { + moveProjectToTrash(project.id); + } + onClose(); + }; + + return ( +
+
+ + + +
+
+ ); +} diff --git a/apps/ui/src/components/layout/project-switcher/components/project-switcher-item.tsx b/apps/ui/src/components/layout/project-switcher/components/project-switcher-item.tsx new file mode 100644 index 00000000..b4434f8b --- /dev/null +++ b/apps/ui/src/components/layout/project-switcher/components/project-switcher-item.tsx @@ -0,0 +1,116 @@ +import { Folder, LucideIcon } from 'lucide-react'; +import * as LucideIcons from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { getAuthenticatedImageUrl } from '@/lib/api-fetch'; +import type { Project } from '@/lib/electron'; + +interface ProjectSwitcherItemProps { + project: Project; + isActive: boolean; + hotkeyIndex?: number; // 0-9 for hotkeys 1-9, 0 + onClick: () => void; + onContextMenu: (event: React.MouseEvent) => void; +} + +export function ProjectSwitcherItem({ + project, + isActive, + hotkeyIndex, + onClick, + onContextMenu, +}: ProjectSwitcherItemProps) { + // Convert index to hotkey label: 0 -> "1", 1 -> "2", ..., 8 -> "9", 9 -> "0" + const hotkeyLabel = + hotkeyIndex !== undefined && hotkeyIndex >= 0 && hotkeyIndex <= 9 + ? hotkeyIndex === 9 + ? '0' + : String(hotkeyIndex + 1) + : undefined; + // Get the icon component from lucide-react + const getIconComponent = (): LucideIcon => { + if (project.icon && project.icon in LucideIcons) { + return (LucideIcons as Record)[project.icon]; + } + return Folder; + }; + + const IconComponent = getIconComponent(); + const hasCustomIcon = !!project.customIconPath; + + return ( + + ); +} diff --git a/apps/ui/src/components/layout/project-switcher/index.ts b/apps/ui/src/components/layout/project-switcher/index.ts new file mode 100644 index 00000000..f540a4f6 --- /dev/null +++ b/apps/ui/src/components/layout/project-switcher/index.ts @@ -0,0 +1 @@ +export { ProjectSwitcher } from './project-switcher'; diff --git a/apps/ui/src/components/layout/project-switcher/project-switcher.tsx b/apps/ui/src/components/layout/project-switcher/project-switcher.tsx new file mode 100644 index 00000000..e6080ab4 --- /dev/null +++ b/apps/ui/src/components/layout/project-switcher/project-switcher.tsx @@ -0,0 +1,317 @@ +import { useState, useCallback, useEffect } from 'react'; +import { Plus, Bug } from 'lucide-react'; +import { useNavigate } from '@tanstack/react-router'; +import { cn } from '@/lib/utils'; +import { useAppStore } 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'; +import { EditProjectDialog } from './components/edit-project-dialog'; +import { NewProjectModal } from '@/components/dialogs/new-project-modal'; +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'; + +function getOSAbbreviation(os: string): string { + switch (os) { + case 'mac': + return 'M'; + case 'windows': + return 'W'; + case 'linux': + return 'L'; + default: + return '?'; + } +} + +export function ProjectSwitcher() { + const navigate = useNavigate(); + const { + projects, + currentProject, + setCurrentProject, + trashedProjects, + upsertAndSetCurrentProject, + } = useAppStore(); + const [contextMenuProject, setContextMenuProject] = useState(null); + const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>( + null + ); + const [editDialogProject, setEditDialogProject] = useState(null); + + // Version info + const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0'; + const { os } = useOSDetection(); + const appMode = import.meta.env.VITE_APP_MODE || '?'; + const versionSuffix = `${getOSAbbreviation(os)}${appMode}`; + + // Get global theme for project creation + const { globalTheme } = useProjectTheme(); + + // Project creation state and handlers + const { + showNewProjectModal, + setShowNewProjectModal, + isCreatingProject, + showOnboardingDialog, + setShowOnboardingDialog, + newProjectName, + handleCreateBlankProject, + handleCreateFromTemplate, + handleCreateFromCustomUrl, + } = useProjectCreation({ + trashedProjects, + currentProject, + globalTheme, + upsertAndSetCurrentProject, + }); + + const handleContextMenu = (project: Project, event: React.MouseEvent) => { + event.preventDefault(); + setContextMenuProject(project); + setContextMenuPosition({ x: event.clientX, y: event.clientY }); + }; + + const handleCloseContextMenu = () => { + setContextMenuProject(null); + setContextMenuPosition(null); + }; + + const handleEditProject = (project: Project) => { + setEditDialogProject(project); + handleCloseContextMenu(); + }; + + const handleProjectClick = useCallback( + (project: Project) => { + setCurrentProject(project); + // Navigate to board view when switching projects + navigate({ to: '/board' }); + }, + [setCurrentProject, navigate] + ); + + const handleNewProject = () => { + // Open the new project modal + setShowNewProjectModal(true); + }; + + const handleOnboardingSkip = () => { + setShowOnboardingDialog(false); + navigate({ to: '/board' }); + }; + + const handleBugReportClick = useCallback(() => { + const api = getElectronAPI(); + api.openExternalLink('https://github.com/AutoMaker-Org/automaker/issues'); + }, []); + + // Keyboard shortcuts for project switching (1-9, 0) + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + // Ignore if user is typing in an input, textarea, or contenteditable + const target = event.target as HTMLElement; + if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) { + return; + } + + // Ignore if modifier keys are pressed (except for standalone number keys) + if (event.ctrlKey || event.metaKey || event.altKey) { + return; + } + + // Map key to project index: "1" -> 0, "2" -> 1, ..., "9" -> 8, "0" -> 9 + const key = event.key; + let projectIndex: number | null = null; + + if (key >= '1' && key <= '9') { + projectIndex = parseInt(key, 10) - 1; // "1" -> 0, "9" -> 8 + } else if (key === '0') { + projectIndex = 9; // "0" -> 9 + } + + if (projectIndex !== null && projectIndex < projects.length) { + const targetProject = projects[projectIndex]; + if (targetProject && targetProject.id !== currentProject?.id) { + handleProjectClick(targetProject); + } + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [projects, currentProject, handleProjectClick]); + + return ( + <> + + + {/* Context Menu */} + {contextMenuProject && contextMenuPosition && ( + + )} + + {/* Edit Project Dialog */} + {editDialogProject && ( + !open && setEditDialogProject(null)} + /> + )} + + {/* New Project Modal */} + + + {/* Onboarding Dialog */} + + + ); +} diff --git a/apps/ui/src/components/layout/sidebar.tsx b/apps/ui/src/components/layout/sidebar.tsx index a8c70cb6..92e804ce 100644 --- a/apps/ui/src/components/layout/sidebar.tsx +++ b/apps/ui/src/components/layout/sidebar.tsx @@ -18,7 +18,6 @@ import { CollapseToggleButton, SidebarHeader, SidebarNavigation, - ProjectSelectorWithOptions, SidebarFooter, } from './sidebar/components'; import { TrashDialog, OnboardingDialog } from './sidebar/dialogs'; @@ -64,9 +63,6 @@ export function Sidebar() { // Get customizable keyboard shortcuts const shortcuts = useKeyboardShortcutsConfig(); - // State for project picker (needed for keyboard shortcuts) - const [isProjectPickerOpen, setIsProjectPickerOpen] = useState(false); - // State for delete project confirmation dialog const [showDeleteProjectDialog, setShowDeleteProjectDialog] = useState(false); @@ -240,7 +236,6 @@ export function Sidebar() { navigate, toggleSidebar, handleOpenFolder, - setIsProjectPickerOpen, cyclePrevProject, cycleNextProject, unviewedValidationsCount, @@ -258,26 +253,25 @@ export function Sidebar() { return ( <> - {/* Mobile overlay backdrop */} + {/* Mobile backdrop overlay */} {sidebarOpen && (