Merge branch 'v0.11.0rc' into feat/dev-server-log-panel

Resolved conflict in worktree-panel.tsx by combining imports:
- DevServerLogsPanel from this branch
- WorktreeMobileDropdown, WorktreeActionsDropdown, BranchSwitchDropdown from v0.11.0rc

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Shirone
2026-01-13 22:01:44 +01:00
111 changed files with 7248 additions and 990 deletions

3
.gitignore vendored
View File

@@ -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/

View File

@@ -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

253
DEVELOPMENT_WORKFLOW.md Normal file
View File

@@ -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/<TARGET_RC>` 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 <branch>`
Override for a single run:
```bash
./check-sync.sh --rc <rc-branch>
```
## 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/<TARGET_RC> --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 <branch>
```
- If you have local fixes to publish, push **origin + upstream**:
```bash
git push origin <branch>
git push upstream <branch>:<TARGET_RC>
```
- Always ask the user which push to perform.
- Origin (origin-only sync):
```bash
git push origin <branch>
```
- Upstream RC (publish the same fixes when you have local commits):
```bash
git push upstream <branch>:<TARGET_RC>
```
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 <branch>
```
2. **Make changes and commit**
```bash
git add -A
git commit -m "type: description"
```
3. **Merge upstream RC before shipping**
```bash
git merge upstream/<TARGET_RC> --no-edit
```
4. **Build and/or test**
```bash
npm run build:packages
npm run build
```
5. **Push to origin**
```bash
git push -u origin <branch>
```
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 <rc-branch>
```
- 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/<TARGET_RC>` 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.

View File

@@ -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());

View File

@@ -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<string, string> {
return env;
}
async function resolveOpenAiApiKey(): Promise<string | null> {
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<CodexExecutionPlan> {
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<Codex
return {
mode: CODEX_EXECUTION_MODE_CLI,
cliPath,
openAiApiKey,
};
}
@@ -701,7 +723,14 @@ export class CodexProvider extends BaseProvider {
const executionPlan = await resolveCodexExecutionPlan(options);
if (executionPlan.mode === CODEX_EXECUTION_MODE_SDK) {
yield* executeCodexSdkQuery(options, combinedSystemPrompt);
const cleanupEnv = executionPlan.openAiApiKey
? createTempEnvOverride({ [OPENAI_API_KEY_ENV]: executionPlan.openAiApiKey })
: null;
try {
yield* executeCodexSdkQuery(options, combinedSystemPrompt);
} finally {
cleanupEnv?.();
}
return;
}
@@ -780,11 +809,16 @@ export class CodexProvider extends BaseProvider {
'-', // Read prompt from stdin to avoid shell escaping issues
];
const envOverrides = buildEnv();
if (executionPlan.openAiApiKey && !envOverrides[OPENAI_API_KEY_ENV]) {
envOverrides[OPENAI_API_KEY_ENV] = executionPlan.openAiApiKey;
}
const stream = spawnJSONLProcess({
command: commandPath,
args,
cwd: options.cwd,
env: buildEnv(),
env: envOverrides,
abortController: options.abortController,
timeout: DEFAULT_TIMEOUT_MS,
stdinData: promptText, // Pass prompt via stdin
@@ -971,7 +1005,7 @@ export class CodexProvider extends BaseProvider {
async detectInstallation(): Promise<InstallationStatus> {
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<CodexAuthStatus> {
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

View File

@@ -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<SimpleQueryResult> {
const model = options.model || DEFAULT_MODEL;
const provider = ProviderFactory.getProviderForModel(model);
const bareModel = stripProviderPrefix(model);
let responseText = '';
let structuredOutput: Record<string, unknown> | undefined;
@@ -104,7 +113,8 @@ export async function simpleQuery(options: SimpleQueryOptions): Promise<SimpleQu
// Build provider options
const providerOptions = {
prompt: options.prompt,
model: model,
model: bareModel,
originalModel: model,
cwd: options.cwd,
systemPrompt: options.systemPrompt,
maxTurns: options.maxTurns ?? 1,
@@ -112,6 +122,7 @@ export async function simpleQuery(options: SimpleQueryOptions): Promise<SimpleQu
abortController: options.abortController,
outputFormat: options.outputFormat,
thinkingLevel: options.thinkingLevel,
reasoningEffort: options.reasoningEffort,
readOnly: options.readOnly,
settingSources: options.settingSources,
};
@@ -176,6 +187,7 @@ export async function simpleQuery(options: SimpleQueryOptions): Promise<SimpleQu
export async function streamingQuery(options: StreamingQueryOptions): Promise<SimpleQueryResult> {
const model = options.model || DEFAULT_MODEL;
const provider = ProviderFactory.getProviderForModel(model);
const bareModel = stripProviderPrefix(model);
let responseText = '';
let structuredOutput: Record<string, unknown> | undefined;
@@ -183,7 +195,8 @@ export async function streamingQuery(options: StreamingQueryOptions): Promise<Si
// Build provider options
const providerOptions = {
prompt: options.prompt,
model: model,
model: bareModel,
originalModel: model,
cwd: options.cwd,
systemPrompt: options.systemPrompt,
maxTurns: options.maxTurns ?? 250,
@@ -191,6 +204,7 @@ export async function streamingQuery(options: StreamingQueryOptions): Promise<Si
abortController: options.abortController,
outputFormat: options.outputFormat,
thinkingLevel: options.thinkingLevel,
reasoningEffort: options.reasoningEffort,
readOnly: options.readOnly,
settingSources: options.settingSources,
};

View File

@@ -5,6 +5,43 @@
import type { Request, Response } from 'express';
import { execAsync, execEnv, getErrorMessage, logError } from './common.js';
const GIT_REMOTE_ORIGIN_COMMAND = 'git remote get-url origin';
const GH_REPO_VIEW_COMMAND = 'gh repo view --json name,owner';
const GITHUB_REPO_URL_PREFIX = 'https://github.com/';
const GITHUB_HTTPS_REMOTE_REGEX = /https:\/\/github\.com\/([^/]+)\/([^/.]+)/;
const GITHUB_SSH_REMOTE_REGEX = /git@github\.com:([^/]+)\/([^/.]+)/;
interface GhRepoViewResponse {
name?: string;
owner?: {
login?: string;
};
}
async function resolveRepoFromGh(projectPath: string): Promise<{
owner: string;
repo: string;
} | null> {
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<GitHubRemo
};
try {
// Get the remote URL (origin by default)
const { stdout } = await execAsync('git remote get-url origin', {
cwd: projectPath,
env: execEnv,
});
let remoteUrl = '';
try {
// Get the remote URL (origin by default)
const { stdout } = await execAsync(GIT_REMOTE_ORIGIN_COMMAND, {
cwd: projectPath,
env: execEnv,
});
remoteUrl = stdout.trim();
status.remoteUrl = remoteUrl || null;
} catch {
// Ignore missing origin remote
}
const remoteUrl = stdout.trim();
status.remoteUrl = remoteUrl;
const ghRepo = await resolveRepoFromGh(projectPath);
if (ghRepo) {
status.hasGitHubRemote = true;
status.owner = ghRepo.owner;
status.repo = ghRepo.repo;
if (!status.remoteUrl) {
status.remoteUrl = `${GITHUB_REPO_URL_PREFIX}${ghRepo.owner}/${ghRepo.repo}`;
}
return status;
}
// Check if it's a GitHub URL
// Formats: https://github.com/owner/repo.git, git@github.com:owner/repo.git
const httpsMatch = remoteUrl.match(/https:\/\/github\.com\/([^/]+)\/([^/.]+)/);
const sshMatch = remoteUrl.match(/git@github\.com:([^/]+)\/([^/.]+)/);
if (!remoteUrl) {
return status;
}
const httpsMatch = remoteUrl.match(GITHUB_HTTPS_REMOTE_REGEX);
const sshMatch = remoteUrl.match(GITHUB_SSH_REMOTE_REGEX);
const match = httpsMatch || sshMatch;
if (match) {

View File

@@ -25,19 +25,24 @@ interface GraphQLComment {
updatedAt: string;
}
interface GraphQLCommentConnection {
totalCount: number;
pageInfo: {
hasNextPage: boolean;
endCursor: string | null;
};
nodes: GraphQLComment[];
}
interface GraphQLIssueOrPullRequest {
__typename: 'Issue' | 'PullRequest';
comments: GraphQLCommentConnection;
}
interface GraphQLResponse {
data?: {
repository?: {
issue?: {
comments: {
totalCount: number;
pageInfo: {
hasNextPage: boolean;
endCursor: string | null;
};
nodes: GraphQLComment[];
};
};
issueOrPullRequest?: GraphQLIssueOrPullRequest | null;
};
};
errors?: Array<{ message: string }>;
@@ -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) => ({

View File

@@ -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,

View File

@@ -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,

View File

@@ -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<void> {
// 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)

View File

@@ -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';

View File

@@ -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<Credentials>` (usually just apiKeys)

View File

@@ -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) {

View File

@@ -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<string, string> = {
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');

View File

@@ -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'),

View File

@@ -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<T>(
generator: AsyncIterable<T>,
timeoutMs: number
): AsyncGenerator<T, void, unknown> {
const timeoutPromise = new Promise<never>((_, 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<string> {
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<string> {
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<void> {
return async (req: Request, res: Response): Promise<void> => {
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);
}
};
}

View File

@@ -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: [],

View File

@@ -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<string, string>,
});
} 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;
}

View File

@@ -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),
},
};
}

View File

@@ -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 () => {

View File

@@ -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

View File

@@ -70,6 +70,8 @@ const eslintConfig = defineConfig([
AbortSignal: 'readonly',
Audio: 'readonly',
ScrollBehavior: 'readonly',
URL: 'readonly',
URLSearchParams: 'readonly',
// Timers
setTimeout: 'readonly',
setInterval: 'readonly',

View File

@@ -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",

View File

@@ -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);

View File

@@ -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<string | null>((project as any).icon || null);
const [customIconPath, setCustomIconPath] = useState<string | null>(
(project as any).customIconPath || null
);
const [isUploadingIcon, setIsUploadingIcon] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Edit Project</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4 overflow-y-auto flex-1 min-h-0">
{/* Project Name */}
<div className="space-y-2">
<Label htmlFor="project-name">Project Name</Label>
<Input
id="project-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter project name"
/>
</div>
{/* Icon Picker */}
<div className="space-y-2">
<Label>Project Icon</Label>
<p className="text-xs text-muted-foreground mb-2">
Choose a preset icon or upload a custom image
</p>
{/* Custom Icon Upload */}
<div className="mb-4">
<div className="flex items-center gap-3">
{customIconPath ? (
<div className="relative">
<img
src={getAuthenticatedImageUrl(customIconPath, project.path)}
alt="Custom project icon"
className="w-12 h-12 rounded-lg object-cover border border-border"
/>
<button
type="button"
onClick={handleRemoveCustomIcon}
className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full bg-destructive text-destructive-foreground flex items-center justify-center hover:bg-destructive/90"
>
<X className="w-3 h-3" />
</button>
</div>
) : (
<div className="w-12 h-12 rounded-lg border border-dashed border-border flex items-center justify-center bg-accent/30">
<ImageIcon className="w-5 h-5 text-muted-foreground" />
</div>
)}
<div className="flex-1">
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
onChange={handleCustomIconUpload}
className="hidden"
id="custom-icon-upload-dialog"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={isUploadingIcon}
className="gap-1.5"
>
<Upload className="w-3.5 h-3.5" />
{isUploadingIcon ? 'Uploading...' : 'Upload Custom Icon'}
</Button>
<p className="text-xs text-muted-foreground mt-1">
PNG, JPG, GIF or WebP. Max 2MB.
</p>
</div>
</div>
</div>
{/* Preset Icon Picker - only show if no custom icon */}
{!customIconPath && <IconPicker selectedIcon={icon} onSelectIcon={setIcon} />}
</div>
</div>
<DialogFooter className="flex-shrink-0">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSave} disabled={!name.trim()}>
Save Changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -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<string, React.ComponentType<{ className?: string }>>)[iconName];
};
return (
<div className="space-y-3">
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search icons..."
className="pl-9"
/>
</div>
{/* Selected Icon Display */}
{selectedIcon && (
<div className="flex items-center gap-2 p-2 rounded-md bg-accent/50 border border-border">
<div className="flex items-center gap-2 flex-1">
{(() => {
const IconComponent = getIconComponent(selectedIcon);
return IconComponent ? <IconComponent className="w-5 h-5 text-brand-500" /> : null;
})()}
<span className="text-sm font-medium">{selectedIcon}</span>
</div>
<button
onClick={() => onSelectIcon(null)}
className="p-1 hover:bg-background rounded transition-colors"
title="Clear icon"
>
<X className="w-4 h-4" />
</button>
</div>
)}
{/* Icons Grid */}
<ScrollArea className="h-64 rounded-md border">
<div className="grid grid-cols-6 gap-1 p-2">
{filteredIcons.map((iconName) => {
const IconComponent = getIconComponent(iconName);
if (!IconComponent) return null;
const isSelected = selectedIcon === iconName;
return (
<button
key={iconName}
onClick={() => onSelectIcon(iconName)}
className={cn(
'aspect-square rounded-md flex items-center justify-center',
'transition-all duration-150',
'hover:bg-accent hover:scale-110',
isSelected
? 'bg-brand-500/20 border-2 border-brand-500'
: 'border border-transparent'
)}
title={iconName}
>
<IconComponent
className={cn('w-5 h-5', isSelected ? 'text-brand-500' : 'text-foreground')}
/>
</button>
);
})}
</div>
</ScrollArea>
</div>
);
}

View File

@@ -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';

View File

@@ -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<HTMLDivElement>(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 (
<div
ref={menuRef}
className={cn(
'fixed z-[100] min-w-48 rounded-lg',
'bg-popover text-popover-foreground',
'border border-border shadow-lg',
'animate-in fade-in zoom-in-95 duration-100'
)}
style={{
top: position.y,
left: position.x,
}}
data-testid="project-context-menu"
>
<div className="p-1">
<button
onClick={handleEdit}
className={cn(
'w-full flex items-center gap-2 px-3 py-2 rounded-md',
'text-sm font-medium text-left',
'hover:bg-accent transition-colors',
'focus:outline-none focus:bg-accent'
)}
data-testid="edit-project-button"
>
<Edit2 className="w-4 h-4" />
<span>Edit Name & Icon</span>
</button>
<button
onClick={handleRemove}
className={cn(
'w-full flex items-center gap-2 px-3 py-2 rounded-md',
'text-sm font-medium text-left',
'text-destructive hover:bg-destructive/10',
'transition-colors',
'focus:outline-none focus:bg-destructive/10'
)}
data-testid="remove-project-button"
>
<Trash2 className="w-4 h-4" />
<span>Remove Project</span>
</button>
</div>
</div>
);
}

View File

@@ -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<string, LucideIcon>)[project.icon];
}
return Folder;
};
const IconComponent = getIconComponent();
const hasCustomIcon = !!project.customIconPath;
return (
<button
onClick={onClick}
onContextMenu={onContextMenu}
className={cn(
'group w-full aspect-square rounded-xl flex items-center justify-center relative overflow-hidden',
'transition-all duration-200 ease-out',
isActive
? [
// Active: Premium gradient with glow
'bg-gradient-to-r from-brand-500/20 via-brand-500/15 to-brand-600/10',
'border border-brand-500/30',
'shadow-md shadow-brand-500/10',
]
: [
// Inactive: Subtle hover state
'hover:bg-accent/50',
'border border-transparent hover:border-border/40',
'hover:shadow-sm',
],
'hover:scale-105 active:scale-95'
)}
title={project.name}
data-testid={`project-switcher-${project.id}`}
>
{hasCustomIcon ? (
<img
src={getAuthenticatedImageUrl(project.customIconPath!, project.path)}
alt={project.name}
className={cn(
'w-8 h-8 rounded-lg object-cover transition-all duration-200',
isActive ? 'ring-1 ring-brand-500/50' : 'group-hover:scale-110'
)}
/>
) : (
<IconComponent
className={cn(
'w-6 h-6 transition-all duration-200',
isActive
? 'text-brand-500 drop-shadow-sm'
: 'text-muted-foreground group-hover:text-brand-400 group-hover:scale-110'
)}
/>
)}
{/* Tooltip on hover */}
<span
className={cn(
'absolute left-full ml-3 px-2.5 py-1.5 rounded-lg',
'bg-popover text-popover-foreground text-xs font-medium',
'border border-border shadow-lg',
'opacity-0 group-hover:opacity-100',
'transition-all duration-200 whitespace-nowrap z-50',
'translate-x-1 group-hover:translate-x-0 pointer-events-none'
)}
>
{project.name}
</span>
{/* Hotkey badge */}
{hotkeyLabel && (
<span
className={cn(
'absolute bottom-0.5 right-0.5 min-w-[16px] h-4 px-1',
'flex items-center justify-center',
'text-[10px] font-medium rounded',
'bg-muted/80 text-muted-foreground',
'border border-border/50',
'pointer-events-none'
)}
>
{hotkeyLabel}
</span>
)}
</button>
);
}

View File

@@ -0,0 +1 @@
export { ProjectSwitcher } from './project-switcher';

View File

@@ -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<Project | null>(null);
const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(
null
);
const [editDialogProject, setEditDialogProject] = useState<Project | null>(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 (
<>
<aside
className={cn(
'flex-shrink-0 flex flex-col w-16 z-50 relative',
// Glass morphism background with gradient
'bg-gradient-to-b from-sidebar/95 via-sidebar/85 to-sidebar/90 backdrop-blur-2xl',
// Premium border with subtle glow
'border-r border-border/60 shadow-[1px_0_20px_-5px_rgba(0,0,0,0.1)]'
)}
data-testid="project-switcher"
>
{/* Automaker Logo and Version */}
<div className="flex flex-col items-center pt-3 pb-2 px-2">
<button
onClick={() => navigate({ to: '/dashboard' })}
className="group flex flex-col items-center gap-0.5"
title="Go to Dashboard"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
role="img"
aria-label="Automaker Logo"
className="size-10 group-hover:rotate-12 transition-transform duration-300 ease-out"
>
<defs>
<linearGradient
id="bg-switcher"
x1="0"
y1="0"
x2="256"
y2="256"
gradientUnits="userSpaceOnUse"
>
<stop offset="0%" style={{ stopColor: 'var(--brand-400)' }} />
<stop offset="100%" style={{ stopColor: 'var(--brand-600)' }} />
</linearGradient>
</defs>
<rect x="16" y="16" width="224" height="224" rx="56" fill="url(#bg-switcher)" />
<g
fill="none"
stroke="#FFFFFF"
strokeWidth="20"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M92 92 L52 128 L92 164" />
<path d="M144 72 L116 184" />
<path d="M164 92 L204 128 L164 164" />
</g>
</svg>
<span className="text-[0.625rem] text-muted-foreground leading-none font-medium">
v{appVersion} {versionSuffix}
</span>
</button>
<div className="w-full h-px bg-border mt-3" />
</div>
{/* Projects List */}
<div className="flex-1 overflow-y-auto py-3 px-2 space-y-2">
{projects.map((project, index) => (
<ProjectSwitcherItem
key={project.id}
project={project}
isActive={currentProject?.id === project.id}
hotkeyIndex={index < 10 ? index : undefined}
onClick={() => handleProjectClick(project)}
onContextMenu={(e) => handleContextMenu(project, e)}
/>
))}
{/* Horizontal rule and Add Project Button - only show if there are projects */}
{projects.length > 0 && (
<>
<div className="w-full h-px bg-border/40 my-2" />
<button
onClick={handleNewProject}
className={cn(
'w-full aspect-square rounded-xl flex items-center justify-center',
'transition-all duration-200 ease-out',
'text-muted-foreground hover:text-foreground',
'hover:bg-accent/50 border border-transparent hover:border-border/40',
'hover:shadow-sm hover:scale-105 active:scale-95'
)}
title="New Project"
data-testid="new-project-button"
>
<Plus className="w-5 h-5" />
</button>
</>
)}
{/* Add Project Button - when no projects, show without rule */}
{projects.length === 0 && (
<button
onClick={handleNewProject}
className={cn(
'w-full aspect-square rounded-xl flex items-center justify-center',
'transition-all duration-200 ease-out',
'text-muted-foreground hover:text-foreground',
'hover:bg-accent/50 border border-transparent hover:border-border/40',
'hover:shadow-sm hover:scale-105 active:scale-95'
)}
title="New Project"
data-testid="new-project-button"
>
<Plus className="w-5 h-5" />
</button>
)}
</div>
{/* Bug Report Button at the very bottom */}
<div className="p-2 border-t border-border/40">
<button
onClick={handleBugReportClick}
className={cn(
'w-full aspect-square rounded-xl flex items-center justify-center',
'transition-all duration-200 ease-out',
'text-muted-foreground hover:text-foreground',
'hover:bg-accent/50 border border-transparent hover:border-border/40',
'hover:shadow-sm hover:scale-105 active:scale-95'
)}
title="Report Bug / Feature Request"
data-testid="bug-report-button"
>
<Bug className="w-5 h-5" />
</button>
</div>
</aside>
{/* Context Menu */}
{contextMenuProject && contextMenuPosition && (
<ProjectContextMenu
project={contextMenuProject}
position={contextMenuPosition}
onClose={handleCloseContextMenu}
onEdit={handleEditProject}
/>
)}
{/* Edit Project Dialog */}
{editDialogProject && (
<EditProjectDialog
project={editDialogProject}
open={!!editDialogProject}
onOpenChange={(open) => !open && setEditDialogProject(null)}
/>
)}
{/* New Project Modal */}
<NewProjectModal
open={showNewProjectModal}
onOpenChange={setShowNewProjectModal}
onCreateBlankProject={handleCreateBlankProject}
onCreateFromTemplate={handleCreateFromTemplate}
onCreateFromCustomUrl={handleCreateFromCustomUrl}
isCreating={isCreatingProject}
/>
{/* Onboarding Dialog */}
<OnboardingDialog
open={showOnboardingDialog}
onOpenChange={setShowOnboardingDialog}
newProjectName={newProjectName}
onSkip={handleOnboardingSkip}
onGenerateSpec={handleOnboardingSkip}
/>
</>
);
}

View File

@@ -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 && (
<div
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
className="fixed inset-0 bg-black/50 z-20 lg:hidden"
onClick={toggleSidebar}
aria-hidden="true"
data-testid="sidebar-backdrop"
/>
)}
<aside
className={cn(
'flex-shrink-0 flex flex-col z-50 relative',
'flex-shrink-0 flex flex-col z-30',
// Glass morphism background with gradient
'bg-gradient-to-b from-sidebar/95 via-sidebar/85 to-sidebar/90 backdrop-blur-2xl',
// Premium border with subtle glow
'border-r border-border/60 shadow-[1px_0_20px_-5px_rgba(0,0,0,0.1)]',
// Smooth width transition
'transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]',
// Mobile: hidden when closed, full width overlay when open
// Desktop: always visible, toggle between narrow and wide
sidebarOpen ? 'fixed lg:relative left-0 top-0 h-full w-72' : 'hidden lg:flex w-16'
// Mobile: overlay when open, collapsed when closed
sidebarOpen ? 'fixed inset-y-0 left-0 w-72 lg:relative lg:w-72' : 'relative w-16'
)}
data-testid="sidebar"
>
@@ -288,14 +282,7 @@ export function Sidebar() {
/>
<div className="flex-1 flex flex-col overflow-hidden">
<SidebarHeader sidebarOpen={sidebarOpen} navigate={navigate} />
<ProjectSelectorWithOptions
sidebarOpen={sidebarOpen}
isProjectPickerOpen={isProjectPickerOpen}
setIsProjectPickerOpen={setIsProjectPickerOpen}
setShowDeleteProjectDialog={setShowDeleteProjectDialog}
/>
<SidebarHeader sidebarOpen={sidebarOpen} currentProject={currentProject} />
<SidebarNavigation
currentProject={currentProject}

View File

@@ -17,9 +17,7 @@ export function CollapseToggleButton({
<button
onClick={toggleSidebar}
className={cn(
// Show on desktop always, show on mobile only when sidebar is open
sidebarOpen ? 'flex' : 'hidden lg:flex',
'absolute top-[68px] -right-3 z-9999',
'flex absolute top-[68px] -right-3 z-9999',
'group/toggle items-center justify-center w-7 h-7 rounded-full',
// Glass morphism button
'bg-card/95 backdrop-blur-sm border border-border/80',

View File

@@ -40,7 +40,7 @@ export function ProjectActions({
data-testid="new-project-button"
>
<Plus className="w-4 h-4 shrink-0 transition-transform duration-200 group-hover:rotate-90 group-hover:text-brand-500" />
<span className="ml-2 text-sm font-medium hidden lg:block whitespace-nowrap">New</span>
<span className="ml-2 text-sm font-medium block whitespace-nowrap">New</span>
</button>
<button
onClick={handleOpenFolder}
@@ -59,7 +59,7 @@ export function ProjectActions({
data-testid="open-project-button"
>
<FolderOpen className="w-4 h-4 shrink-0 transition-transform duration-200 group-hover:scale-110" />
<span className="hidden lg:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md bg-muted/80 text-muted-foreground ml-2">
<span className="flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md bg-muted/80 text-muted-foreground ml-2">
{formatShortcut(shortcuts.openProject, true)}
</span>
</button>

View File

@@ -235,7 +235,7 @@ export function SidebarFooter({
{sidebarOpen && (
<span
className={cn(
'hidden sm:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200',
'flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200',
isActiveRoute('settings')
? 'bg-brand-500/20 text-brand-400'
: 'bg-muted text-muted-foreground group-hover:bg-accent'

View File

@@ -1,43 +1,64 @@
import type { NavigateOptions } from '@tanstack/react-router';
import { Folder, LucideIcon } from 'lucide-react';
import * as LucideIcons from 'lucide-react';
import { cn, isMac } from '@/lib/utils';
import { AutomakerLogo } from './automaker-logo';
import { BugReportButton } from './bug-report-button';
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
import type { Project } from '@/lib/electron';
interface SidebarHeaderProps {
sidebarOpen: boolean;
navigate: (opts: NavigateOptions) => void;
currentProject: Project | null;
}
export function SidebarHeader({ sidebarOpen, navigate }: SidebarHeaderProps) {
return (
<>
{/* Logo */}
<div
className={cn(
'h-20 shrink-0 titlebar-drag-region',
// Subtle bottom border with gradient fade
'border-b border-border/40',
// Background gradient for depth
'bg-gradient-to-b from-transparent to-background/5',
'flex items-center',
sidebarOpen ? 'px-4 lg:px-5 justify-start' : 'px-3 justify-center',
// Add padding on macOS to avoid overlapping with traffic light buttons
isMac && sidebarOpen && 'pt-4',
// Smaller top padding on macOS when collapsed
isMac && !sidebarOpen && 'pt-4'
)}
>
<AutomakerLogo sidebarOpen={sidebarOpen} navigate={navigate} />
{/* Bug Report Button - Inside logo container when expanded */}
{sidebarOpen && <BugReportButton sidebarExpanded />}
</div>
export function SidebarHeader({ sidebarOpen, currentProject }: SidebarHeaderProps) {
// Get the icon component from lucide-react
const getIconComponent = (): LucideIcon => {
if (currentProject?.icon && currentProject.icon in LucideIcons) {
return (LucideIcons as Record<string, LucideIcon>)[currentProject.icon];
}
return Folder;
};
{/* Bug Report Button - Collapsed sidebar version */}
{!sidebarOpen && (
<div className="px-3 mt-1.5 flex justify-center">
<BugReportButton sidebarExpanded={false} />
const IconComponent = getIconComponent();
const hasCustomIcon = !!currentProject?.customIconPath;
return (
<div
className={cn(
'shrink-0 flex flex-col',
// Add minimal padding on macOS for traffic light buttons
isMac && 'pt-2'
)}
>
{/* Project name and icon display */}
{currentProject && (
<div
className={cn('flex items-center gap-3 px-4 py-3', !sidebarOpen && 'justify-center px-2')}
>
{/* Project Icon */}
<div className="shrink-0">
{hasCustomIcon ? (
<img
src={getAuthenticatedImageUrl(currentProject.customIconPath!, currentProject.path)}
alt={currentProject.name}
className="w-8 h-8 rounded-lg object-cover ring-1 ring-border/50"
/>
) : (
<div className="w-8 h-8 rounded-lg bg-brand-500/10 border border-brand-500/20 flex items-center justify-center">
<IconComponent className="w-5 h-5 text-brand-500" />
</div>
)}
</div>
{/* Project Name - only show when sidebar is open */}
{sidebarOpen && (
<div className="flex-1 min-w-0">
<h2 className="text-sm font-semibold text-foreground truncate">
{currentProject.name}
</h2>
</div>
)}
</div>
)}
</>
</div>
);
}

View File

@@ -26,7 +26,7 @@ export function SidebarNavigation({
// Placeholder when no project is selected (only in expanded state)
<div className="flex items-center justify-center h-full px-4">
<p className="text-muted-foreground text-sm text-center">
<span className="hidden lg:block">Select or create a project above</span>
<span className="block">Select or create a project above</span>
</p>
</div>
) : currentProject ? (
@@ -137,7 +137,7 @@ export function SidebarNavigation({
{item.shortcut && sidebarOpen && !item.count && (
<span
className={cn(
'hidden sm:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200',
'flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200',
isActive
? 'bg-brand-500/20 text-brand-400'
: 'bg-muted text-muted-foreground group-hover:bg-accent'

View File

@@ -45,7 +45,6 @@ interface UseNavigationProps {
navigate: (opts: NavigateOptions) => void;
toggleSidebar: () => void;
handleOpenFolder: () => void;
setIsProjectPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
cyclePrevProject: () => void;
cycleNextProject: () => void;
/** Count of unviewed validations to show on GitHub Issues nav item */
@@ -65,7 +64,6 @@ export function useNavigation({
navigate,
toggleSidebar,
handleOpenFolder,
setIsProjectPickerOpen,
cyclePrevProject,
cycleNextProject,
unviewedValidationsCount,
@@ -230,15 +228,6 @@ export function useNavigation({
description: 'Open folder selection dialog',
});
// Project picker shortcut - only when we have projects
if (projects.length > 0) {
shortcutsList.push({
key: shortcuts.projectPicker,
action: () => setIsProjectPickerOpen((prev) => !prev),
description: 'Toggle project picker',
});
}
// Project cycling shortcuts - only when we have project history
if (projectHistory.length > 1) {
shortcutsList.push({
@@ -288,7 +277,6 @@ export function useNavigation({
cyclePrevProject,
cycleNextProject,
navSections,
setIsProjectPickerOpen,
]);
return {

View File

@@ -1,6 +1,6 @@
import { useState, useCallback, useMemo } from 'react';
import { useAppStore } from '@/store/app-store';
import type { ModelAlias, CursorModelId, PhaseModelKey, PhaseModelEntry } from '@automaker/types';
import type { ModelId, PhaseModelKey, PhaseModelEntry } from '@automaker/types';
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
export interface UseModelOverrideOptions {
@@ -14,7 +14,7 @@ export interface UseModelOverrideResult {
/** The effective model entry (override or global default) */
effectiveModelEntry: PhaseModelEntry;
/** The effective model string (for backward compatibility with APIs that only accept strings) */
effectiveModel: ModelAlias | CursorModelId;
effectiveModel: ModelId;
/** Whether the model is currently overridden */
isOverridden: boolean;
/** Set a model override */
@@ -32,7 +32,7 @@ export interface UseModelOverrideResult {
*/
function normalizeEntry(entry: PhaseModelEntry | string): PhaseModelEntry {
if (typeof entry === 'string') {
return { model: entry as ModelAlias | CursorModelId };
return { model: entry as ModelId };
}
return entry;
}
@@ -40,9 +40,9 @@ function normalizeEntry(entry: PhaseModelEntry | string): PhaseModelEntry {
/**
* Extract model string from PhaseModelEntry or string
*/
function extractModel(entry: PhaseModelEntry | string): ModelAlias | CursorModelId {
function extractModel(entry: PhaseModelEntry | string): ModelId {
if (typeof entry === 'string') {
return entry as ModelAlias | CursorModelId;
return entry as ModelId;
}
return entry.model;
}

View File

@@ -137,6 +137,8 @@ export function Autocomplete({
width: Math.max(triggerWidth, 200),
}}
data-testid={testId ? `${testId}-list` : undefined}
onWheel={(e) => e.stopPropagation()}
onTouchMove={(e) => e.stopPropagation()}
>
<Command shouldFilter={false}>
<CommandInput

View File

@@ -78,7 +78,14 @@ function CommandList({ className, ...props }: React.ComponentProps<typeof Comman
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn('max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto', className)}
className={cn(
'max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto',
// Mobile touch scrolling support
'touch-pan-y overscroll-contain',
// iOS Safari momentum scrolling
'[&]:[-webkit-overflow-scrolling:touch]',
className
)}
{...props}
/>
);

View File

@@ -0,0 +1,73 @@
import * as React from 'react';
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
import { cn } from '@/lib/utils';
// Type-safe wrappers for Radix UI primitives (React 19 compatibility)
const ScrollAreaRootPrimitive = ScrollAreaPrimitive.Root as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> & {
children?: React.ReactNode;
className?: string;
} & React.RefAttributes<HTMLDivElement>
>;
const ScrollAreaViewportPrimitive = ScrollAreaPrimitive.Viewport as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Viewport> & {
children?: React.ReactNode;
className?: string;
} & React.RefAttributes<HTMLDivElement>
>;
const ScrollAreaScrollbarPrimitive =
ScrollAreaPrimitive.Scrollbar as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Scrollbar> & {
children?: React.ReactNode;
className?: string;
} & React.RefAttributes<HTMLDivElement>
>;
const ScrollAreaThumbPrimitive = ScrollAreaPrimitive.Thumb as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Thumb> & {
className?: string;
} & React.RefAttributes<HTMLDivElement>
>;
const ScrollArea = React.forwardRef<
HTMLDivElement,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaRootPrimitive
ref={ref}
className={cn('relative overflow-hidden', className)}
{...props}
>
<ScrollAreaViewportPrimitive className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaViewportPrimitive>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaRootPrimitive>
));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
const ScrollBar = React.forwardRef<
HTMLDivElement,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Scrollbar>
>(({ className, orientation = 'vertical', ...props }, ref) => (
<ScrollAreaScrollbarPrimitive
ref={ref}
orientation={orientation}
className={cn(
'flex touch-none select-none transition-colors',
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent p-[1px]',
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent p-[1px]',
className
)}
{...props}
>
<ScrollAreaThumbPrimitive className="relative flex-1 rounded-full bg-border" />
</ScrollAreaScrollbarPrimitive>
));
ScrollBar.displayName = ScrollAreaPrimitive.Scrollbar.displayName;
export { ScrollArea, ScrollBar };

View File

@@ -72,18 +72,17 @@ export function UsagePopover() {
const [codexError, setCodexError] = useState<UsageError | null>(null);
// Check authentication status
const isClaudeCliVerified =
claudeAuthStatus?.authenticated && claudeAuthStatus?.method === 'cli_authenticated';
const isClaudeAuthenticated = !!claudeAuthStatus?.authenticated;
const isCodexAuthenticated = codexAuthStatus?.authenticated;
// Determine which tab to show by default
useEffect(() => {
if (isClaudeCliVerified) {
if (isClaudeAuthenticated) {
setActiveTab('claude');
} else if (isCodexAuthenticated) {
setActiveTab('codex');
}
}, [isClaudeCliVerified, isCodexAuthenticated]);
}, [isClaudeAuthenticated, isCodexAuthenticated]);
// Check if data is stale (older than 2 minutes)
const isClaudeStale = useMemo(() => {
@@ -174,10 +173,10 @@ export function UsagePopover() {
// Auto-fetch on mount if data is stale
useEffect(() => {
if (isClaudeStale && isClaudeCliVerified) {
if (isClaudeStale && isClaudeAuthenticated) {
fetchClaudeUsage(true);
}
}, [isClaudeStale, isClaudeCliVerified, fetchClaudeUsage]);
}, [isClaudeStale, isClaudeAuthenticated, fetchClaudeUsage]);
useEffect(() => {
if (isCodexStale && isCodexAuthenticated) {
@@ -190,7 +189,7 @@ export function UsagePopover() {
if (!open) return;
// Fetch based on active tab
if (activeTab === 'claude' && isClaudeCliVerified) {
if (activeTab === 'claude' && isClaudeAuthenticated) {
if (!claudeUsage || isClaudeStale) {
fetchClaudeUsage();
}
@@ -214,7 +213,7 @@ export function UsagePopover() {
activeTab,
claudeUsage,
isClaudeStale,
isClaudeCliVerified,
isClaudeAuthenticated,
codexUsage,
isCodexStale,
isCodexAuthenticated,
@@ -349,7 +348,7 @@ export function UsagePopover() {
);
// Determine which tabs to show
const showClaudeTab = isClaudeCliVerified;
const showClaudeTab = isClaudeAuthenticated;
const showCodexTab = isCodexAuthenticated;
return (

View File

@@ -16,11 +16,32 @@ import {
import { NoProjectState, AgentHeader, ChatArea } from './agent-view/components';
import { AgentInputArea } from './agent-view/input-area';
/** Tailwind lg breakpoint in pixels */
const LG_BREAKPOINT = 1024;
export function AgentView() {
const { currentProject } = useAppStore();
const [input, setInput] = useState('');
const [currentTool, setCurrentTool] = useState<string | null>(null);
// Initialize session manager state - starts as true to match SSR
// Then updates on mount based on actual screen size to prevent hydration mismatch
const [showSessionManager, setShowSessionManager] = useState(true);
// Update session manager visibility based on screen size after mount and on resize
useEffect(() => {
const updateVisibility = () => {
const isDesktop = window.innerWidth >= LG_BREAKPOINT;
setShowSessionManager(isDesktop);
};
// Set initial value
updateVisibility();
// Listen for resize events
window.addEventListener('resize', updateVisibility);
return () => window.removeEventListener('resize', updateVisibility);
}, []);
const [modelSelection, setModelSelection] = useState<PhaseModelEntry>({ model: 'sonnet' });
// Input ref for auto-focus
@@ -119,6 +140,13 @@ export function AgentView() {
}
}, [currentSessionId]);
// Auto-close session manager on mobile when a session is selected
useEffect(() => {
if (currentSessionId && typeof window !== 'undefined' && window.innerWidth < 1024) {
setShowSessionManager(false);
}
}, [currentSessionId]);
// Show welcome message if no messages yet
const displayMessages =
messages.length === 0
@@ -139,9 +167,18 @@ export function AgentView() {
return (
<div className="flex-1 flex overflow-hidden bg-background" data-testid="agent-view">
{/* Mobile backdrop overlay for Session Manager */}
{showSessionManager && currentProject && (
<div
className="fixed inset-0 bg-black/50 z-20 lg:hidden"
onClick={() => setShowSessionManager(false)}
data-testid="session-manager-backdrop"
/>
)}
{/* Session Manager Sidebar */}
{showSessionManager && currentProject && (
<div className="w-80 border-r border-border shrink-0 bg-card/50">
<div className="fixed inset-y-0 left-0 w-72 z-30 lg:relative lg:w-80 lg:z-auto border-r border-border shrink-0 bg-card">
<SessionManager
currentSessionId={currentSessionId}
onSelectSession={handleSelectSession}

View File

@@ -79,7 +79,7 @@ export function InputControls({
{/* Text Input and Controls */}
<div
className={cn(
'flex gap-2 transition-all duration-200 rounded-xl p-1',
'flex flex-col gap-2 transition-all duration-200 rounded-xl p-1',
isDragOver && 'bg-primary/5 ring-2 ring-primary/30'
)}
onDragEnter={onDragEnter}
@@ -87,7 +87,8 @@ export function InputControls({
onDragOver={onDragOver}
onDrop={onDrop}
>
<div className="flex-1 relative">
{/* Textarea - full width on mobile */}
<div className="relative w-full">
<Textarea
ref={inputRef}
placeholder={
@@ -105,14 +106,14 @@ export function InputControls({
data-testid="agent-input"
rows={1}
className={cn(
'min-h-11 bg-background border-border rounded-xl pl-4 pr-20 text-sm transition-all resize-none max-h-36 overflow-y-auto py-2.5',
'min-h-11 w-full bg-background border-border rounded-xl pl-4 pr-4 sm:pr-20 text-sm transition-all resize-none max-h-36 overflow-y-auto py-2.5',
'focus:ring-2 focus:ring-primary/20 focus:border-primary/50',
hasFiles && 'border-primary/30',
isDragOver && 'border-primary bg-primary/5'
)}
/>
{hasFiles && !isDragOver && (
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-xs bg-primary text-primary-foreground px-2 py-0.5 rounded-full font-medium">
<div className="hidden sm:block absolute right-3 top-1/2 -translate-y-1/2 text-xs bg-primary text-primary-foreground px-2 py-0.5 rounded-full font-medium">
files attached
</div>
)}
@@ -124,58 +125,64 @@ export function InputControls({
)}
</div>
{/* Model Selector */}
<AgentModelSelector
value={modelSelection}
onChange={onModelSelect}
disabled={!isConnected}
/>
{/* File Attachment Button */}
<Button
variant="outline"
size="icon"
onClick={onToggleImageDropZone}
disabled={!isConnected}
className={cn(
'h-11 w-11 rounded-xl border-border',
showImageDropZone && 'bg-primary/10 text-primary border-primary/30',
hasFiles && 'border-primary/30 text-primary'
)}
title="Attach files (images, .txt, .md)"
>
<Paperclip className="w-4 h-4" />
</Button>
{/* Stop Button (only when processing) */}
{isProcessing && (
<Button
onClick={onStop}
{/* Controls row - responsive layout */}
<div className="flex items-center gap-2 flex-wrap">
{/* Model Selector */}
<AgentModelSelector
value={modelSelection}
onChange={onModelSelect}
disabled={!isConnected}
className="h-11 px-4 rounded-xl"
variant="destructive"
data-testid="stop-agent"
title="Stop generation"
>
<Square className="w-4 h-4 fill-current" />
</Button>
)}
/>
{/* Send / Queue Button */}
<Button
onClick={onSend}
disabled={!canSend}
className="h-11 px-4 rounded-xl"
variant={isProcessing ? 'outline' : 'default'}
data-testid="send-message"
title={isProcessing ? 'Add to queue' : 'Send message'}
>
{isProcessing ? <ListOrdered className="w-4 h-4" /> : <Send className="w-4 h-4" />}
</Button>
{/* File Attachment Button */}
<Button
variant="outline"
size="icon"
onClick={onToggleImageDropZone}
disabled={!isConnected}
className={cn(
'h-11 w-11 rounded-xl border-border shrink-0',
showImageDropZone && 'bg-primary/10 text-primary border-primary/30',
hasFiles && 'border-primary/30 text-primary'
)}
title="Attach files (images, .txt, .md)"
>
<Paperclip className="w-4 h-4" />
</Button>
{/* Spacer to push action buttons to the right */}
<div className="flex-1" />
{/* Stop Button (only when processing) */}
{isProcessing && (
<Button
onClick={onStop}
disabled={!isConnected}
className="h-11 px-4 rounded-xl shrink-0"
variant="destructive"
data-testid="stop-agent"
title="Stop generation"
>
<Square className="w-4 h-4 fill-current" />
</Button>
)}
{/* Send / Queue Button */}
<Button
onClick={onSend}
disabled={!canSend}
className="h-11 px-4 rounded-xl shrink-0"
variant={isProcessing ? 'outline' : 'default'}
data-testid="send-message"
title={isProcessing ? 'Add to queue' : 'Send message'}
>
{isProcessing ? <ListOrdered className="w-4 h-4" /> : <Send className="w-4 h-4" />}
</Button>
</div>
</div>
{/* Keyboard hint */}
<p className="text-[11px] text-muted-foreground mt-2 text-center">
<p className="text-[11px] text-muted-foreground mt-2 text-center hidden sm:block">
Press <kbd className="px-1.5 py-0.5 bg-muted rounded text-[10px] font-medium">Enter</kbd> to
send,{' '}
<kbd className="px-1.5 py-0.5 bg-muted rounded text-[10px] font-medium">Shift+Enter</kbd>{' '}

View File

@@ -60,7 +60,7 @@ import { CreatePRDialog } from './board-view/dialogs/create-pr-dialog';
import { CreateBranchDialog } from './board-view/dialogs/create-branch-dialog';
import { WorktreePanel } from './board-view/worktree-panel';
import type { PRInfo, WorktreeInfo } from './board-view/worktree-panel/types';
import { COLUMNS } from './board-view/constants';
import { COLUMNS, getColumnsWithPipeline } from './board-view/constants';
import {
useBoardFeatures,
useBoardDragDrop,
@@ -72,8 +72,9 @@ import {
useBoardPersistence,
useFollowUpState,
useSelectionMode,
useListViewState,
} from './board-view/hooks';
import { SelectionActionBar } from './board-view/components';
import { SelectionActionBar, ListView } from './board-view/components';
import { MassEditDialog } from './board-view/dialogs';
import { InitScriptIndicator } from './board-view/init-script-indicator';
import { useInitScriptEvents } from '@/hooks/use-init-script-events';
@@ -194,6 +195,9 @@ export function BoardView() {
} = useSelectionMode();
const [showMassEditDialog, setShowMassEditDialog] = useState(false);
// View mode state (kanban vs list)
const { viewMode, setViewMode, isListView, sortConfig, setSortColumn } = useListViewState();
// Search filter for Kanban cards
const [searchQuery, setSearchQuery] = useState('');
// Plan approval loading state
@@ -1119,6 +1123,19 @@ export function BoardView() {
projectPath: currentProject?.path || null,
});
// Build columnFeaturesMap for ListView
const pipelineConfig = currentProject?.path
? pipelineConfigByProject[currentProject.path] || null
: null;
const columnFeaturesMap = useMemo(() => {
const columns = getColumnsWithPipeline(pipelineConfig);
const map: Record<string, typeof hookFeatures> = {};
for (const column of columns) {
map[column.id] = getColumnFeatures(column.id as any);
}
return map;
}, [pipelineConfig, getColumnFeatures]);
// Use background hook
const { backgroundSettings, backgroundImageStyle } = useBoardBackground({
currentProject,
@@ -1306,6 +1323,8 @@ export function BoardView() {
onShowBoardBackground={() => setShowBoardBackgroundModal(true)}
onShowCompletedModal={() => setShowCompletedModal(true)}
completedCount={completedFeatures.length}
viewMode={viewMode}
onViewModeChange={setViewMode}
/>
{/* Worktree Panel - conditionally rendered based on visibility setting */}
@@ -1344,48 +1363,89 @@ export function BoardView() {
{/* Main Content Area */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* View Content - Kanban Board */}
<KanbanBoard
sensors={sensors}
collisionDetectionStrategy={collisionDetectionStrategy}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
activeFeature={activeFeature}
getColumnFeatures={getColumnFeatures}
backgroundImageStyle={backgroundImageStyle}
backgroundSettings={backgroundSettings}
onEdit={(feature) => setEditingFeature(feature)}
onDelete={(featureId) => handleDeleteFeature(featureId)}
onViewOutput={handleViewOutput}
onVerify={handleVerifyFeature}
onResume={handleResumeFeature}
onForceStop={handleForceStopFeature}
onManualVerify={handleManualVerify}
onMoveBackToInProgress={handleMoveBackToInProgress}
onFollowUp={handleOpenFollowUp}
onComplete={handleCompleteFeature}
onImplement={handleStartImplementation}
onViewPlan={(feature) => setViewPlanFeature(feature)}
onApprovePlan={handleOpenApprovalDialog}
onSpawnTask={(feature) => {
setSpawnParentFeature(feature);
setShowAddDialog(true);
}}
featuresWithContext={featuresWithContext}
runningAutoTasks={runningAutoTasks}
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
onAddFeature={() => setShowAddDialog(true)}
pipelineConfig={
currentProject?.path ? pipelineConfigByProject[currentProject.path] || null : null
}
onOpenPipelineSettings={() => setShowPipelineSettings(true)}
isSelectionMode={isSelectionMode}
selectedFeatureIds={selectedFeatureIds}
onToggleFeatureSelection={toggleFeatureSelection}
onToggleSelectionMode={toggleSelectionMode}
isDragging={activeFeature !== null}
onAiSuggest={() => setShowPlanDialog(true)}
/>
{/* View Content - Kanban Board or List View */}
{isListView ? (
<ListView
columnFeaturesMap={columnFeaturesMap}
allFeatures={hookFeatures}
sortConfig={sortConfig}
onSortChange={setSortColumn}
actionHandlers={{
onEdit: (feature) => setEditingFeature(feature),
onDelete: (featureId) => handleDeleteFeature(featureId),
onViewOutput: handleViewOutput,
onVerify: handleVerifyFeature,
onResume: handleResumeFeature,
onForceStop: handleForceStopFeature,
onManualVerify: handleManualVerify,
onFollowUp: handleOpenFollowUp,
onImplement: handleStartImplementation,
onComplete: handleCompleteFeature,
onViewPlan: (feature) => setViewPlanFeature(feature),
onApprovePlan: handleOpenApprovalDialog,
onSpawnTask: (feature) => {
setSpawnParentFeature(feature);
setShowAddDialog(true);
},
}}
runningAutoTasks={runningAutoTasks}
pipelineConfig={pipelineConfig}
onAddFeature={() => setShowAddDialog(true)}
isSelectionMode={isSelectionMode}
selectedFeatureIds={selectedFeatureIds}
onToggleFeatureSelection={toggleFeatureSelection}
onRowClick={(feature) => {
if (feature.status === 'backlog') {
setEditingFeature(feature);
} else {
handleViewOutput(feature);
}
}}
className="transition-opacity duration-200"
/>
) : (
<KanbanBoard
sensors={sensors}
collisionDetectionStrategy={collisionDetectionStrategy}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
activeFeature={activeFeature}
getColumnFeatures={getColumnFeatures}
backgroundImageStyle={backgroundImageStyle}
backgroundSettings={backgroundSettings}
onEdit={(feature) => setEditingFeature(feature)}
onDelete={(featureId) => handleDeleteFeature(featureId)}
onViewOutput={handleViewOutput}
onVerify={handleVerifyFeature}
onResume={handleResumeFeature}
onForceStop={handleForceStopFeature}
onManualVerify={handleManualVerify}
onMoveBackToInProgress={handleMoveBackToInProgress}
onFollowUp={handleOpenFollowUp}
onComplete={handleCompleteFeature}
onImplement={handleStartImplementation}
onViewPlan={(feature) => setViewPlanFeature(feature)}
onApprovePlan={handleOpenApprovalDialog}
onSpawnTask={(feature) => {
setSpawnParentFeature(feature);
setShowAddDialog(true);
}}
featuresWithContext={featuresWithContext}
runningAutoTasks={runningAutoTasks}
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
onAddFeature={() => setShowAddDialog(true)}
pipelineConfig={pipelineConfig}
onOpenPipelineSettings={() => setShowPipelineSettings(true)}
isSelectionMode={isSelectionMode}
selectedFeatureIds={selectedFeatureIds}
onToggleFeatureSelection={toggleFeatureSelection}
onToggleSelectionMode={toggleSelectionMode}
viewMode={viewMode}
isDragging={activeFeature !== null}
onAiSuggest={() => setShowPlanDialog(true)}
className="transition-opacity duration-200"
/>
)}
</div>
{/* Selection Action Bar */}
@@ -1507,7 +1567,7 @@ export function BoardView() {
open={showPipelineSettings}
onClose={() => setShowPipelineSettings(false)}
projectPath={currentProject.path}
pipelineConfig={pipelineConfigByProject[currentProject.path] || null}
pipelineConfig={pipelineConfig}
onSave={async (config) => {
const api = getHttpApiClient();
const result = await api.pipeline.saveConfig(currentProject.path, config);

View File

@@ -19,7 +19,7 @@ export function BoardControls({
return (
<TooltipProvider>
<div className="flex items-center gap-2">
<div className="flex items-center gap-5">
{/* Board Background Button */}
<Tooltip>
<TooltipTrigger asChild>

View File

@@ -8,12 +8,17 @@ import { Bot, Wand2, Settings2, GitBranch } from 'lucide-react';
import { UsagePopover } from '@/components/usage-popover';
import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
import { useIsMobile } from '@/hooks/use-media-query';
import { AutoModeSettingsDialog } from './dialogs/auto-mode-settings-dialog';
import { WorktreeSettingsDialog } from './dialogs/worktree-settings-dialog';
import { PlanSettingsDialog } from './dialogs/plan-settings-dialog';
import { getHttpApiClient } from '@/lib/http-api-client';
import { BoardSearchBar } from './board-search-bar';
import { BoardControls } from './board-controls';
import { ViewToggle, type ViewMode } from './components';
import { HeaderMobileMenu } from './header-mobile-menu';
export type { ViewMode };
interface BoardHeaderProps {
projectPath: string;
@@ -33,6 +38,9 @@ interface BoardHeaderProps {
onShowBoardBackground: () => void;
onShowCompletedModal: () => void;
completedCount: number;
// View toggle props
viewMode: ViewMode;
onViewModeChange: (mode: ViewMode) => void;
}
// Shared styles for header control containers
@@ -55,11 +63,12 @@ export function BoardHeader({
onShowBoardBackground,
onShowCompletedModal,
completedCount,
viewMode,
onViewModeChange,
}: BoardHeaderProps) {
const [showAutoModeSettings, setShowAutoModeSettings] = useState(false);
const [showWorktreeSettings, setShowWorktreeSettings] = useState(false);
const [showPlanSettings, setShowPlanSettings] = useState(false);
const apiKeys = useAppStore((state) => state.apiKeys);
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
const skipVerificationInAutoMode = useAppStore((state) => state.skipVerificationInAutoMode);
const setSkipVerificationInAutoMode = useAppStore((state) => state.setSkipVerificationInAutoMode);
@@ -98,22 +107,17 @@ export function BoardHeader({
[projectPath, setWorktreePanelVisible]
);
// Claude usage tracking visibility logic
// Hide when using API key (only show for Claude Code CLI users)
// Also hide on Windows for now (CLI usage command not supported)
const isWindows =
typeof navigator !== 'undefined' && navigator.platform?.toLowerCase().includes('win');
const hasClaudeApiKey = !!apiKeys.anthropic || !!claudeAuthStatus?.hasEnvApiKey;
const isClaudeCliVerified =
claudeAuthStatus?.authenticated && claudeAuthStatus?.method === 'cli_authenticated';
const showClaudeUsage = !hasClaudeApiKey && !isWindows && isClaudeCliVerified;
const isClaudeCliVerified = !!claudeAuthStatus?.authenticated;
const showClaudeUsage = isClaudeCliVerified;
// Codex usage tracking visibility logic
// Show if Codex is authenticated (CLI or API key)
const showCodexUsage = !!codexAuthStatus?.authenticated;
const isMobile = useIsMobile();
return (
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
<div className="flex items-center justify-between gap-5 p-4 border-b border-border bg-glass backdrop-blur-md">
<div className="flex items-center gap-4">
<BoardSearchBar
searchQuery={searchQuery}
@@ -122,6 +126,7 @@ export function BoardHeader({
creatingSpecProjectPath={creatingSpecProjectPath}
currentProjectPath={projectPath}
/>
{isMounted && <ViewToggle viewMode={viewMode} onViewModeChange={onViewModeChange} />}
<BoardControls
isMounted={isMounted}
onShowBoardBackground={onShowBoardBackground}
@@ -129,12 +134,30 @@ export function BoardHeader({
completedCount={completedCount}
/>
</div>
<div className="flex gap-2 items-center">
{/* Usage Popover - show if either provider is authenticated */}
{isMounted && (showClaudeUsage || showCodexUsage) && <UsagePopover />}
<div className="flex gap-4 items-center">
{/* Usage Popover - show if either provider is authenticated, only on desktop */}
{isMounted && !isMobile && (showClaudeUsage || showCodexUsage) && <UsagePopover />}
{/* Mobile view: show hamburger menu with all controls */}
{isMounted && isMobile && (
<HeaderMobileMenu
isWorktreePanelVisible={isWorktreePanelVisible}
onWorktreePanelToggle={handleWorktreePanelToggle}
maxConcurrency={maxConcurrency}
runningAgentsCount={runningAgentsCount}
onConcurrencyChange={onConcurrencyChange}
isAutoModeRunning={isAutoModeRunning}
onAutoModeToggle={onAutoModeToggle}
onOpenAutoModeSettings={() => setShowAutoModeSettings(true)}
onOpenPlanDialog={onOpenPlanDialog}
showClaudeUsage={showClaudeUsage}
showCodexUsage={showCodexUsage}
/>
)}
{/* Desktop view: show full controls */}
{/* Worktrees Toggle - only show after mount to prevent hydration issues */}
{isMounted && (
{isMounted && !isMobile && (
<div className={controlContainerClass} data-testid="worktrees-toggle-container">
<GitBranch className="w-4 h-4 text-muted-foreground" />
<Label htmlFor="worktrees-toggle" className="text-sm font-medium cursor-pointer">
@@ -166,7 +189,7 @@ export function BoardHeader({
/>
{/* Concurrency Control - only show after mount to prevent hydration issues */}
{isMounted && (
{isMounted && !isMobile && (
<Popover>
<PopoverTrigger asChild>
<button
@@ -209,7 +232,7 @@ export function BoardHeader({
)}
{/* Auto Mode Toggle - only show after mount to prevent hydration issues */}
{isMounted && (
{isMounted && !isMobile && (
<div className={controlContainerClass} data-testid="auto-mode-toggle-container">
<Label htmlFor="auto-mode-toggle" className="text-sm font-medium cursor-pointer">
Auto Mode
@@ -239,25 +262,27 @@ export function BoardHeader({
onSkipVerificationChange={setSkipVerificationInAutoMode}
/>
{/* Plan Button with Settings */}
<div className={controlContainerClass} data-testid="plan-button-container">
<button
onClick={onOpenPlanDialog}
className="flex items-center gap-1.5 hover:text-foreground transition-colors"
data-testid="plan-backlog-button"
>
<Wand2 className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium">Plan</span>
</button>
<button
onClick={() => setShowPlanSettings(true)}
className="p-1 rounded hover:bg-accent/50 transition-colors"
title="Plan Settings"
data-testid="plan-settings-button"
>
<Settings2 className="w-4 h-4 text-muted-foreground" />
</button>
</div>
{/* Plan Button with Settings - only show on desktop, mobile has it in the menu */}
{isMounted && !isMobile && (
<div className={controlContainerClass} data-testid="plan-button-container">
<button
onClick={onOpenPlanDialog}
className="flex items-center gap-1.5 hover:text-foreground transition-colors"
data-testid="plan-backlog-button"
>
<Wand2 className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium">Plan</span>
</button>
<button
onClick={() => setShowPlanSettings(true)}
className="p-1 rounded hover:bg-accent/50 transition-colors"
title="Plan Settings"
data-testid="plan-settings-button"
>
<Settings2 className="w-4 h-4 text-muted-foreground" />
</button>
</div>
)}
{/* Plan Settings Dialog */}
<PlanSettingsDialog

View File

@@ -2,3 +2,33 @@ export { KanbanCard } from './kanban-card/kanban-card';
export { KanbanColumn } from './kanban-column';
export { SelectionActionBar } from './selection-action-bar';
export { EmptyStateCard } from './empty-state-card';
export { ViewToggle, type ViewMode } from './view-toggle';
// List view components
export {
ListHeader,
LIST_COLUMNS,
getColumnById,
getColumnWidth,
getColumnAlign,
ListRow,
getFeatureSortValue,
sortFeatures,
ListView,
getFlatFeatures,
getTotalFeatureCount,
RowActions,
createRowActionHandlers,
StatusBadge,
getStatusLabel,
getStatusOrder,
} from './list-view';
export type {
ListHeaderProps,
ListRowProps,
ListViewProps,
ListViewActionHandlers,
RowActionsProps,
RowActionHandlers,
StatusBadgeProps,
} from './list-view';

View File

@@ -0,0 +1,20 @@
export {
ListHeader,
LIST_COLUMNS,
getColumnById,
getColumnWidth,
getColumnAlign,
} from './list-header';
export type { ListHeaderProps } from './list-header';
export { ListRow, getFeatureSortValue, sortFeatures } from './list-row';
export type { ListRowProps } from './list-row';
export { ListView, getFlatFeatures, getTotalFeatureCount } from './list-view';
export type { ListViewProps, ListViewActionHandlers } from './list-view';
export { RowActions, createRowActionHandlers } from './row-actions';
export type { RowActionsProps, RowActionHandlers } from './row-actions';
export { StatusBadge, getStatusLabel, getStatusOrder } from './status-badge';
export type { StatusBadgeProps } from './status-badge';

View File

@@ -0,0 +1,284 @@
import { memo, useCallback } from 'react';
import { ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { SortColumn, SortConfig, SortDirection } from '../../hooks/use-list-view-state';
/**
* Column definition for the list header
*/
interface ColumnDef {
id: SortColumn;
label: string;
/** Whether this column is sortable */
sortable?: boolean;
/** Minimum width for the column */
minWidth?: string;
/** Width class for the column */
width?: string;
/** Alignment of the column content */
align?: 'left' | 'center' | 'right';
/** Additional className for the column */
className?: string;
}
/**
* Default column definitions for the list view
* Only showing title column with full width for a cleaner, more spacious layout
*/
export const LIST_COLUMNS: ColumnDef[] = [
{
id: 'title',
label: 'Title',
sortable: true,
width: 'flex-1',
minWidth: 'min-w-0',
align: 'left',
},
];
export interface ListHeaderProps {
/** Current sort configuration */
sortConfig: SortConfig;
/** Callback when a sortable column is clicked */
onSortChange: (column: SortColumn) => void;
/** Whether to show a checkbox column for selection */
showCheckbox?: boolean;
/** Whether all items are selected (for checkbox state) */
allSelected?: boolean;
/** Whether some but not all items are selected */
someSelected?: boolean;
/** Callback when the select all checkbox is clicked */
onSelectAll?: () => void;
/** Custom column definitions (defaults to LIST_COLUMNS) */
columns?: ColumnDef[];
/** Additional className for the header */
className?: string;
}
/**
* SortIcon displays the current sort state for a column
*/
function SortIcon({ column, sortConfig }: { column: SortColumn; sortConfig: SortConfig }) {
if (sortConfig.column !== column) {
// Not sorted by this column - show neutral indicator
return (
<ChevronsUpDown className="w-3.5 h-3.5 text-muted-foreground/50 group-hover:text-muted-foreground transition-colors" />
);
}
// Currently sorted by this column
if (sortConfig.direction === 'asc') {
return <ChevronUp className="w-3.5 h-3.5 text-foreground" />;
}
return <ChevronDown className="w-3.5 h-3.5 text-foreground" />;
}
/**
* SortableColumnHeader renders a clickable header cell that triggers sorting
*/
const SortableColumnHeader = memo(function SortableColumnHeader({
column,
sortConfig,
onSortChange,
}: {
column: ColumnDef;
sortConfig: SortConfig;
onSortChange: (column: SortColumn) => void;
}) {
const handleClick = useCallback(() => {
onSortChange(column.id);
}, [column.id, onSortChange]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSortChange(column.id);
}
},
[column.id, onSortChange]
);
const isSorted = sortConfig.column === column.id;
const sortDirection: SortDirection | undefined = isSorted ? sortConfig.direction : undefined;
return (
<div
role="columnheader"
aria-sort={isSorted ? (sortDirection === 'asc' ? 'ascending' : 'descending') : 'none'}
tabIndex={0}
onClick={handleClick}
onKeyDown={handleKeyDown}
className={cn(
'group flex items-center gap-1.5 px-3 py-2 text-xs font-medium text-muted-foreground',
'cursor-pointer select-none transition-colors duration-200',
'hover:text-foreground hover:bg-accent/50 rounded-md',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1',
column.width,
column.minWidth,
column.align === 'center' && 'justify-center',
column.align === 'right' && 'justify-end',
isSorted && 'text-foreground',
column.className
)}
data-testid={`list-header-${column.id}`}
>
<span>{column.label}</span>
<SortIcon column={column.id} sortConfig={sortConfig} />
</div>
);
});
/**
* StaticColumnHeader renders a non-sortable header cell
*/
const StaticColumnHeader = memo(function StaticColumnHeader({ column }: { column: ColumnDef }) {
return (
<div
role="columnheader"
className={cn(
'flex items-center px-3 py-2 text-xs font-medium text-muted-foreground',
column.width,
column.minWidth,
column.align === 'center' && 'justify-center',
column.align === 'right' && 'justify-end',
column.className
)}
data-testid={`list-header-${column.id}`}
>
<span>{column.label}</span>
</div>
);
});
/**
* ListHeader displays the header row for the list view table with sortable columns.
*
* Features:
* - Clickable column headers for sorting
* - Visual sort direction indicators (chevron up/down)
* - Keyboard accessible (Tab + Enter/Space to sort)
* - ARIA attributes for screen readers
* - Optional checkbox column for bulk selection
* - Customizable column definitions
*
* @example
* ```tsx
* const { sortConfig, setSortColumn } = useListViewState();
*
* <ListHeader
* sortConfig={sortConfig}
* onSortChange={setSortColumn}
* />
* ```
*
* @example
* ```tsx
* // With selection support
* <ListHeader
* sortConfig={sortConfig}
* onSortChange={setSortColumn}
* showCheckbox
* allSelected={allSelected}
* someSelected={someSelected}
* onSelectAll={handleSelectAll}
* />
* ```
*/
export const ListHeader = memo(function ListHeader({
sortConfig,
onSortChange,
showCheckbox = false,
allSelected = false,
someSelected = false,
onSelectAll,
columns = LIST_COLUMNS,
className,
}: ListHeaderProps) {
return (
<div
role="row"
className={cn(
'flex items-center w-full border-b border-border bg-muted/30',
'sticky top-0 z-10 backdrop-blur-sm',
className
)}
data-testid="list-header"
>
{/* Checkbox column for selection */}
{showCheckbox && (
<div
role="columnheader"
className="flex items-center justify-center w-10 px-2 py-2 shrink-0"
>
<input
type="checkbox"
checked={allSelected}
ref={(el) => {
if (el) {
el.indeterminate = someSelected && !allSelected;
}
}}
onChange={onSelectAll}
className={cn(
'h-4 w-4 rounded border-border text-primary cursor-pointer',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1'
)}
aria-label={allSelected ? 'Deselect all' : 'Select all'}
data-testid="list-header-select-all"
/>
</div>
)}
{/* Column headers */}
{columns.map((column) =>
column.sortable !== false ? (
<SortableColumnHeader
key={column.id}
column={column}
sortConfig={sortConfig}
onSortChange={onSortChange}
/>
) : (
<StaticColumnHeader key={column.id} column={column} />
)
)}
{/* Actions column (placeholder for row action buttons) */}
<div
role="columnheader"
className="w-[80px] px-3 py-2 text-xs font-medium text-muted-foreground shrink-0"
aria-label="Actions"
data-testid="list-header-actions"
>
<span className="sr-only">Actions</span>
</div>
</div>
);
});
/**
* Helper function to get a column definition by ID
*/
export function getColumnById(columnId: SortColumn): ColumnDef | undefined {
return LIST_COLUMNS.find((col) => col.id === columnId);
}
/**
* Helper function to get column width class for consistent styling in rows
*/
export function getColumnWidth(columnId: SortColumn): string {
const column = getColumnById(columnId);
return cn(column?.width, column?.minWidth);
}
/**
* Helper function to get column alignment class
*/
export function getColumnAlign(columnId: SortColumn): string {
const column = getColumnById(columnId);
if (column?.align === 'center') return 'justify-center text-center';
if (column?.align === 'right') return 'justify-end text-right';
return '';
}

View File

@@ -0,0 +1,382 @@
// TODO: Remove @ts-nocheck after fixing BaseFeature's index signature issue
// The `[key: string]: unknown` in BaseFeature causes property access type errors
// @ts-nocheck
import { memo, useCallback, useState, useEffect } from 'react';
import { cn } from '@/lib/utils';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { AlertCircle, Lock, Hand, Sparkles, FileText } from 'lucide-react';
import type { Feature } from '@/store/app-store';
import { RowActions, type RowActionHandlers } from './row-actions';
import { getColumnWidth, getColumnAlign } from './list-header';
export interface ListRowProps {
/** The feature to display */
feature: Feature;
/** Action handlers for the row */
handlers: RowActionHandlers;
/** Whether this feature is the current auto task (agent is running) */
isCurrentAutoTask?: boolean;
/** Whether the row is selected */
isSelected?: boolean;
/** Whether to show the checkbox for selection */
showCheckbox?: boolean;
/** Callback when the row selection is toggled */
onToggleSelect?: () => void;
/** Callback when the row is clicked */
onClick?: () => void;
/** Blocking dependency feature IDs */
blockingDependencies?: string[];
/** Additional className for custom styling */
className?: string;
}
/**
* IndicatorBadges shows small indicator icons for special states (error, blocked, manual verification, just finished)
*/
const IndicatorBadges = memo(function IndicatorBadges({
feature,
blockingDependencies = [],
isCurrentAutoTask,
}: {
feature: Feature;
blockingDependencies?: string[];
isCurrentAutoTask?: boolean;
}) {
const hasError = feature.error && !isCurrentAutoTask;
const isBlocked =
blockingDependencies.length > 0 && !feature.error && feature.status === 'backlog';
const showManualVerification =
feature.skipTests && !feature.error && feature.status === 'backlog';
const hasPlan = feature.planSpec?.content;
// Check if just finished (within 2 minutes) - uses timer to auto-expire
const [isJustFinished, setIsJustFinished] = useState(false);
useEffect(() => {
if (!feature.justFinishedAt || feature.status !== 'waiting_approval' || feature.error) {
setIsJustFinished(false);
return;
}
const finishedTime = new Date(feature.justFinishedAt).getTime();
const twoMinutes = 2 * 60 * 1000;
const elapsed = Date.now() - finishedTime;
if (elapsed >= twoMinutes) {
setIsJustFinished(false);
return;
}
// Set as just finished
setIsJustFinished(true);
// Set a timeout to clear the "just finished" state when 2 minutes have passed
const remainingTime = twoMinutes - elapsed;
const timeoutId = setTimeout(() => {
setIsJustFinished(false);
}, remainingTime);
return () => clearTimeout(timeoutId);
}, [feature.justFinishedAt, feature.status, feature.error]);
const badges: Array<{
key: string;
icon: typeof AlertCircle;
tooltip: string;
colorClass: string;
bgClass: string;
borderClass: string;
animate?: boolean;
}> = [];
if (hasError) {
badges.push({
key: 'error',
icon: AlertCircle,
tooltip: feature.error || 'Error',
colorClass: 'text-[var(--status-error)]',
bgClass: 'bg-[var(--status-error)]/15',
borderClass: 'border-[var(--status-error)]/30',
});
}
if (isBlocked) {
badges.push({
key: 'blocked',
icon: Lock,
tooltip: `Blocked by ${blockingDependencies.length} incomplete ${blockingDependencies.length === 1 ? 'dependency' : 'dependencies'}`,
colorClass: 'text-orange-500',
bgClass: 'bg-orange-500/15',
borderClass: 'border-orange-500/30',
});
}
if (showManualVerification) {
badges.push({
key: 'manual',
icon: Hand,
tooltip: 'Manual verification required',
colorClass: 'text-[var(--status-warning)]',
bgClass: 'bg-[var(--status-warning)]/15',
borderClass: 'border-[var(--status-warning)]/30',
});
}
if (hasPlan) {
badges.push({
key: 'plan',
icon: FileText,
tooltip: 'Has implementation plan',
colorClass: 'text-[var(--status-info)]',
bgClass: 'bg-[var(--status-info)]/15',
borderClass: 'border-[var(--status-info)]/30',
});
}
if (isJustFinished) {
badges.push({
key: 'just-finished',
icon: Sparkles,
tooltip: 'Agent just finished working on this feature',
colorClass: 'text-[var(--status-success)]',
bgClass: 'bg-[var(--status-success)]/15',
borderClass: 'border-[var(--status-success)]/30',
animate: true,
});
}
if (badges.length === 0) return null;
return (
<div className="flex items-center gap-1 ml-2">
<TooltipProvider delayDuration={200}>
{badges.map((badge) => (
<Tooltip key={badge.key}>
<TooltipTrigger asChild>
<div
className={cn(
'inline-flex items-center justify-center w-5 h-5 rounded border',
badge.colorClass,
badge.bgClass,
badge.borderClass,
badge.animate && 'animate-pulse'
)}
data-testid={`list-row-badge-${badge.key}`}
>
<badge.icon className="w-3 h-3" />
</div>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs max-w-[250px]">
<p>{badge.tooltip}</p>
</TooltipContent>
</Tooltip>
))}
</TooltipProvider>
</div>
);
});
/**
* ListRow displays a single feature row in the list view table.
*
* Features:
* - Displays feature data in columns matching ListHeader
* - Hover state with highlight and action buttons
* - Click handler for opening feature details
* - Animated border for currently running auto task
* - Status badge with appropriate colors
* - Priority indicator
* - Indicator badges for errors, blocked state, manual verification, etc.
* - Selection checkbox for bulk operations
*
* @example
* ```tsx
* <ListRow
* feature={feature}
* handlers={{
* onEdit: () => handleEdit(feature.id),
* onDelete: () => handleDelete(feature.id),
* // ... other handlers
* }}
* onClick={() => handleViewDetails(feature)}
* />
* ```
*/
export const ListRow = memo(function ListRow({
feature,
handlers,
isCurrentAutoTask = false,
isSelected = false,
showCheckbox = false,
onToggleSelect,
onClick,
blockingDependencies = [],
className,
}: ListRowProps) {
const handleRowClick = useCallback(
(e: React.MouseEvent) => {
// Don't trigger row click if clicking on checkbox or actions
if ((e.target as HTMLElement).closest('[data-testid^="row-actions"]')) {
return;
}
if ((e.target as HTMLElement).closest('input[type="checkbox"]')) {
return;
}
onClick?.();
},
[onClick]
);
const handleCheckboxChange = useCallback(() => {
onToggleSelect?.();
}, [onToggleSelect]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick?.();
}
},
[onClick]
);
const hasError = feature.error && !isCurrentAutoTask;
const rowContent = (
<div
role="row"
tabIndex={onClick ? 0 : undefined}
onClick={handleRowClick}
onKeyDown={onClick ? handleKeyDown : undefined}
className={cn(
'group flex items-center w-full border-b border-border/50',
'transition-colors duration-200',
onClick && 'cursor-pointer',
'hover:bg-accent/50',
isSelected && 'bg-accent/70',
hasError && 'bg-[var(--status-error)]/5 hover:bg-[var(--status-error)]/10',
className
)}
data-testid={`list-row-${feature.id}`}
>
{/* Checkbox column */}
{showCheckbox && (
<div role="cell" className="flex items-center justify-center w-10 px-2 py-3 shrink-0">
<input
type="checkbox"
checked={isSelected}
onChange={handleCheckboxChange}
className={cn(
'h-4 w-4 rounded border-border text-primary cursor-pointer',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1'
)}
aria-label={`Select ${feature.title || feature.description}`}
data-testid={`list-row-checkbox-${feature.id}`}
/>
</div>
)}
{/* Title column - full width with margin for actions */}
<div
role="cell"
className={cn(
'flex items-center px-3 py-3 gap-2',
getColumnWidth('title'),
getColumnAlign('title')
)}
>
<div className="flex-1 min-w-0">
<div className="flex items-center">
<span
className={cn(
'font-medium truncate',
feature.titleGenerating && 'animate-pulse text-muted-foreground'
)}
title={feature.title || feature.description}
>
{feature.title || feature.description}
</span>
<IndicatorBadges
feature={feature}
blockingDependencies={blockingDependencies}
isCurrentAutoTask={isCurrentAutoTask}
/>
</div>
{/* Show description as subtitle if title exists and is different */}
{feature.title && feature.title !== feature.description && (
<p
className="text-xs text-muted-foreground truncate mt-0.5"
title={feature.description}
>
{feature.description}
</p>
)}
</div>
</div>
{/* Actions column */}
<div role="cell" className="flex items-center justify-end px-3 py-3 w-[80px] shrink-0">
<RowActions feature={feature} handlers={handlers} isCurrentAutoTask={isCurrentAutoTask} />
</div>
</div>
);
// Wrap with animated border for currently running auto task
if (isCurrentAutoTask) {
return <div className="animated-border-wrapper-row">{rowContent}</div>;
}
return rowContent;
});
/**
* Helper function to get feature sort value for a column
*/
export function getFeatureSortValue(
feature: Feature,
column: 'title' | 'status' | 'category' | 'priority' | 'createdAt' | 'updatedAt'
): string | number | Date {
switch (column) {
case 'title':
return (feature.title || feature.description).toLowerCase();
case 'status':
return feature.status;
case 'category':
return (feature.category || '').toLowerCase();
case 'priority':
return feature.priority || 999; // No priority sorts last
case 'createdAt':
return feature.createdAt ? new Date(feature.createdAt) : new Date(0);
case 'updatedAt':
return feature.updatedAt ? new Date(feature.updatedAt) : new Date(0);
default:
return '';
}
}
/**
* Helper function to sort features by a column
*/
export function sortFeatures(
features: Feature[],
column: 'title' | 'status' | 'category' | 'priority' | 'createdAt' | 'updatedAt',
direction: 'asc' | 'desc'
): Feature[] {
return [...features].sort((a, b) => {
const aValue = getFeatureSortValue(a, column);
const bValue = getFeatureSortValue(b, column);
let comparison = 0;
if (aValue instanceof Date && bValue instanceof Date) {
comparison = aValue.getTime() - bValue.getTime();
} else if (typeof aValue === 'number' && typeof bValue === 'number') {
comparison = aValue - bValue;
} else {
comparison = String(aValue).localeCompare(String(bValue));
}
return direction === 'asc' ? comparison : -comparison;
});
}

View File

@@ -0,0 +1,460 @@
import { memo, useMemo, useCallback, useState } from 'react';
import { ChevronDown, ChevronRight, Plus } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { getBlockingDependencies } from '@automaker/dependency-resolver';
import type { Feature } from '@/store/app-store';
import type { PipelineConfig, FeatureStatusWithPipeline } from '@automaker/types';
import { ListHeader } from './list-header';
import { ListRow, sortFeatures } from './list-row';
import { createRowActionHandlers, type RowActionHandlers } from './row-actions';
import { getStatusLabel, getStatusOrder } from './status-badge';
import { getColumnsWithPipeline } from '../../constants';
import type { SortConfig, SortColumn } from '../../hooks/use-list-view-state';
/** Empty set constant to avoid creating new instances on each render */
const EMPTY_SET = new Set<string>();
/**
* Status group configuration for the list view
*/
interface StatusGroup {
id: FeatureStatusWithPipeline;
title: string;
colorClass: string;
features: Feature[];
}
/**
* Props for action handlers passed from the parent board view
*/
export interface ListViewActionHandlers {
onEdit: (feature: Feature) => void;
onDelete: (featureId: string) => void;
onViewOutput?: (feature: Feature) => void;
onVerify?: (feature: Feature) => void;
onResume?: (feature: Feature) => void;
onForceStop?: (feature: Feature) => void;
onManualVerify?: (feature: Feature) => void;
onFollowUp?: (feature: Feature) => void;
onImplement?: (feature: Feature) => void;
onComplete?: (feature: Feature) => void;
onViewPlan?: (feature: Feature) => void;
onApprovePlan?: (feature: Feature) => void;
onSpawnTask?: (feature: Feature) => void;
}
export interface ListViewProps {
/** Map of column/status ID to features in that column */
columnFeaturesMap: Record<string, Feature[]>;
/** All features (for dependency checking) */
allFeatures: Feature[];
/** Current sort configuration */
sortConfig: SortConfig;
/** Callback when sort column is changed */
onSortChange: (column: SortColumn) => void;
/** Action handlers for rows */
actionHandlers: ListViewActionHandlers;
/** Set of feature IDs that are currently running */
runningAutoTasks: string[];
/** Pipeline configuration for custom statuses */
pipelineConfig?: PipelineConfig | null;
/** Callback to add a new feature */
onAddFeature?: () => void;
/** Whether selection mode is enabled */
isSelectionMode?: boolean;
/** Set of selected feature IDs */
selectedFeatureIds?: Set<string>;
/** Callback when a feature's selection is toggled */
onToggleFeatureSelection?: (featureId: string) => void;
/** Callback when the row is clicked */
onRowClick?: (feature: Feature) => void;
/** Additional className for custom styling */
className?: string;
}
/**
* StatusGroupHeader displays the header for a status group with collapse toggle
*/
const StatusGroupHeader = memo(function StatusGroupHeader({
group,
isExpanded,
onToggle,
}: {
group: StatusGroup;
isExpanded: boolean;
onToggle: () => void;
}) {
return (
<button
type="button"
onClick={onToggle}
className={cn(
'flex items-center gap-2 w-full px-3 py-2 text-left',
'bg-muted/50 hover:bg-muted/70 transition-colors duration-200',
'border-b border-border/50',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset'
)}
aria-expanded={isExpanded}
data-testid={`list-group-header-${group.id}`}
>
{/* Collapse indicator */}
<span className="text-muted-foreground">
{isExpanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
</span>
{/* Status color indicator */}
<span
className={cn('w-2.5 h-2.5 rounded-full shrink-0', group.colorClass)}
aria-hidden="true"
/>
{/* Group title */}
<span className="font-medium text-sm">{group.title}</span>
{/* Feature count */}
<span className="text-xs text-muted-foreground">({group.features.length})</span>
</button>
);
});
/**
* EmptyState displays a message when there are no features
*/
const EmptyState = memo(function EmptyState({ onAddFeature }: { onAddFeature?: () => void }) {
return (
<div
className={cn(
'flex flex-col items-center justify-center py-16 px-4',
'text-center text-muted-foreground'
)}
data-testid="list-view-empty"
>
<p className="text-sm mb-4">No features to display</p>
{onAddFeature && (
<Button variant="outline" size="sm" onClick={onAddFeature}>
<Plus className="w-4 h-4 mr-2" />
Add Feature
</Button>
)}
</div>
);
});
/**
* ListView displays features in a table format grouped by status.
*
* Features:
* - Groups features by status (backlog, in_progress, waiting_approval, verified, pipeline steps)
* - Collapsible status groups
* - Sortable columns (title, status, category, priority, dates)
* - Inline row actions with hover state
* - Selection support for bulk operations
* - Animated border for currently running features
* - Keyboard accessible
*
* The component receives features grouped by status via columnFeaturesMap
* and applies the current sort configuration within each group.
*
* @example
* ```tsx
* const { sortConfig, setSortColumn } = useListViewState();
* const { columnFeaturesMap } = useBoardColumnFeatures({ features, ... });
*
* <ListView
* columnFeaturesMap={columnFeaturesMap}
* allFeatures={features}
* sortConfig={sortConfig}
* onSortChange={setSortColumn}
* actionHandlers={{
* onEdit: handleEdit,
* onDelete: handleDelete,
* // ...
* }}
* runningAutoTasks={runningAutoTasks}
* pipelineConfig={pipelineConfig}
* onAddFeature={handleAddFeature}
* />
* ```
*/
export const ListView = memo(function ListView({
columnFeaturesMap,
allFeatures,
sortConfig,
onSortChange,
actionHandlers,
runningAutoTasks,
pipelineConfig = null,
onAddFeature,
isSelectionMode = false,
selectedFeatureIds = EMPTY_SET,
onToggleFeatureSelection,
onRowClick,
className,
}: ListViewProps) {
// Track collapsed state for each status group
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
// Generate status groups from columnFeaturesMap
const statusGroups = useMemo<StatusGroup[]>(() => {
const columns = getColumnsWithPipeline(pipelineConfig);
const groups: StatusGroup[] = [];
for (const column of columns) {
const features = columnFeaturesMap[column.id] || [];
if (features.length > 0) {
// Sort features within the group according to current sort config
const sortedFeatures = sortFeatures(features, sortConfig.column, sortConfig.direction);
groups.push({
id: column.id as FeatureStatusWithPipeline,
title: column.title,
colorClass: column.colorClass,
features: sortedFeatures,
});
}
}
// Sort groups by status order
return groups.sort((a, b) => getStatusOrder(a.id) - getStatusOrder(b.id));
}, [columnFeaturesMap, pipelineConfig, sortConfig]);
// Calculate total feature count
const totalFeatures = useMemo(
() => statusGroups.reduce((sum, group) => sum + group.features.length, 0),
[statusGroups]
);
// Toggle group collapse state
const toggleGroup = useCallback((groupId: string) => {
setCollapsedGroups((prev) => {
const next = new Set(prev);
if (next.has(groupId)) {
next.delete(groupId);
} else {
next.add(groupId);
}
return next;
});
}, []);
// Create row action handlers for a feature
const createHandlers = useCallback(
(feature: Feature): RowActionHandlers => {
return createRowActionHandlers(feature.id, {
editFeature: (id) => {
const f = allFeatures.find((f) => f.id === id);
if (f) actionHandlers.onEdit(f);
},
deleteFeature: (id) => actionHandlers.onDelete(id),
viewOutput: actionHandlers.onViewOutput
? (id) => {
const f = allFeatures.find((f) => f.id === id);
if (f) actionHandlers.onViewOutput?.(f);
}
: undefined,
verifyFeature: actionHandlers.onVerify
? (id) => {
const f = allFeatures.find((f) => f.id === id);
if (f) actionHandlers.onVerify?.(f);
}
: undefined,
resumeFeature: actionHandlers.onResume
? (id) => {
const f = allFeatures.find((f) => f.id === id);
if (f) actionHandlers.onResume?.(f);
}
: undefined,
forceStop: actionHandlers.onForceStop
? (id) => {
const f = allFeatures.find((f) => f.id === id);
if (f) actionHandlers.onForceStop?.(f);
}
: undefined,
manualVerify: actionHandlers.onManualVerify
? (id) => {
const f = allFeatures.find((f) => f.id === id);
if (f) actionHandlers.onManualVerify?.(f);
}
: undefined,
followUp: actionHandlers.onFollowUp
? (id) => {
const f = allFeatures.find((f) => f.id === id);
if (f) actionHandlers.onFollowUp?.(f);
}
: undefined,
implement: actionHandlers.onImplement
? (id) => {
const f = allFeatures.find((f) => f.id === id);
if (f) actionHandlers.onImplement?.(f);
}
: undefined,
complete: actionHandlers.onComplete
? (id) => {
const f = allFeatures.find((f) => f.id === id);
if (f) actionHandlers.onComplete?.(f);
}
: undefined,
viewPlan: actionHandlers.onViewPlan
? (id) => {
const f = allFeatures.find((f) => f.id === id);
if (f) actionHandlers.onViewPlan?.(f);
}
: undefined,
approvePlan: actionHandlers.onApprovePlan
? (id) => {
const f = allFeatures.find((f) => f.id === id);
if (f) actionHandlers.onApprovePlan?.(f);
}
: undefined,
spawnTask: actionHandlers.onSpawnTask
? (id) => {
const f = allFeatures.find((f) => f.id === id);
if (f) actionHandlers.onSpawnTask?.(f);
}
: undefined,
});
},
[actionHandlers, allFeatures]
);
// Get blocking dependencies for a feature
const getBlockingDeps = useCallback(
(feature: Feature): string[] => {
return getBlockingDependencies(feature, allFeatures);
},
[allFeatures]
);
// Calculate selection state for header checkbox
const selectionState = useMemo(() => {
if (!isSelectionMode || totalFeatures === 0) {
return { allSelected: false, someSelected: false };
}
const selectedCount = selectedFeatureIds.size;
return {
allSelected: selectedCount === totalFeatures && selectedCount > 0,
someSelected: selectedCount > 0 && selectedCount < totalFeatures,
};
}, [isSelectionMode, totalFeatures, selectedFeatureIds]);
// Handle select all toggle
const handleSelectAll = useCallback(() => {
if (!onToggleFeatureSelection) return;
// If all selected, deselect all; otherwise select all
if (selectionState.allSelected) {
// Clear all selections
selectedFeatureIds.forEach((id) => onToggleFeatureSelection(id));
} else {
// Select all features that aren't already selected
for (const group of statusGroups) {
for (const feature of group.features) {
if (!selectedFeatureIds.has(feature.id)) {
onToggleFeatureSelection(feature.id);
}
}
}
}
}, [onToggleFeatureSelection, selectionState.allSelected, selectedFeatureIds, statusGroups]);
// Show empty state if no features
if (totalFeatures === 0) {
return (
<div className={cn('flex flex-col h-full bg-background', className)} data-testid="list-view">
<EmptyState onAddFeature={onAddFeature} />
</div>
);
}
return (
<div
className={cn('flex flex-col h-full bg-background', className)}
role="table"
aria-label="Features list"
data-testid="list-view"
>
{/* Table header */}
<ListHeader
sortConfig={sortConfig}
onSortChange={onSortChange}
showCheckbox={isSelectionMode}
allSelected={selectionState.allSelected}
someSelected={selectionState.someSelected}
onSelectAll={handleSelectAll}
/>
{/* Table body with status groups */}
<div className="flex-1 overflow-y-auto" role="rowgroup">
{statusGroups.map((group) => {
const isExpanded = !collapsedGroups.has(group.id);
return (
<div
key={group.id}
className="border-b border-border/30"
data-testid={`list-group-${group.id}`}
>
{/* Group header */}
<StatusGroupHeader
group={group}
isExpanded={isExpanded}
onToggle={() => toggleGroup(group.id)}
/>
{/* Group rows */}
{isExpanded && (
<div role="rowgroup">
{group.features.map((feature) => (
<ListRow
key={feature.id}
feature={feature}
handlers={createHandlers(feature)}
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
pipelineConfig={pipelineConfig}
isSelected={selectedFeatureIds.has(feature.id)}
showCheckbox={isSelectionMode}
onToggleSelect={() => onToggleFeatureSelection?.(feature.id)}
onClick={() => onRowClick?.(feature)}
blockingDependencies={getBlockingDeps(feature)}
/>
))}
</div>
)}
</div>
);
})}
</div>
{/* Footer with Add Feature button */}
{onAddFeature && (
<div className="border-t border-border px-4 py-3">
<Button
variant="outline"
size="sm"
onClick={onAddFeature}
className="w-full sm:w-auto"
data-testid="list-view-add-feature"
>
<Plus className="w-4 h-4 mr-2" />
Add Feature
</Button>
</div>
)}
</div>
);
});
/**
* Helper to get all features from the columnFeaturesMap as a flat array
*/
export function getFlatFeatures(columnFeaturesMap: Record<string, Feature[]>): Feature[] {
return Object.values(columnFeaturesMap).flat();
}
/**
* Helper to count total features across all groups
*/
export function getTotalFeatureCount(columnFeaturesMap: Record<string, Feature[]>): number {
return Object.values(columnFeaturesMap).reduce((sum, features) => sum + features.length, 0);
}

View File

@@ -0,0 +1,635 @@
import { memo, useCallback, useState } from 'react';
import {
MoreHorizontal,
Edit,
Trash2,
PlayCircle,
RotateCcw,
StopCircle,
CheckCircle2,
FileText,
Eye,
Wand2,
Archive,
GitBranch,
GitFork,
ExternalLink,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import type { Feature } from '@/store/app-store';
/**
* Action handler types for row actions
*/
export interface RowActionHandlers {
onEdit: () => void;
onDelete: () => void;
onViewOutput?: () => void;
onVerify?: () => void;
onResume?: () => void;
onForceStop?: () => void;
onManualVerify?: () => void;
onFollowUp?: () => void;
onImplement?: () => void;
onComplete?: () => void;
onViewPlan?: () => void;
onApprovePlan?: () => void;
onSpawnTask?: () => void;
}
export interface RowActionsProps {
/** The feature for this row */
feature: Feature;
/** Action handlers */
handlers: RowActionHandlers;
/** Whether this feature is the current auto task (agent is running) */
isCurrentAutoTask?: boolean;
/** Whether the dropdown menu is open */
isOpen?: boolean;
/** Callback when the dropdown open state changes */
onOpenChange?: (open: boolean) => void;
/** Additional className for custom styling */
className?: string;
}
/**
* MenuItem is a helper component for dropdown menu items with consistent styling
*/
const MenuItem = memo(function MenuItem({
icon: Icon,
label,
onClick,
variant = 'default',
disabled = false,
}: {
icon: React.ComponentType<{ className?: string }>;
label: string;
onClick: () => void;
variant?: 'default' | 'destructive' | 'primary' | 'success' | 'warning';
disabled?: boolean;
}) {
const variantClasses = {
default: '',
destructive: 'text-destructive focus:text-destructive focus:bg-destructive/10',
primary: 'text-primary focus:text-primary focus:bg-primary/10',
success:
'text-[var(--status-success)] focus:text-[var(--status-success)] focus:bg-[var(--status-success)]/10',
warning:
'text-[var(--status-waiting)] focus:text-[var(--status-waiting)] focus:bg-[var(--status-waiting)]/10',
};
return (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onClick();
}}
disabled={disabled}
className={cn('gap-2', variantClasses[variant])}
>
<Icon className="w-4 h-4" />
<span>{label}</span>
</DropdownMenuItem>
);
});
/**
* Get the primary action for quick access button based on feature status
*/
function getPrimaryAction(
feature: Feature,
handlers: RowActionHandlers,
isCurrentAutoTask: boolean
): {
icon: React.ComponentType<{ className?: string }>;
label: string;
onClick: () => void;
variant?: 'default' | 'destructive' | 'primary' | 'success' | 'warning';
} | null {
// Running task - force stop is primary
if (isCurrentAutoTask) {
if (handlers.onForceStop) {
return {
icon: StopCircle,
label: 'Stop',
onClick: handlers.onForceStop,
variant: 'destructive',
};
}
return null;
}
// Backlog - implement is primary
if (feature.status === 'backlog' && handlers.onImplement) {
return {
icon: PlayCircle,
label: 'Make',
onClick: handlers.onImplement,
variant: 'primary',
};
}
// In progress with plan approval pending
if (
feature.status === 'in_progress' &&
feature.planSpec?.status === 'generated' &&
handlers.onApprovePlan
) {
return {
icon: FileText,
label: 'Approve',
onClick: handlers.onApprovePlan,
variant: 'warning',
};
}
// In progress - resume is primary
if (feature.status === 'in_progress' && handlers.onResume) {
return {
icon: RotateCcw,
label: 'Resume',
onClick: handlers.onResume,
variant: 'success',
};
}
// Waiting approval - verify is primary
if (feature.status === 'waiting_approval' && handlers.onManualVerify) {
return {
icon: CheckCircle2,
label: 'Verify',
onClick: handlers.onManualVerify,
variant: 'success',
};
}
// Verified - complete is primary
if (feature.status === 'verified' && handlers.onComplete) {
return {
icon: Archive,
label: 'Complete',
onClick: handlers.onComplete,
variant: 'primary',
};
}
return null;
}
/**
* Get secondary actions for inline display based on feature status
*/
function getSecondaryActions(
feature: Feature,
handlers: RowActionHandlers
): Array<{
icon: React.ComponentType<{ className?: string }>;
label: string;
onClick: () => void;
}> {
const actions = [];
// Refine action for waiting_approval status
if (feature.status === 'waiting_approval' && handlers.onFollowUp) {
actions.push({
icon: Wand2,
label: 'Refine',
onClick: handlers.onFollowUp,
});
}
return actions;
}
/**
* RowActions provides an inline action menu for list view rows.
*
* Features:
* - Quick access button for primary action (Make, Resume, Verify, etc.)
* - Dropdown menu with all available actions
* - Context-aware actions based on feature status
* - Support for running task actions (view logs, force stop)
* - Keyboard accessible (focus, Enter/Space to open)
*
* Actions by status:
* - Backlog: Edit, Delete, Make (implement), View Plan, Spawn Sub-Task
* - In Progress: View Logs, Resume, Approve Plan, Manual Verify, Edit, Spawn Sub-Task, Delete
* - Waiting Approval: Refine (inline secondary), Verify, View Logs, View PR, Edit, Spawn Sub-Task, Delete
* - Verified: View Logs, View PR, View Branch, Complete, Edit, Spawn Sub-Task, Delete
* - Running (auto task): View Logs, Approve Plan, Edit, Spawn Sub-Task, Force Stop
* - Pipeline statuses: View Logs, Edit, Spawn Sub-Task, Delete
*
* @example
* ```tsx
* <RowActions
* feature={feature}
* handlers={{
* onEdit: () => handleEdit(feature.id),
* onDelete: () => handleDelete(feature.id),
* onImplement: () => handleImplement(feature.id),
* // ... other handlers
* }}
* />
* ```
*/
export const RowActions = memo(function RowActions({
feature,
handlers,
isCurrentAutoTask = false,
isOpen,
onOpenChange,
className,
}: RowActionsProps) {
const [internalOpen, setInternalOpen] = useState(false);
// Use controlled or uncontrolled state
const open = isOpen ?? internalOpen;
const setOpen = (value: boolean) => {
if (onOpenChange) {
onOpenChange(value);
} else {
setInternalOpen(value);
}
};
const handleOpenChange = useCallback(
(newOpen: boolean) => {
setOpen(newOpen);
},
[setOpen]
);
const primaryAction = getPrimaryAction(feature, handlers, isCurrentAutoTask);
const secondaryActions = getSecondaryActions(feature, handlers);
// Helper to close menu after action
const withClose = useCallback(
(handler: () => void) => () => {
setOpen(false);
handler();
},
[setOpen]
);
return (
<div
className={cn('flex items-center gap-1', className)}
onClick={(e) => e.stopPropagation()}
data-testid={`row-actions-${feature.id}`}
>
{/* Primary action quick button */}
{primaryAction && (
<Button
variant="ghost"
size="icon-sm"
className={cn(
'h-7 w-7',
primaryAction.variant === 'destructive' &&
'hover:bg-destructive/10 hover:text-destructive',
primaryAction.variant === 'primary' && 'hover:bg-primary/10 hover:text-primary',
primaryAction.variant === 'success' &&
'hover:bg-[var(--status-success)]/10 hover:text-[var(--status-success)]',
primaryAction.variant === 'warning' &&
'hover:bg-[var(--status-waiting)]/10 hover:text-[var(--status-waiting)]'
)}
onClick={(e) => {
e.stopPropagation();
primaryAction.onClick();
}}
title={primaryAction.label}
data-testid={`row-action-primary-${feature.id}`}
>
<primaryAction.icon className="w-4 h-4" />
</Button>
)}
{/* Secondary action buttons */}
{secondaryActions.map((action, index) => (
<Button
key={`secondary-action-${index}`}
variant="ghost"
size="icon-sm"
className={cn('h-7 w-7', 'text-muted-foreground', 'hover:bg-muted hover:text-foreground')}
onClick={(e) => {
e.stopPropagation();
action.onClick();
}}
title={action.label}
data-testid={`row-action-secondary-${feature.id}-${action.label.toLowerCase()}`}
>
<action.icon className="w-4 h-4" />
</Button>
))}
{/* Dropdown menu */}
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="h-7 w-7 text-muted-foreground hover:text-foreground"
data-testid={`row-actions-trigger-${feature.id}`}
>
<MoreHorizontal className="w-4 h-4" />
<span className="sr-only">Open actions menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{/* Running task actions */}
{isCurrentAutoTask && (
<>
{handlers.onViewOutput && (
<MenuItem
icon={FileText}
label="View Logs"
onClick={withClose(handlers.onViewOutput)}
/>
)}
{feature.planSpec?.status === 'generated' && handlers.onApprovePlan && (
<MenuItem
icon={FileText}
label="Approve Plan"
onClick={withClose(handlers.onApprovePlan)}
variant="warning"
/>
)}
<MenuItem icon={Edit} label="Edit" onClick={withClose(handlers.onEdit)} />
{handlers.onSpawnTask && (
<MenuItem
icon={GitFork}
label="Spawn Sub-Task"
onClick={withClose(handlers.onSpawnTask)}
/>
)}
{handlers.onForceStop && (
<>
<DropdownMenuSeparator />
<MenuItem
icon={StopCircle}
label="Force Stop"
onClick={withClose(handlers.onForceStop)}
variant="destructive"
/>
</>
)}
</>
)}
{/* Backlog actions */}
{!isCurrentAutoTask && feature.status === 'backlog' && (
<>
<MenuItem icon={Edit} label="Edit" onClick={withClose(handlers.onEdit)} />
{feature.planSpec?.content && handlers.onViewPlan && (
<MenuItem icon={Eye} label="View Plan" onClick={withClose(handlers.onViewPlan)} />
)}
{handlers.onImplement && (
<MenuItem
icon={PlayCircle}
label="Make"
onClick={withClose(handlers.onImplement)}
variant="primary"
/>
)}
{handlers.onSpawnTask && (
<MenuItem
icon={GitFork}
label="Spawn Sub-Task"
onClick={withClose(handlers.onSpawnTask)}
/>
)}
<DropdownMenuSeparator />
<MenuItem
icon={Trash2}
label="Delete"
onClick={withClose(handlers.onDelete)}
variant="destructive"
/>
</>
)}
{/* In Progress actions */}
{!isCurrentAutoTask && feature.status === 'in_progress' && (
<>
{handlers.onViewOutput && (
<MenuItem
icon={FileText}
label="View Logs"
onClick={withClose(handlers.onViewOutput)}
/>
)}
{feature.planSpec?.status === 'generated' && handlers.onApprovePlan && (
<MenuItem
icon={FileText}
label="Approve Plan"
onClick={withClose(handlers.onApprovePlan)}
variant="warning"
/>
)}
{feature.skipTests && handlers.onManualVerify ? (
<MenuItem
icon={CheckCircle2}
label="Verify"
onClick={withClose(handlers.onManualVerify)}
variant="success"
/>
) : handlers.onResume ? (
<MenuItem
icon={RotateCcw}
label="Resume"
onClick={withClose(handlers.onResume)}
variant="success"
/>
) : null}
<DropdownMenuSeparator />
<MenuItem icon={Edit} label="Edit" onClick={withClose(handlers.onEdit)} />
{handlers.onSpawnTask && (
<MenuItem
icon={GitFork}
label="Spawn Sub-Task"
onClick={withClose(handlers.onSpawnTask)}
/>
)}
<MenuItem
icon={Trash2}
label="Delete"
onClick={withClose(handlers.onDelete)}
variant="destructive"
/>
</>
)}
{/* Waiting Approval actions */}
{!isCurrentAutoTask && feature.status === 'waiting_approval' && (
<>
{handlers.onViewOutput && (
<MenuItem
icon={FileText}
label="View Logs"
onClick={withClose(handlers.onViewOutput)}
/>
)}
{handlers.onFollowUp && (
<MenuItem icon={Wand2} label="Refine" onClick={withClose(handlers.onFollowUp)} />
)}
{feature.prUrl && (
<MenuItem
icon={ExternalLink}
label="View PR"
onClick={withClose(() => window.open(feature.prUrl, '_blank'))}
/>
)}
{handlers.onManualVerify && (
<MenuItem
icon={CheckCircle2}
label={feature.prUrl ? 'Verify' : 'Mark as Verified'}
onClick={withClose(handlers.onManualVerify)}
variant="success"
/>
)}
<DropdownMenuSeparator />
<MenuItem icon={Edit} label="Edit" onClick={withClose(handlers.onEdit)} />
{handlers.onSpawnTask && (
<MenuItem
icon={GitFork}
label="Spawn Sub-Task"
onClick={withClose(handlers.onSpawnTask)}
/>
)}
<MenuItem
icon={Trash2}
label="Delete"
onClick={withClose(handlers.onDelete)}
variant="destructive"
/>
</>
)}
{/* Verified actions */}
{!isCurrentAutoTask && feature.status === 'verified' && (
<>
{handlers.onViewOutput && (
<MenuItem
icon={FileText}
label="View Logs"
onClick={withClose(handlers.onViewOutput)}
/>
)}
{feature.prUrl && (
<MenuItem
icon={ExternalLink}
label="View PR"
onClick={withClose(() => window.open(feature.prUrl, '_blank'))}
/>
)}
{feature.worktree && (
<MenuItem
icon={GitBranch}
label="View Branch"
onClick={withClose(() => {})}
disabled
/>
)}
{handlers.onComplete && (
<MenuItem
icon={Archive}
label="Complete"
onClick={withClose(handlers.onComplete)}
variant="primary"
/>
)}
<DropdownMenuSeparator />
<MenuItem icon={Edit} label="Edit" onClick={withClose(handlers.onEdit)} />
{handlers.onSpawnTask && (
<MenuItem
icon={GitFork}
label="Spawn Sub-Task"
onClick={withClose(handlers.onSpawnTask)}
/>
)}
<MenuItem
icon={Trash2}
label="Delete"
onClick={withClose(handlers.onDelete)}
variant="destructive"
/>
</>
)}
{/* Pipeline status actions (generic fallback) */}
{!isCurrentAutoTask && feature.status.startsWith('pipeline_') && (
<>
{handlers.onViewOutput && (
<MenuItem
icon={FileText}
label="View Logs"
onClick={withClose(handlers.onViewOutput)}
/>
)}
<MenuItem icon={Edit} label="Edit" onClick={withClose(handlers.onEdit)} />
{handlers.onSpawnTask && (
<MenuItem
icon={GitFork}
label="Spawn Sub-Task"
onClick={withClose(handlers.onSpawnTask)}
/>
)}
<DropdownMenuSeparator />
<MenuItem
icon={Trash2}
label="Delete"
onClick={withClose(handlers.onDelete)}
variant="destructive"
/>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
});
/**
* Helper function to create action handlers from common patterns
*/
export function createRowActionHandlers(
featureId: string,
actions: {
editFeature?: (id: string) => void;
deleteFeature?: (id: string) => void;
viewOutput?: (id: string) => void;
verifyFeature?: (id: string) => void;
resumeFeature?: (id: string) => void;
forceStop?: (id: string) => void;
manualVerify?: (id: string) => void;
followUp?: (id: string) => void;
implement?: (id: string) => void;
complete?: (id: string) => void;
viewPlan?: (id: string) => void;
approvePlan?: (id: string) => void;
spawnTask?: (id: string) => void;
}
): RowActionHandlers {
return {
onEdit: () => actions.editFeature?.(featureId),
onDelete: () => actions.deleteFeature?.(featureId),
onViewOutput: actions.viewOutput ? () => actions.viewOutput!(featureId) : undefined,
onVerify: actions.verifyFeature ? () => actions.verifyFeature!(featureId) : undefined,
onResume: actions.resumeFeature ? () => actions.resumeFeature!(featureId) : undefined,
onForceStop: actions.forceStop ? () => actions.forceStop!(featureId) : undefined,
onManualVerify: actions.manualVerify ? () => actions.manualVerify!(featureId) : undefined,
onFollowUp: actions.followUp ? () => actions.followUp!(featureId) : undefined,
onImplement: actions.implement ? () => actions.implement!(featureId) : undefined,
onComplete: actions.complete ? () => actions.complete!(featureId) : undefined,
onViewPlan: actions.viewPlan ? () => actions.viewPlan!(featureId) : undefined,
onApprovePlan: actions.approvePlan ? () => actions.approvePlan!(featureId) : undefined,
onSpawnTask: actions.spawnTask ? () => actions.spawnTask!(featureId) : undefined,
};
}

View File

@@ -0,0 +1,218 @@
import { memo, useMemo } from 'react';
import { cn } from '@/lib/utils';
import { COLUMNS, isPipelineStatus } from '../../constants';
import type { FeatureStatusWithPipeline, PipelineConfig } from '@automaker/types';
/**
* Status display configuration
*/
interface StatusDisplay {
label: string;
colorClass: string;
bgClass: string;
borderClass: string;
}
/**
* Base status display configurations using CSS variables
*/
const BASE_STATUS_DISPLAY: Record<string, StatusDisplay> = {
backlog: {
label: 'Backlog',
colorClass: 'text-[var(--status-backlog)]',
bgClass: 'bg-[var(--status-backlog)]/15',
borderClass: 'border-[var(--status-backlog)]/30',
},
in_progress: {
label: 'In Progress',
colorClass: 'text-[var(--status-in-progress)]',
bgClass: 'bg-[var(--status-in-progress)]/15',
borderClass: 'border-[var(--status-in-progress)]/30',
},
waiting_approval: {
label: 'Waiting Approval',
colorClass: 'text-[var(--status-waiting)]',
bgClass: 'bg-[var(--status-waiting)]/15',
borderClass: 'border-[var(--status-waiting)]/30',
},
verified: {
label: 'Verified',
colorClass: 'text-[var(--status-success)]',
bgClass: 'bg-[var(--status-success)]/15',
borderClass: 'border-[var(--status-success)]/30',
},
};
/**
* Get display configuration for a pipeline status
*/
function getPipelineStatusDisplay(
status: string,
pipelineConfig: PipelineConfig | null
): StatusDisplay | null {
if (!isPipelineStatus(status) || !pipelineConfig?.steps) {
return null;
}
const stepId = status.replace('pipeline_', '');
const step = pipelineConfig.steps.find((s) => s.id === stepId);
if (!step) {
return null;
}
// Extract the color variable from the colorClass (e.g., "bg-[var(--status-in-progress)]")
// and use it for the badge styling
const colorVar = step.colorClass?.match(/var\(([^)]+)\)/)?.[1] || '--status-in-progress';
return {
label: step.name || 'Pipeline Step',
colorClass: `text-[var(${colorVar})]`,
bgClass: `bg-[var(${colorVar})]/15`,
borderClass: `border-[var(${colorVar})]/30`,
};
}
/**
* Get the display configuration for a status
*/
function getStatusDisplay(
status: FeatureStatusWithPipeline,
pipelineConfig: PipelineConfig | null
): StatusDisplay {
// Check for pipeline status first
if (isPipelineStatus(status)) {
const pipelineDisplay = getPipelineStatusDisplay(status, pipelineConfig);
if (pipelineDisplay) {
return pipelineDisplay;
}
// Fallback for unknown pipeline status
return {
label: status.replace('pipeline_', '').replace(/_/g, ' '),
colorClass: 'text-[var(--status-in-progress)]',
bgClass: 'bg-[var(--status-in-progress)]/15',
borderClass: 'border-[var(--status-in-progress)]/30',
};
}
// Check base status
const baseDisplay = BASE_STATUS_DISPLAY[status];
if (baseDisplay) {
return baseDisplay;
}
// Try to find from COLUMNS constant
const column = COLUMNS.find((c) => c.id === status);
if (column) {
return {
label: column.title,
colorClass: 'text-muted-foreground',
bgClass: 'bg-muted/50',
borderClass: 'border-border/50',
};
}
// Fallback for unknown status
return {
label: status.replace(/_/g, ' '),
colorClass: 'text-muted-foreground',
bgClass: 'bg-muted/50',
borderClass: 'border-border/50',
};
}
export interface StatusBadgeProps {
/** The status to display */
status: FeatureStatusWithPipeline;
/** Optional pipeline configuration for custom pipeline steps */
pipelineConfig?: PipelineConfig | null;
/** Size variant for the badge */
size?: 'sm' | 'default' | 'lg';
/** Additional className for custom styling */
className?: string;
}
/**
* StatusBadge displays a feature status as a colored chip/badge for use in the list view table.
*
* Features:
* - Displays status with appropriate color based on status type
* - Supports base statuses (backlog, in_progress, waiting_approval, verified)
* - Supports pipeline statuses with custom colors from pipeline configuration
* - Size variants (sm, default, lg)
* - Uses CSS variables for consistent theming
*
* @example
* ```tsx
* // Basic usage
* <StatusBadge status="backlog" />
*
* // With pipeline configuration
* <StatusBadge status="pipeline_review" pipelineConfig={pipelineConfig} />
*
* // Small size
* <StatusBadge status="verified" size="sm" />
* ```
*/
export const StatusBadge = memo(function StatusBadge({
status,
pipelineConfig = null,
size = 'default',
className,
}: StatusBadgeProps) {
const display = useMemo(() => getStatusDisplay(status, pipelineConfig), [status, pipelineConfig]);
const sizeClasses = {
sm: 'px-1.5 py-0.5 text-[10px]',
default: 'px-2 py-0.5 text-xs',
lg: 'px-2.5 py-1 text-sm',
};
return (
<span
className={cn(
'inline-flex items-center rounded-full border font-medium whitespace-nowrap',
'transition-colors duration-200',
sizeClasses[size],
display.colorClass,
display.bgClass,
display.borderClass,
className
)}
data-testid={`status-badge-${status}`}
>
{display.label}
</span>
);
});
/**
* Helper function to get the status label without rendering the badge
* Useful for sorting or filtering operations
*/
export function getStatusLabel(
status: FeatureStatusWithPipeline,
pipelineConfig: PipelineConfig | null = null
): string {
return getStatusDisplay(status, pipelineConfig).label;
}
/**
* Helper function to get the status order for sorting
* Returns a numeric value representing the status position in the workflow
*/
export function getStatusOrder(status: FeatureStatusWithPipeline): number {
const baseOrder: Record<string, number> = {
backlog: 0,
in_progress: 1,
waiting_approval: 2,
verified: 3,
};
if (isPipelineStatus(status)) {
// Pipeline statuses come after in_progress but before waiting_approval
return 1.5;
}
return baseOrder[status] ?? 0;
}

View File

@@ -0,0 +1,62 @@
import { LayoutGrid, List } from 'lucide-react';
import { cn } from '@/lib/utils';
export type ViewMode = 'kanban' | 'list';
interface ViewToggleProps {
viewMode: ViewMode;
onViewModeChange: (mode: ViewMode) => void;
className?: string;
}
/**
* A segmented control component for switching between kanban (grid) and list views.
* Uses icons to represent each view mode with clear visual feedback.
*/
export function ViewToggle({ viewMode, onViewModeChange, className }: ViewToggleProps) {
return (
<div
className={cn(
'inline-flex h-8 items-center rounded-md bg-muted p-[3px] border border-border',
className
)}
role="tablist"
aria-label="View mode"
>
<button
role="tab"
aria-selected={viewMode === 'kanban'}
aria-label="Kanban view"
onClick={() => onViewModeChange('kanban')}
className={cn(
'inline-flex h-[calc(100%-1px)] items-center justify-center gap-1.5 rounded-md px-2.5 py-1 text-sm font-medium transition-all duration-200 cursor-pointer',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
viewMode === 'kanban'
? 'bg-primary text-primary-foreground shadow-md'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
)}
data-testid="view-toggle-kanban"
>
<LayoutGrid className="w-4 h-4" />
<span className="sr-only sm:not-sr-only">Kanban</span>
</button>
<button
role="tab"
aria-selected={viewMode === 'list'}
aria-label="List view"
onClick={() => onViewModeChange('list')}
className={cn(
'inline-flex h-[calc(100%-1px)] items-center justify-center gap-1.5 rounded-md px-2.5 py-1 text-sm font-medium transition-all duration-200 cursor-pointer',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
viewMode === 'list'
? 'bg-primary text-primary-foreground shadow-md'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
)}
data-testid="view-toggle-list"
>
<List className="w-4 h-4" />
<span className="sr-only sm:not-sr-only">List</span>
</button>
</div>
);
}

View File

@@ -304,22 +304,22 @@ export function AgentOutputModal({
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent
className="w-[60vw] max-w-[60vw] max-h-[80vh] flex flex-col"
className="w-full h-full max-w-full max-h-full sm:w-[60vw] sm:max-w-[60vw] sm:max-h-[80vh] sm:h-auto sm:rounded-xl rounded-none flex flex-col"
data-testid="agent-output-modal"
>
<DialogHeader className="shrink-0">
<div className="flex items-center justify-between pr-8">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 pr-8">
<DialogTitle className="flex items-center gap-2">
{featureStatus !== 'verified' && featureStatus !== 'waiting_approval' && (
<Loader2 className="w-5 h-5 text-primary animate-spin" />
)}
Agent Output
</DialogTitle>
<div className="flex items-center gap-1 bg-muted rounded-lg p-1">
<div className="flex items-center gap-1 bg-muted rounded-lg p-1 overflow-x-auto">
{summary && (
<button
onClick={() => setViewMode('summary')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all whitespace-nowrap ${
effectiveViewMode === 'summary'
? 'bg-primary/20 text-primary shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
@@ -332,7 +332,7 @@ export function AgentOutputModal({
)}
<button
onClick={() => setViewMode('parsed')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all whitespace-nowrap ${
effectiveViewMode === 'parsed'
? 'bg-primary/20 text-primary shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
@@ -344,7 +344,7 @@ export function AgentOutputModal({
</button>
<button
onClick={() => setViewMode('changes')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all whitespace-nowrap ${
effectiveViewMode === 'changes'
? 'bg-primary/20 text-primary shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
@@ -356,7 +356,7 @@ export function AgentOutputModal({
</button>
<button
onClick={() => setViewMode('raw')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all whitespace-nowrap ${
effectiveViewMode === 'raw'
? 'bg-primary/20 text-primary shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
@@ -384,7 +384,7 @@ export function AgentOutputModal({
/>
{effectiveViewMode === 'changes' ? (
<div className="flex-1 min-h-[400px] max-h-[60vh] overflow-y-auto scrollbar-visible">
<div className="flex-1 min-h-0 sm:min-h-[400px] sm:max-h-[60vh] overflow-y-auto scrollbar-visible">
{projectPath ? (
<GitDiffPanel
projectPath={projectPath}
@@ -401,7 +401,7 @@ export function AgentOutputModal({
)}
</div>
) : effectiveViewMode === 'summary' && summary ? (
<div className="flex-1 overflow-y-auto bg-zinc-950 rounded-lg p-4 min-h-[400px] max-h-[60vh] scrollbar-visible">
<div className="flex-1 min-h-0 sm:min-h-[400px] sm:max-h-[60vh] overflow-y-auto bg-card border border-border/50 rounded-lg p-4 scrollbar-visible">
<Markdown>{summary}</Markdown>
</div>
) : (
@@ -409,7 +409,7 @@ export function AgentOutputModal({
<div
ref={scrollRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto bg-zinc-950 rounded-lg p-4 font-mono text-xs min-h-[400px] max-h-[60vh] scrollbar-visible"
className="flex-1 min-h-0 sm:min-h-[400px] sm:max-h-[60vh] overflow-y-auto bg-zinc-950 rounded-lg p-4 font-mono text-xs scrollbar-visible"
>
{isLoading && !output ? (
<div className="flex items-center justify-center h-full text-muted-foreground">

View File

@@ -419,7 +419,7 @@ export function BacklogPlanDialog({
</DialogDescription>
</DialogHeader>
<div className="py-4">{renderContent()}</div>
<div className="py-4 overflow-y-auto">{renderContent()}</div>
<DialogFooter>
{mode === 'input' && (

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
@@ -10,9 +10,10 @@ import {
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { GitCommit, Loader2 } from 'lucide-react';
import { GitCommit, Loader2, Sparkles } from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import { useAppStore } from '@/store/app-store';
interface WorktreeInfo {
path: string;
@@ -37,7 +38,9 @@ export function CommitWorktreeDialog({
}: CommitWorktreeDialogProps) {
const [message, setMessage] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isGenerating, setIsGenerating] = useState(false);
const [error, setError] = useState<string | null>(null);
const enableAiCommitMessages = useAppStore((state) => state.enableAiCommitMessages);
const handleCommit = async () => {
if (!worktree || !message.trim()) return;
@@ -77,11 +80,68 @@ export function CommitWorktreeDialog({
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && e.metaKey && !isLoading && message.trim()) {
// Prevent commit while loading or while AI is generating a message
if (e.key === 'Enter' && e.metaKey && !isLoading && !isGenerating && message.trim()) {
handleCommit();
}
};
// Generate AI commit message when dialog opens (if enabled)
useEffect(() => {
if (open && worktree) {
// Reset state
setMessage('');
setError(null);
// Only generate AI commit message if enabled
if (!enableAiCommitMessages) {
return;
}
setIsGenerating(true);
let cancelled = false;
const generateMessage = async () => {
try {
const api = getElectronAPI();
if (!api?.worktree?.generateCommitMessage) {
if (!cancelled) {
setIsGenerating(false);
}
return;
}
const result = await api.worktree.generateCommitMessage(worktree.path);
if (cancelled) return;
if (result.success && result.message) {
setMessage(result.message);
} else {
// Don't show error toast, just log it and leave message empty
console.warn('Failed to generate commit message:', result.error);
setMessage('');
}
} catch (err) {
if (cancelled) return;
// Don't show error toast for generation failures
console.warn('Error generating commit message:', err);
setMessage('');
} finally {
if (!cancelled) {
setIsGenerating(false);
}
}
};
generateMessage();
return () => {
cancelled = true;
};
}
}, [open, worktree, enableAiCommitMessages]);
if (!worktree) return null;
return (
@@ -106,10 +166,20 @@ export function CommitWorktreeDialog({
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="commit-message">Commit Message</Label>
<Label htmlFor="commit-message" className="flex items-center gap-2">
Commit Message
{isGenerating && (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Sparkles className="w-3 h-3 animate-pulse" />
Generating...
</span>
)}
</Label>
<Textarea
id="commit-message"
placeholder="Describe your changes..."
placeholder={
isGenerating ? 'Generating commit message...' : 'Describe your changes...'
}
value={message}
onChange={(e) => {
setMessage(e.target.value);
@@ -118,6 +188,7 @@ export function CommitWorktreeDialog({
onKeyDown={handleKeyDown}
className="min-h-[100px] font-mono text-sm"
autoFocus
disabled={isGenerating}
/>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
@@ -128,10 +199,14 @@ export function CommitWorktreeDialog({
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={isLoading}>
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={isLoading || isGenerating}
>
Cancel
</Button>
<Button onClick={handleCommit} disabled={isLoading || !message.trim()}>
<Button onClick={handleCommit} disabled={isLoading || isGenerating || !message.trim()}>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />

View File

@@ -117,7 +117,7 @@ export function CreatePRDialog({
description: `PR already exists for ${result.result.branch}`,
action: {
label: 'View PR',
onClick: () => window.open(result.result!.prUrl!, '_blank'),
onClick: () => window.open(result.result!.prUrl!, '_blank', 'noopener,noreferrer'),
},
});
} else {
@@ -125,7 +125,7 @@ export function CreatePRDialog({
description: `PR created from ${result.result.branch}`,
action: {
label: 'View PR',
onClick: () => window.open(result.result!.prUrl!, '_blank'),
onClick: () => window.open(result.result!.prUrl!, '_blank', 'noopener,noreferrer'),
},
});
}
@@ -251,7 +251,10 @@ export function CreatePRDialog({
<p className="text-sm text-muted-foreground mt-1">Your PR is ready for review</p>
</div>
<div className="flex gap-2 justify-center">
<Button onClick={() => window.open(prUrl, '_blank')} className="gap-2">
<Button
onClick={() => window.open(prUrl, '_blank', 'noopener,noreferrer')}
className="gap-2"
>
<ExternalLink className="w-4 h-4" />
View Pull Request
</Button>
@@ -277,7 +280,7 @@ export function CreatePRDialog({
<Button
onClick={() => {
if (browserUrl) {
window.open(browserUrl, '_blank');
window.open(browserUrl, '_blank', 'noopener,noreferrer');
}
}}
className="gap-2 w-full"

View File

@@ -0,0 +1,174 @@
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Slider } from '@/components/ui/slider';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Menu, Bot, Wand2, Settings2, GitBranch, Zap } from 'lucide-react';
import { cn } from '@/lib/utils';
import { MobileUsageBar } from './mobile-usage-bar';
interface HeaderMobileMenuProps {
// Worktree panel visibility
isWorktreePanelVisible: boolean;
onWorktreePanelToggle: (visible: boolean) => void;
// Concurrency control
maxConcurrency: number;
runningAgentsCount: number;
onConcurrencyChange: (value: number) => void;
// Auto mode
isAutoModeRunning: boolean;
onAutoModeToggle: (enabled: boolean) => void;
onOpenAutoModeSettings: () => void;
// Plan button
onOpenPlanDialog: () => void;
// Usage bar visibility
showClaudeUsage: boolean;
showCodexUsage: boolean;
}
export function HeaderMobileMenu({
isWorktreePanelVisible,
onWorktreePanelToggle,
maxConcurrency,
runningAgentsCount,
onConcurrencyChange,
isAutoModeRunning,
onAutoModeToggle,
onOpenAutoModeSettings,
onOpenPlanDialog,
showClaudeUsage,
showCodexUsage,
}: HeaderMobileMenuProps) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8 w-8 p-0"
data-testid="header-mobile-menu-trigger"
>
<Menu className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
{/* Usage Bar - show if either provider is authenticated */}
{(showClaudeUsage || showCodexUsage) && (
<>
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
Usage
</DropdownMenuLabel>
<MobileUsageBar showClaudeUsage={showClaudeUsage} showCodexUsage={showCodexUsage} />
<DropdownMenuSeparator />
</>
)}
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
Controls
</DropdownMenuLabel>
<DropdownMenuSeparator />
{/* Auto Mode Toggle */}
<div
className="flex items-center justify-between px-2 py-2 cursor-pointer hover:bg-accent rounded-sm"
onClick={() => onAutoModeToggle(!isAutoModeRunning)}
data-testid="mobile-auto-mode-toggle-container"
>
<div className="flex items-center gap-2">
<Zap
className={cn(
'w-4 h-4',
isAutoModeRunning ? 'text-yellow-500' : 'text-muted-foreground'
)}
/>
<span className="text-sm font-medium">Auto Mode</span>
</div>
<div className="flex items-center gap-2">
<Switch
id="mobile-auto-mode-toggle"
checked={isAutoModeRunning}
onCheckedChange={onAutoModeToggle}
onClick={(e) => e.stopPropagation()}
data-testid="mobile-auto-mode-toggle"
/>
<button
onClick={(e) => {
e.stopPropagation();
onOpenAutoModeSettings();
}}
className="p-1 rounded hover:bg-accent/50 transition-colors"
title="Auto Mode Settings"
data-testid="mobile-auto-mode-settings-button"
>
<Settings2 className="w-4 h-4 text-muted-foreground" />
</button>
</div>
</div>
<DropdownMenuSeparator />
{/* Worktrees Toggle */}
<div
className="flex items-center justify-between px-2 py-2 cursor-pointer hover:bg-accent rounded-sm"
onClick={() => onWorktreePanelToggle(!isWorktreePanelVisible)}
data-testid="mobile-worktrees-toggle-container"
>
<div className="flex items-center gap-2">
<GitBranch className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium">Worktrees</span>
</div>
<Switch
id="mobile-worktrees-toggle"
checked={isWorktreePanelVisible}
onCheckedChange={onWorktreePanelToggle}
onClick={(e) => e.stopPropagation()}
data-testid="mobile-worktrees-toggle"
/>
</div>
<DropdownMenuSeparator />
{/* Concurrency Control */}
<div className="px-2 py-2" data-testid="mobile-concurrency-control">
<div className="flex items-center gap-2 mb-2">
<Bot className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium">Max Agents</span>
<span
className="text-sm text-muted-foreground ml-auto"
data-testid="mobile-concurrency-value"
>
{runningAgentsCount}/{maxConcurrency}
</span>
</div>
<Slider
value={[maxConcurrency]}
onValueChange={(value) => onConcurrencyChange(value[0])}
min={1}
max={10}
step={1}
className="w-full"
data-testid="mobile-concurrency-slider"
/>
</div>
<DropdownMenuSeparator />
{/* Plan Button */}
<DropdownMenuItem
onClick={onOpenPlanDialog}
className="flex items-center gap-2"
data-testid="mobile-plan-button"
>
<Wand2 className="w-4 h-4" />
<span>Plan</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -8,3 +8,4 @@ export { useBoardBackground } from './use-board-background';
export { useBoardPersistence } from './use-board-persistence';
export { useFollowUpState } from './use-follow-up-state';
export { useSelectionMode } from './use-selection-mode';
export { useListViewState } from './use-list-view-state';

View File

@@ -0,0 +1,223 @@
import { useState, useCallback, useEffect, useMemo } from 'react';
import { getJSON, setJSON } from '@/lib/storage';
import type { ViewMode } from '../components/view-toggle';
// Re-export ViewMode for convenience
export type { ViewMode };
/** Columns that can be sorted in the list view */
export type SortColumn = 'title' | 'status' | 'category' | 'priority' | 'createdAt' | 'updatedAt';
/** Sort direction */
export type SortDirection = 'asc' | 'desc';
/** Sort configuration */
export interface SortConfig {
column: SortColumn;
direction: SortDirection;
}
/** Persisted state for the list view */
interface ListViewPersistedState {
viewMode: ViewMode;
sortConfig: SortConfig;
}
/** Storage key for list view preferences */
const STORAGE_KEY = 'automaker:list-view-state';
/** Default sort configuration */
const DEFAULT_SORT_CONFIG: SortConfig = {
column: 'createdAt',
direction: 'desc',
};
/** Default persisted state */
const DEFAULT_STATE: ListViewPersistedState = {
viewMode: 'kanban',
sortConfig: DEFAULT_SORT_CONFIG,
};
/**
* Validates and returns a valid ViewMode, defaulting to 'kanban' if invalid
*/
function validateViewMode(value: unknown): ViewMode {
if (value === 'kanban' || value === 'list') {
return value;
}
return 'kanban';
}
/**
* Validates and returns a valid SortColumn, defaulting to 'createdAt' if invalid
*/
function validateSortColumn(value: unknown): SortColumn {
const validColumns: SortColumn[] = [
'title',
'status',
'category',
'priority',
'createdAt',
'updatedAt',
];
if (typeof value === 'string' && validColumns.includes(value as SortColumn)) {
return value as SortColumn;
}
return 'createdAt';
}
/**
* Validates and returns a valid SortDirection, defaulting to 'desc' if invalid
*/
function validateSortDirection(value: unknown): SortDirection {
if (value === 'asc' || value === 'desc') {
return value;
}
return 'desc';
}
/**
* Load persisted state from localStorage with validation
*/
function loadPersistedState(): ListViewPersistedState {
const stored = getJSON<Partial<ListViewPersistedState>>(STORAGE_KEY);
if (!stored) {
return DEFAULT_STATE;
}
return {
viewMode: validateViewMode(stored.viewMode),
sortConfig: {
column: validateSortColumn(stored.sortConfig?.column),
direction: validateSortDirection(stored.sortConfig?.direction),
},
};
}
/**
* Save state to localStorage
*/
function savePersistedState(state: ListViewPersistedState): void {
setJSON(STORAGE_KEY, state);
}
export interface UseListViewStateReturn {
/** Current view mode (kanban or list) */
viewMode: ViewMode;
/** Set the view mode */
setViewMode: (mode: ViewMode) => void;
/** Toggle between kanban and list views */
toggleViewMode: () => void;
/** Whether the current view is list mode */
isListView: boolean;
/** Whether the current view is kanban mode */
isKanbanView: boolean;
/** Current sort configuration */
sortConfig: SortConfig;
/** Set the sort column (toggles direction if same column) */
setSortColumn: (column: SortColumn) => void;
/** Set the full sort configuration */
setSortConfig: (config: SortConfig) => void;
/** Reset sort to default */
resetSort: () => void;
}
/**
* Hook for managing list view state including view mode, sorting, and localStorage persistence.
*
* Features:
* - View mode toggle between kanban and list views
* - Sort configuration with column and direction
* - Automatic persistence to localStorage
* - Validated state restoration on mount
*
* @example
* ```tsx
* const { viewMode, setViewMode, sortConfig, setSortColumn } = useListViewState();
*
* // Toggle view mode
* <ViewToggle viewMode={viewMode} onViewModeChange={setViewMode} />
*
* // Sort by column (clicking same column toggles direction)
* <TableHeader onClick={() => setSortColumn('title')}>Title</TableHeader>
* ```
*/
export function useListViewState(): UseListViewStateReturn {
// Initialize state from localStorage
const [viewMode, setViewModeState] = useState<ViewMode>(() => loadPersistedState().viewMode);
const [sortConfig, setSortConfigState] = useState<SortConfig>(
() => loadPersistedState().sortConfig
);
// Derived state
const isListView = viewMode === 'list';
const isKanbanView = viewMode === 'kanban';
// Persist state changes to localStorage
useEffect(() => {
savePersistedState({ viewMode, sortConfig });
}, [viewMode, sortConfig]);
// Set view mode
const setViewMode = useCallback((mode: ViewMode) => {
setViewModeState(mode);
}, []);
// Toggle between kanban and list views
const toggleViewMode = useCallback(() => {
setViewModeState((prev) => (prev === 'kanban' ? 'list' : 'kanban'));
}, []);
// Set sort column - toggles direction if same column is clicked
const setSortColumn = useCallback((column: SortColumn) => {
setSortConfigState((prev) => {
if (prev.column === column) {
// Toggle direction if same column
return {
column,
direction: prev.direction === 'asc' ? 'desc' : 'asc',
};
}
// New column - default to descending for dates, ascending for others
const defaultDirection: SortDirection =
column === 'createdAt' || column === 'updatedAt' ? 'desc' : 'asc';
return { column, direction: defaultDirection };
});
}, []);
// Set full sort configuration
const setSortConfig = useCallback((config: SortConfig) => {
setSortConfigState(config);
}, []);
// Reset sort to default
const resetSort = useCallback(() => {
setSortConfigState(DEFAULT_SORT_CONFIG);
}, []);
return useMemo(
() => ({
viewMode,
setViewMode,
toggleViewMode,
isListView,
isKanbanView,
sortConfig,
setSortColumn,
setSortConfig,
resetSort,
}),
[
viewMode,
setViewMode,
toggleViewMode,
isListView,
isKanbanView,
sortConfig,
setSortColumn,
setSortConfig,
resetSort,
]
);
}

View File

@@ -8,7 +8,7 @@ import { Archive, Settings2, CheckSquare, GripVertical, Plus } from 'lucide-reac
import { useResponsiveKanban } from '@/hooks/use-responsive-kanban';
import { getColumnsWithPipeline, type ColumnId } from './constants';
import type { PipelineConfig } from '@automaker/types';
import { cn } from '@/lib/utils';
interface KanbanBoardProps {
sensors: any;
collisionDetectionStrategy: (args: any) => any;
@@ -57,6 +57,8 @@ interface KanbanBoardProps {
isDragging?: boolean;
/** Whether the board is in read-only mode */
isReadOnly?: boolean;
/** Additional className for custom styling (e.g., transition classes) */
className?: string;
}
export function KanbanBoard({
@@ -95,6 +97,7 @@ export function KanbanBoard({
onAiSuggest,
isDragging = false,
isReadOnly = false,
className,
}: KanbanBoardProps) {
// Generate columns including pipeline steps
const columns = useMemo(() => getColumnsWithPipeline(pipelineConfig), [pipelineConfig]);
@@ -108,7 +111,14 @@ export function KanbanBoard({
const { columnWidth, containerStyle } = useResponsiveKanban(columns.length);
return (
<div className="flex-1 overflow-x-auto px-5 pt-4 pb-4 relative" style={backgroundImageStyle}>
<div
className={cn(
'flex-1 overflow-x-auto px-5 pt-4 pb-4 relative',
'transition-opacity duration-200',
className
)}
style={backgroundImageStyle}
>
<DndContext
sensors={sensors}
collisionDetection={collisionDetectionStrategy}

View File

@@ -0,0 +1,229 @@
import { useEffect, useCallback, useState, type ComponentType, type ReactNode } from 'react';
import { RefreshCw } from 'lucide-react';
import { cn } from '@/lib/utils';
import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { AnthropicIcon, OpenAIIcon } from '@/components/ui/provider-icon';
interface MobileUsageBarProps {
showClaudeUsage: boolean;
showCodexUsage: boolean;
}
// Helper to get progress bar color based on percentage
function getProgressBarColor(percentage: number): string {
if (percentage >= 80) return 'bg-red-500';
if (percentage >= 50) return 'bg-yellow-500';
return 'bg-green-500';
}
// Individual usage bar component
function UsageBar({
label,
percentage,
isStale,
}: {
label: string;
percentage: number;
isStale: boolean;
}) {
return (
<div className="mt-1.5 first:mt-0">
<div className="flex items-center justify-between mb-0.5">
<span className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">
{label}
</span>
<span
className={cn(
'text-[10px] font-mono font-bold',
percentage >= 80
? 'text-red-500'
: percentage >= 50
? 'text-yellow-500'
: 'text-green-500'
)}
>
{Math.round(percentage)}%
</span>
</div>
<div
className={cn(
'h-1 w-full bg-muted-foreground/10 rounded-full overflow-hidden transition-opacity',
isStale && 'opacity-60'
)}
>
<div
className={cn('h-full transition-all duration-500', getProgressBarColor(percentage))}
style={{ width: `${Math.min(percentage, 100)}%` }}
/>
</div>
</div>
);
}
// Container for a provider's usage info
function UsageItem({
icon: Icon,
label,
isLoading,
onRefresh,
children,
}: {
icon: ComponentType<{ className?: string }>;
label: string;
isLoading: boolean;
onRefresh: () => void;
children: ReactNode;
}) {
return (
<div className="px-2 py-2">
<div className="flex items-center justify-between mb-1.5">
<div className="flex items-center gap-2">
<Icon className="w-4 h-4 text-muted-foreground shrink-0" />
<span className="text-sm font-semibold">{label}</span>
</div>
<button
onClick={(e) => {
e.stopPropagation();
onRefresh();
}}
className="p-1 rounded hover:bg-accent/50 transition-colors"
title="Refresh usage"
>
<RefreshCw
className={cn('w-3.5 h-3.5 text-muted-foreground', isLoading && 'animate-spin')}
/>
</button>
</div>
<div className="pl-6 space-y-2">{children}</div>
</div>
);
}
export function MobileUsageBar({ showClaudeUsage, showCodexUsage }: MobileUsageBarProps) {
const { claudeUsage, claudeUsageLastUpdated, setClaudeUsage } = useAppStore();
const { codexUsage, codexUsageLastUpdated, setCodexUsage } = useAppStore();
const [isClaudeLoading, setIsClaudeLoading] = useState(false);
const [isCodexLoading, setIsCodexLoading] = useState(false);
// Check if data is stale (older than 2 minutes)
const isClaudeStale =
!claudeUsageLastUpdated || Date.now() - claudeUsageLastUpdated > 2 * 60 * 1000;
const isCodexStale = !codexUsageLastUpdated || Date.now() - codexUsageLastUpdated > 2 * 60 * 1000;
const fetchClaudeUsage = useCallback(async () => {
setIsClaudeLoading(true);
try {
const api = getElectronAPI();
if (!api.claude) return;
const data = await api.claude.getUsage();
if (!('error' in data)) {
setClaudeUsage(data);
}
} catch {
// Silently fail - usage display is optional
} finally {
setIsClaudeLoading(false);
}
}, [setClaudeUsage]);
const fetchCodexUsage = useCallback(async () => {
setIsCodexLoading(true);
try {
const api = getElectronAPI();
if (!api.codex) return;
const data = await api.codex.getUsage();
if (!('error' in data)) {
setCodexUsage(data);
}
} catch {
// Silently fail - usage display is optional
} finally {
setIsCodexLoading(false);
}
}, [setCodexUsage]);
const getCodexWindowLabel = (durationMins: number) => {
if (durationMins < 60) return `${durationMins}m Window`;
if (durationMins < 1440) return `${Math.round(durationMins / 60)}h Window`;
return `${Math.round(durationMins / 1440)}d Window`;
};
// Auto-fetch on mount if data is stale
useEffect(() => {
if (showClaudeUsage && isClaudeStale) {
fetchClaudeUsage();
}
}, [showClaudeUsage, isClaudeStale, fetchClaudeUsage]);
useEffect(() => {
if (showCodexUsage && isCodexStale) {
fetchCodexUsage();
}
}, [showCodexUsage, isCodexStale, fetchCodexUsage]);
// Don't render if there's nothing to show
if (!showClaudeUsage && !showCodexUsage) {
return null;
}
return (
<div className="space-y-2 py-1" data-testid="mobile-usage-bar">
{showClaudeUsage && (
<UsageItem
icon={AnthropicIcon}
label="Claude"
isLoading={isClaudeLoading}
onRefresh={fetchClaudeUsage}
>
{claudeUsage ? (
<>
<UsageBar
label="Session"
percentage={claudeUsage.sessionPercentage}
isStale={isClaudeStale}
/>
<UsageBar
label="Weekly"
percentage={claudeUsage.weeklyPercentage}
isStale={isClaudeStale}
/>
</>
) : (
<p className="text-[10px] text-muted-foreground italic">Loading usage data...</p>
)}
</UsageItem>
)}
{showCodexUsage && (
<UsageItem
icon={OpenAIIcon}
label="Codex"
isLoading={isCodexLoading}
onRefresh={fetchCodexUsage}
>
{codexUsage?.rateLimits ? (
<>
{codexUsage.rateLimits.primary && (
<UsageBar
label={getCodexWindowLabel(codexUsage.rateLimits.primary.windowDurationMins)}
percentage={codexUsage.rateLimits.primary.usedPercent}
isStale={isCodexStale}
/>
)}
{codexUsage.rateLimits.secondary && (
<UsageBar
label={getCodexWindowLabel(codexUsage.rateLimits.secondary.windowDurationMins)}
percentage={codexUsage.rateLimits.secondary.usedPercent}
isStale={isCodexStale}
/>
)}
</>
) : (
<p className="text-[10px] text-muted-foreground italic">Loading usage data...</p>
)}
</UsageItem>
)}
</div>
);
}

View File

@@ -17,7 +17,7 @@ export function PrioritySelector({
type="button"
onClick={() => onPrioritySelect(1)}
className={cn(
'flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors',
'flex-1 px-2 sm:px-3 py-2 rounded-md text-xs sm:text-sm font-medium transition-colors',
selectedPriority === 1
? 'bg-red-500/20 text-red-500 border-2 border-red-500/50'
: 'bg-muted/50 text-muted-foreground border border-border hover:bg-muted'
@@ -30,7 +30,7 @@ export function PrioritySelector({
type="button"
onClick={() => onPrioritySelect(2)}
className={cn(
'flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors',
'flex-1 px-2 sm:px-3 py-2 rounded-md text-xs sm:text-sm font-medium transition-colors',
selectedPriority === 2
? 'bg-yellow-500/20 text-yellow-500 border-2 border-yellow-500/50'
: 'bg-muted/50 text-muted-foreground border border-border hover:bg-muted'
@@ -43,7 +43,7 @@ export function PrioritySelector({
type="button"
onClick={() => onPrioritySelect(3)}
className={cn(
'flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors',
'flex-1 px-2 sm:px-3 py-2 rounded-md text-xs sm:text-sm font-medium transition-colors',
selectedPriority === 3
? 'bg-blue-500/20 text-blue-500 border-2 border-blue-500/50'
: 'bg-muted/50 text-muted-foreground border border-border hover:bg-muted'

View File

@@ -20,6 +20,8 @@ interface BranchSwitchDropdownProps {
branchFilter: string;
isLoadingBranches: boolean;
isSwitching: boolean;
/** When true, renders as a standalone button (not attached to another element) */
standalone?: boolean;
onOpenChange: (open: boolean) => void;
onFilterChange: (value: string) => void;
onSwitchBranch: (worktree: WorktreeInfo, branchName: string) => void;
@@ -33,6 +35,7 @@ export function BranchSwitchDropdown({
branchFilter,
isLoadingBranches,
isSwitching,
standalone = false,
onOpenChange,
onFilterChange,
onSwitchBranch,
@@ -42,16 +45,18 @@ export function BranchSwitchDropdown({
<DropdownMenu onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild>
<Button
variant={isSelected ? 'default' : 'outline'}
variant={standalone ? 'outline' : isSelected ? 'default' : 'outline'}
size="sm"
className={cn(
'h-7 w-7 p-0 rounded-none border-r-0',
isSelected && 'bg-primary text-primary-foreground',
!isSelected && 'bg-secondary/50 hover:bg-secondary'
'h-7 w-7 p-0',
!standalone && 'rounded-none border-r-0',
standalone && 'h-8 w-8 shrink-0',
!standalone && isSelected && 'bg-primary text-primary-foreground',
!standalone && !isSelected && 'bg-secondary/50 hover:bg-secondary'
)}
title="Switch branch"
>
<GitBranch className="w-3 h-3" />
<GitBranch className={standalone ? 'w-3.5 h-3.5' : 'w-3 h-3'} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64">

View File

@@ -1,4 +1,5 @@
export { BranchSwitchDropdown } from './branch-switch-dropdown';
export { DevServerLogsPanel } from './dev-server-logs-panel';
export { WorktreeActionsDropdown } from './worktree-actions-dropdown';
export { WorktreeMobileDropdown } from './worktree-mobile-dropdown';
export { WorktreeTab } from './worktree-tab';

View File

@@ -45,6 +45,8 @@ interface WorktreeActionsDropdownProps {
isDevServerRunning: boolean;
devServerInfo?: DevServerInfo;
gitRepoStatus: GitRepoStatus;
/** When true, renders as a standalone button (not attached to another element) */
standalone?: boolean;
onOpenChange: (open: boolean) => void;
onPull: (worktree: WorktreeInfo) => void;
onPush: (worktree: WorktreeInfo) => void;
@@ -73,6 +75,7 @@ export function WorktreeActionsDropdown({
isDevServerRunning,
devServerInfo,
gitRepoStatus,
standalone = false,
onOpenChange,
onPull,
onPush,
@@ -118,15 +121,17 @@ export function WorktreeActionsDropdown({
<DropdownMenu onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild>
<Button
variant={isSelected ? 'default' : 'outline'}
variant={standalone ? 'outline' : isSelected ? 'default' : 'outline'}
size="sm"
className={cn(
'h-7 w-7 p-0 rounded-l-none',
isSelected && 'bg-primary text-primary-foreground',
!isSelected && 'bg-secondary/50 hover:bg-secondary'
'h-7 w-7 p-0',
!standalone && 'rounded-l-none',
standalone && 'h-8 w-8 shrink-0',
!standalone && isSelected && 'bg-primary text-primary-foreground',
!standalone && !isSelected && 'bg-secondary/50 hover:bg-secondary'
)}
>
<MoreHorizontal className="w-3 h-3" />
<MoreHorizontal className="w-3.5 h-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
@@ -146,8 +151,12 @@ export function WorktreeActionsDropdown({
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
Dev Server Running (:{devServerInfo?.port})
</DropdownMenuLabel>
<DropdownMenuItem onClick={() => onOpenDevServerUrl(worktree)} className="text-xs">
<Globe className="w-3.5 h-3.5 mr-2" />
<DropdownMenuItem
onClick={() => onOpenDevServerUrl(worktree)}
className="text-xs"
aria-label={`Open dev server on port ${devServerInfo?.port} in browser`}
>
<Globe className="w-3.5 h-3.5 mr-2" aria-hidden="true" />
Open in Browser
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onViewDevServerLogs(worktree)} className="text-xs">
@@ -327,7 +336,7 @@ export function WorktreeActionsDropdown({
<>
<DropdownMenuItem
onClick={() => {
window.open(worktree.pr!.url, '_blank');
window.open(worktree.pr!.url, '_blank', 'noopener,noreferrer');
}}
className="text-xs"
>

View File

@@ -0,0 +1,112 @@
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { GitBranch, ChevronDown, Loader2, CircleDot, Check } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { WorktreeInfo } from '../types';
interface WorktreeMobileDropdownProps {
worktrees: WorktreeInfo[];
isWorktreeSelected: (worktree: WorktreeInfo) => boolean;
hasRunningFeatures: (worktree: WorktreeInfo) => boolean;
isActivating: boolean;
branchCardCounts?: Record<string, number>;
onSelectWorktree: (worktree: WorktreeInfo) => void;
}
export function WorktreeMobileDropdown({
worktrees,
isWorktreeSelected,
hasRunningFeatures,
isActivating,
branchCardCounts,
onSelectWorktree,
}: WorktreeMobileDropdownProps) {
// Find the currently selected worktree to display in the trigger
const selectedWorktree = worktrees.find((w) => isWorktreeSelected(w));
const displayBranch = selectedWorktree?.branch || 'Select branch';
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8 px-3 gap-2 font-mono text-xs bg-secondary/50 hover:bg-secondary flex-1 min-w-0"
disabled={isActivating}
>
<GitBranch className="w-3.5 h-3.5 shrink-0" />
<span className="truncate">{displayBranch}</span>
{isActivating ? (
<Loader2 className="w-3 h-3 animate-spin shrink-0" />
) : (
<ChevronDown className="w-3 h-3 shrink-0 ml-auto" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64 max-h-80 overflow-y-auto">
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
Branches & Worktrees
</DropdownMenuLabel>
<DropdownMenuSeparator />
{worktrees.map((worktree) => {
const isSelected = isWorktreeSelected(worktree);
const isRunning = hasRunningFeatures(worktree);
const cardCount = branchCardCounts?.[worktree.branch];
const hasChanges = worktree.hasChanges;
const changedFilesCount = worktree.changedFilesCount;
return (
<DropdownMenuItem
key={worktree.path}
onClick={() => onSelectWorktree(worktree)}
className={cn('flex items-center gap-2 cursor-pointer', isSelected && 'bg-accent')}
>
<div className="flex items-center gap-2 flex-1 min-w-0">
{isSelected ? (
<Check className="w-3.5 h-3.5 shrink-0 text-primary" />
) : (
<div className="w-3.5 h-3.5 shrink-0" />
)}
{isRunning && <Loader2 className="w-3 h-3 animate-spin shrink-0" />}
<span className={cn('font-mono text-xs truncate', isSelected && 'font-medium')}>
{worktree.branch}
</span>
{worktree.isMain && (
<span className="text-[10px] px-1 py-0.5 rounded bg-muted text-muted-foreground shrink-0">
main
</span>
)}
</div>
<div className="flex items-center gap-1.5 shrink-0">
{cardCount !== undefined && cardCount > 0 && (
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
{cardCount}
</span>
)}
{hasChanges && (
<span
className={cn(
'inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded border',
'bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30'
)}
title={`${changedFilesCount ?? 'Some'} uncommitted file${changedFilesCount !== 1 ? 's' : ''}`}
>
<CircleDot className="w-2.5 h-2.5 mr-0.5" />
{changedFilesCount ?? '!'}
</span>
)}
</div>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -300,20 +300,29 @@ export function WorktreeTab({
)}
{isDevServerRunning && (
<Button
variant={isSelected ? 'default' : 'outline'}
size="sm"
className={cn(
'h-7 w-7 p-0 rounded-none border-r-0',
isSelected && 'bg-primary text-primary-foreground',
!isSelected && 'bg-secondary/50 hover:bg-secondary',
'text-green-500'
)}
onClick={() => onOpenDevServerUrl(worktree)}
title={`Open dev server (port ${devServerInfo?.port})`}
>
<Globe className="w-3 h-3" />
</Button>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={isSelected ? 'default' : 'outline'}
size="sm"
className={cn(
'h-7 w-7 p-0 rounded-none border-r-0',
isSelected && 'bg-primary text-primary-foreground',
!isSelected && 'bg-secondary/50 hover:bg-secondary',
'text-green-500'
)}
onClick={() => onOpenDevServerUrl(worktree)}
aria-label={`Open dev server on port ${devServerInfo?.port} in browser`}
>
<Globe className="w-3 h-3" aria-hidden="true" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Open dev server (:{devServerInfo?.port})</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<WorktreeActionsDropdown

View File

@@ -118,8 +118,37 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
const handleOpenDevServerUrl = useCallback(
(worktree: WorktreeInfo) => {
const serverInfo = runningDevServers.get(getWorktreeKey(worktree));
if (serverInfo) {
window.open(serverInfo.url, '_blank');
if (!serverInfo) {
logger.warn('No dev server info found for worktree:', getWorktreeKey(worktree));
toast.error('Dev server not found', {
description: 'The dev server may have stopped. Try starting it again.',
});
return;
}
try {
// Rewrite URL hostname to match the current browser's hostname.
// This ensures dev server URLs work when accessing Automaker from
// remote machines (e.g., 192.168.x.x or hostname.local instead of localhost).
const devServerUrl = new URL(serverInfo.url);
// Security: Only allow http/https protocols to prevent potential attacks
// via data:, javascript:, file:, or other dangerous URL schemes
if (devServerUrl.protocol !== 'http:' && devServerUrl.protocol !== 'https:') {
logger.error('Invalid dev server URL protocol:', devServerUrl.protocol);
toast.error('Invalid dev server URL', {
description: 'The server returned an unsupported URL protocol.',
});
return;
}
devServerUrl.hostname = window.location.hostname;
window.open(devServerUrl.toString(), '_blank', 'noopener,noreferrer');
} catch (error) {
logger.error('Failed to parse dev server URL:', error);
toast.error('Failed to open dev server', {
description: 'The server URL could not be processed. Please try again.',
});
}
},
[runningDevServers, getWorktreeKey]

View File

@@ -4,6 +4,7 @@ import { GitBranch, Plus, RefreshCw } from 'lucide-react';
import { cn, pathsEqual } from '@/lib/utils';
import { toast } from 'sonner';
import { getHttpApiClient } from '@/lib/http-api-client';
import { useIsMobile } from '@/hooks/use-media-query';
import type { WorktreePanelProps, WorktreeInfo } from './types';
import {
useWorktrees,
@@ -12,7 +13,13 @@ import {
useWorktreeActions,
useRunningFeatures,
} from './hooks';
import { WorktreeTab, DevServerLogsPanel } from './components';
import {
WorktreeTab,
DevServerLogsPanel,
WorktreeMobileDropdown,
WorktreeActionsDropdown,
BranchSwitchDropdown,
} from './components';
export function WorktreePanel({
projectPath,
@@ -107,6 +114,8 @@ export function WorktreePanel({
checkInitScript();
}, [projectPath]);
const isMobile = useIsMobile();
// Periodic interval check (5 seconds) to detect branch changes on disk
// Reduced from 1s to 5s to minimize GPU/CPU usage from frequent re-renders
const intervalRef = useRef<NodeJS.Timeout | null>(null);
@@ -183,6 +192,105 @@ export function WorktreePanel({
const mainWorktree = worktrees.find((w) => w.isMain);
const nonMainWorktrees = worktrees.filter((w) => !w.isMain);
// Mobile view: single dropdown for all worktrees
if (isMobile) {
// Find the currently selected worktree for the actions menu
const selectedWorktree = worktrees.find((w) => isWorktreeSelected(w)) || mainWorktree;
return (
<div className="flex items-center gap-2 px-3 py-2 border-b border-border bg-glass/50 backdrop-blur-sm">
<WorktreeMobileDropdown
worktrees={worktrees}
isWorktreeSelected={isWorktreeSelected}
hasRunningFeatures={hasRunningFeatures}
isActivating={isActivating}
branchCardCounts={branchCardCounts}
onSelectWorktree={handleSelectWorktree}
/>
{/* Branch switch dropdown for the selected worktree */}
{selectedWorktree && (
<BranchSwitchDropdown
worktree={selectedWorktree}
isSelected={true}
standalone={true}
branches={branches}
filteredBranches={filteredBranches}
branchFilter={branchFilter}
isLoadingBranches={isLoadingBranches}
isSwitching={isSwitching}
onOpenChange={handleBranchDropdownOpenChange(selectedWorktree)}
onFilterChange={setBranchFilter}
onSwitchBranch={handleSwitchBranch}
onCreateBranch={onCreateBranch}
/>
)}
{/* Actions menu for the selected worktree */}
{selectedWorktree && (
<WorktreeActionsDropdown
worktree={selectedWorktree}
isSelected={true}
standalone={true}
aheadCount={aheadCount}
behindCount={behindCount}
isPulling={isPulling}
isPushing={isPushing}
isStartingDevServer={isStartingDevServer}
isDevServerRunning={isDevServerRunning(selectedWorktree)}
devServerInfo={getDevServerInfo(selectedWorktree)}
gitRepoStatus={gitRepoStatus}
onOpenChange={handleActionsDropdownOpenChange(selectedWorktree)}
onPull={handlePull}
onPush={handlePush}
onOpenInEditor={handleOpenInEditor}
onCommit={onCommit}
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}
onResolveConflicts={onResolveConflicts}
onDeleteWorktree={onDeleteWorktree}
onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
onRunInitScript={handleRunInitScript}
hasInitScript={hasInitScript}
/>
)}
{useWorktreesEnabled && (
<>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground shrink-0"
onClick={onCreateWorktree}
title="Create new worktree"
>
<Plus className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground shrink-0"
onClick={async () => {
const removedWorktrees = await fetchWorktrees();
if (removedWorktrees && removedWorktrees.length > 0 && onRemovedWorktrees) {
onRemovedWorktrees(removedWorktrees);
}
}}
disabled={isLoading}
title="Refresh worktrees"
>
<RefreshCw className={cn('w-3.5 h-3.5', isLoading && 'animate-spin')} />
</Button>
</>
)}
</div>
);
}
// Desktop view: full tabs layout
return (
<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-glass/50 backdrop-blur-sm">
<GitBranch className="w-4 h-4 text-muted-foreground" />

View File

@@ -9,7 +9,7 @@ import {
IssueValidationEvent,
StoredValidation,
} from '@/lib/electron';
import type { LinkedPRInfo, PhaseModelEntry, ModelAlias, CursorModelId } from '@automaker/types';
import type { LinkedPRInfo, PhaseModelEntry, ModelId } from '@automaker/types';
import { useAppStore } from '@/store/app-store';
import { toast } from 'sonner';
import { isValidationStale } from '../utils';
@@ -19,12 +19,10 @@ const logger = createLogger('IssueValidation');
/**
* Extract model string from PhaseModelEntry or string (handles both formats)
*/
function extractModel(
entry: PhaseModelEntry | string | undefined
): ModelAlias | CursorModelId | undefined {
function extractModel(entry: PhaseModelEntry | string | undefined): ModelId | undefined {
if (!entry) return undefined;
if (typeof entry === 'string') {
return entry as ModelAlias | CursorModelId;
return entry as ModelId;
}
return entry.model;
}
@@ -228,8 +226,8 @@ export function useIssueValidation({
issue: GitHubIssue,
options: {
forceRevalidate?: boolean;
model?: string | PhaseModelEntry; // Accept either string (backward compat) or PhaseModelEntry
modelEntry?: PhaseModelEntry; // New preferred way to pass model with thinking level
model?: ModelId | PhaseModelEntry; // Accept either model ID (backward compat) or PhaseModelEntry
modelEntry?: PhaseModelEntry; // New preferred way to pass model with thinking/reasoning
comments?: GitHubComment[];
linkedPRs?: LinkedPRInfo[];
} = {}
@@ -267,15 +265,16 @@ export function useIssueValidation({
? modelEntry
: model
? typeof model === 'string'
? { model: model as ModelAlias | CursorModelId }
? { model: model as ModelId }
: model
: phaseModels.validationModel;
const normalizedEntry =
typeof effectiveModelEntry === 'string'
? { model: effectiveModelEntry as ModelAlias | CursorModelId }
? { model: effectiveModelEntry as ModelId }
: effectiveModelEntry;
const modelToUse = normalizedEntry.model;
const thinkingLevelToUse = normalizedEntry.thinkingLevel;
const reasoningEffortToUse = normalizedEntry.reasoningEffort;
try {
const api = getElectronAPI();
@@ -292,7 +291,8 @@ export function useIssueValidation({
currentProject.path,
validationInput,
modelToUse,
thinkingLevelToUse
thinkingLevelToUse,
reasoningEffortToUse
);
if (!result.success) {

View File

@@ -1,5 +1,5 @@
import type { GitHubIssue, StoredValidation, GitHubComment } from '@/lib/electron';
import type { ModelAlias, CursorModelId, LinkedPRInfo, PhaseModelEntry } from '@automaker/types';
import type { ModelId, LinkedPRInfo, PhaseModelEntry } from '@automaker/types';
export interface IssueRowProps {
issue: GitHubIssue;
@@ -37,7 +37,7 @@ export interface IssueDetailPanelProps {
/** Model override state */
modelOverride: {
effectiveModelEntry: PhaseModelEntry;
effectiveModel: ModelAlias | CursorModelId;
effectiveModel: ModelId;
isOverridden: boolean;
setOverride: (entry: PhaseModelEntry | null) => void;
};

View File

@@ -8,6 +8,7 @@ import {
useNodesState,
useEdgesState,
ReactFlowProvider,
useReactFlow,
SelectionMode,
ConnectionMode,
Node,
@@ -244,6 +245,82 @@ function GraphCanvasInner({
[]
);
// Get fitView from React Flow for orientation change handling
const { fitView } = useReactFlow();
// Handle orientation changes on mobile devices
// When rotating from landscape to portrait, the view may incorrectly zoom in
// This effect listens for orientation changes and calls fitView to correct the viewport
useEffect(() => {
if (typeof window === 'undefined') return;
// Track the previous orientation to detect changes
let previousWidth = window.innerWidth;
let previousHeight = window.innerHeight;
// Track timeout IDs for cleanup
let orientationTimeoutId: ReturnType<typeof setTimeout> | null = null;
let resizeTimeoutId: ReturnType<typeof setTimeout> | null = null;
const handleOrientationChange = () => {
// Clear any pending timeout
if (orientationTimeoutId) {
clearTimeout(orientationTimeoutId);
}
// Small delay to allow the browser to complete the orientation change
orientationTimeoutId = setTimeout(() => {
fitView({ padding: 0.2, duration: 300 });
orientationTimeoutId = null;
}, 100);
};
const handleResize = () => {
const currentWidth = window.innerWidth;
const currentHeight = window.innerHeight;
// Detect orientation change by checking if width and height swapped significantly
// This happens when device rotates between portrait and landscape
const widthDiff = Math.abs(currentWidth - previousHeight);
const heightDiff = Math.abs(currentHeight - previousWidth);
// If the dimensions are close to being swapped (within 100px tolerance)
// it's likely an orientation change
const isOrientationChange = widthDiff < 100 && heightDiff < 100;
if (isOrientationChange) {
// Clear any pending timeout
if (resizeTimeoutId) {
clearTimeout(resizeTimeoutId);
}
// Delay fitView to allow browser to complete the layout
resizeTimeoutId = setTimeout(() => {
fitView({ padding: 0.2, duration: 300 });
resizeTimeoutId = null;
}, 150);
}
previousWidth = currentWidth;
previousHeight = currentHeight;
};
// Listen for orientation change event (mobile specific)
window.addEventListener('orientationchange', handleOrientationChange);
// Also listen for resize as a fallback (some browsers don't fire orientationchange)
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('orientationchange', handleOrientationChange);
window.removeEventListener('resize', handleResize);
// Clear any pending timeouts
if (orientationTimeoutId) {
clearTimeout(orientationTimeoutId);
}
if (resizeTimeoutId) {
clearTimeout(resizeTimeoutId);
}
};
}, [fitView]);
// MiniMap node color based on status
const minimapNodeColor = useCallback((node: Node<TaskNodeData>) => {
const data = node.data as TaskNodeData | undefined;

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useSearch } from '@tanstack/react-router';
import { useAppStore } from '@/store/app-store';
@@ -30,6 +30,9 @@ import { PromptCustomizationSection } from './settings-view/prompts';
import type { Project as SettingsProject, Theme } from './settings-view/shared/types';
import type { Project as ElectronProject } from '@/lib/electron';
// Breakpoint constant for mobile (matches Tailwind lg breakpoint)
const LG_BREAKPOINT = 1024;
export function SettingsView() {
const {
theme,
@@ -41,6 +44,8 @@ export function SettingsView() {
setEnableDependencyBlocking,
skipVerificationInAutoMode,
setSkipVerificationInAutoMode,
enableAiCommitMessages,
setEnableAiCommitMessages,
useWorktrees,
setUseWorktrees,
muteDoneSound,
@@ -69,6 +74,8 @@ export function SettingsView() {
name: project.name,
path: project.path,
theme: project.theme as Theme | undefined,
icon: project.icon,
customIconPath: project.customIconPath,
};
};
@@ -106,6 +113,33 @@ export function SettingsView() {
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showKeyboardMapDialog, setShowKeyboardMapDialog] = useState(false);
// Mobile navigation state - default to showing on desktop, hidden on mobile
const [showNavigation, setShowNavigation] = useState(() => {
if (typeof window !== 'undefined') {
return window.innerWidth >= LG_BREAKPOINT;
}
return true; // Default to showing on SSR
});
// Auto-close navigation on mobile when a section is selected
useEffect(() => {
if (typeof window !== 'undefined' && window.innerWidth < LG_BREAKPOINT) {
setShowNavigation(false);
}
}, [activeView]);
// Handle window resize to show/hide navigation appropriately
useEffect(() => {
const handleResize = () => {
if (window.innerWidth >= LG_BREAKPOINT) {
setShowNavigation(true);
}
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
// Render the active section based on current view
const renderActiveSection = () => {
switch (activeView) {
@@ -157,12 +191,14 @@ export function SettingsView() {
skipVerificationInAutoMode={skipVerificationInAutoMode}
defaultPlanningMode={defaultPlanningMode}
defaultRequirePlanApproval={defaultRequirePlanApproval}
enableAiCommitMessages={enableAiCommitMessages}
defaultFeatureModel={defaultFeatureModel}
onDefaultSkipTestsChange={setDefaultSkipTests}
onEnableDependencyBlockingChange={setEnableDependencyBlocking}
onSkipVerificationInAutoModeChange={setSkipVerificationInAutoMode}
onDefaultPlanningModeChange={setDefaultPlanningMode}
onDefaultRequirePlanApprovalChange={setDefaultRequirePlanApproval}
onEnableAiCommitMessagesChange={setEnableAiCommitMessages}
onDefaultFeatureModelChange={setDefaultFeatureModel}
/>
);
@@ -194,20 +230,25 @@ export function SettingsView() {
return (
<div className="flex-1 flex flex-col overflow-hidden content-bg" data-testid="settings-view">
{/* Header Section */}
<SettingsHeader />
<SettingsHeader
showNavigation={showNavigation}
onToggleNavigation={() => setShowNavigation(!showNavigation)}
/>
{/* Content Area with Sidebar */}
<div className="flex-1 flex overflow-hidden">
{/* Side Navigation - No longer scrolls, just switches views */}
{/* Side Navigation - Overlay on mobile, sidebar on desktop */}
<SettingsNavigation
navItems={NAV_ITEMS}
activeSection={activeView}
currentProject={currentProject}
onNavigate={handleNavigate}
isOpen={showNavigation}
onClose={() => setShowNavigation(false)}
/>
{/* Content Panel - Shows only the active section */}
<div className="flex-1 overflow-y-auto p-8">
<div className="flex-1 overflow-y-auto p-4 lg:p-8">
<div className="max-w-4xl mx-auto">{renderActiveSection()}</div>
</div>
</div>

View File

@@ -1,6 +1,157 @@
import { useCallback, useEffect, useState } from 'react';
import { cn } from '@/lib/utils';
import { getElectronAPI } from '@/lib/electron';
import { useSetupStore } from '@/store/setup-store';
import { useAppStore } from '@/store/app-store';
import { Button } from '@/components/ui/button';
import { RefreshCw, AlertCircle } from 'lucide-react';
const ERROR_NO_API = 'Claude usage API not available';
const CLAUDE_USAGE_TITLE = 'Claude Usage';
const CLAUDE_USAGE_SUBTITLE = 'Shows usage limits reported by the Claude CLI.';
const CLAUDE_AUTH_WARNING = 'Authenticate Claude CLI to view usage limits.';
const CLAUDE_LOGIN_COMMAND = 'claude login';
const CLAUDE_NO_USAGE_MESSAGE =
'Usage limits are not available yet. Try refreshing if this persists.';
const UPDATED_LABEL = 'Updated';
const CLAUDE_FETCH_ERROR = 'Failed to fetch usage';
const CLAUDE_REFRESH_LABEL = 'Refresh Claude usage';
const WARNING_THRESHOLD = 75;
const CAUTION_THRESHOLD = 50;
const MAX_PERCENTAGE = 100;
const REFRESH_INTERVAL_MS = 60_000;
const STALE_THRESHOLD_MS = 2 * 60_000;
// Using purple/indigo for Claude branding
const USAGE_COLOR_CRITICAL = 'bg-red-500';
const USAGE_COLOR_WARNING = 'bg-amber-500';
const USAGE_COLOR_OK = 'bg-indigo-500';
/**
* Get the appropriate color class for a usage percentage
*/
function getUsageColor(percentage: number): string {
if (percentage >= WARNING_THRESHOLD) {
return USAGE_COLOR_CRITICAL;
}
if (percentage >= CAUTION_THRESHOLD) {
return USAGE_COLOR_WARNING;
}
return USAGE_COLOR_OK;
}
/**
* Individual usage card displaying a usage metric with progress bar
*/
function UsageCard({
title,
subtitle,
percentage,
resetText,
}: {
title: string;
subtitle: string;
percentage: number;
resetText?: string;
}) {
const safePercentage = Math.min(Math.max(percentage, 0), MAX_PERCENTAGE);
return (
<div className="rounded-xl border border-border/60 bg-card/50 p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-semibold text-foreground">{title}</p>
<p className="text-xs text-muted-foreground">{subtitle}</p>
</div>
<span className="text-sm font-semibold text-foreground">{Math.round(safePercentage)}%</span>
</div>
<div className="mt-3 h-2 w-full rounded-full bg-secondary/60">
<div
className={cn(
'h-full rounded-full transition-all duration-300',
getUsageColor(safePercentage)
)}
style={{ width: `${safePercentage}%` }}
/>
</div>
{resetText && <p className="mt-2 text-xs text-muted-foreground">{resetText}</p>}
</div>
);
}
export function ClaudeUsageSection() {
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
const { claudeUsage, claudeUsageLastUpdated, setClaudeUsage } = useAppStore();
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const canFetchUsage = !!claudeAuthStatus?.authenticated;
// If we have usage data, we can show it even if auth status is unsure
const hasUsage = !!claudeUsage;
const lastUpdatedLabel = claudeUsageLastUpdated
? new Date(claudeUsageLastUpdated).toLocaleString()
: null;
const showAuthWarning =
(!canFetchUsage && !hasUsage && !isLoading) ||
(error && error.includes('Authentication required'));
const isStale =
!claudeUsageLastUpdated || Date.now() - claudeUsageLastUpdated > STALE_THRESHOLD_MS;
const fetchUsage = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const api = getElectronAPI();
if (!api.claude) {
setError(ERROR_NO_API);
return;
}
const result = await api.claude.getUsage();
if ('error' in result) {
// Check for auth errors specifically
if (
result.message?.includes('Authentication required') ||
result.error?.includes('Authentication required')
) {
// We'll show the auth warning UI instead of a generic error
} else {
setError(result.message || result.error);
}
return;
}
setClaudeUsage(result);
} catch (fetchError) {
const message = fetchError instanceof Error ? fetchError.message : CLAUDE_FETCH_ERROR;
setError(message);
} finally {
setIsLoading(false);
}
}, [setClaudeUsage]);
useEffect(() => {
// Initial fetch if authenticated and stale
// Compute staleness inside effect to avoid re-running when Date.now() changes
const isDataStale =
!claudeUsageLastUpdated || Date.now() - claudeUsageLastUpdated > STALE_THRESHOLD_MS;
if (canFetchUsage && isDataStale) {
void fetchUsage();
}
}, [fetchUsage, canFetchUsage, claudeUsageLastUpdated]);
useEffect(() => {
if (!canFetchUsage) return undefined;
const intervalId = setInterval(() => {
void fetchUsage();
}, REFRESH_INTERVAL_MS);
return () => clearInterval(intervalId);
}, [fetchUsage, canFetchUsage]);
return (
<div
className={cn(
@@ -12,30 +163,73 @@ export function ClaudeUsageSection() {
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-green-500/20 to-green-600/10 flex items-center justify-center border border-green-500/20">
<div className="w-5 h-5 rounded-full bg-green-500/50" />
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-indigo-500/20 to-indigo-600/10 flex items-center justify-center border border-indigo-500/20">
<div className="w-5 h-5 rounded-full bg-indigo-500/50" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">
Claude Usage Tracking
{CLAUDE_USAGE_TITLE}
</h2>
<Button
variant="ghost"
size="icon"
onClick={fetchUsage}
disabled={isLoading}
className="ml-auto h-9 w-9 rounded-lg hover:bg-accent/50"
data-testid="refresh-claude-usage"
title={CLAUDE_REFRESH_LABEL}
>
<RefreshCw className={cn('w-4 h-4', isLoading && 'animate-spin')} />
</Button>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Track your Claude Code usage limits. Uses the Claude CLI for data.
</p>
<p className="text-sm text-muted-foreground/80 ml-12">{CLAUDE_USAGE_SUBTITLE}</p>
</div>
<div className="p-6 space-y-6">
{/* Info about CLI requirement */}
<div className="rounded-lg bg-secondary/30 p-3 text-xs text-muted-foreground space-y-2 border border-border/50">
<p>Usage tracking requires Claude Code CLI to be installed and authenticated:</p>
<ol className="list-decimal list-inside space-y-1 ml-1">
<li>Install Claude Code CLI if not already installed</li>
<li>
Run <code className="font-mono bg-muted px-1 rounded">claude login</code> to
authenticate
</li>
<li>Usage data will be fetched automatically every ~minute</li>
</ol>
</div>
<div className="p-6 space-y-4">
{showAuthWarning && (
<div className="flex items-start gap-3 p-4 rounded-xl bg-amber-500/10 border border-amber-500/20">
<AlertCircle className="w-5 h-5 text-amber-500 mt-0.5" />
<div className="text-sm text-amber-400">
{CLAUDE_AUTH_WARNING} Run <span className="font-mono">{CLAUDE_LOGIN_COMMAND}</span>.
</div>
</div>
)}
{error && !showAuthWarning && (
<div className="flex items-start gap-3 p-4 rounded-xl bg-red-500/10 border border-red-500/20">
<AlertCircle className="w-5 h-5 text-red-500 mt-0.5" />
<div className="text-sm text-red-400">{error}</div>
</div>
)}
{hasUsage && (
<div className="grid gap-3 sm:grid-cols-2">
<UsageCard
title="Session Limit"
subtitle="5-hour rolling window"
percentage={claudeUsage.sessionPercentage}
resetText={claudeUsage.sessionResetText}
/>
<UsageCard
title="Weekly Limit"
subtitle="Resets every Thursday"
percentage={claudeUsage.weeklyPercentage}
resetText={claudeUsage.weeklyResetText}
/>
</div>
)}
{!hasUsage && !error && !showAuthWarning && !isLoading && (
<div className="rounded-xl border border-border/60 bg-secondary/20 p-4 text-xs text-muted-foreground">
{CLAUDE_NO_USAGE_MESSAGE}
</div>
)}
{lastUpdatedLabel && (
<div className="text-[10px] text-muted-foreground text-right">
{UPDATED_LABEL} {lastUpdatedLabel}
</div>
)}
</div>
</div>
);

View File

@@ -1,8 +1,14 @@
import { useState } from 'react';
import { useState, useRef, useEffect } from 'react';
import { Label } from '@/components/ui/label';
import { Palette, Moon, Sun } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Palette, Moon, Sun, Upload, X, ImageIcon } from 'lucide-react';
import { darkThemes, lightThemes } from '@/config/theme-options';
import { cn } from '@/lib/utils';
import { useAppStore } from '@/store/app-store';
import { IconPicker } from '@/components/layout/project-switcher/components/icon-picker';
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
import { getHttpApiClient } from '@/lib/http-api-client';
import type { Theme, Project } from '../shared/types';
interface AppearanceSectionProps {
@@ -16,10 +22,97 @@ export function AppearanceSection({
currentProject,
onThemeChange,
}: AppearanceSectionProps) {
const { setProjectIcon, setProjectName, setProjectCustomIcon } = useAppStore();
const [activeTab, setActiveTab] = useState<'dark' | 'light'>('dark');
const [projectName, setProjectNameLocal] = useState(currentProject?.name || '');
const [projectIcon, setProjectIconLocal] = useState<string | null>(currentProject?.icon || null);
const [customIconPath, setCustomIconPathLocal] = useState<string | null>(
currentProject?.customIconPath || null
);
const [isUploadingIcon, setIsUploadingIcon] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Sync local state when currentProject changes
useEffect(() => {
setProjectNameLocal(currentProject?.name || '');
setProjectIconLocal(currentProject?.icon || null);
setCustomIconPathLocal(currentProject?.customIconPath || null);
}, [currentProject]);
const themesToShow = activeTab === 'dark' ? darkThemes : lightThemes;
// Auto-save when values change
const handleNameChange = (name: string) => {
setProjectNameLocal(name);
if (currentProject && name.trim() && name.trim() !== currentProject.name) {
setProjectName(currentProject.id, name.trim());
}
};
const handleIconChange = (icon: string | null) => {
setProjectIconLocal(icon);
if (currentProject) {
setProjectIcon(currentProject.id, icon);
}
};
const handleCustomIconChange = (path: string | null) => {
setCustomIconPathLocal(path);
if (currentProject) {
setProjectCustomIcon(currentProject.id, path);
// Clear Lucide icon when custom icon is set
if (path) {
setProjectIconLocal(null);
setProjectIcon(currentProject.id, null);
}
}
};
const handleCustomIconUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !currentProject) 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,
currentProject.path
);
if (result.success && result.path) {
handleCustomIconChange(result.path);
}
setIsUploadingIcon(false);
};
reader.readAsDataURL(file);
} catch {
setIsUploadingIcon(false);
}
};
const handleRemoveCustomIcon = () => {
handleCustomIconChange(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
return (
<div
className={cn(
@@ -40,7 +133,87 @@ export function AppearanceSection({
Customize the look and feel of your application.
</p>
</div>
<div className="p-6 space-y-4">
<div className="p-6 space-y-6">
{/* Project Details Section */}
{currentProject && (
<div className="space-y-4 pb-6 border-b border-border/50">
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="project-name-settings">Project Name</Label>
<Input
id="project-name-settings"
value={projectName}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="Enter project name"
/>
</div>
<div className="space-y-2">
<Label>Project Icon</Label>
<p className="text-xs text-muted-foreground mb-2">
Choose a preset icon or upload a custom image
</p>
{/* Custom Icon Upload */}
<div className="mb-4">
<div className="flex items-center gap-3">
{customIconPath ? (
<div className="relative">
<img
src={getAuthenticatedImageUrl(customIconPath, currentProject.path)}
alt="Custom project icon"
className="w-12 h-12 rounded-lg object-cover border border-border"
/>
<button
type="button"
onClick={handleRemoveCustomIcon}
className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full bg-destructive text-destructive-foreground flex items-center justify-center hover:bg-destructive/90"
>
<X className="w-3 h-3" />
</button>
</div>
) : (
<div className="w-12 h-12 rounded-lg border border-dashed border-border flex items-center justify-center bg-accent/30">
<ImageIcon className="w-5 h-5 text-muted-foreground" />
</div>
)}
<div className="flex-1">
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
onChange={handleCustomIconUpload}
className="hidden"
id="custom-icon-upload"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={isUploadingIcon}
className="gap-1.5"
>
<Upload className="w-3.5 h-3.5" />
{isUploadingIcon ? 'Uploading...' : 'Upload Custom Icon'}
</Button>
<p className="text-xs text-muted-foreground mt-1">
PNG, JPG, GIF or WebP. Max 2MB.
</p>
</div>
</div>
</div>
{/* Preset Icon Picker - only show if no custom icon */}
{!customIconPath && (
<IconPicker selectedIcon={projectIcon} onSelectIcon={handleIconChange} />
)}
</div>
</div>
</div>
)}
{/* Theme Section */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label className="text-foreground font-medium">

View File

@@ -1,14 +1,19 @@
import { Settings } from 'lucide-react';
import { Settings, PanelLeft, PanelLeftClose } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
interface SettingsHeaderProps {
title?: string;
description?: string;
showNavigation?: boolean;
onToggleNavigation?: () => void;
}
export function SettingsHeader({
title = 'Settings',
description = 'Configure your API keys and preferences',
showNavigation,
onToggleNavigation,
}: SettingsHeaderProps) {
return (
<div
@@ -18,21 +23,39 @@ export function SettingsHeader({
'bg-gradient-to-r from-card/90 via-card/70 to-card/80 backdrop-blur-xl'
)}
>
<div className="px-8 py-6">
<div className="flex items-center gap-4">
<div className="px-4 py-4 lg:px-8 lg:py-6">
<div className="flex items-center gap-3 lg:gap-4">
{/* Mobile menu toggle button - only visible on mobile */}
{onToggleNavigation && (
<Button
variant="ghost"
size="sm"
onClick={onToggleNavigation}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground lg:hidden"
aria-label={showNavigation ? 'Close navigation menu' : 'Open navigation menu'}
>
{showNavigation ? (
<PanelLeftClose className="w-5 h-5" />
) : (
<PanelLeft className="w-5 h-5" />
)}
</Button>
)}
<div
className={cn(
'w-12 h-12 rounded-2xl flex items-center justify-center',
'w-10 h-10 lg:w-12 lg:h-12 rounded-xl lg:rounded-2xl flex items-center justify-center',
'bg-gradient-to-br from-brand-500 to-brand-600',
'shadow-lg shadow-brand-500/25',
'ring-1 ring-white/10'
)}
>
<Settings className="w-6 h-6 text-white" />
<Settings className="w-5 h-5 lg:w-6 lg:h-6 text-white" />
</div>
<div>
<h1 className="text-2xl font-bold text-foreground tracking-tight">{title}</h1>
<p className="text-sm text-muted-foreground/80 mt-0.5">{description}</p>
<h1 className="text-xl lg:text-2xl font-bold text-foreground tracking-tight">
{title}
</h1>
<p className="text-xs lg:text-sm text-muted-foreground/80 mt-0.5">{description}</p>
</div>
</div>
</div>

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { ChevronDown, ChevronRight } from 'lucide-react';
import { ChevronDown, ChevronRight, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import type { Project } from '@/lib/electron';
import type { NavigationItem, NavigationGroup } from '../config/navigation';
import { GLOBAL_NAV_GROUPS, PROJECT_NAV_ITEMS } from '../config/navigation';
@@ -13,6 +14,8 @@ interface SettingsNavigationProps {
activeSection: SettingsViewId;
currentProject: Project | null;
onNavigate: (sectionId: SettingsViewId) => void;
isOpen?: boolean;
onClose?: () => void;
}
function NavButton({
@@ -167,75 +170,116 @@ export function SettingsNavigation({
activeSection,
currentProject,
onNavigate,
isOpen = true,
onClose,
}: SettingsNavigationProps) {
// On mobile, only show when isOpen is true
// On desktop (lg+), always show regardless of isOpen
// The desktop visibility is handled by CSS, but we need to render on mobile only when open
return (
<nav
className={cn(
'hidden lg:block w-64 shrink-0 overflow-y-auto',
'border-r border-border/50',
'bg-gradient-to-b from-card/80 via-card/60 to-card/40 backdrop-blur-xl'
<>
{/* Mobile backdrop overlay - only shown when isOpen is true on mobile */}
{isOpen && (
<div
className="fixed inset-0 bg-black/50 z-20 lg:hidden"
onClick={onClose}
data-testid="settings-nav-backdrop"
/>
)}
>
<div className="sticky top-0 p-4 space-y-1">
{/* Global Settings Groups */}
{GLOBAL_NAV_GROUPS.map((group, groupIndex) => (
<div key={group.label}>
{/* Group divider (except for first group) */}
{groupIndex > 0 && <div className="my-3 border-t border-border/50" />}
{/* Group Label */}
<div className="px-3 py-2 text-xs font-semibold text-muted-foreground/70 uppercase tracking-wider">
{group.label}
{/* Navigation sidebar */}
<nav
className={cn(
// Mobile: fixed position overlay with slide transition
'fixed inset-y-0 left-0 w-72 z-30',
'transition-transform duration-200 ease-out',
// Hide on mobile when closed, show when open
isOpen ? 'translate-x-0' : '-translate-x-full',
// Desktop: relative position in layout, always visible
'lg:relative lg:w-64 lg:z-auto lg:translate-x-0',
'shrink-0 overflow-y-auto',
'border-r border-border/50',
'bg-gradient-to-b from-card/95 via-card/90 to-card/85 backdrop-blur-xl',
// Desktop background
'lg:from-card/80 lg:via-card/60 lg:to-card/40'
)}
>
{/* Mobile close button */}
<div className="lg:hidden flex items-center justify-between px-4 py-3 border-b border-border/50">
<span className="text-sm font-semibold text-foreground">Navigation</span>
<Button
variant="ghost"
size="sm"
onClick={onClose}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
aria-label="Close navigation menu"
>
<X className="w-4 h-4" />
</Button>
</div>
<div className="sticky top-0 p-4 space-y-1">
{/* Global Settings Groups */}
{GLOBAL_NAV_GROUPS.map((group, groupIndex) => (
<div key={group.label}>
{/* Group divider (except for first group) */}
{groupIndex > 0 && <div className="my-3 border-t border-border/50" />}
{/* Group Label */}
<div className="px-3 py-2 text-xs font-semibold text-muted-foreground/70 uppercase tracking-wider">
{group.label}
</div>
{/* Group Items */}
<div className="space-y-1">
{group.items.map((item) =>
item.subItems ? (
<NavItemWithSubItems
key={item.id}
item={item}
activeSection={activeSection}
onNavigate={onNavigate}
/>
) : (
<NavButton
key={item.id}
item={item}
isActive={activeSection === item.id}
onNavigate={onNavigate}
/>
)
)}
</div>
</div>
))}
{/* Group Items */}
<div className="space-y-1">
{group.items.map((item) =>
item.subItems ? (
<NavItemWithSubItems
key={item.id}
item={item}
activeSection={activeSection}
onNavigate={onNavigate}
/>
) : (
{/* Project Settings - only show when a project is selected */}
{currentProject && (
<>
{/* Divider */}
<div className="my-3 border-t border-border/50" />
{/* Project Settings Label */}
<div className="px-3 py-2 text-xs font-semibold text-muted-foreground/70 uppercase tracking-wider">
Project Settings
</div>
{/* Project Settings Items */}
<div className="space-y-1">
{PROJECT_NAV_ITEMS.map((item) => (
<NavButton
key={item.id}
item={item}
isActive={activeSection === item.id}
onNavigate={onNavigate}
/>
)
)}
</div>
</div>
))}
{/* Project Settings - only show when a project is selected */}
{currentProject && (
<>
{/* Divider */}
<div className="my-3 border-t border-border/50" />
{/* Project Settings Label */}
<div className="px-3 py-2 text-xs font-semibold text-muted-foreground/70 uppercase tracking-wider">
Project Settings
</div>
{/* Project Settings Items */}
<div className="space-y-1">
{PROJECT_NAV_ITEMS.map((item) => (
<NavButton
key={item.id}
item={item}
isActive={activeSection === item.id}
onNavigate={onNavigate}
/>
))}
</div>
</>
)}
</div>
</nav>
))}
</div>
</>
)}
</div>
</nav>
</>
);
}

View File

@@ -10,6 +10,7 @@ import {
ScrollText,
ShieldCheck,
FastForward,
Sparkles,
Cpu,
} from 'lucide-react';
import { cn } from '@/lib/utils';
@@ -31,12 +32,14 @@ interface FeatureDefaultsSectionProps {
skipVerificationInAutoMode: boolean;
defaultPlanningMode: PlanningMode;
defaultRequirePlanApproval: boolean;
enableAiCommitMessages: boolean;
defaultFeatureModel: PhaseModelEntry;
onDefaultSkipTestsChange: (value: boolean) => void;
onEnableDependencyBlockingChange: (value: boolean) => void;
onSkipVerificationInAutoModeChange: (value: boolean) => void;
onDefaultPlanningModeChange: (value: PlanningMode) => void;
onDefaultRequirePlanApprovalChange: (value: boolean) => void;
onEnableAiCommitMessagesChange: (value: boolean) => void;
onDefaultFeatureModelChange: (value: PhaseModelEntry) => void;
}
@@ -46,12 +49,14 @@ export function FeatureDefaultsSection({
skipVerificationInAutoMode,
defaultPlanningMode,
defaultRequirePlanApproval,
enableAiCommitMessages,
defaultFeatureModel,
onDefaultSkipTestsChange,
onEnableDependencyBlockingChange,
onSkipVerificationInAutoModeChange,
onDefaultPlanningModeChange,
onDefaultRequirePlanApprovalChange,
onEnableAiCommitMessagesChange,
onDefaultFeatureModelChange,
}: FeatureDefaultsSectionProps) {
return (
@@ -281,6 +286,34 @@ export function FeatureDefaultsSection({
</p>
</div>
</div>
{/* Separator */}
<div className="border-t border-border/30" />
{/* AI Commit Messages Setting */}
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<Checkbox
id="enable-ai-commit-messages"
checked={enableAiCommitMessages}
onCheckedChange={(checked) => onEnableAiCommitMessagesChange(checked === true)}
className="mt-1"
data-testid="enable-ai-commit-messages-checkbox"
/>
<div className="space-y-1.5">
<Label
htmlFor="enable-ai-commit-messages"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
<Sparkles className="w-4 h-4 text-brand-500" />
Generate AI commit messages
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
When enabled, opening the commit dialog will automatically generate a commit message
using AI based on your staged or unstaged changes. You can configure the model used in
Model Defaults.
</p>
</div>
</div>
</div>
</div>
);

View File

@@ -48,7 +48,7 @@ export function AddEditServerDialog({
Configure an MCP server to extend agent capabilities with custom tools.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-4 py-4 overflow-y-auto">
<div className="space-y-2">
<Label htmlFor="server-name">Name</Label>
<Input

View File

@@ -28,6 +28,11 @@ const QUICK_TASKS: PhaseConfig[] = [
label: 'Image Descriptions',
description: 'Analyzes and describes context images',
},
{
key: 'commitMessageModel',
label: 'Commit Messages',
description: 'Generates git commit messages from diffs',
},
];
const VALIDATION_TASKS: PhaseConfig[] = [

View File

@@ -1,6 +1,7 @@
import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
import { cn } from '@/lib/utils';
import { useAppStore } from '@/store/app-store';
import { useIsMobile } from '@/hooks/use-media-query';
import type {
ModelAlias,
CursorModelId,
@@ -167,6 +168,9 @@ export function PhaseModelSelector({
dynamicOpencodeModels,
} = useAppStore();
// Detect mobile devices to use inline expansion instead of nested popovers
const isMobile = useIsMobile();
// Extract model and thinking/reasoning levels from value
const selectedModel = value.model;
const selectedThinkingLevel = value.thinkingLevel || 'none';
@@ -585,6 +589,107 @@ export function PhaseModelSelector({
}
// Model supports reasoning - show popover with reasoning effort options
// On mobile, render inline expansion instead of nested popover
if (isMobile) {
return (
<div key={model.id}>
<CommandItem
value={model.label}
onSelect={() => setExpandedCodexModel(isExpanded ? null : (model.id as CodexModelId))}
className="group flex items-center justify-between py-2"
>
<div className="flex items-center gap-3 overflow-hidden">
<OpenAIIcon
className={cn(
'h-4 w-4 shrink-0',
isSelected ? 'text-primary' : 'text-muted-foreground'
)}
/>
<div className="flex flex-col truncate">
<span className={cn('truncate font-medium', isSelected && 'text-primary')}>
{model.label}
</span>
<span className="truncate text-xs text-muted-foreground">
{isSelected && currentReasoning !== 'none'
? `Reasoning: ${REASONING_EFFORT_LABELS[currentReasoning]}`
: model.description}
</span>
</div>
</div>
<div className="flex items-center gap-1 ml-2">
<Button
variant="ghost"
size="icon"
className={cn(
'h-6 w-6 hover:bg-transparent hover:text-yellow-500 focus:ring-0',
isFavorite
? 'text-yellow-500 opacity-100'
: 'opacity-0 group-hover:opacity-100 text-muted-foreground'
)}
onClick={(e) => {
e.stopPropagation();
toggleFavoriteModel(model.id);
}}
>
<Star className={cn('h-3.5 w-3.5', isFavorite && 'fill-current')} />
</Button>
{isSelected && !isExpanded && <Check className="h-4 w-4 text-primary shrink-0" />}
<ChevronRight
className={cn(
'h-4 w-4 text-muted-foreground transition-transform',
isExpanded && 'rotate-90'
)}
/>
</div>
</CommandItem>
{/* Inline reasoning effort options on mobile */}
{isExpanded && (
<div className="pl-6 pr-2 pb-2 space-y-1">
<div className="px-2 py-1 text-xs font-medium text-muted-foreground">
Reasoning Effort
</div>
{REASONING_EFFORT_LEVELS.map((effort) => (
<button
key={effort}
onClick={() => {
onChange({
model: model.id as CodexModelId,
reasoningEffort: effort,
});
setExpandedCodexModel(null);
setOpen(false);
}}
className={cn(
'w-full flex items-center justify-between px-2 py-2 rounded-sm text-sm',
'hover:bg-accent cursor-pointer transition-colors',
isSelected && currentReasoning === effort && 'bg-accent text-accent-foreground'
)}
>
<div className="flex flex-col items-start">
<span className="font-medium text-xs">{REASONING_EFFORT_LABELS[effort]}</span>
<span className="text-[10px] text-muted-foreground">
{effort === 'none' && 'No reasoning capability'}
{effort === 'minimal' && 'Minimal reasoning'}
{effort === 'low' && 'Light reasoning'}
{effort === 'medium' && 'Moderate reasoning'}
{effort === 'high' && 'Deep reasoning'}
{effort === 'xhigh' && 'Maximum reasoning'}
</span>
</div>
{isSelected && currentReasoning === effort && (
<Check className="h-3.5 w-3.5 text-primary" />
)}
</button>
))}
</div>
)}
</div>
);
}
// Desktop: Use nested popover
return (
<CommandItem
key={model.id}
@@ -829,6 +934,106 @@ export function PhaseModelSelector({
const isExpanded = expandedClaudeModel === model.id;
const currentThinking = isSelected ? selectedThinkingLevel : 'none';
// On mobile, render inline expansion instead of nested popover
if (isMobile) {
return (
<div key={model.id}>
<CommandItem
value={model.label}
onSelect={() => setExpandedClaudeModel(isExpanded ? null : (model.id as ModelAlias))}
className="group flex items-center justify-between py-2"
>
<div className="flex items-center gap-3 overflow-hidden">
<AnthropicIcon
className={cn(
'h-4 w-4 shrink-0',
isSelected ? 'text-primary' : 'text-muted-foreground'
)}
/>
<div className="flex flex-col truncate">
<span className={cn('truncate font-medium', isSelected && 'text-primary')}>
{model.label}
</span>
<span className="truncate text-xs text-muted-foreground">
{isSelected && currentThinking !== 'none'
? `Thinking: ${THINKING_LEVEL_LABELS[currentThinking]}`
: model.description}
</span>
</div>
</div>
<div className="flex items-center gap-1 ml-2">
<Button
variant="ghost"
size="icon"
className={cn(
'h-6 w-6 hover:bg-transparent hover:text-yellow-500 focus:ring-0',
isFavorite
? 'text-yellow-500 opacity-100'
: 'opacity-0 group-hover:opacity-100 text-muted-foreground'
)}
onClick={(e) => {
e.stopPropagation();
toggleFavoriteModel(model.id);
}}
>
<Star className={cn('h-3.5 w-3.5', isFavorite && 'fill-current')} />
</Button>
{isSelected && !isExpanded && <Check className="h-4 w-4 text-primary shrink-0" />}
<ChevronRight
className={cn(
'h-4 w-4 text-muted-foreground transition-transform',
isExpanded && 'rotate-90'
)}
/>
</div>
</CommandItem>
{/* Inline thinking level options on mobile */}
{isExpanded && (
<div className="pl-6 pr-2 pb-2 space-y-1">
<div className="px-2 py-1 text-xs font-medium text-muted-foreground">
Thinking Level
</div>
{THINKING_LEVELS.map((level) => (
<button
key={level}
onClick={() => {
onChange({
model: model.id as ModelAlias,
thinkingLevel: level,
});
setExpandedClaudeModel(null);
setOpen(false);
}}
className={cn(
'w-full flex items-center justify-between px-2 py-2 rounded-sm text-sm',
'hover:bg-accent cursor-pointer transition-colors',
isSelected && currentThinking === level && 'bg-accent text-accent-foreground'
)}
>
<div className="flex flex-col items-start">
<span className="font-medium text-xs">{THINKING_LEVEL_LABELS[level]}</span>
<span className="text-[10px] text-muted-foreground">
{level === 'none' && 'No extended thinking'}
{level === 'low' && 'Light reasoning (1k tokens)'}
{level === 'medium' && 'Moderate reasoning (10k tokens)'}
{level === 'high' && 'Deep reasoning (16k tokens)'}
{level === 'ultrathink' && 'Maximum reasoning (32k tokens)'}
</span>
</div>
{isSelected && currentThinking === level && (
<Check className="h-3.5 w-3.5 text-primary" />
)}
</button>
))}
</div>
)}
</div>
);
}
// Desktop: Use nested popover
return (
<CommandItem
key={model.id}
@@ -963,6 +1168,90 @@ export function PhaseModelSelector({
? 'Reasoning Mode'
: 'Capacity Options';
// On mobile, render inline expansion instead of nested popover
if (isMobile) {
return (
<div key={group.baseId}>
<CommandItem
value={group.label}
onSelect={() => setExpandedGroup(isExpanded ? null : group.baseId)}
className="group flex items-center justify-between py-2"
>
<div className="flex items-center gap-3 overflow-hidden">
<CursorIcon
className={cn(
'h-4 w-4 shrink-0',
groupIsSelected ? 'text-primary' : 'text-muted-foreground'
)}
/>
<div className="flex flex-col truncate">
<span className={cn('truncate font-medium', groupIsSelected && 'text-primary')}>
{group.label}
</span>
<span className="truncate text-xs text-muted-foreground">
{selectedVariant ? `Selected: ${selectedVariant.label}` : group.description}
</span>
</div>
</div>
<div className="flex items-center gap-1 ml-2">
{groupIsSelected && !isExpanded && (
<Check className="h-4 w-4 text-primary shrink-0" />
)}
<ChevronRight
className={cn(
'h-4 w-4 text-muted-foreground transition-transform',
isExpanded && 'rotate-90'
)}
/>
</div>
</CommandItem>
{/* Inline variant options on mobile */}
{isExpanded && (
<div className="pl-6 pr-2 pb-2 space-y-1">
<div className="px-2 py-1 text-xs font-medium text-muted-foreground">
{variantTypeLabel}
</div>
{group.variants.map((variant) => (
<button
key={variant.id}
onClick={() => {
onChange({ model: variant.id });
setExpandedGroup(null);
setOpen(false);
}}
className={cn(
'w-full flex items-center justify-between px-2 py-2 rounded-sm text-sm',
'hover:bg-accent cursor-pointer transition-colors',
selectedModel === variant.id && 'bg-accent text-accent-foreground'
)}
>
<div className="flex flex-col items-start">
<span className="font-medium text-xs">{variant.label}</span>
{variant.description && (
<span className="text-[10px] text-muted-foreground">
{variant.description}
</span>
)}
</div>
<div className="flex items-center gap-1.5">
{variant.badge && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
{variant.badge}
</span>
)}
{selectedModel === variant.id && <Check className="h-3.5 w-3.5 text-primary" />}
</div>
</button>
))}
</div>
)}
</div>
);
}
// Desktop: Use nested popover
return (
<CommandItem
key={group.baseId}
@@ -1111,6 +1400,7 @@ export function PhaseModelSelector({
className="w-[320px] p-0"
align={align}
onWheel={(e) => e.stopPropagation()}
onTouchMove={(e) => e.stopPropagation()}
onPointerDownOutside={(e) => {
// Only prevent close if clicking inside a nested popover (thinking level panel)
const target = e.target as HTMLElement;
@@ -1123,7 +1413,7 @@ export function PhaseModelSelector({
<CommandInput placeholder="Search models..." />
<CommandList
ref={commandListRef}
className="max-h-[300px] overflow-y-auto overscroll-contain"
className="max-h-[300px] overflow-y-auto overscroll-contain touch-pan-y"
>
<CommandEmpty>No model found.</CommandEmpty>

View File

@@ -12,6 +12,7 @@ import {
RotateCcw,
Info,
AlertTriangle,
GitCommitHorizontal,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import type { PromptCustomization, CustomPrompt } from '@automaker/types';
@@ -20,6 +21,7 @@ import {
DEFAULT_AGENT_PROMPTS,
DEFAULT_BACKLOG_PLAN_PROMPTS,
DEFAULT_ENHANCEMENT_PROMPTS,
DEFAULT_COMMIT_MESSAGE_PROMPTS,
} from '@automaker/prompts';
interface PromptCustomizationSectionProps {
@@ -219,7 +221,7 @@ export function PromptCustomizationSection({
{/* Tabs */}
<div className="p-6">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid grid-cols-4 w-full">
<TabsList className="grid grid-cols-5 w-full">
<TabsTrigger value="auto-mode" className="gap-2">
<Bot className="w-4 h-4" />
Auto Mode
@@ -236,6 +238,10 @@ export function PromptCustomizationSection({
<Sparkles className="w-4 h-4" />
Enhancement
</TabsTrigger>
<TabsTrigger value="commit-message" className="gap-2">
<GitCommitHorizontal className="w-4 h-4" />
Commit
</TabsTrigger>
</TabsList>
{/* Auto Mode Tab */}
@@ -443,6 +449,34 @@ export function PromptCustomizationSection({
/>
</div>
</TabsContent>
{/* Commit Message Tab */}
<TabsContent value="commit-message" className="space-y-6 mt-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-foreground">Commit Message Prompts</h3>
<Button
variant="ghost"
size="sm"
onClick={() => resetToDefaults('commitMessage')}
className="gap-2"
>
<RotateCcw className="w-3 h-3" />
Reset Section
</Button>
</div>
<div className="space-y-4">
<PromptField
label="System Prompt"
description="Instructions for generating git commit messages from diffs. The AI will receive the git diff and generate a conventional commit message."
defaultValue={DEFAULT_COMMIT_MESSAGE_PROMPTS.systemPrompt}
customValue={promptCustomization?.commitMessage?.systemPrompt}
onCustomValueChange={(value) =>
updatePrompt('commitMessage', 'systemPrompt', value)
}
/>
</div>
</TabsContent>
</Tabs>
</div>
</div>

View File

@@ -26,6 +26,8 @@ export interface Project {
name: string;
path: string;
theme?: string;
icon?: string;
customIconPath?: string;
}
export interface ApiKeys {

View File

@@ -50,7 +50,7 @@ export function CreateSpecDialog({
<DialogDescription className="text-muted-foreground">{description}</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-4 py-4 overflow-y-auto">
<div className="space-y-2">
<label className="text-sm font-medium">Project Overview</label>
<p className="text-xs text-muted-foreground">

View File

@@ -51,7 +51,7 @@ export function RegenerateSpecDialog({
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-4 py-4 overflow-y-auto">
<div className="space-y-2">
<label className="text-sm font-medium">Describe your project</label>
<p className="text-xs text-muted-foreground">

View File

@@ -0,0 +1,58 @@
import { useState, useEffect, useRef } from 'react';
/**
* Hook to detect if a media query matches
* @param query - The media query string (e.g., '(max-width: 768px)')
* @returns boolean indicating if the media query matches
*/
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(() => {
if (typeof window === 'undefined') return false;
return window.matchMedia(query).matches;
});
// Track if this is the initial mount to avoid redundant setMatches call
const isInitialMount = useRef(true);
useEffect(() => {
if (typeof window === 'undefined') return;
const mediaQuery = window.matchMedia(query);
const handleChange = (e: MediaQueryListEvent) => {
setMatches(e.matches);
};
// Only sync state when query changes after initial mount
// (initial mount already has correct value from useState initializer)
if (isInitialMount.current) {
isInitialMount.current = false;
} else {
setMatches(mediaQuery.matches);
}
// Listen for changes
mediaQuery.addEventListener('change', handleChange);
return () => {
mediaQuery.removeEventListener('change', handleChange);
};
}, [query]);
return matches;
}
/**
* Hook to detect if the device is mobile (screen width <= 768px)
* @returns boolean indicating if the device is mobile
*/
export function useIsMobile(): boolean {
return useMediaQuery('(max-width: 768px)');
}
/**
* Hook to detect if the device is tablet or smaller (screen width <= 1024px)
* @returns boolean indicating if the device is tablet or smaller
*/
export function useIsTablet(): boolean {
return useMediaQuery('(max-width: 1024px)');
}

View File

@@ -0,0 +1,102 @@
import { useEffect, useRef, useCallback } from 'react';
import { useSetupStore, type ClaudeAuthMethod, type CodexAuthMethod } from '@/store/setup-store';
import { getHttpApiClient } from '@/lib/http-api-client';
import { createLogger } from '@automaker/utils/logger';
const logger = createLogger('ProviderAuthInit');
/**
* Hook to initialize Claude and Codex authentication statuses on app startup.
* This ensures that usage tracking information is available in the board header
* without needing to visit the settings page first.
*/
export function useProviderAuthInit() {
const { setClaudeAuthStatus, setCodexAuthStatus, claudeAuthStatus, codexAuthStatus } =
useSetupStore();
const initialized = useRef(false);
const refreshStatuses = useCallback(async () => {
const api = getHttpApiClient();
// 1. Claude Auth Status
try {
const result = await api.setup.getClaudeStatus();
if (result.success && result.auth) {
// Cast to extended type that includes server-added fields
const auth = result.auth as typeof result.auth & {
oauthTokenValid?: boolean;
apiKeyValid?: boolean;
};
const validMethods: ClaudeAuthMethod[] = [
'oauth_token_env',
'oauth_token',
'api_key',
'api_key_env',
'credentials_file',
'cli_authenticated',
'none',
];
const method = validMethods.includes(auth.method as ClaudeAuthMethod)
? (auth.method as ClaudeAuthMethod)
: ((auth.authenticated ? 'api_key' : 'none') as ClaudeAuthMethod);
setClaudeAuthStatus({
authenticated: auth.authenticated,
method,
hasCredentialsFile: auth.hasCredentialsFile ?? false,
oauthTokenValid: !!(
auth.oauthTokenValid ||
auth.hasStoredOAuthToken ||
auth.hasEnvOAuthToken
),
apiKeyValid: !!(auth.apiKeyValid || auth.hasStoredApiKey || auth.hasEnvApiKey),
hasEnvOAuthToken: !!auth.hasEnvOAuthToken,
hasEnvApiKey: !!auth.hasEnvApiKey,
});
}
} catch (error) {
logger.error('Failed to init Claude auth status:', error);
}
// 2. Codex Auth Status
try {
const result = await api.setup.getCodexStatus();
if (result.success && result.auth) {
const auth = result.auth;
const validMethods: CodexAuthMethod[] = [
'api_key_env',
'api_key',
'cli_authenticated',
'none',
];
const method = validMethods.includes(auth.method as CodexAuthMethod)
? (auth.method as CodexAuthMethod)
: ((auth.authenticated ? 'api_key' : 'none') as CodexAuthMethod);
setCodexAuthStatus({
authenticated: auth.authenticated,
method,
hasAuthFile: auth.hasAuthFile ?? false,
hasApiKey: auth.hasApiKey ?? false,
hasEnvApiKey: auth.hasEnvApiKey ?? false,
});
}
} catch (error) {
logger.error('Failed to init Codex auth status:', error);
}
}, [setClaudeAuthStatus, setCodexAuthStatus]);
useEffect(() => {
// Only initialize once per session if not already set
if (initialized.current || (claudeAuthStatus !== null && codexAuthStatus !== null)) {
return;
}
initialized.current = true;
void refreshStatuses();
}, [refreshStatuses, claudeAuthStatus, codexAuthStatus]);
}

View File

@@ -533,6 +533,8 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
lastOpened: ref.lastOpened,
theme: ref.theme,
isFavorite: ref.isFavorite,
icon: ref.icon,
customIconPath: ref.customIconPath,
features: [], // Features are loaded separately when project is opened
}));

View File

@@ -10,7 +10,9 @@ import type {
IssueValidationResponse,
IssueValidationEvent,
StoredValidation,
AgentModel,
ModelId,
ThinkingLevel,
ReasoningEffort,
GitHubComment,
IssueCommentsResult,
Idea,
@@ -314,7 +316,9 @@ export interface GitHubAPI {
validateIssue: (
projectPath: string,
issue: IssueValidationInput,
model?: AgentModel
model?: ModelId,
thinkingLevel?: ThinkingLevel,
reasoningEffort?: ReasoningEffort
) => Promise<{ success: boolean; message?: string; issueNumber?: number; error?: string }>;
/** Check validation status for an issue or all issues */
getValidationStatus: (
@@ -1294,6 +1298,7 @@ interface SetupAPI {
success: boolean;
hasAnthropicKey: boolean;
hasGoogleKey: boolean;
hasOpenaiKey: boolean;
}>;
deleteApiKey: (
provider: string
@@ -1377,6 +1382,7 @@ function createMockSetupAPI(): SetupAPI {
success: true,
hasAnthropicKey: false,
hasGoogleKey: false,
hasOpenaiKey: false,
};
},
@@ -1537,6 +1543,14 @@ function createMockWorktreeAPI(): WorktreeAPI {
};
},
generateCommitMessage: async (worktreePath: string) => {
console.log('[Mock] Generating commit message for:', worktreePath);
return {
success: true,
message: 'feat: Add mock commit message generation',
};
},
push: async (worktreePath: string, force?: boolean) => {
console.log('[Mock] Pushing worktree:', { worktreePath, force });
return {
@@ -3024,8 +3038,20 @@ function createMockGitHubAPI(): GitHubAPI {
mergedPRs: [],
};
},
validateIssue: async (projectPath: string, issue: IssueValidationInput, model?: AgentModel) => {
console.log('[Mock] Starting async validation:', { projectPath, issue, model });
validateIssue: async (
projectPath: string,
issue: IssueValidationInput,
model?: ModelId,
thinkingLevel?: ThinkingLevel,
reasoningEffort?: ReasoningEffort
) => {
console.log('[Mock] Starting async validation:', {
projectPath,
issue,
model,
thinkingLevel,
reasoningEffort,
});
// Simulate async validation in background
setTimeout(() => {
@@ -3120,6 +3146,8 @@ export interface Project {
lastOpened?: string;
theme?: string; // Per-project theme override (uses ThemeMode from app-store)
isFavorite?: boolean; // Pin project to top of dashboard
icon?: string; // Lucide icon name for project identification
customIconPath?: string; // Path to custom uploaded icon image in .automaker/images/
}
export interface TrashedProject extends Project {

View File

@@ -36,6 +36,7 @@ import type {
import type { Message, SessionListItem } from '@/types/electron';
import type { Feature, ClaudeUsageResponse, CodexUsageResponse } from '@/store/app-store';
import type { WorktreeAPI, GitAPI, ModelDefinition, ProviderStatus } from '@/types/electron';
import type { ModelId, ThinkingLevel, ReasoningEffort } from '@automaker/types';
import { getGlobalFileBrowser } from '@/contexts/file-browser-context';
const logger = createLogger('HttpClient');
@@ -1219,6 +1220,7 @@ export class HttpApiClient implements ElectronAPI {
success: boolean;
hasAnthropicKey: boolean;
hasGoogleKey: boolean;
hasOpenaiKey: boolean;
}> => this.get('/api/setup/api-keys'),
getPlatform: (): Promise<{
@@ -1727,6 +1729,8 @@ export class HttpApiClient implements ElectronAPI {
}),
commit: (worktreePath: string, message: string) =>
this.post('/api/worktree/commit', { worktreePath, message }),
generateCommitMessage: (worktreePath: string) =>
this.post('/api/worktree/generate-commit-message', { worktreePath }),
push: (worktreePath: string, force?: boolean) =>
this.post('/api/worktree/push', { worktreePath, force }),
createPR: (worktreePath: string, options?: any) =>
@@ -1902,9 +1906,17 @@ export class HttpApiClient implements ElectronAPI {
validateIssue: (
projectPath: string,
issue: IssueValidationInput,
model?: string,
thinkingLevel?: string
) => this.post('/api/github/validate-issue', { projectPath, ...issue, model, thinkingLevel }),
model?: ModelId,
thinkingLevel?: ThinkingLevel,
reasoningEffort?: ReasoningEffort
) =>
this.post('/api/github/validate-issue', {
projectPath,
...issue,
model,
thinkingLevel,
reasoningEffort,
}),
getValidationStatus: (projectPath: string, issueNumber?: number) =>
this.post('/api/github/validation-status', { projectPath, issueNumber }),
stopValidation: (projectPath: string, issueNumber: number) =>

View File

@@ -2,6 +2,7 @@ import { createRootRoute, Outlet, useLocation, useNavigate } from '@tanstack/rea
import { useEffect, useState, useCallback, useDeferredValue, useRef } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { Sidebar } from '@/components/layout/sidebar';
import { ProjectSwitcher } from '@/components/layout/project-switcher';
import {
FileBrowserProvider,
useFileBrowser,
@@ -803,6 +804,10 @@ function RootLayoutContent() {
);
}
// Show project switcher on all app pages (not on dashboard, setup, or login)
const showProjectSwitcher =
!isDashboardRoute && !isSetupRoute && !isLoginRoute && !isLoggedOutRoute;
return (
<>
<main className="flex h-screen overflow-hidden" data-testid="app-container">
@@ -813,6 +818,7 @@ function RootLayoutContent() {
aria-hidden="true"
/>
)}
{showProjectSwitcher && <ProjectSwitcher />}
<Sidebar />
{/* Mobile menu toggle button - only shows when sidebar is closed on mobile */}
{!sidebarOpen && (

View File

@@ -340,7 +340,7 @@ export type ClaudeModel = 'opus' | 'sonnet' | 'haiku';
export interface Feature extends Omit<
BaseFeature,
'steps' | 'imagePaths' | 'textFilePaths' | 'status'
'steps' | 'imagePaths' | 'textFilePaths' | 'status' | 'planSpec'
> {
id: string;
title?: string;
@@ -354,6 +354,7 @@ export interface Feature extends Omit<
textFilePaths?: FeatureTextFilePath[]; // Text file attachments for context
justFinishedAt?: string; // UI-specific: ISO timestamp when agent just finished
prUrl?: string; // UI-specific: Pull request URL
planSpec?: PlanSpec; // Explicit planSpec type to override BaseFeature's index signature
}
// Parsed task from spec (for spec and full planning modes)
@@ -536,6 +537,7 @@ export interface AppState {
defaultSkipTests: boolean; // Default value for skip tests when creating new features
enableDependencyBlocking: boolean; // When true, show blocked badges and warnings for features with incomplete dependencies (default: true)
skipVerificationInAutoMode: boolean; // When true, auto-mode grabs features even if dependencies are not verified (only checks they're not running)
enableAiCommitMessages: boolean; // When true, auto-generate commit messages using AI when opening commit dialog
planUseSelectedWorktreeBranch: boolean; // When true, Plan dialog creates features on the currently selected worktree branch
addFeatureUseSelectedWorktreeBranch: boolean; // When true, Add Feature dialog defaults to custom mode with selected worktree branch
@@ -875,6 +877,9 @@ export interface AppActions {
cycleNextProject: () => void; // Cycle forward through project history (E)
clearProjectHistory: () => void; // Clear history, keeping only current project
toggleProjectFavorite: (projectId: string) => void; // Toggle project favorite status
setProjectIcon: (projectId: string, icon: string | null) => void; // Set project icon (null to clear)
setProjectCustomIcon: (projectId: string, customIconPath: string | null) => void; // Set custom project icon image path (null to clear)
setProjectName: (projectId: string, name: string) => void; // Update project name
// View actions
setCurrentView: (view: ViewMode) => void;
@@ -934,6 +939,7 @@ export interface AppActions {
setDefaultSkipTests: (skip: boolean) => void;
setEnableDependencyBlocking: (enabled: boolean) => void;
setSkipVerificationInAutoMode: (enabled: boolean) => Promise<void>;
setEnableAiCommitMessages: (enabled: boolean) => Promise<void>;
setPlanUseSelectedWorktreeBranch: (enabled: boolean) => Promise<void>;
setAddFeatureUseSelectedWorktreeBranch: (enabled: boolean) => Promise<void>;
@@ -1221,6 +1227,7 @@ const initialState: AppState = {
defaultSkipTests: true, // Default to manual verification (tests disabled)
enableDependencyBlocking: true, // Default to enabled (show dependency blocking UI)
skipVerificationInAutoMode: false, // Default to disabled (require dependencies to be verified)
enableAiCommitMessages: true, // Default to enabled (auto-generate commit messages)
planUseSelectedWorktreeBranch: true, // Default to enabled (Plan creates features on selected worktree branch)
addFeatureUseSelectedWorktreeBranch: false, // Default to disabled (Add Feature uses normal defaults)
useWorktrees: true, // Default to enabled (git worktree isolation)
@@ -1565,6 +1572,57 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
}
},
setProjectIcon: (projectId, icon) => {
const { projects, currentProject } = get();
const updatedProjects = projects.map((p) =>
p.id === projectId ? { ...p, icon: icon === null ? undefined : icon } : p
);
set({ projects: updatedProjects });
// Also update currentProject if it matches
if (currentProject?.id === projectId) {
set({
currentProject: {
...currentProject,
icon: icon === null ? undefined : icon,
},
});
}
},
setProjectCustomIcon: (projectId, customIconPath) => {
const { projects, currentProject } = get();
const updatedProjects = projects.map((p) =>
p.id === projectId
? { ...p, customIconPath: customIconPath === null ? undefined : customIconPath }
: p
);
set({ projects: updatedProjects });
// Also update currentProject if it matches
if (currentProject?.id === projectId) {
set({
currentProject: {
...currentProject,
customIconPath: customIconPath === null ? undefined : customIconPath,
},
});
}
},
setProjectName: (projectId, name) => {
const { projects, currentProject } = get();
const updatedProjects = projects.map((p) => (p.id === projectId ? { ...p, name } : p));
set({ projects: updatedProjects });
// Also update currentProject if it matches
if (currentProject?.id === projectId) {
set({
currentProject: {
...currentProject,
name,
},
});
}
},
// View actions
setCurrentView: (view) => set({ currentView: view }),
toggleSidebar: () => set({ sidebarOpen: !get().sidebarOpen }),
@@ -1853,6 +1911,17 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
await syncSettingsToServer();
},
setEnableAiCommitMessages: async (enabled) => {
const previous = get().enableAiCommitMessages;
set({ enableAiCommitMessages: enabled });
// Sync to server settings file
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
const ok = await syncSettingsToServer();
if (!ok) {
logger.error('Failed to sync enableAiCommitMessages setting to server - reverting');
set({ enableAiCommitMessages: previous });
}
},
setPlanUseSelectedWorktreeBranch: async (enabled) => {
const previous = get().planUseSelectedWorktreeBranch;
set({ planUseSelectedWorktreeBranch: enabled });

View File

@@ -770,6 +770,13 @@ export interface WorktreeAPI {
error?: string;
}>;
// Generate an AI commit message from git diff
generateCommitMessage: (worktreePath: string) => Promise<{
success: boolean;
message?: string;
error?: string;
}>;
// Push a worktree branch to remote
push: (
worktreePath: string,

View File

@@ -1,431 +0,0 @@
/**
* Board Background Persistence End-to-End Test
*
* Tests that board background settings are properly saved and loaded when switching projects.
* This verifies that:
* 1. Background settings are saved to .automaker-local/settings.json
* 2. Settings are loaded when switching back to a project
* 3. Background image, opacity, and other settings are correctly restored
* 4. Settings persist across app restarts (new page loads)
*
* This test prevents regression of the board background loading bug where
* settings were saved but never loaded when switching projects.
*/
import { test, expect } from '@playwright/test';
import * as fs from 'fs';
import * as path from 'path';
import {
createTempDirPath,
cleanupTempDir,
authenticateForTests,
handleLoginScreenIfPresent,
setupWelcomeView,
} from '../utils';
// Create unique temp dirs for this test run
const TEST_TEMP_DIR = createTempDirPath('board-bg-test');
test.describe('Board Background Persistence', () => {
test.beforeAll(async () => {
// Create test temp directory
if (!fs.existsSync(TEST_TEMP_DIR)) {
fs.mkdirSync(TEST_TEMP_DIR, { recursive: true });
}
});
test.afterAll(async () => {
// Cleanup temp directory
cleanupTempDir(TEST_TEMP_DIR);
});
test('should load board background settings when switching projects', async ({ page }) => {
const projectAName = `project-a-${Date.now()}`;
const projectBName = `project-b-${Date.now()}`;
const projectAPath = path.join(TEST_TEMP_DIR, projectAName);
const projectBPath = path.join(TEST_TEMP_DIR, projectBName);
const projectAId = `project-a-${Date.now()}`;
const projectBId = `project-b-${Date.now()}`;
// Create both project directories
fs.mkdirSync(projectAPath, { recursive: true });
fs.mkdirSync(projectBPath, { recursive: true });
// Create basic files for both projects
for (const [name, projectPath] of [
[projectAName, projectAPath],
[projectBName, projectBPath],
]) {
fs.writeFileSync(
path.join(projectPath, 'package.json'),
JSON.stringify({ name, version: '1.0.0' }, null, 2)
);
fs.writeFileSync(path.join(projectPath, 'README.md'), `# ${name}\n`);
}
// Create .automaker-local directory for project A with background settings
const automakerDirA = path.join(projectAPath, '.automaker-local');
fs.mkdirSync(automakerDirA, { recursive: true });
fs.mkdirSync(path.join(automakerDirA, 'board'), { recursive: true });
fs.mkdirSync(path.join(automakerDirA, 'features'), { recursive: true });
fs.mkdirSync(path.join(automakerDirA, 'context'), { recursive: true });
// Copy actual background image from test fixtures
const backgroundPath = path.join(automakerDirA, 'board', 'background.jpg');
const testImagePath = path.join(__dirname, '..', 'img', 'background.jpg');
fs.copyFileSync(testImagePath, backgroundPath);
// Create settings.json with board background configuration
const settingsPath = path.join(automakerDirA, 'settings.json');
const backgroundSettings = {
version: 1,
boardBackground: {
imagePath: backgroundPath,
cardOpacity: 85,
columnOpacity: 60,
columnBorderEnabled: true,
cardGlassmorphism: true,
cardBorderEnabled: false,
cardBorderOpacity: 50,
hideScrollbar: true,
imageVersion: Date.now(),
},
};
fs.writeFileSync(settingsPath, JSON.stringify(backgroundSettings, null, 2));
// Create minimal automaker-local directory for project B (no background)
const automakerDirB = path.join(projectBPath, '.automaker-local');
fs.mkdirSync(automakerDirB, { recursive: true });
fs.mkdirSync(path.join(automakerDirB, 'features'), { recursive: true });
fs.mkdirSync(path.join(automakerDirB, 'context'), { recursive: true });
fs.writeFileSync(
path.join(automakerDirB, 'settings.json'),
JSON.stringify({ version: 1 }, null, 2)
);
// Set up welcome view with both projects in the list
await setupWelcomeView(page, {
workspaceDir: TEST_TEMP_DIR,
recentProjects: [
{
id: projectAId,
name: projectAName,
path: projectAPath,
lastOpened: new Date(Date.now() - 86400000).toISOString(),
},
{
id: projectBId,
name: projectBName,
path: projectBPath,
lastOpened: new Date(Date.now() - 172800000).toISOString(),
},
],
});
// Intercept settings API BEFORE authenticateForTests (which navigates to the page)
// This ensures the app shows the welcome view with our test projects
await page.route('**/api/settings/global', async (route) => {
const response = await route.fetch();
const json = await response.json();
if (json.settings) {
// Clear currentProjectId to show welcome view
json.settings.currentProjectId = null;
// Include our test projects so they appear in the recent projects list
json.settings.projects = [
{
id: projectAId,
name: projectAName,
path: projectAPath,
lastOpened: new Date(Date.now() - 86400000).toISOString(),
},
{
id: projectBId,
name: projectBName,
path: projectBPath,
lastOpened: new Date(Date.now() - 172800000).toISOString(),
},
];
}
await route.fulfill({ response, json });
});
await authenticateForTests(page);
// Track API calls to /api/settings/project to verify settings are being loaded
const settingsApiCalls: Array<{ url: string; method: string; body: string }> = [];
page.on('request', (request) => {
if (request.url().includes('/api/settings/project') && request.method() === 'POST') {
settingsApiCalls.push({
url: request.url(),
method: request.method(),
body: request.postData() || '',
});
}
});
// Navigate directly to dashboard to avoid auto-open which would bypass the project selection
await page.goto('/dashboard');
await page.waitForLoadState('load');
await handleLoginScreenIfPresent(page);
// Wait for dashboard view
await expect(page.locator('[data-testid="dashboard-view"]')).toBeVisible({ timeout: 10000 });
// Open project A (has background settings)
const projectACard = page.locator(`[data-testid="project-card-${projectAId}"]`);
await expect(projectACard).toBeVisible();
await projectACard.click();
// Wait for board view
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 15000 });
// Ensure sidebar is expanded before checking project name
const expandSidebarButton = page.locator('button:has-text("Expand sidebar")');
if (await expandSidebarButton.isVisible()) {
await expandSidebarButton.click();
await page.waitForTimeout(300);
}
// Verify project A is current (project name appears in sidebar button)
await expect(page.getByRole('button', { name: new RegExp(projectAName) })).toBeVisible({
timeout: 5000,
});
// CRITICAL: Wait for settings to be loaded (useProjectSettingsLoader hook)
// This ensures the background settings are fetched from the server
await page.waitForTimeout(2000);
// Check if background settings were applied by checking the store
// We can't directly access React state, so we'll verify via DOM/CSS
const boardView = page.locator('[data-testid="board-view"]');
await expect(boardView).toBeVisible();
// Wait for initial project load to stabilize
await page.waitForTimeout(500);
// Switch to project B (no background)
const projectSelector = page.locator('[data-testid="project-selector"]');
await expect(projectSelector).toBeVisible({ timeout: 5000 });
await projectSelector.click();
// Wait for dropdown to be visible
await expect(page.locator('[data-testid="project-picker-dropdown"]')).toBeVisible({
timeout: 5000,
});
const projectPickerB = page.locator(`[data-testid="project-option-${projectBId}"]`);
await expect(projectPickerB).toBeVisible({ timeout: 5000 });
await projectPickerB.click();
// Wait for project B to load
await expect(
page.locator('[data-testid="project-selector"]').getByText(projectBName)
).toBeVisible({ timeout: 5000 });
// Wait a bit for project B to fully load before switching
await page.waitForTimeout(500);
// Switch back to project A
await projectSelector.click();
// Wait for dropdown to be visible
await expect(page.locator('[data-testid="project-picker-dropdown"]')).toBeVisible({
timeout: 5000,
});
const projectPickerA = page.locator(`[data-testid="project-option-${projectAId}"]`);
await expect(projectPickerA).toBeVisible({ timeout: 5000 });
await projectPickerA.click();
// Verify we're back on project A
await expect(
page.locator('[data-testid="project-selector"]').getByText(projectAName)
).toBeVisible({ timeout: 5000 });
// CRITICAL: Wait for settings to be loaded again
await page.waitForTimeout(2000);
// Verify that the settings API was called for project A (at least twice - initial load and switch back)
const projectASettingsCalls = settingsApiCalls.filter((call) =>
call.body.includes(projectAPath)
);
// Debug: log all API calls if test fails
if (projectASettingsCalls.length < 2) {
console.log('Total settings API calls:', settingsApiCalls.length);
console.log('API calls:', JSON.stringify(settingsApiCalls, null, 2));
console.log('Looking for path:', projectAPath);
}
expect(projectASettingsCalls.length).toBeGreaterThanOrEqual(2);
// Verify settings file still exists with correct data
const loadedSettings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
expect(loadedSettings.boardBackground).toBeDefined();
expect(loadedSettings.boardBackground.imagePath).toBe(backgroundPath);
expect(loadedSettings.boardBackground.cardOpacity).toBe(85);
expect(loadedSettings.boardBackground.columnOpacity).toBe(60);
expect(loadedSettings.boardBackground.hideScrollbar).toBe(true);
// The test passing means:
// 1. The useProjectSettingsLoader hook is working
// 2. Settings are loaded when switching projects
// 3. The API call to /api/settings/project is made correctly
});
test('should load background settings on app restart', async ({ page }) => {
const projectName = `restart-test-${Date.now()}`;
const projectPath = path.join(TEST_TEMP_DIR, projectName);
const projectId = `project-${Date.now()}`;
// Create project directory
fs.mkdirSync(projectPath, { recursive: true });
fs.writeFileSync(
path.join(projectPath, 'package.json'),
JSON.stringify({ name: projectName, version: '1.0.0' }, null, 2)
);
// Create .automaker-local with background settings
const automakerDir = path.join(projectPath, '.automaker-local');
fs.mkdirSync(automakerDir, { recursive: true });
fs.mkdirSync(path.join(automakerDir, 'board'), { recursive: true });
fs.mkdirSync(path.join(automakerDir, 'features'), { recursive: true });
fs.mkdirSync(path.join(automakerDir, 'context'), { recursive: true });
// Copy actual background image from test fixtures
const backgroundPath = path.join(automakerDir, 'board', 'background.jpg');
const testImagePath = path.join(__dirname, '..', 'img', 'background.jpg');
fs.copyFileSync(testImagePath, backgroundPath);
const settingsPath = path.join(automakerDir, 'settings.json');
fs.writeFileSync(
settingsPath,
JSON.stringify(
{
version: 1,
boardBackground: {
imagePath: backgroundPath,
cardOpacity: 90,
columnOpacity: 70,
imageVersion: Date.now(),
},
},
null,
2
)
);
// Set up with project as current using direct localStorage
await page.addInitScript(
({ project }: { project: string[] }) => {
const projectObj = {
id: project[0],
name: project[1],
path: project[2],
lastOpened: new Date().toISOString(),
};
const appState = {
state: {
projects: [projectObj],
currentProject: projectObj,
currentView: 'board',
theme: 'dark',
sidebarOpen: true,
skipSandboxWarning: true,
apiKeys: { anthropic: '', google: '' },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: 3,
boardBackgroundByProject: {},
},
version: 2,
};
localStorage.setItem('automaker-storage', JSON.stringify(appState));
// Setup complete - use correct key name
const setupState = {
state: {
isFirstRun: false,
setupComplete: true,
skipClaudeSetup: false,
},
version: 1,
};
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
// Disable splash screen in tests
sessionStorage.setItem('automaker-splash-shown', 'true');
},
{ project: [projectId, projectName, projectPath] }
);
await authenticateForTests(page);
// Intercept settings API to use our test project instead of the E2E fixture
await page.route('**/api/settings/global', async (route) => {
const response = await route.fetch();
const json = await response.json();
// Override to use our test project
if (json.settings) {
json.settings.currentProjectId = projectId;
json.settings.projects = [
{
id: projectId,
name: projectName,
path: projectPath,
lastOpened: new Date().toISOString(),
},
];
}
await route.fulfill({ response, json });
});
// Track API calls to /api/settings/project to verify settings are being loaded
const settingsApiCalls: Array<{ url: string; method: string; body: string }> = [];
page.on('request', (request) => {
if (request.url().includes('/api/settings/project') && request.method() === 'POST') {
settingsApiCalls.push({
url: request.url(),
method: request.method(),
body: request.postData() || '',
});
}
});
// Navigate to the app
await page.goto('/');
await page.waitForLoadState('load');
await handleLoginScreenIfPresent(page);
// Should go straight to board view (not welcome) since we have currentProject
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 15000 });
// Wait for settings to load
await page.waitForTimeout(2000);
// Verify that the settings API was called for this project
const projectSettingsCalls = settingsApiCalls.filter((call) => call.body.includes(projectPath));
// Debug: log all API calls if test fails
if (projectSettingsCalls.length < 1) {
console.log('Total settings API calls:', settingsApiCalls.length);
console.log('API calls:', JSON.stringify(settingsApiCalls, null, 2));
console.log('Looking for path:', projectPath);
}
expect(projectSettingsCalls.length).toBeGreaterThanOrEqual(1);
// Verify settings file exists with correct data
const loadedSettings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
expect(loadedSettings.boardBackground).toBeDefined();
expect(loadedSettings.boardBackground.imagePath).toBe(backgroundPath);
expect(loadedSettings.boardBackground.cardOpacity).toBe(90);
expect(loadedSettings.boardBackground.columnOpacity).toBe(70);
// The test passing means:
// 1. The useProjectSettingsLoader hook is working
// 2. Settings are loaded when app starts with a currentProject
// 3. The API call to /api/settings/project is made correctly
});
});

215
check-sync.sh Executable file
View File

@@ -0,0 +1,215 @@
#!/usr/bin/env bash
set -euo pipefail
DEFAULT_RC_PATTERN="v*rc"
DEFAULT_PREVIEW_COUNT=5
PREVIEW_COUNT="${PREVIEW_COUNT:-$DEFAULT_PREVIEW_COUNT}"
CURRENT_BRANCH="$(git rev-parse --abbrev-ref HEAD)"
ORIGIN_REF="origin/${CURRENT_BRANCH}"
TARGET_RC_SOURCE="auto"
print_header() {
echo "=== Sync Status Check ==="
echo
printf "Target RC: %s (%s)\n" "$TARGET_RC" "$TARGET_RC_SOURCE"
echo
}
ensure_git_repo() {
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
echo "Not inside a git repository."
exit 1
fi
}
ensure_remote() {
local remote="$1"
if ! git remote get-url "$remote" >/dev/null 2>&1; then
echo "Remote '$remote' is not configured."
exit 1
fi
}
fetch_remote() {
local remote="$1"
git fetch --quiet "$remote"
}
warn_if_dirty() {
if [[ -n "$(git status --porcelain)" ]]; then
echo "Warning: working tree has uncommitted changes."
echo
fi
}
resolve_target_rc() {
if [[ -n "${TARGET_RC:-}" ]]; then
return
fi
local rc_candidates
rc_candidates="$(git for-each-ref --format='%(refname:short)' "refs/remotes/upstream/${DEFAULT_RC_PATTERN}" || true)"
if [[ -n "$rc_candidates" ]]; then
TARGET_RC="$(printf "%s\n" "$rc_candidates" | sed 's|^upstream/||' | sort -V | tail -n 1)"
TARGET_RC_SOURCE="auto:latest"
return
fi
local upstream_head
upstream_head="$(git symbolic-ref --quiet --short refs/remotes/upstream/HEAD 2>/dev/null || true)"
if [[ -n "$upstream_head" ]]; then
TARGET_RC="${upstream_head#upstream/}"
TARGET_RC_SOURCE="auto:upstream-head"
return
fi
echo "Unable to resolve target RC automatically. Use --rc <branch>."
exit 1
}
ref_exists() {
local ref="$1"
git show-ref --verify --quiet "refs/remotes/${ref}"
}
print_status_line() {
local label="$1"
local behind="$2"
local ahead="$3"
if [[ "$behind" -eq 0 && "$ahead" -eq 0 ]]; then
printf "✅ %s: in sync (behind %s, ahead %s)\n" "$label" "$behind" "$ahead"
elif [[ "$behind" -eq 0 ]]; then
printf "⬆️ %s: ahead %s (behind %s)\n" "$label" "$ahead" "$behind"
elif [[ "$ahead" -eq 0 ]]; then
printf "⬇️ %s: behind %s (ahead %s)\n" "$label" "$behind" "$ahead"
else
printf "⚠️ %s: %s behind, %s ahead (diverged)\n" "$label" "$behind" "$ahead"
fi
}
print_preview() {
local title="$1"
local range="$2"
echo
echo "$title"
git log --oneline -n "$PREVIEW_COUNT" "$range"
}
print_branch_context() {
echo "Branch: $CURRENT_BRANCH"
echo "Upstream RC: $UPSTREAM_REF"
echo "Upstream push: enabled for sync workflow"
echo
}
print_upstream_summary() {
local behind="$1"
local ahead="$2"
if [[ "$behind" -eq 0 && "$ahead" -eq 0 ]]; then
echo "Branch vs upstream RC: in sync (behind $behind, ahead $ahead)"
else
echo "Branch vs upstream RC: behind $behind, ahead $ahead"
fi
}
print_workflow_hint() {
local behind="$1"
local ahead="$2"
if [[ "$behind" -eq 0 && "$ahead" -eq 0 ]]; then
echo "Workflow: sync"
elif [[ "$behind" -gt 0 && "$ahead" -eq 0 ]]; then
echo "Workflow: sync (merge upstream RC)"
elif [[ "$ahead" -gt 0 && "$behind" -eq 0 ]]; then
echo "Workflow: pr (local work not in upstream)"
else
echo "Workflow: diverged (resolve manually)"
fi
}
print_usage() {
echo "Usage: ./check-sync.sh [--rc <branch>] [--preview <count>]"
}
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--rc)
shift
if [[ -z "${1-}" ]]; then
echo "Missing value for --rc"
exit 1
fi
TARGET_RC="$1"
TARGET_RC_SOURCE="flag"
;;
--preview)
shift
if [[ -z "${1-}" ]]; then
echo "Missing value for --preview"
exit 1
fi
if ! [[ "$1" =~ ^[0-9]+$ ]]; then
echo "Invalid preview count: $1"
exit 1
fi
PREVIEW_COUNT="$1"
;;
-h|--help)
print_usage
exit 0
;;
*)
echo "Unknown argument: $1"
print_usage
exit 1
;;
esac
shift
done
}
ensure_git_repo
ensure_remote origin
ensure_remote upstream
parse_args "$@"
fetch_remote origin
fetch_remote upstream
resolve_target_rc
UPSTREAM_REF="upstream/${TARGET_RC}"
print_header
warn_if_dirty
print_branch_context
if ! ref_exists "$ORIGIN_REF"; then
echo "Origin branch '$ORIGIN_REF' does not exist."
else
read -r origin_behind origin_ahead < <(git rev-list --left-right --count "$ORIGIN_REF...HEAD")
print_status_line "Origin" "$origin_behind" "$origin_ahead"
fi
if ! ref_exists "$UPSTREAM_REF"; then
echo "Upstream ref '$UPSTREAM_REF' does not exist."
else
read -r upstream_behind upstream_ahead < <(git rev-list --left-right --count "$UPSTREAM_REF...HEAD")
print_status_line "Upstream" "$upstream_behind" "$upstream_ahead"
echo
print_upstream_summary "$upstream_behind" "$upstream_ahead"
print_workflow_hint "$upstream_behind" "$upstream_ahead"
if [[ "$upstream_behind" -gt 0 ]]; then
print_preview "Recent upstream commits:" "HEAD..$UPSTREAM_REF"
fi
if [[ "$upstream_ahead" -gt 0 ]]; then
print_preview "Commits on this branch not in upstream:" "$UPSTREAM_REF..HEAD"
fi
fi

Some files were not shown because too many files have changed in this diff Show More