Compare commits

..

1 Commits

Author SHA1 Message Date
Shirone
c7d2033277 fix: update structured output logic to support Claude and Codex models
- Modify the condition for using structured output to include both Claude and Codex models, ensuring compatibility with the latest model capabilities.
2026-01-23 15:55:36 +01:00
255 changed files with 9510 additions and 12916 deletions

View File

@@ -25,7 +25,6 @@ COPY libs/types/package*.json ./libs/types/
COPY libs/utils/package*.json ./libs/utils/
COPY libs/prompts/package*.json ./libs/prompts/
COPY libs/platform/package*.json ./libs/platform/
COPY libs/spec-parser/package*.json ./libs/spec-parser/
COPY libs/model-resolver/package*.json ./libs/model-resolver/
COPY libs/dependency-resolver/package*.json ./libs/dependency-resolver/
COPY libs/git-utils/package*.json ./libs/git-utils/

300
SECURITY_TODO.md Normal file
View File

@@ -0,0 +1,300 @@
# Security Audit Findings - v0.13.0rc Branch
**Date:** $(date)
**Audit Type:** Git diff security review against v0.13.0rc branch
**Status:** ⚠️ Security vulnerabilities found - requires fixes before release
## Executive Summary
No intentionally malicious code was detected in the changes. However, several **critical security vulnerabilities** were identified that could allow command injection attacks. These must be fixed before release.
---
## 🔴 Critical Security Issues
### 1. Command Injection in Merge Handler
**File:** `apps/server/src/routes/worktree/routes/merge.ts`
**Lines:** 43, 54, 65-66, 93
**Severity:** CRITICAL
**Issue:**
User-controlled inputs (`branchName`, `mergeTo`, `options?.message`) are directly interpolated into shell commands without validation, allowing command injection attacks.
**Vulnerable Code:**
```typescript
// Line 43 - branchName not validated
await execAsync(`git rev-parse --verify ${branchName}`, { cwd: projectPath });
// Line 54 - mergeTo not validated
await execAsync(`git rev-parse --verify ${mergeTo}`, { cwd: projectPath });
// Lines 65-66 - branchName and message not validated
const mergeCmd = options?.squash
? `git merge --squash ${branchName}`
: `git merge ${branchName} -m "${options?.message || `Merge ${branchName} into ${mergeTo}`}"`;
// Line 93 - message not sanitized
await execAsync(`git commit -m "${options?.message || `Merge ${branchName} (squash)`}"`, {
cwd: projectPath,
});
```
**Attack Vector:**
An attacker could inject shell commands via branch names or commit messages:
- Branch name: `main; rm -rf /`
- Commit message: `"; malicious_command; "`
**Fix Required:**
1. Validate `branchName` and `mergeTo` using `isValidBranchName()` before use
2. Sanitize commit messages or use `execGitCommand` with proper escaping
3. Replace `execAsync` template literals with `execGitCommand` array-based calls
**Note:** `isValidBranchName` is imported but only used AFTER deletion (line 119), not before execAsync calls.
---
### 2. Command Injection in Push Handler
**File:** `apps/server/src/routes/worktree/routes/push.ts`
**Lines:** 44, 49
**Severity:** CRITICAL
**Issue:**
User-controlled `remote` parameter and `branchName` are directly interpolated into shell commands without validation.
**Vulnerable Code:**
```typescript
// Line 38 - remote defaults to 'origin' but not validated
const targetRemote = remote || 'origin';
// Lines 44, 49 - targetRemote and branchName not validated
await execAsync(`git push -u ${targetRemote} ${branchName} ${forceFlag}`, {
cwd: worktreePath,
});
await execAsync(`git push --set-upstream ${targetRemote} ${branchName} ${forceFlag}`, {
cwd: worktreePath,
});
```
**Attack Vector:**
An attacker could inject commands via the remote name:
- Remote: `origin; malicious_command; #`
**Fix Required:**
1. Validate `targetRemote` parameter (alphanumeric + `-`, `_` only)
2. Validate `branchName` before use (even though it comes from git output)
3. Use `execGitCommand` with array arguments instead of template literals
---
### 3. Unsafe Environment Variable Export in Shell Script
**File:** `start-automaker.sh`
**Lines:** 5068, 5085
**Severity:** CRITICAL
**Issue:**
Unsafe parsing and export of `.env` file contents using `xargs` without proper handling of special characters.
**Vulnerable Code:**
```bash
export $(grep -v '^#' .env | xargs)
```
**Attack Vector:**
If `.env` file contains malicious content with spaces, special characters, or code, it could be executed:
- `.env` entry: `VAR="value; malicious_command"`
- Could lead to code execution during startup
**Fix Required:**
Replace with safer parsing method:
```bash
# Safer approach
set -a
source <(grep -v '^#' .env | sed 's/^/export /')
set +a
# Or even safer - validate each line
while IFS= read -r line; do
[[ "$line" =~ ^[[:space:]]*# ]] && continue
[[ -z "$line" ]] && continue
if [[ "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then
export "${BASH_REMATCH[1]}"="${BASH_REMATCH[2]}"
fi
done < .env
```
---
## 🟡 Moderate Security Concerns
### 4. Inconsistent Use of Secure Command Execution
**Issue:**
The codebase has `execGitCommand()` function available (which uses array arguments and is safer), but it's not consistently used. Some places still use `execAsync` with template literals.
**Files Affected:**
- `apps/server/src/routes/worktree/routes/merge.ts`
- `apps/server/src/routes/worktree/routes/push.ts`
**Recommendation:**
- Audit all `execAsync` calls with template literals
- Replace with `execGitCommand` where possible
- Document when `execAsync` is acceptable (only with fully validated inputs)
---
### 5. Missing Input Validation
**Issues:**
1. `targetRemote` in `push.ts` defaults to 'origin' but isn't validated
2. Commit messages in `merge.ts` aren't sanitized before use in shell commands
3. `worktreePath` validation relies on middleware but should be double-checked
**Recommendation:**
- Add validation functions for remote names
- Sanitize commit messages (remove shell metacharacters)
- Add defensive validation even when middleware exists
---
## ✅ Positive Security Findings
1. **No Hardcoded Credentials:** No API keys, passwords, or tokens found in the diff
2. **No Data Exfiltration:** No suspicious network requests or data transmission patterns
3. **No Backdoors:** No hidden functionality or unauthorized access patterns detected
4. **Safe Command Execution:** `execGitCommand` function properly uses array arguments in some places
5. **Environment Variable Handling:** `init-script-service.ts` properly sanitizes environment variables (lines 194-220)
---
## 📋 Action Items
### Immediate (Before Release)
- [ ] **Fix command injection in `merge.ts`**
- [ ] Validate `branchName` with `isValidBranchName()` before line 43
- [ ] Validate `mergeTo` with `isValidBranchName()` before line 54
- [ ] Sanitize commit messages or use `execGitCommand` for merge commands
- [ ] Replace `execAsync` template literals with `execGitCommand` array calls
- [ ] **Fix command injection in `push.ts`**
- [ ] Add validation function for remote names
- [ ] Validate `targetRemote` before use
- [ ] Validate `branchName` before use (defensive programming)
- [ ] Replace `execAsync` template literals with `execGitCommand`
- [ ] **Fix shell script security issue**
- [ ] Replace unsafe `export $(grep ... | xargs)` with safer parsing
- [ ] Add validation for `.env` file contents
- [ ] Test with edge cases (spaces, special chars, quotes)
### Short-term (Next Sprint)
- [ ] **Audit all `execAsync` calls**
- [ ] Create inventory of all `execAsync` calls with template literals
- [ ] Replace with `execGitCommand` where possible
- [ ] Document exceptions and why they're safe
- [ ] **Add input validation utilities**
- [ ] Create `isValidRemoteName()` function
- [ ] Create `sanitizeCommitMessage()` function
- [ ] Add validation for all user-controlled inputs
- [ ] **Security testing**
- [ ] Add unit tests for command injection prevention
- [ ] Add integration tests with malicious inputs
- [ ] Test shell script with malicious `.env` files
### Long-term (Security Hardening)
- [ ] **Code review process**
- [ ] Add security checklist for PR reviews
- [ ] Require security review for shell command execution changes
- [ ] Add automated security scanning
- [ ] **Documentation**
- [ ] Document secure coding practices for shell commands
- [ ] Create security guidelines for contributors
- [ ] Add security section to CONTRIBUTING.md
---
## 🔍 Testing Recommendations
### Command Injection Tests
```typescript
// Test cases for merge.ts
describe('merge handler security', () => {
it('should reject branch names with shell metacharacters', () => {
// Test: branchName = "main; rm -rf /"
// Expected: Validation error, command not executed
});
it('should sanitize commit messages', () => {
// Test: message = '"; malicious_command; "'
// Expected: Sanitized or rejected
});
});
// Test cases for push.ts
describe('push handler security', () => {
it('should reject remote names with shell metacharacters', () => {
// Test: remote = "origin; malicious_command; #"
// Expected: Validation error, command not executed
});
});
```
### Shell Script Tests
```bash
# Test with malicious .env content
echo 'VAR="value; echo PWNED"' > test.env
# Expected: Should not execute the command
# Test with spaces in values
echo 'VAR="value with spaces"' > test.env
# Expected: Should handle correctly
# Test with special characters
echo 'VAR="value\$with\$dollars"' > test.env
# Expected: Should handle correctly
```
---
## 📚 References
- [OWASP Command Injection](https://owasp.org/www-community/attacks/Command_Injection)
- [Node.js Child Process Security](https://nodejs.org/api/child_process.html#child_process_security_concerns)
- [Shell Script Security Best Practices](https://mywiki.wooledge.org/BashGuide/Practices)
---
## Notes
- All findings are based on code diff analysis
- No runtime testing was performed
- Assumes attacker has access to API endpoints (authenticated or unauthenticated)
- Fixes should be tested thoroughly before deployment
---
**Last Updated:** $(date)
**Next Review:** After fixes are implemented

25
TODO.md Normal file
View File

@@ -0,0 +1,25 @@
# Bugs
- Setting the default model does not seem like it works.
# Performance (completed)
- [x] Graph performance mode for large graphs (compact nodes/edges + visible-only rendering)
- [x] Render containment on heavy scroll regions (kanban columns, chat history)
- [x] Reduce blur/shadow effects when lists get large
- [x] React Query tuning for heavy datasets (less refetch on focus/reconnect)
- [x] DnD/list rendering optimizations (virtualized kanban + memoized card sections)
# UX
- Consolidate all models to a single place in the settings instead of having AI profiles and all this other stuff
- Simplify the create feature modal. It should just be one page. I don't need nessa tabs and all these nested buttons. It's too complex.
- added to do's list checkbox directly into the card so as it's going through if there's any to do items we can see those update live
- When the feature is done, I want to see a summary of the LLM. That's the first thing I should see when I double click the card.
- I went away to mass edit all my features. For example, when I created a new project, it added auto testing on every single feature card. Now I have to manually go through one by one and change those. Have a way to mass edit those, the configuration of all them.
- Double check and debug if there's memory leaks. It seems like the memory of automaker grows like 3 gigabytes. It's 5gb right now and I'm running three different cursor cli features implementing at the same time.
- Typing in the text area of the plan mode was super laggy.
- When I have a bunch of features running at the same time, it seems like I cannot edit the features in the backlog. Like they don't persist their file changes and I think this is because of the secure FS file has an internal queue to prevent hitting that file open write limit. We may have to reconsider refactoring away from file system and do Postgres or SQLite or something.
- modals are not scrollable if height of the screen is small enough
- and the Agent Runner add an archival button for the new sessions.
- investigate a potential issue with the feature cards not refreshing. I see a lock icon on the feature card But it doesn't go away until I open the card and edit it and I turn the testing mode off. I think there's like a refresh sync issue.

View File

@@ -16,7 +16,7 @@ import { createServer } from 'http';
import dotenv from 'dotenv';
import { createEventEmitter, type EventEmitter } from './lib/events.js';
import { initAllowedPaths, getClaudeAuthIndicators } from '@automaker/platform';
import { initAllowedPaths } from '@automaker/platform';
import { createLogger, setLogLevel, LogLevel } from '@automaker/utils';
const logger = createLogger('Server');
@@ -117,44 +117,15 @@ export function isRequestLoggingEnabled(): boolean {
// Width for log box content (excluding borders)
const BOX_CONTENT_WIDTH = 67;
// Check for Claude authentication (async - runs in background)
// The Claude Agent SDK can use either ANTHROPIC_API_KEY or Claude Code CLI authentication
(async () => {
const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY;
// Check for required environment variables
const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY;
if (hasAnthropicKey) {
logger.info('✓ ANTHROPIC_API_KEY detected');
return;
}
// Check for Claude Code CLI authentication
try {
const indicators = await getClaudeAuthIndicators();
const hasCliAuth =
indicators.hasStatsCacheWithActivity ||
(indicators.hasSettingsFile && indicators.hasProjectsSessions) ||
(indicators.hasCredentialsFile &&
(indicators.credentials?.hasOAuthToken || indicators.credentials?.hasApiKey));
if (hasCliAuth) {
logger.info('✓ Claude Code CLI authentication detected');
return;
}
} catch (error) {
// Ignore errors checking CLI auth - will fall through to warning
logger.warn('Error checking for Claude Code CLI authentication:', error);
}
// No authentication found - show warning
if (!hasAnthropicKey) {
const wHeader = '⚠️ WARNING: No Claude authentication configured'.padEnd(BOX_CONTENT_WIDTH);
const w1 = 'The Claude Agent SDK requires authentication to function.'.padEnd(BOX_CONTENT_WIDTH);
const w2 = 'Options:'.padEnd(BOX_CONTENT_WIDTH);
const w3 = '1. Install Claude Code CLI and authenticate with subscription'.padEnd(
BOX_CONTENT_WIDTH
);
const w4 = '2. Set your Anthropic API key:'.padEnd(BOX_CONTENT_WIDTH);
const w5 = ' export ANTHROPIC_API_KEY="sk-ant-..."'.padEnd(BOX_CONTENT_WIDTH);
const w6 = '3. Use the setup wizard in Settings to configure authentication.'.padEnd(
const w2 = 'Set your Anthropic API key:'.padEnd(BOX_CONTENT_WIDTH);
const w3 = ' export ANTHROPIC_API_KEY="sk-ant-..."'.padEnd(BOX_CONTENT_WIDTH);
const w4 = 'Or use the setup wizard in Settings to configure authentication.'.padEnd(
BOX_CONTENT_WIDTH
);
@@ -167,13 +138,14 @@ const BOX_CONTENT_WIDTH = 67;
║ ║
${w2}
${w3}
║ ║
${w4}
${w5}
${w6}
║ ║
╚═════════════════════════════════════════════════════════════════════╝
`);
})();
} else {
logger.info('✓ ANTHROPIC_API_KEY detected');
}
// Initialize security
initAllowedPaths();
@@ -354,10 +326,7 @@ app.get('/api/health/detailed', createDetailedHandler());
app.use('/api/fs', createFsRoutes(events));
app.use('/api/agent', createAgentRoutes(agentService, events));
app.use('/api/sessions', createSessionsRoutes(agentService));
app.use(
'/api/features',
createFeaturesRoutes(featureLoader, settingsService, events, autoModeService)
);
app.use('/api/features', createFeaturesRoutes(featureLoader, settingsService, events));
app.use('/api/auto-mode', createAutoModeRoutes(autoModeService));
app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService));
app.use('/api/worktree', createWorktreeRoutes(events, settingsService));
@@ -800,36 +769,21 @@ process.on('uncaughtException', (error: Error) => {
process.exit(1);
});
// Graceful shutdown timeout (30 seconds)
const SHUTDOWN_TIMEOUT_MS = 30000;
// Graceful shutdown helper
const gracefulShutdown = async (signal: string) => {
logger.info(`${signal} received, shutting down...`);
// Set up a force-exit timeout to prevent hanging
const forceExitTimeout = setTimeout(() => {
logger.error(`Shutdown timed out after ${SHUTDOWN_TIMEOUT_MS}ms, forcing exit`);
process.exit(1);
}, SHUTDOWN_TIMEOUT_MS);
// Mark all running features as interrupted before shutdown
// This ensures they can be resumed when the server restarts
// Note: markAllRunningFeaturesInterrupted handles errors internally and never rejects
await autoModeService.markAllRunningFeaturesInterrupted(`${signal} signal received`);
// Graceful shutdown
process.on('SIGTERM', () => {
logger.info('SIGTERM received, shutting down...');
terminalService.cleanup();
server.close(() => {
clearTimeout(forceExitTimeout);
logger.info('Server closed');
process.exit(0);
});
};
process.on('SIGTERM', () => {
gracefulShutdown('SIGTERM');
});
process.on('SIGINT', () => {
gracefulShutdown('SIGINT');
logger.info('SIGINT received, shutting down...');
terminalService.cleanup();
server.close(() => {
logger.info('Server closed');
process.exit(0);
});
});

View File

@@ -98,14 +98,9 @@ const TEXT_ENCODING = 'utf-8';
* This is the "no output" timeout - if the CLI doesn't produce any JSONL output
* for this duration, the process is killed. For reasoning models with high
* reasoning effort, this timeout is dynamically extended via calculateReasoningTimeout().
*
* For feature generation (which can generate 50+ features), we use a much longer
* base timeout (5 minutes) since Codex models are slower at generating large JSON responses.
*
* @see calculateReasoningTimeout from @automaker/types
*/
const CODEX_CLI_TIMEOUT_MS = DEFAULT_TIMEOUT_MS;
const CODEX_FEATURE_GENERATION_BASE_TIMEOUT_MS = 300000; // 5 minutes for feature generation
const CONTEXT_WINDOW_256K = 256000;
const MAX_OUTPUT_32K = 32000;
const MAX_OUTPUT_16K = 16000;
@@ -832,14 +827,7 @@ export class CodexProvider extends BaseProvider {
// Higher reasoning effort (e.g., 'xhigh' for "xtra thinking" mode) requires more time
// for the model to generate reasoning tokens before producing output.
// This fixes GitHub issue #530 where features would get stuck with reasoning models.
//
// For feature generation with 'xhigh', use the extended 5-minute base timeout
// since generating 50+ features takes significantly longer than normal operations.
const baseTimeout =
options.reasoningEffort === 'xhigh'
? CODEX_FEATURE_GENERATION_BASE_TIMEOUT_MS
: CODEX_CLI_TIMEOUT_MS;
const timeout = calculateReasoningTimeout(options.reasoningEffort, baseTimeout);
const timeout = calculateReasoningTimeout(options.reasoningEffort, CODEX_CLI_TIMEOUT_MS);
const stream = spawnJSONLProcess({
command: commandPath,

View File

@@ -8,11 +8,10 @@
import * as secureFs from '../../lib/secure-fs.js';
import type { EventEmitter } from '../../lib/events.js';
import { createLogger } from '@automaker/utils';
import { DEFAULT_PHASE_MODELS, supportsStructuredOutput, isCodexModel } from '@automaker/types';
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver';
import { streamingQuery } from '../../providers/simple-query-service.js';
import { parseAndCreateFeatures } from './parse-and-create-features.js';
import { extractJsonWithArray } from '../../lib/json-extractor.js';
import { getAppSpecPath } from '@automaker/platform';
import type { SettingsService } from '../../services/settings-service.js';
import {
@@ -26,64 +25,6 @@ const logger = createLogger('SpecRegeneration');
const DEFAULT_MAX_FEATURES = 50;
/**
* Timeout for Codex models when generating features (5 minutes).
* Codex models are slower and need more time to generate 50+ features.
*/
const CODEX_FEATURE_GENERATION_TIMEOUT_MS = 300000; // 5 minutes
/**
* Type for extracted features JSON response
*/
interface FeaturesExtractionResult {
features: Array<{
id: string;
category?: string;
title: string;
description: string;
priority?: number;
complexity?: 'simple' | 'moderate' | 'complex';
dependencies?: string[];
}>;
}
/**
* JSON schema for features output format (Claude/Codex structured output)
*/
const featuresOutputSchema = {
type: 'object',
properties: {
features: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'Unique feature identifier (kebab-case)' },
category: { type: 'string', description: 'Feature category' },
title: { type: 'string', description: 'Short, descriptive title' },
description: { type: 'string', description: 'Detailed feature description' },
priority: {
type: 'number',
description: 'Priority level: 1 (highest) to 5 (lowest)',
},
complexity: {
type: 'string',
enum: ['simple', 'moderate', 'complex'],
description: 'Implementation complexity',
},
dependencies: {
type: 'array',
items: { type: 'string' },
description: 'IDs of features this depends on',
},
},
required: ['id', 'title', 'description'],
},
},
},
required: ['features'],
} as const;
export async function generateFeaturesFromSpec(
projectPath: string,
events: EventEmitter,
@@ -195,80 +136,23 @@ Generate ${featureCount} NEW features that build on each other logically. Rememb
provider: undefined,
credentials: undefined,
};
const { model, thinkingLevel, reasoningEffort } = resolvePhaseModel(phaseModelEntry);
const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
logger.info('Using model:', model, provider ? `via provider: ${provider.name}` : 'direct API');
// Codex models need extended timeout for generating many features.
// Use 'xhigh' reasoning effort to get 5-minute timeout (300s base * 1.0x = 300s).
// The Codex provider has a special 5-minute base timeout for feature generation.
const isCodex = isCodexModel(model);
const effectiveReasoningEffort = isCodex ? 'xhigh' : reasoningEffort;
if (isCodex) {
logger.info('Codex model detected - using extended timeout (5 minutes for feature generation)');
}
if (effectiveReasoningEffort) {
logger.info('Reasoning effort:', effectiveReasoningEffort);
}
// Determine if we should use structured output based on model type
const useStructuredOutput = supportsStructuredOutput(model);
logger.info(
`Structured output mode: ${useStructuredOutput ? 'enabled (Claude/Codex)' : 'disabled (using JSON instructions)'}`
);
// Build the final prompt - for non-Claude/Codex models, include explicit JSON instructions
let finalPrompt = prompt;
if (!useStructuredOutput) {
finalPrompt = `${prompt}
CRITICAL INSTRUCTIONS:
1. DO NOT write any files. Return the JSON in your response only.
2. After analyzing the spec, respond with ONLY a JSON object - no explanations, no markdown, just raw JSON.
3. The JSON must have this exact structure:
{
"features": [
{
"id": "unique-feature-id",
"category": "Category Name",
"title": "Short Feature Title",
"description": "Detailed description of the feature",
"priority": 1,
"complexity": "simple|moderate|complex",
"dependencies": ["other-feature-id"]
}
]
}
4. Feature IDs must be unique, lowercase, kebab-case (e.g., "user-authentication", "data-export")
5. Priority ranges from 1 (highest) to 5 (lowest)
6. Complexity must be one of: "simple", "moderate", "complex"
7. Dependencies is an array of feature IDs that must be completed first (can be empty)
Your entire response should be valid JSON starting with { and ending with }. No text before or after.`;
}
// Use streamingQuery with event callbacks
const result = await streamingQuery({
prompt: finalPrompt,
prompt,
model,
cwd: projectPath,
maxTurns: 250,
allowedTools: ['Read', 'Glob', 'Grep'],
abortController,
thinkingLevel,
reasoningEffort: effectiveReasoningEffort, // Extended timeout for Codex models
readOnly: true, // Feature generation only reads code, doesn't write
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
claudeCompatibleProvider: provider, // Pass provider for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
outputFormat: useStructuredOutput
? {
type: 'json_schema',
schema: featuresOutputSchema,
}
: undefined,
onText: (text) => {
logger.debug(`Feature text block received (${text.length} chars)`);
events.emit('spec-regeneration:event', {
@@ -279,51 +163,15 @@ Your entire response should be valid JSON starting with { and ending with }. No
},
});
// Get response content - prefer structured output if available
let contentForParsing: string;
const responseText = result.text;
if (result.structured_output) {
// Use structured output from Claude/Codex models
logger.info('✅ Received structured output from model');
contentForParsing = JSON.stringify(result.structured_output);
logger.debug('Structured output:', contentForParsing);
} else {
// Use text response (for non-Claude/Codex models or fallback)
// Pre-extract JSON to handle conversational text that may surround the JSON response
// This follows the same pattern used in generate-spec.ts and validate-issue.ts
const rawText = result.text;
logger.info(`Feature stream complete.`);
logger.info(`Feature response length: ${rawText.length} chars`);
logger.info('========== FULL RESPONSE TEXT ==========');
logger.info(rawText);
logger.info('========== END RESPONSE TEXT ==========');
logger.info(`Feature stream complete.`);
logger.info(`Feature response length: ${responseText.length} chars`);
logger.info('========== FULL RESPONSE TEXT ==========');
logger.info(responseText);
logger.info('========== END RESPONSE TEXT ==========');
// Pre-extract JSON from response - handles conversational text around the JSON
const extracted = extractJsonWithArray<FeaturesExtractionResult>(rawText, 'features', {
logger,
});
if (extracted) {
contentForParsing = JSON.stringify(extracted);
logger.info('✅ Pre-extracted JSON from text response');
} else {
// If pre-extraction fails, we know the next step will also fail.
// Throw an error here to avoid redundant parsing and make the failure point clearer.
logger.error(
'❌ Could not extract features JSON from model response. Full response text was:\n' +
rawText
);
const errorMessage =
'Failed to parse features from model response: No valid JSON with a "features" array found.';
events.emit('spec-regeneration:event', {
type: 'spec_regeneration_error',
error: errorMessage,
projectPath: projectPath,
});
throw new Error(errorMessage);
}
}
await parseAndCreateFeatures(projectPath, contentForParsing, events);
await parseAndCreateFeatures(projectPath, responseText, events);
logger.debug('========== generateFeaturesFromSpec() completed ==========');
}

View File

@@ -9,7 +9,7 @@ import * as secureFs from '../../lib/secure-fs.js';
import type { EventEmitter } from '../../lib/events.js';
import { specOutputSchema, specToXml, type SpecOutput } from '../../lib/app-spec-format.js';
import { createLogger } from '@automaker/utils';
import { DEFAULT_PHASE_MODELS, supportsStructuredOutput } from '@automaker/types';
import { DEFAULT_PHASE_MODELS, isClaudeModel, isCodexModel } from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver';
import { extractJson } from '../../lib/json-extractor.js';
import { streamingQuery } from '../../providers/simple-query-service.js';
@@ -120,13 +120,10 @@ ${prompts.appSpec.structuredSpecInstructions}`;
let responseText = '';
let structuredOutput: SpecOutput | null = null;
// Determine if we should use structured output based on model type
const useStructuredOutput = supportsStructuredOutput(model);
logger.info(
`Structured output mode: ${useStructuredOutput ? 'enabled (Claude/Codex)' : 'disabled (using JSON instructions)'}`
);
// Determine if we should use structured output (only Claude and Codex support it)
const useStructuredOutput = isClaudeModel(model) || isCodexModel(model);
// Build the final prompt - for non-Claude/Codex models, include JSON schema instructions
// Build the final prompt - for Cursor, include JSON schema instructions
let finalPrompt = prompt;
if (!useStructuredOutput) {
finalPrompt = `${prompt}

View File

@@ -10,10 +10,9 @@
import * as secureFs from '../../lib/secure-fs.js';
import type { EventEmitter } from '../../lib/events.js';
import { createLogger } from '@automaker/utils';
import { DEFAULT_PHASE_MODELS, supportsStructuredOutput } from '@automaker/types';
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver';
import { streamingQuery } from '../../providers/simple-query-service.js';
import { extractJson } from '../../lib/json-extractor.js';
import { getAppSpecPath } from '@automaker/platform';
import type { SettingsService } from '../../services/settings-service.js';
import {
@@ -35,28 +34,6 @@ import { getNotificationService } from '../../services/notification-service.js';
const logger = createLogger('SpecSync');
/**
* Type for extracted tech stack JSON response
*/
interface TechStackExtractionResult {
technologies: string[];
}
/**
* JSON schema for tech stack analysis output (Claude/Codex structured output)
*/
const techStackOutputSchema = {
type: 'object',
properties: {
technologies: {
type: 'array',
items: { type: 'string' },
description: 'List of technologies detected in the project',
},
},
required: ['technologies'],
} as const;
/**
* Result of a sync operation
*/
@@ -199,14 +176,8 @@ export async function syncSpec(
logger.info('Using model:', model, provider ? `via provider: ${provider.name}` : 'direct API');
// Determine if we should use structured output based on model type
const useStructuredOutput = supportsStructuredOutput(model);
logger.info(
`Structured output mode: ${useStructuredOutput ? 'enabled (Claude/Codex)' : 'disabled (using JSON instructions)'}`
);
// Use AI to analyze tech stack
let techAnalysisPrompt = `Analyze this project and return ONLY a JSON object with the current technology stack.
const techAnalysisPrompt = `Analyze this project and return ONLY a JSON object with the current technology stack.
Current known technologies: ${currentTechStack.join(', ')}
@@ -222,16 +193,6 @@ Return ONLY this JSON format, no other text:
"technologies": ["Technology 1", "Technology 2", ...]
}`;
// Add explicit JSON instructions for non-Claude/Codex models
if (!useStructuredOutput) {
techAnalysisPrompt = `${techAnalysisPrompt}
CRITICAL INSTRUCTIONS:
1. DO NOT write any files. Return the JSON in your response only.
2. Your entire response should be valid JSON starting with { and ending with }.
3. No explanations, no markdown, no text before or after the JSON.`;
}
try {
const techResult = await streamingQuery({
prompt: techAnalysisPrompt,
@@ -245,67 +206,44 @@ CRITICAL INSTRUCTIONS:
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
claudeCompatibleProvider: provider, // Pass provider for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
outputFormat: useStructuredOutput
? {
type: 'json_schema',
schema: techStackOutputSchema,
}
: undefined,
onText: (text) => {
logger.debug(`Tech analysis text: ${text.substring(0, 100)}`);
},
});
// Parse tech stack from response - prefer structured output if available
let parsedTechnologies: string[] | null = null;
// Parse tech stack from response
const jsonMatch = techResult.text.match(/\{[\s\S]*"technologies"[\s\S]*\}/);
if (jsonMatch) {
const parsed = JSON.parse(jsonMatch[0]);
if (Array.isArray(parsed.technologies)) {
const newTechStack = parsed.technologies as string[];
if (techResult.structured_output) {
// Use structured output from Claude/Codex models
const structured = techResult.structured_output as unknown as TechStackExtractionResult;
if (Array.isArray(structured.technologies)) {
parsedTechnologies = structured.technologies;
logger.info('✅ Received structured output for tech analysis');
}
} else {
// Fall back to text parsing for non-Claude/Codex models
const extracted = extractJson<TechStackExtractionResult>(techResult.text, {
logger,
requiredKey: 'technologies',
requireArray: true,
});
if (extracted && Array.isArray(extracted.technologies)) {
parsedTechnologies = extracted.technologies;
logger.info('✅ Extracted tech stack from text response');
} else {
logger.warn('⚠️ Failed to extract tech stack JSON from response');
}
}
// Calculate differences
const currentSet = new Set(currentTechStack.map((t) => t.toLowerCase()));
const newSet = new Set(newTechStack.map((t) => t.toLowerCase()));
if (parsedTechnologies) {
const newTechStack = parsedTechnologies;
// Calculate differences
const currentSet = new Set(currentTechStack.map((t) => t.toLowerCase()));
const newSet = new Set(newTechStack.map((t) => t.toLowerCase()));
for (const tech of newTechStack) {
if (!currentSet.has(tech.toLowerCase())) {
result.techStackUpdates.added.push(tech);
for (const tech of newTechStack) {
if (!currentSet.has(tech.toLowerCase())) {
result.techStackUpdates.added.push(tech);
}
}
}
for (const tech of currentTechStack) {
if (!newSet.has(tech.toLowerCase())) {
result.techStackUpdates.removed.push(tech);
for (const tech of currentTechStack) {
if (!newSet.has(tech.toLowerCase())) {
result.techStackUpdates.removed.push(tech);
}
}
}
// Update spec with new tech stack if there are changes
if (result.techStackUpdates.added.length > 0 || result.techStackUpdates.removed.length > 0) {
specContent = updateTechnologyStack(specContent, newTechStack);
logger.info(
`Updated tech stack: +${result.techStackUpdates.added.length}, -${result.techStackUpdates.removed.length}`
);
// Update spec with new tech stack if there are changes
if (
result.techStackUpdates.added.length > 0 ||
result.techStackUpdates.removed.length > 0
) {
specContent = updateTechnologyStack(specContent, newTechStack);
logger.info(
`Updated tech stack: +${result.techStackUpdates.added.length}, -${result.techStackUpdates.removed.length}`
);
}
}
}
} catch (error) {

View File

@@ -5,7 +5,6 @@
import { Router } from 'express';
import { FeatureLoader } from '../../services/feature-loader.js';
import type { SettingsService } from '../../services/settings-service.js';
import type { AutoModeService } from '../../services/auto-mode-service.js';
import type { EventEmitter } from '../../lib/events.js';
import { validatePathParams } from '../../middleware/validate-paths.js';
import { createListHandler } from './routes/list.js';
@@ -23,16 +22,11 @@ import { createImportHandler, createConflictCheckHandler } from './routes/import
export function createFeaturesRoutes(
featureLoader: FeatureLoader,
settingsService?: SettingsService,
events?: EventEmitter,
autoModeService?: AutoModeService
events?: EventEmitter
): Router {
const router = Router();
router.post(
'/list',
validatePathParams('projectPath'),
createListHandler(featureLoader, autoModeService)
);
router.post('/list', validatePathParams('projectPath'), createListHandler(featureLoader));
router.post('/get', validatePathParams('projectPath'), createGetHandler(featureLoader));
router.post(
'/create',

View File

@@ -43,7 +43,7 @@ export function createCreateHandler(featureLoader: FeatureLoader, events?: Event
if (events) {
events.emit('feature:created', {
featureId: created.id,
featureName: created.title || 'Untitled Feature',
featureName: created.name,
projectPath,
});
}

View File

@@ -1,19 +1,12 @@
/**
* POST /list endpoint - List all features for a project
*
* Also performs orphan detection when a project is loaded to identify
* features whose branches no longer exist. This runs on every project load/switch.
*/
import type { Request, Response } from 'express';
import { FeatureLoader } from '../../../services/feature-loader.js';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import { getErrorMessage, logError } from '../common.js';
import { createLogger } from '@automaker/utils';
const logger = createLogger('FeaturesListRoute');
export function createListHandler(featureLoader: FeatureLoader, autoModeService?: AutoModeService) {
export function createListHandler(featureLoader: FeatureLoader) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath } = req.body as { projectPath: string };
@@ -24,26 +17,6 @@ export function createListHandler(featureLoader: FeatureLoader, autoModeService?
}
const features = await featureLoader.getAll(projectPath);
// Run orphan detection in background when project is loaded
// This detects features whose branches no longer exist (e.g., after merge/delete)
// We don't await this to keep the list response fast
// Note: detectOrphanedFeatures handles errors internally and always resolves
if (autoModeService) {
autoModeService.detectOrphanedFeatures(projectPath).then((orphanedFeatures) => {
if (orphanedFeatures.length > 0) {
logger.info(
`[ProjectLoad] Detected ${orphanedFeatures.length} orphaned feature(s) in ${projectPath}`
);
for (const { feature, missingBranch } of orphanedFeatures) {
logger.info(
`[ProjectLoad] Orphaned: ${feature.title || feature.id} - branch "${missingBranch}" no longer exists`
);
}
}
});
}
res.json({ success: true, features });
} catch (error) {
logError(error, 'List features failed');

View File

@@ -31,9 +31,7 @@ export function createSaveBoardBackgroundHandler() {
await secureFs.mkdir(boardDir, { recursive: true });
// Decode base64 data (remove data URL prefix if present)
// Use a regex that handles all data URL formats including those with extra params
// e.g., data:image/gif;charset=utf-8;base64,R0lGOD...
const base64Data = data.replace(/^data:[^,]+,/, '');
const base64Data = data.replace(/^data:image\/\w+;base64,/, '');
const buffer = Buffer.from(base64Data, 'base64');
// Use a fixed filename for the board background (overwrite previous)

View File

@@ -31,9 +31,7 @@ export function createSaveImageHandler() {
await secureFs.mkdir(imagesDir, { recursive: true });
// Decode base64 data (remove data URL prefix if present)
// Use a regex that handles all data URL formats including those with extra params
// e.g., data:image/gif;charset=utf-8;base64,R0lGOD...
const base64Data = data.replace(/^data:[^,]+,/, '');
const base64Data = data.replace(/^data:image\/\w+;base64,/, '');
const buffer = Buffer.from(base64Data, 'base64');
// Generate unique filename with timestamp

View File

@@ -23,7 +23,6 @@ import {
isCodexModel,
isCursorModel,
isOpencodeModel,
supportsStructuredOutput,
} from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver';
import { extractJson } from '../../../lib/json-extractor.js';
@@ -125,9 +124,8 @@ async function runValidation(
const prompts = await getPromptCustomization(settingsService, '[ValidateIssue]');
const issueValidationSystemPrompt = prompts.issueValidation.systemPrompt;
// Determine if we should use structured output based on model type
// Claude and Codex support it; Cursor, Gemini, OpenCode, Copilot don't
const useStructuredOutput = supportsStructuredOutput(model);
// 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;

View File

@@ -4,21 +4,15 @@
import type { Request, Response } from 'express';
import type { IdeationService } from '../../../services/ideation-service.js';
import type { IdeationContextSources } from '@automaker/types';
import { createLogger } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
const logger = createLogger('ideation:suggestions-generate');
/**
* Creates an Express route handler for generating AI-powered ideation suggestions.
* Accepts a prompt, category, and optional context sources configuration,
* then returns structured suggestions that can be added to the board.
*/
export function createSuggestionsGenerateHandler(ideationService: IdeationService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, promptId, category, count, contextSources } = req.body;
const { projectPath, promptId, category, count } = req.body;
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
@@ -44,8 +38,7 @@ export function createSuggestionsGenerateHandler(ideationService: IdeationServic
projectPath,
promptId,
category,
suggestionCount,
contextSources as IdeationContextSources | undefined
suggestionCount
);
res.json({

View File

@@ -39,15 +39,8 @@ interface GitHubRemoteCacheEntry {
checkedAt: number;
}
interface GitHubPRCacheEntry {
prs: Map<string, WorktreePRInfo>;
fetchedAt: number;
}
const githubRemoteCache = new Map<string, GitHubRemoteCacheEntry>();
const githubPRCache = new Map<string, GitHubPRCacheEntry>();
const GITHUB_REMOTE_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
const GITHUB_PR_CACHE_TTL_MS = 2 * 60 * 1000; // 2 minutes - avoid hitting GitHub on every poll
interface WorktreeInfo {
path: string;
@@ -187,21 +180,9 @@ async function getGitHubRemoteStatus(projectPath: string): Promise<GitHubRemoteS
* This also allows detecting PRs that were created outside the app.
*
* Uses cached GitHub remote status to avoid repeated warnings when the
* project doesn't have a GitHub remote configured. Results are cached
* briefly to avoid hammering GitHub on frequent worktree polls.
* project doesn't have a GitHub remote configured.
*/
async function fetchGitHubPRs(
projectPath: string,
forceRefresh = false
): Promise<Map<string, WorktreePRInfo>> {
const now = Date.now();
const cached = githubPRCache.get(projectPath);
// Return cached result if valid and not forcing refresh
if (!forceRefresh && cached && now - cached.fetchedAt < GITHUB_PR_CACHE_TTL_MS) {
return cached.prs;
}
async function fetchGitHubPRs(projectPath: string): Promise<Map<string, WorktreePRInfo>> {
const prMap = new Map<string, WorktreePRInfo>();
try {
@@ -244,22 +225,8 @@ async function fetchGitHubPRs(
createdAt: pr.createdAt,
});
}
// Only update cache on successful fetch
githubPRCache.set(projectPath, {
prs: prMap,
fetchedAt: Date.now(),
});
} catch (error) {
// On fetch failure, return stale cached data if available to avoid
// repeated API calls during GitHub API flakiness or temporary outages
if (cached) {
logger.warn(`Failed to fetch GitHub PRs, returning stale cache: ${getErrorMessage(error)}`);
// Extend cache TTL to avoid repeated retries during outages
githubPRCache.set(projectPath, { prs: cached.prs, fetchedAt: Date.now() });
return cached.prs;
}
// No cache available, log warning and return empty map
// Silently fail - PR detection is optional
logger.warn(`Failed to fetch GitHub PRs: ${getErrorMessage(error)}`);
}
@@ -397,7 +364,7 @@ export function createListHandler() {
// Only fetch GitHub PRs if includeDetails is requested (performance optimization).
// Uses --state all to detect merged/closed PRs, limited to 1000 recent PRs.
const githubPRs = includeDetails
? await fetchGitHubPRs(projectPath, forceRefreshGitHub)
? await fetchGitHubPRs(projectPath)
: new Map<string, WorktreePRInfo>();
for (const worktree of worktrees) {

File diff suppressed because it is too large Load Diff

View File

@@ -169,10 +169,9 @@ export class EventHookService {
}
// Build context for variable substitution
// Use loaded featureName (from feature.title) or fall back to payload.featureName
const context: HookContext = {
featureId: payload.featureId,
featureName: featureName || payload.featureName,
featureName: payload.featureName,
projectPath: payload.projectPath,
projectName: payload.projectPath ? this.extractProjectName(payload.projectPath) : undefined,
error: payload.error || payload.message,

View File

@@ -23,9 +23,7 @@ import type {
SendMessageOptions,
PromptCategory,
IdeationPrompt,
IdeationContextSources,
} from '@automaker/types';
import { DEFAULT_IDEATION_CONTEXT_SOURCES } from '@automaker/types';
import {
getIdeationDir,
getIdeasDir,
@@ -34,10 +32,8 @@ import {
getIdeationSessionsDir,
getIdeationSessionPath,
getIdeationAnalysisPath,
getAppSpecPath,
ensureIdeationDir,
} from '@automaker/platform';
import { extractXmlElements, extractImplementedFeatures } from '../lib/xml-extractor.js';
import { createLogger, loadContextFiles, isAbortError } from '@automaker/utils';
import { ProviderFactory } from '../providers/provider-factory.js';
import type { SettingsService } from './settings-service.js';
@@ -642,12 +638,8 @@ export class IdeationService {
projectPath: string,
promptId: string,
category: IdeaCategory,
count: number = 10,
contextSources?: IdeationContextSources
count: number = 10
): Promise<AnalysisSuggestion[]> {
const suggestionCount = Math.min(Math.max(Math.floor(count ?? 10), 1), 20);
// Merge with defaults for backward compatibility
const sources = { ...DEFAULT_IDEATION_CONTEXT_SOURCES, ...contextSources };
validateWorkingDirectory(projectPath);
// Get the prompt
@@ -664,26 +656,16 @@ export class IdeationService {
});
try {
// Load context files (respecting toggle settings)
// Load context files
const contextResult = await loadContextFiles({
projectPath,
fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'],
includeContextFiles: sources.useContextFiles,
includeMemory: sources.useMemoryFiles,
});
// Build context from multiple sources
let contextPrompt = contextResult.formattedPrompt;
// Add app spec context if enabled
if (sources.useAppSpec) {
const appSpecContext = await this.buildAppSpecContext(projectPath);
if (appSpecContext) {
contextPrompt = contextPrompt ? `${contextPrompt}\n\n${appSpecContext}` : appSpecContext;
}
}
// If no context was found, try to gather basic project info
// If no context files, try to gather basic project info
if (!contextPrompt) {
const projectInfo = await this.gatherBasicProjectInfo(projectPath);
if (projectInfo) {
@@ -691,11 +673,8 @@ export class IdeationService {
}
}
// Gather existing features and ideas to prevent duplicates (respecting toggle settings)
const existingWorkContext = await this.gatherExistingWorkContext(projectPath, {
includeFeatures: sources.useExistingFeatures,
includeIdeas: sources.useExistingIdeas,
});
// Gather existing features and ideas to prevent duplicates
const existingWorkContext = await this.gatherExistingWorkContext(projectPath);
// Get customized prompts from settings
const prompts = await getPromptCustomization(this.settingsService, '[IdeationService]');
@@ -705,7 +684,7 @@ export class IdeationService {
prompts.ideation.suggestionsSystemPrompt,
contextPrompt,
category,
suggestionCount,
count,
existingWorkContext
);
@@ -772,11 +751,7 @@ export class IdeationService {
}
// Parse the response into structured suggestions
const suggestions = this.parseSuggestionsFromResponse(
responseText,
category,
suggestionCount
);
const suggestions = this.parseSuggestionsFromResponse(responseText, category);
// Emit complete event
this.events.emit('ideation:suggestions', {
@@ -839,47 +814,40 @@ ${contextSection}${existingWorkSection}`;
*/
private parseSuggestionsFromResponse(
response: string,
category: IdeaCategory,
count: number
category: IdeaCategory
): AnalysisSuggestion[] {
try {
// Try to extract JSON from the response
const jsonMatch = response.match(/\[[\s\S]*\]/);
if (!jsonMatch) {
logger.warn('No JSON array found in response, falling back to text parsing');
return this.parseTextResponse(response, category, count);
return this.parseTextResponse(response, category);
}
const parsed = JSON.parse(jsonMatch[0]);
if (!Array.isArray(parsed)) {
return this.parseTextResponse(response, category, count);
return this.parseTextResponse(response, category);
}
return parsed
.map((item: any, index: number) => ({
id: this.generateId('sug'),
category,
title: item.title || `Suggestion ${index + 1}`,
description: item.description || '',
rationale: item.rationale || '',
priority: item.priority || 'medium',
relatedFiles: item.relatedFiles || [],
}))
.slice(0, count);
return parsed.map((item: any, index: number) => ({
id: this.generateId('sug'),
category,
title: item.title || `Suggestion ${index + 1}`,
description: item.description || '',
rationale: item.rationale || '',
priority: item.priority || 'medium',
relatedFiles: item.relatedFiles || [],
}));
} catch (error) {
logger.warn('Failed to parse JSON response:', error);
return this.parseTextResponse(response, category, count);
return this.parseTextResponse(response, category);
}
}
/**
* Fallback: parse text response into suggestions
*/
private parseTextResponse(
response: string,
category: IdeaCategory,
count: number
): AnalysisSuggestion[] {
private parseTextResponse(response: string, category: IdeaCategory): AnalysisSuggestion[] {
const suggestions: AnalysisSuggestion[] = [];
// Try to find numbered items or headers
@@ -939,7 +907,7 @@ ${contextSection}${existingWorkSection}`;
});
}
return suggestions.slice(0, count);
return suggestions.slice(0, 5); // Max 5 suggestions
}
// ============================================================================
@@ -1377,68 +1345,6 @@ ${contextSection}${existingWorkSection}`;
return descriptions[category] || '';
}
/**
* Build context from app_spec.txt for suggestion generation
* Extracts project name, overview, capabilities, and implemented features
*/
private async buildAppSpecContext(projectPath: string): Promise<string> {
try {
const specPath = getAppSpecPath(projectPath);
const specContent = (await secureFs.readFile(specPath, 'utf-8')) as string;
const parts: string[] = [];
parts.push('## App Specification');
// Extract project name
const projectNames = extractXmlElements(specContent, 'project_name');
if (projectNames.length > 0 && projectNames[0]) {
parts.push(`**Project:** ${projectNames[0]}`);
}
// Extract overview
const overviews = extractXmlElements(specContent, 'overview');
if (overviews.length > 0 && overviews[0]) {
parts.push(`**Overview:** ${overviews[0]}`);
}
// Extract core capabilities
const capabilities = extractXmlElements(specContent, 'capability');
if (capabilities.length > 0) {
parts.push('**Core Capabilities:**');
for (const cap of capabilities) {
parts.push(`- ${cap}`);
}
}
// Extract implemented features
const implementedFeatures = extractImplementedFeatures(specContent);
if (implementedFeatures.length > 0) {
parts.push('**Implemented Features:**');
for (const feature of implementedFeatures) {
if (feature.description) {
parts.push(`- ${feature.name}: ${feature.description}`);
} else {
parts.push(`- ${feature.name}`);
}
}
}
// Only return content if we extracted something meaningful
if (parts.length > 1) {
return parts.join('\n');
}
return '';
} catch (error) {
// If file doesn't exist, return empty string silently
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return '';
}
// For other errors, log and return empty string
logger.warn('Failed to build app spec context:', error);
return '';
}
}
/**
* Gather basic project information for context when no context files exist
*/
@@ -1534,15 +1440,11 @@ ${contextSection}${existingWorkSection}`;
* Gather existing features and ideas to prevent duplicate suggestions
* Returns a concise list of titles grouped by status to avoid polluting context
*/
private async gatherExistingWorkContext(
projectPath: string,
options?: { includeFeatures?: boolean; includeIdeas?: boolean }
): Promise<string> {
const { includeFeatures = true, includeIdeas = true } = options ?? {};
private async gatherExistingWorkContext(projectPath: string): Promise<string> {
const parts: string[] = [];
// Load existing features from the board
if (includeFeatures && this.featureLoader) {
if (this.featureLoader) {
try {
const features = await this.featureLoader.getAll(projectPath);
if (features.length > 0) {
@@ -1590,36 +1492,34 @@ ${contextSection}${existingWorkSection}`;
}
// Load existing ideas
if (includeIdeas) {
try {
const ideas = await this.getIdeas(projectPath);
// Filter out archived ideas
const activeIdeas = ideas.filter((idea) => idea.status !== 'archived');
try {
const ideas = await this.getIdeas(projectPath);
// Filter out archived ideas
const activeIdeas = ideas.filter((idea) => idea.status !== 'archived');
if (activeIdeas.length > 0) {
parts.push('## Existing Ideas (Do NOT regenerate these)');
parts.push(
'The following ideas have already been captured. Do NOT suggest similar ideas:\n'
);
if (activeIdeas.length > 0) {
parts.push('## Existing Ideas (Do NOT regenerate these)');
parts.push(
'The following ideas have already been captured. Do NOT suggest similar ideas:\n'
);
// Group by category for organization
const byCategory: Record<string, string[]> = {};
for (const idea of activeIdeas) {
const cat = idea.category || 'feature';
if (!byCategory[cat]) {
byCategory[cat] = [];
}
byCategory[cat].push(idea.title);
// Group by category for organization
const byCategory: Record<string, string[]> = {};
for (const idea of activeIdeas) {
const cat = idea.category || 'feature';
if (!byCategory[cat]) {
byCategory[cat] = [];
}
for (const [category, titles] of Object.entries(byCategory)) {
parts.push(`**${category}:** ${titles.join(', ')}`);
}
parts.push('');
byCategory[cat].push(idea.title);
}
} catch (error) {
logger.warn('Failed to load existing ideas:', error);
for (const [category, titles] of Object.entries(byCategory)) {
parts.push(`**${category}:** ${titles.join(', ')}`);
}
parts.push('');
}
} catch (error) {
logger.warn('Failed to load existing ideas:', error);
}
return parts.join('\n');

View File

@@ -325,12 +325,8 @@ describe('codex-provider.ts', () => {
);
const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0];
// xhigh reasoning effort uses 5-minute base timeout (300000ms) for feature generation
// then applies 4x multiplier: 300000 * 4.0 = 1200000ms (20 minutes)
const CODEX_FEATURE_GENERATION_BASE_TIMEOUT_MS = 300000;
expect(call.timeout).toBe(
CODEX_FEATURE_GENERATION_BASE_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.xhigh
);
// xhigh reasoning effort should have 4x the default timeout (120000ms)
expect(call.timeout).toBe(DEFAULT_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.xhigh);
});
it('uses default timeout when no reasoning effort is specified', async () => {

View File

@@ -315,531 +315,4 @@ describe('auto-mode-service.ts', () => {
expect(duration).toBeLessThan(40);
});
});
describe('detectOrphanedFeatures', () => {
// Helper to mock featureLoader.getAll
const mockFeatureLoaderGetAll = (svc: AutoModeService, mockFn: ReturnType<typeof vi.fn>) => {
(svc as any).featureLoader = { getAll: mockFn };
};
// Helper to mock getExistingBranches
const mockGetExistingBranches = (svc: AutoModeService, branches: string[]) => {
(svc as any).getExistingBranches = vi.fn().mockResolvedValue(new Set(branches));
};
it('should return empty array when no features have branch names', async () => {
const getAllMock = vi.fn().mockResolvedValue([
{ id: 'f1', title: 'Feature 1', description: 'desc', category: 'test' },
{ id: 'f2', title: 'Feature 2', description: 'desc', category: 'test' },
] satisfies Feature[]);
mockFeatureLoaderGetAll(service, getAllMock);
mockGetExistingBranches(service, ['main', 'develop']);
const result = await service.detectOrphanedFeatures('/test/project');
expect(result).toEqual([]);
});
it('should return empty array when all feature branches exist', async () => {
const getAllMock = vi.fn().mockResolvedValue([
{
id: 'f1',
title: 'Feature 1',
description: 'desc',
category: 'test',
branchName: 'feature-1',
},
{
id: 'f2',
title: 'Feature 2',
description: 'desc',
category: 'test',
branchName: 'feature-2',
},
] satisfies Feature[]);
mockFeatureLoaderGetAll(service, getAllMock);
mockGetExistingBranches(service, ['main', 'feature-1', 'feature-2']);
const result = await service.detectOrphanedFeatures('/test/project');
expect(result).toEqual([]);
});
it('should detect orphaned features with missing branches', async () => {
const features: Feature[] = [
{
id: 'f1',
title: 'Feature 1',
description: 'desc',
category: 'test',
branchName: 'feature-1',
},
{
id: 'f2',
title: 'Feature 2',
description: 'desc',
category: 'test',
branchName: 'deleted-branch',
},
{ id: 'f3', title: 'Feature 3', description: 'desc', category: 'test' }, // No branch
];
const getAllMock = vi.fn().mockResolvedValue(features);
mockFeatureLoaderGetAll(service, getAllMock);
mockGetExistingBranches(service, ['main', 'feature-1']); // deleted-branch not in list
const result = await service.detectOrphanedFeatures('/test/project');
expect(result).toHaveLength(1);
expect(result[0].feature.id).toBe('f2');
expect(result[0].missingBranch).toBe('deleted-branch');
});
it('should detect multiple orphaned features', async () => {
const features: Feature[] = [
{
id: 'f1',
title: 'Feature 1',
description: 'desc',
category: 'test',
branchName: 'orphan-1',
},
{
id: 'f2',
title: 'Feature 2',
description: 'desc',
category: 'test',
branchName: 'orphan-2',
},
{
id: 'f3',
title: 'Feature 3',
description: 'desc',
category: 'test',
branchName: 'valid-branch',
},
];
const getAllMock = vi.fn().mockResolvedValue(features);
mockFeatureLoaderGetAll(service, getAllMock);
mockGetExistingBranches(service, ['main', 'valid-branch']);
const result = await service.detectOrphanedFeatures('/test/project');
expect(result).toHaveLength(2);
expect(result.map((r) => r.feature.id)).toContain('f1');
expect(result.map((r) => r.feature.id)).toContain('f2');
});
it('should return empty array when getAll throws error', async () => {
const getAllMock = vi.fn().mockRejectedValue(new Error('Failed to load features'));
mockFeatureLoaderGetAll(service, getAllMock);
const result = await service.detectOrphanedFeatures('/test/project');
expect(result).toEqual([]);
});
it('should ignore empty branchName strings', async () => {
const features: Feature[] = [
{ id: 'f1', title: 'Feature 1', description: 'desc', category: 'test', branchName: '' },
{ id: 'f2', title: 'Feature 2', description: 'desc', category: 'test', branchName: ' ' },
];
const getAllMock = vi.fn().mockResolvedValue(features);
mockFeatureLoaderGetAll(service, getAllMock);
mockGetExistingBranches(service, ['main']);
const result = await service.detectOrphanedFeatures('/test/project');
expect(result).toEqual([]);
});
it('should skip features whose branchName matches the primary branch', async () => {
const features: Feature[] = [
{ id: 'f1', title: 'Feature 1', description: 'desc', category: 'test', branchName: 'main' },
{
id: 'f2',
title: 'Feature 2',
description: 'desc',
category: 'test',
branchName: 'orphaned',
},
];
const getAllMock = vi.fn().mockResolvedValue(features);
mockFeatureLoaderGetAll(service, getAllMock);
mockGetExistingBranches(service, ['main', 'develop']);
// Mock getCurrentBranch to return 'main'
(service as any).getCurrentBranch = vi.fn().mockResolvedValue('main');
const result = await service.detectOrphanedFeatures('/test/project');
// Only f2 should be orphaned (orphaned branch doesn't exist)
expect(result).toHaveLength(1);
expect(result[0].feature.id).toBe('f2');
});
});
describe('markFeatureInterrupted', () => {
// Helper to mock updateFeatureStatus
const mockUpdateFeatureStatus = (svc: AutoModeService, mockFn: ReturnType<typeof vi.fn>) => {
(svc as any).updateFeatureStatus = mockFn;
};
// Helper to mock loadFeature
const mockLoadFeature = (svc: AutoModeService, mockFn: ReturnType<typeof vi.fn>) => {
(svc as any).loadFeature = mockFn;
};
it('should call updateFeatureStatus with interrupted status for non-pipeline features', async () => {
const loadMock = vi.fn().mockResolvedValue({ id: 'feature-123', status: 'in_progress' });
const updateMock = vi.fn().mockResolvedValue(undefined);
mockLoadFeature(service, loadMock);
mockUpdateFeatureStatus(service, updateMock);
await service.markFeatureInterrupted('/test/project', 'feature-123');
expect(updateMock).toHaveBeenCalledWith('/test/project', 'feature-123', 'interrupted');
});
it('should call updateFeatureStatus with reason when provided', async () => {
const loadMock = vi.fn().mockResolvedValue({ id: 'feature-123', status: 'in_progress' });
const updateMock = vi.fn().mockResolvedValue(undefined);
mockLoadFeature(service, loadMock);
mockUpdateFeatureStatus(service, updateMock);
await service.markFeatureInterrupted('/test/project', 'feature-123', 'server shutdown');
expect(updateMock).toHaveBeenCalledWith('/test/project', 'feature-123', 'interrupted');
});
it('should propagate errors from updateFeatureStatus', async () => {
const loadMock = vi.fn().mockResolvedValue({ id: 'feature-123', status: 'in_progress' });
const updateMock = vi.fn().mockRejectedValue(new Error('Update failed'));
mockLoadFeature(service, loadMock);
mockUpdateFeatureStatus(service, updateMock);
await expect(service.markFeatureInterrupted('/test/project', 'feature-123')).rejects.toThrow(
'Update failed'
);
});
it('should preserve pipeline_implementation status instead of marking as interrupted', async () => {
const loadMock = vi
.fn()
.mockResolvedValue({ id: 'feature-123', status: 'pipeline_implementation' });
const updateMock = vi.fn().mockResolvedValue(undefined);
mockLoadFeature(service, loadMock);
mockUpdateFeatureStatus(service, updateMock);
await service.markFeatureInterrupted('/test/project', 'feature-123', 'server shutdown');
// updateFeatureStatus should NOT be called for pipeline statuses
expect(updateMock).not.toHaveBeenCalled();
});
it('should preserve pipeline_testing status instead of marking as interrupted', async () => {
const loadMock = vi.fn().mockResolvedValue({ id: 'feature-123', status: 'pipeline_testing' });
const updateMock = vi.fn().mockResolvedValue(undefined);
mockLoadFeature(service, loadMock);
mockUpdateFeatureStatus(service, updateMock);
await service.markFeatureInterrupted('/test/project', 'feature-123');
expect(updateMock).not.toHaveBeenCalled();
});
it('should preserve pipeline_review status instead of marking as interrupted', async () => {
const loadMock = vi.fn().mockResolvedValue({ id: 'feature-123', status: 'pipeline_review' });
const updateMock = vi.fn().mockResolvedValue(undefined);
mockLoadFeature(service, loadMock);
mockUpdateFeatureStatus(service, updateMock);
await service.markFeatureInterrupted('/test/project', 'feature-123');
expect(updateMock).not.toHaveBeenCalled();
});
it('should mark feature as interrupted when loadFeature returns null', async () => {
const loadMock = vi.fn().mockResolvedValue(null);
const updateMock = vi.fn().mockResolvedValue(undefined);
mockLoadFeature(service, loadMock);
mockUpdateFeatureStatus(service, updateMock);
await service.markFeatureInterrupted('/test/project', 'feature-123');
expect(updateMock).toHaveBeenCalledWith('/test/project', 'feature-123', 'interrupted');
});
it('should mark feature as interrupted for pending status', async () => {
const loadMock = vi.fn().mockResolvedValue({ id: 'feature-123', status: 'pending' });
const updateMock = vi.fn().mockResolvedValue(undefined);
mockLoadFeature(service, loadMock);
mockUpdateFeatureStatus(service, updateMock);
await service.markFeatureInterrupted('/test/project', 'feature-123');
expect(updateMock).toHaveBeenCalledWith('/test/project', 'feature-123', 'interrupted');
});
});
describe('markAllRunningFeaturesInterrupted', () => {
// Helper to access private runningFeatures Map
const getRunningFeaturesMap = (svc: AutoModeService) =>
(svc as any).runningFeatures as Map<
string,
{ featureId: string; projectPath: string; isAutoMode: boolean }
>;
// Helper to mock updateFeatureStatus
const mockUpdateFeatureStatus = (svc: AutoModeService, mockFn: ReturnType<typeof vi.fn>) => {
(svc as any).updateFeatureStatus = mockFn;
};
// Helper to mock loadFeature
const mockLoadFeature = (svc: AutoModeService, mockFn: ReturnType<typeof vi.fn>) => {
(svc as any).loadFeature = mockFn;
};
it('should do nothing when no features are running', async () => {
const updateMock = vi.fn().mockResolvedValue(undefined);
mockUpdateFeatureStatus(service, updateMock);
await service.markAllRunningFeaturesInterrupted();
expect(updateMock).not.toHaveBeenCalled();
});
it('should mark a single running feature as interrupted', async () => {
const runningFeaturesMap = getRunningFeaturesMap(service);
runningFeaturesMap.set('feature-1', {
featureId: 'feature-1',
projectPath: '/project/path',
isAutoMode: true,
});
const loadMock = vi.fn().mockResolvedValue({ id: 'feature-1', status: 'in_progress' });
const updateMock = vi.fn().mockResolvedValue(undefined);
mockLoadFeature(service, loadMock);
mockUpdateFeatureStatus(service, updateMock);
await service.markAllRunningFeaturesInterrupted();
expect(updateMock).toHaveBeenCalledWith('/project/path', 'feature-1', 'interrupted');
});
it('should mark multiple running features as interrupted', async () => {
const runningFeaturesMap = getRunningFeaturesMap(service);
runningFeaturesMap.set('feature-1', {
featureId: 'feature-1',
projectPath: '/project-a',
isAutoMode: true,
});
runningFeaturesMap.set('feature-2', {
featureId: 'feature-2',
projectPath: '/project-b',
isAutoMode: false,
});
runningFeaturesMap.set('feature-3', {
featureId: 'feature-3',
projectPath: '/project-a',
isAutoMode: true,
});
const loadMock = vi.fn().mockResolvedValue({ status: 'in_progress' });
const updateMock = vi.fn().mockResolvedValue(undefined);
mockLoadFeature(service, loadMock);
mockUpdateFeatureStatus(service, updateMock);
await service.markAllRunningFeaturesInterrupted();
expect(updateMock).toHaveBeenCalledTimes(3);
expect(updateMock).toHaveBeenCalledWith('/project-a', 'feature-1', 'interrupted');
expect(updateMock).toHaveBeenCalledWith('/project-b', 'feature-2', 'interrupted');
expect(updateMock).toHaveBeenCalledWith('/project-a', 'feature-3', 'interrupted');
});
it('should mark features in parallel', async () => {
const runningFeaturesMap = getRunningFeaturesMap(service);
for (let i = 1; i <= 5; i++) {
runningFeaturesMap.set(`feature-${i}`, {
featureId: `feature-${i}`,
projectPath: `/project-${i}`,
isAutoMode: true,
});
}
const loadMock = vi.fn().mockResolvedValue({ status: 'in_progress' });
const callOrder: string[] = [];
const updateMock = vi.fn().mockImplementation(async (_path: string, featureId: string) => {
callOrder.push(featureId);
await new Promise((resolve) => setTimeout(resolve, 10));
});
mockLoadFeature(service, loadMock);
mockUpdateFeatureStatus(service, updateMock);
const startTime = Date.now();
await service.markAllRunningFeaturesInterrupted();
const duration = Date.now() - startTime;
expect(updateMock).toHaveBeenCalledTimes(5);
// If executed in parallel, total time should be ~10ms
// If sequential, it would be ~50ms (5 * 10ms)
expect(duration).toBeLessThan(40);
});
it('should continue marking other features when one fails', async () => {
const runningFeaturesMap = getRunningFeaturesMap(service);
runningFeaturesMap.set('feature-1', {
featureId: 'feature-1',
projectPath: '/project-a',
isAutoMode: true,
});
runningFeaturesMap.set('feature-2', {
featureId: 'feature-2',
projectPath: '/project-b',
isAutoMode: false,
});
const loadMock = vi.fn().mockResolvedValue({ status: 'in_progress' });
const updateMock = vi
.fn()
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error('Failed to update'));
mockLoadFeature(service, loadMock);
mockUpdateFeatureStatus(service, updateMock);
// Should not throw even though one feature failed
await expect(service.markAllRunningFeaturesInterrupted()).resolves.not.toThrow();
expect(updateMock).toHaveBeenCalledTimes(2);
});
it('should use provided reason in logging', async () => {
const runningFeaturesMap = getRunningFeaturesMap(service);
runningFeaturesMap.set('feature-1', {
featureId: 'feature-1',
projectPath: '/project/path',
isAutoMode: true,
});
const loadMock = vi.fn().mockResolvedValue({ id: 'feature-1', status: 'in_progress' });
const updateMock = vi.fn().mockResolvedValue(undefined);
mockLoadFeature(service, loadMock);
mockUpdateFeatureStatus(service, updateMock);
await service.markAllRunningFeaturesInterrupted('manual stop');
expect(updateMock).toHaveBeenCalledWith('/project/path', 'feature-1', 'interrupted');
});
it('should use default reason when none provided', async () => {
const runningFeaturesMap = getRunningFeaturesMap(service);
runningFeaturesMap.set('feature-1', {
featureId: 'feature-1',
projectPath: '/project/path',
isAutoMode: true,
});
const loadMock = vi.fn().mockResolvedValue({ id: 'feature-1', status: 'in_progress' });
const updateMock = vi.fn().mockResolvedValue(undefined);
mockLoadFeature(service, loadMock);
mockUpdateFeatureStatus(service, updateMock);
await service.markAllRunningFeaturesInterrupted();
expect(updateMock).toHaveBeenCalledWith('/project/path', 'feature-1', 'interrupted');
});
it('should preserve pipeline statuses for running features', async () => {
const runningFeaturesMap = getRunningFeaturesMap(service);
runningFeaturesMap.set('feature-1', {
featureId: 'feature-1',
projectPath: '/project-a',
isAutoMode: true,
});
runningFeaturesMap.set('feature-2', {
featureId: 'feature-2',
projectPath: '/project-b',
isAutoMode: false,
});
runningFeaturesMap.set('feature-3', {
featureId: 'feature-3',
projectPath: '/project-c',
isAutoMode: true,
});
// feature-1 has in_progress (should be interrupted)
// feature-2 has pipeline_testing (should be preserved)
// feature-3 has pipeline_implementation (should be preserved)
const loadMock = vi
.fn()
.mockImplementation(async (_projectPath: string, featureId: string) => {
if (featureId === 'feature-1') return { id: 'feature-1', status: 'in_progress' };
if (featureId === 'feature-2') return { id: 'feature-2', status: 'pipeline_testing' };
if (featureId === 'feature-3')
return { id: 'feature-3', status: 'pipeline_implementation' };
return null;
});
const updateMock = vi.fn().mockResolvedValue(undefined);
mockLoadFeature(service, loadMock);
mockUpdateFeatureStatus(service, updateMock);
await service.markAllRunningFeaturesInterrupted();
// Only feature-1 should be marked as interrupted
expect(updateMock).toHaveBeenCalledTimes(1);
expect(updateMock).toHaveBeenCalledWith('/project-a', 'feature-1', 'interrupted');
});
});
describe('isFeatureRunning', () => {
// Helper to access private runningFeatures Map
const getRunningFeaturesMap = (svc: AutoModeService) =>
(svc as any).runningFeatures as Map<
string,
{ featureId: string; projectPath: string; isAutoMode: boolean }
>;
it('should return false when no features are running', () => {
expect(service.isFeatureRunning('feature-123')).toBe(false);
});
it('should return true when the feature is running', () => {
const runningFeaturesMap = getRunningFeaturesMap(service);
runningFeaturesMap.set('feature-123', {
featureId: 'feature-123',
projectPath: '/project/path',
isAutoMode: true,
});
expect(service.isFeatureRunning('feature-123')).toBe(true);
});
it('should return false for non-running feature when others are running', () => {
const runningFeaturesMap = getRunningFeaturesMap(service);
runningFeaturesMap.set('feature-other', {
featureId: 'feature-other',
projectPath: '/project/path',
isAutoMode: true,
});
expect(service.isFeatureRunning('feature-123')).toBe(false);
});
it('should correctly track multiple running features', () => {
const runningFeaturesMap = getRunningFeaturesMap(service);
runningFeaturesMap.set('feature-1', {
featureId: 'feature-1',
projectPath: '/project-a',
isAutoMode: true,
});
runningFeaturesMap.set('feature-2', {
featureId: 'feature-2',
projectPath: '/project-b',
isAutoMode: false,
});
expect(service.isFeatureRunning('feature-1')).toBe(true);
expect(service.isFeatureRunning('feature-2')).toBe(true);
expect(service.isFeatureRunning('feature-3')).toBe(false);
});
});
});

View File

@@ -1,11 +1,18 @@
import { describe, it, expect } from 'vitest';
import type { ParsedTask } from '@automaker/types';
/**
* Test the task parsing logic by reimplementing the parsing functions
* These mirror the logic in auto-mode-service.ts parseTasksFromSpec and parseTaskLine
*/
interface ParsedTask {
id: string;
description: string;
filePath?: string;
phase?: string;
status: 'pending' | 'in_progress' | 'completed';
}
function parseTaskLine(line: string, currentPhase?: string): ParsedTask | null {
// Match pattern: - [ ] T###: Description | File: path
const taskMatch = line.match(/- \[ \] (T\d{3}):\s*([^|]+)(?:\|\s*File:\s*(.+))?$/);
@@ -335,236 +342,4 @@ Some other text
expect(fullModeOutput).toContain('[SPEC_GENERATED]');
});
});
describe('detectSpecFallback - non-Claude model support', () => {
/**
* Reimplementation of detectSpecFallback for testing
* This mirrors the logic in auto-mode-service.ts for detecting specs
* when the [SPEC_GENERATED] marker is missing (common with non-Claude models)
*/
function detectSpecFallback(text: string): boolean {
// Check for key structural elements of a spec
const hasTasksBlock = /```tasks[\s\S]*```/.test(text);
const hasTaskLines = /- \[ \] T\d{3}:/.test(text);
// Check for common spec sections (case-insensitive)
const hasAcceptanceCriteria = /acceptance criteria/i.test(text);
const hasTechnicalContext = /technical context/i.test(text);
const hasProblemStatement = /problem statement/i.test(text);
const hasUserStory = /user story/i.test(text);
// Additional patterns for different model outputs
const hasGoal = /\*\*Goal\*\*:/i.test(text);
const hasSolution = /\*\*Solution\*\*:/i.test(text);
const hasImplementation = /implementation\s*(plan|steps|approach)/i.test(text);
const hasOverview = /##\s*(overview|summary)/i.test(text);
// Spec is detected if we have task structure AND at least some spec content
const hasTaskStructure = hasTasksBlock || hasTaskLines;
const hasSpecContent =
hasAcceptanceCriteria ||
hasTechnicalContext ||
hasProblemStatement ||
hasUserStory ||
hasGoal ||
hasSolution ||
hasImplementation ||
hasOverview;
return hasTaskStructure && hasSpecContent;
}
it('should detect spec with tasks block and acceptance criteria', () => {
const content = `
## Acceptance Criteria
- GIVEN a user, WHEN they login, THEN they see the dashboard
\`\`\`tasks
- [ ] T001: Create login form | File: src/Login.tsx
\`\`\`
`;
expect(detectSpecFallback(content)).toBe(true);
});
it('should detect spec with task lines and problem statement', () => {
const content = `
## Problem Statement
Users cannot currently log in to the application.
## Implementation Plan
- [ ] T001: Add authentication endpoint
- [ ] T002: Create login UI
`;
expect(detectSpecFallback(content)).toBe(true);
});
it('should detect spec with Goal section (lite planning mode style)', () => {
const content = `
**Goal**: Implement user authentication
**Solution**: Use JWT tokens for session management
- [ ] T001: Setup auth middleware
- [ ] T002: Create token service
`;
expect(detectSpecFallback(content)).toBe(true);
});
it('should detect spec with User Story format', () => {
const content = `
## User Story
As a user, I want to reset my password, so that I can regain access.
## Technical Context
This will modify the auth module.
\`\`\`tasks
- [ ] T001: Add reset endpoint
\`\`\`
`;
expect(detectSpecFallback(content)).toBe(true);
});
it('should detect spec with Overview section', () => {
const content = `
## Overview
This feature adds dark mode support.
\`\`\`tasks
- [ ] T001: Add theme toggle
- [ ] T002: Update CSS variables
\`\`\`
`;
expect(detectSpecFallback(content)).toBe(true);
});
it('should detect spec with Summary section', () => {
const content = `
## Summary
Adding a new dashboard component.
- [ ] T001: Create dashboard layout
- [ ] T002: Add widgets
`;
expect(detectSpecFallback(content)).toBe(true);
});
it('should detect spec with implementation plan', () => {
const content = `
## Implementation Plan
We will add the feature in two phases.
- [ ] T001: Phase 1 setup
- [ ] T002: Phase 2 implementation
`;
expect(detectSpecFallback(content)).toBe(true);
});
it('should detect spec with implementation steps', () => {
const content = `
## Implementation Steps
Follow these steps:
- [ ] T001: Step one
- [ ] T002: Step two
`;
expect(detectSpecFallback(content)).toBe(true);
});
it('should detect spec with implementation approach', () => {
const content = `
## Implementation Approach
We will use a modular approach.
- [ ] T001: Create modules
`;
expect(detectSpecFallback(content)).toBe(true);
});
it('should NOT detect spec without task structure', () => {
const content = `
## Problem Statement
Users cannot log in.
## Acceptance Criteria
- GIVEN a user, WHEN they try to login, THEN it works
`;
expect(detectSpecFallback(content)).toBe(false);
});
it('should NOT detect spec without spec content sections', () => {
const content = `
Here are some tasks:
- [ ] T001: Do something
- [ ] T002: Do another thing
`;
expect(detectSpecFallback(content)).toBe(false);
});
it('should NOT detect random text as spec', () => {
const content = 'Just some random text without any structure';
expect(detectSpecFallback(content)).toBe(false);
});
it('should handle case-insensitive matching for spec sections', () => {
const content = `
## ACCEPTANCE CRITERIA
All caps section header
- [ ] T001: Task
`;
expect(detectSpecFallback(content)).toBe(true);
const content2 = `
## acceptance criteria
Lower case section header
- [ ] T001: Task
`;
expect(detectSpecFallback(content2)).toBe(true);
});
it('should detect OpenAI-style output without explicit marker', () => {
// Non-Claude models may format specs differently but still have the key elements
const openAIStyleOutput = `
# Feature Specification: User Authentication
**Goal**: Allow users to securely log into the application
**Solution**: Implement JWT-based authentication with refresh tokens
## Acceptance Criteria
1. Users can log in with email and password
2. Invalid credentials show error message
3. Sessions persist across page refreshes
## Implementation Tasks
\`\`\`tasks
- [ ] T001: Create auth service | File: src/services/auth.ts
- [ ] T002: Build login component | File: src/components/Login.tsx
- [ ] T003: Add protected routes | File: src/App.tsx
\`\`\`
`;
expect(detectSpecFallback(openAIStyleOutput)).toBe(true);
});
it('should detect Gemini-style output without explicit marker', () => {
const geminiStyleOutput = `
## Overview
This specification describes the implementation of a user profile page.
## Technical Context
- Framework: React
- State: Redux
## Tasks
- [ ] T001: Create ProfilePage component
- [ ] T002: Add profile API endpoint
- [ ] T003: Style the profile page
`;
expect(detectSpecFallback(geminiStyleOutput)).toBe(true);
});
});
});

View File

@@ -30,16 +30,11 @@ import net from 'net';
describe('dev-server-service.ts', () => {
let testDir: string;
let originalHostname: string | undefined;
beforeEach(async () => {
vi.clearAllMocks();
vi.resetModules();
// Store and set HOSTNAME for consistent test behavior
originalHostname = process.env.HOSTNAME;
process.env.HOSTNAME = 'localhost';
testDir = path.join(os.tmpdir(), `dev-server-test-${Date.now()}`);
await fs.mkdir(testDir, { recursive: true });
@@ -61,13 +56,6 @@ describe('dev-server-service.ts', () => {
});
afterEach(async () => {
// Restore original HOSTNAME
if (originalHostname === undefined) {
delete process.env.HOSTNAME;
} else {
process.env.HOSTNAME = originalHostname;
}
try {
await fs.rm(testDir, { recursive: true, force: true });
} catch {

View File

@@ -15,7 +15,7 @@ import type {
} from '@automaker/types';
import { ProviderFactory } from '@/providers/provider-factory.js';
// Create shared mock instances for assertions using vi.hoisted
// Create a shared mock logger instance for assertions using vi.hoisted
const mockLogger = vi.hoisted(() => ({
info: vi.fn(),
error: vi.fn(),
@@ -23,13 +23,6 @@ const mockLogger = vi.hoisted(() => ({
debug: vi.fn(),
}));
const mockCreateChatOptions = vi.hoisted(() =>
vi.fn(() => ({
model: 'claude-sonnet-4-20250514',
systemPrompt: 'test prompt',
}))
);
// Mock dependencies
vi.mock('@/lib/secure-fs.js');
vi.mock('@automaker/platform');
@@ -44,7 +37,10 @@ vi.mock('@automaker/utils', async () => {
});
vi.mock('@/providers/provider-factory.js');
vi.mock('@/lib/sdk-options.js', () => ({
createChatOptions: mockCreateChatOptions,
createChatOptions: vi.fn(() => ({
model: 'claude-sonnet-4-20250514',
systemPrompt: 'test prompt',
})),
validateWorkingDirectory: vi.fn(),
}));
@@ -790,143 +786,6 @@ describe('IdeationService', () => {
service.generateSuggestions(testProjectPath, 'non-existent', 'features', 5)
).rejects.toThrow('Prompt non-existent not found');
});
it('should include app spec context when useAppSpec is enabled', async () => {
const mockAppSpec = `
<project_specification>
<project_name>Test Project</project_name>
<overview>A test application for unit testing</overview>
<core_capabilities>
<capability>User authentication</capability>
<capability>Data visualization</capability>
</core_capabilities>
<implemented_features>
<feature>
<name>Login System</name>
<description>Basic auth with email/password</description>
</feature>
</implemented_features>
</project_specification>
`;
vi.mocked(platform.getAppSpecPath).mockReturnValue('/test/project/.automaker/app_spec.txt');
// First call returns app spec, subsequent calls return empty JSON
vi.mocked(secureFs.readFile)
.mockResolvedValueOnce(mockAppSpec)
.mockResolvedValue(JSON.stringify({}));
const mockProvider = {
executeQuery: vi.fn().mockReturnValue({
async *[Symbol.asyncIterator]() {
yield {
type: 'result',
subtype: 'success',
result: JSON.stringify([{ title: 'Test', description: 'Test' }]),
};
},
}),
};
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any);
const prompts = service.getAllPrompts();
await service.generateSuggestions(testProjectPath, prompts[0].id, 'feature', 5, {
useAppSpec: true,
useContextFiles: false,
useMemoryFiles: false,
useExistingFeatures: false,
useExistingIdeas: false,
});
// Verify createChatOptions was called with systemPrompt containing app spec info
expect(mockCreateChatOptions).toHaveBeenCalled();
const chatOptionsCall = mockCreateChatOptions.mock.calls[0][0];
expect(chatOptionsCall.systemPrompt).toContain('Test Project');
expect(chatOptionsCall.systemPrompt).toContain('A test application for unit testing');
expect(chatOptionsCall.systemPrompt).toContain('User authentication');
expect(chatOptionsCall.systemPrompt).toContain('Login System');
});
it('should exclude app spec context when useAppSpec is disabled', async () => {
const mockAppSpec = `
<project_specification>
<project_name>Hidden Project</project_name>
<overview>This should not appear</overview>
</project_specification>
`;
vi.mocked(platform.getAppSpecPath).mockReturnValue('/test/project/.automaker/app_spec.txt');
vi.mocked(secureFs.readFile).mockResolvedValue(mockAppSpec);
const mockProvider = {
executeQuery: vi.fn().mockReturnValue({
async *[Symbol.asyncIterator]() {
yield {
type: 'result',
subtype: 'success',
result: JSON.stringify([{ title: 'Test', description: 'Test' }]),
};
},
}),
};
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any);
const prompts = service.getAllPrompts();
await service.generateSuggestions(testProjectPath, prompts[0].id, 'feature', 5, {
useAppSpec: false,
useContextFiles: false,
useMemoryFiles: false,
useExistingFeatures: false,
useExistingIdeas: false,
});
// Verify createChatOptions was called with systemPrompt NOT containing app spec info
expect(mockCreateChatOptions).toHaveBeenCalled();
const chatOptionsCall = mockCreateChatOptions.mock.calls[0][0];
expect(chatOptionsCall.systemPrompt).not.toContain('Hidden Project');
expect(chatOptionsCall.systemPrompt).not.toContain('This should not appear');
});
it('should handle missing app spec file gracefully', async () => {
vi.mocked(platform.getAppSpecPath).mockReturnValue('/test/project/.automaker/app_spec.txt');
const enoentError = new Error('ENOENT: no such file or directory') as NodeJS.ErrnoException;
enoentError.code = 'ENOENT';
// First call fails with ENOENT for app spec, subsequent calls return empty JSON
vi.mocked(secureFs.readFile)
.mockRejectedValueOnce(enoentError)
.mockResolvedValue(JSON.stringify({}));
const mockProvider = {
executeQuery: vi.fn().mockReturnValue({
async *[Symbol.asyncIterator]() {
yield {
type: 'result',
subtype: 'success',
result: JSON.stringify([{ title: 'Test', description: 'Test' }]),
};
},
}),
};
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any);
const prompts = service.getAllPrompts();
// Should not throw
await expect(
service.generateSuggestions(testProjectPath, prompts[0].id, 'feature', 5, {
useAppSpec: true,
useContextFiles: false,
useMemoryFiles: false,
useExistingFeatures: false,
useExistingIdeas: false,
})
).resolves.toBeDefined();
// Should not log warning for ENOENT
expect(mockLogger.warn).not.toHaveBeenCalled();
});
});
});
});

View File

@@ -14,13 +14,8 @@ const eslintConfig = defineConfig([
require: 'readonly',
__dirname: 'readonly',
__filename: 'readonly',
setTimeout: 'readonly',
clearTimeout: 'readonly',
},
},
rules: {
'no-unused-vars': ['warn', { argsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' }],
},
},
{
files: ['**/*.ts', '**/*.tsx'],
@@ -50,8 +45,6 @@ const eslintConfig = defineConfig([
confirm: 'readonly',
getComputedStyle: 'readonly',
requestAnimationFrame: 'readonly',
cancelAnimationFrame: 'readonly',
alert: 'readonly',
// DOM Element Types
HTMLElement: 'readonly',
HTMLInputElement: 'readonly',
@@ -63,8 +56,6 @@ const eslintConfig = defineConfig([
HTMLParagraphElement: 'readonly',
HTMLImageElement: 'readonly',
Element: 'readonly',
SVGElement: 'readonly',
SVGSVGElement: 'readonly',
// Event Types
Event: 'readonly',
KeyboardEvent: 'readonly',
@@ -73,24 +64,14 @@ const eslintConfig = defineConfig([
CustomEvent: 'readonly',
ClipboardEvent: 'readonly',
WheelEvent: 'readonly',
MouseEvent: 'readonly',
UIEvent: 'readonly',
MediaQueryListEvent: 'readonly',
DataTransfer: 'readonly',
// Web APIs
ResizeObserver: 'readonly',
AbortSignal: 'readonly',
AbortController: 'readonly',
IntersectionObserver: 'readonly',
Audio: 'readonly',
HTMLAudioElement: 'readonly',
ScrollBehavior: 'readonly',
URL: 'readonly',
URLSearchParams: 'readonly',
XMLHttpRequest: 'readonly',
Response: 'readonly',
RequestInit: 'readonly',
RequestCache: 'readonly',
// Timers
setTimeout: 'readonly',
setInterval: 'readonly',
@@ -109,8 +90,6 @@ const eslintConfig = defineConfig([
Electron: 'readonly',
// Console
console: 'readonly',
// Vite defines
__APP_VERSION__: 'readonly',
},
},
plugins: {
@@ -120,13 +99,6 @@ const eslintConfig = defineConfig([
...ts.configs.recommended.rules,
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/ban-ts-comment': [
'error',
{
'ts-nocheck': 'allow-with-description',
minimumDescriptionLength: 10,
},
],
},
},
globalIgnores([

View File

@@ -107,7 +107,6 @@
"sonner": "2.0.7",
"tailwind-merge": "3.4.0",
"usehooks-ts": "3.1.1",
"zod": "^3.24.1 || ^4.0.0",
"zustand": "5.0.9"
},
"optionalDependencies": {
@@ -170,10 +169,6 @@
"from": "server-bundle/node_modules",
"to": "server/node_modules"
},
{
"from": "server-bundle/libs",
"to": "server/libs"
},
{
"from": "server-bundle/package.json",
"to": "server/package.json"

View File

@@ -29,7 +29,7 @@ async function killProcessOnPort(port) {
try {
await execAsync(`kill -9 ${pid}`);
console.log(`[KillTestServers] Killed process ${pid}`);
} catch (_error) {
} catch (error) {
// Process might have already exited
}
}
@@ -47,7 +47,7 @@ async function killProcessOnPort(port) {
await new Promise((resolve) => setTimeout(resolve, 500));
return;
}
} catch (_error) {
} catch (error) {
// No process on port, which is fine
}
}

View File

@@ -6,26 +6,14 @@ import { SplashScreen } from './components/splash-screen';
import { useSettingsSync } from './hooks/use-settings-sync';
import { useCursorStatusInit } from './hooks/use-cursor-status-init';
import { useProviderAuthInit } from './hooks/use-provider-auth-init';
import { useAppStore } from './store/app-store';
import { TooltipProvider } from '@/components/ui/tooltip';
import './styles/global.css';
import './styles/theme-imports';
import './styles/font-imports';
const logger = createLogger('App');
// Key for localStorage to persist splash screen preference
const DISABLE_SPLASH_KEY = 'automaker-disable-splash';
export default function App() {
const disableSplashScreen = useAppStore((state) => state.disableSplashScreen);
const [showSplash, setShowSplash] = useState(() => {
// Check localStorage for user preference (available synchronously)
const savedPreference = localStorage.getItem(DISABLE_SPLASH_KEY);
if (savedPreference === 'true') {
return false;
}
// Only show splash once per session
if (sessionStorage.getItem('automaker-splash-shown')) {
return false;
@@ -33,11 +21,6 @@ export default function App() {
return true;
});
// Sync the disableSplashScreen setting to localStorage for fast access on next startup
useEffect(() => {
localStorage.setItem(DISABLE_SPLASH_KEY, String(disableSplashScreen));
}, [disableSplashScreen]);
// Clear accumulated PerformanceMeasure entries to prevent memory leak in dev mode
// React's internal scheduler creates performance marks/measures that accumulate without cleanup
useEffect(() => {
@@ -76,9 +59,9 @@ export default function App() {
}, []);
return (
<TooltipProvider delayDuration={300}>
<>
<RouterProvider router={router} />
{showSplash && !disableSplashScreen && <SplashScreen onComplete={handleSplashComplete} />}
</TooltipProvider>
{showSplash && <SplashScreen onComplete={handleSplashComplete} />}
</>
);
}

View File

@@ -68,6 +68,7 @@ export function CodexUsagePopover() {
// Use React Query for data fetching with automatic polling
const {
data: codexUsage,
isLoading,
isFetching,
error: queryError,
dataUpdatedAt,

View File

@@ -40,6 +40,8 @@ interface FileBrowserDialogProps {
initialPath?: string;
}
const MAX_RECENT_FOLDERS = 5;
export function FileBrowserDialog({
open,
onOpenChange,

View File

@@ -191,7 +191,7 @@ export function NewProjectModal({
// Use platform-specific path separator
const pathSep =
typeof window !== 'undefined' && window.electronAPI
typeof window !== 'undefined' && (window as any).electronAPI
? navigator.platform.indexOf('Win') !== -1
? '\\'
: '/'

View File

@@ -15,7 +15,6 @@ import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
import { getHttpApiClient } from '@/lib/http-api-client';
import type { Project } from '@/lib/electron';
import { IconPicker } from './icon-picker';
import { toast } from 'sonner';
interface EditProjectDialogProps {
project: Project;
@@ -26,9 +25,9 @@ interface EditProjectDialogProps {
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.icon || null);
const [icon, setIcon] = useState<string | null>((project as any).icon || null);
const [customIconPath, setCustomIconPath] = useState<string | null>(
project.customIconPath || null
(project as any).customIconPath || null
);
const [isUploadingIcon, setIsUploadingIcon] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -37,10 +36,10 @@ export function EditProjectDialog({ project, open, onOpenChange }: EditProjectDi
if (name.trim() !== project.name) {
setProjectName(project.id, name.trim());
}
if (icon !== project.icon) {
if (icon !== (project as any).icon) {
setProjectIcon(project.id, icon);
}
if (customIconPath !== project.customIconPath) {
if (customIconPath !== (project as any).customIconPath) {
setProjectCustomIcon(project.id, customIconPath);
}
onOpenChange(false);
@@ -53,18 +52,11 @@ export function EditProjectDialog({ project, open, onOpenChange }: EditProjectDi
// Validate file type
const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if (!validTypes.includes(file.type)) {
toast.error(
`Invalid file type: ${file.type || 'unknown'}. Please use JPG, PNG, GIF or WebP.`
);
return;
}
// Validate file size (max 5MB for icons - allows animated GIFs)
const maxSize = 5 * 1024 * 1024;
if (file.size > maxSize) {
toast.error(
`File too large (${(file.size / 1024 / 1024).toFixed(2)} MB). Maximum size is 5 MB.`
);
// Validate file size (max 2MB for icons)
if (file.size > 2 * 1024 * 1024) {
return;
}
@@ -80,24 +72,15 @@ export function EditProjectDialog({ project, open, onOpenChange }: EditProjectDi
file.type,
project.path
);
if (result.success && result.path) {
setCustomIconPath(result.path);
// Clear the Lucide icon when custom icon is set
setIcon(null);
toast.success('Icon uploaded successfully');
} else {
toast.error('Failed to upload icon');
}
setIsUploadingIcon(false);
};
reader.onerror = () => {
toast.error('Failed to read file');
setIsUploadingIcon(false);
};
reader.readAsDataURL(file);
} catch {
toast.error('Failed to upload icon');
setIsUploadingIcon(false);
}
};
@@ -179,7 +162,7 @@ export function EditProjectDialog({ project, open, onOpenChange }: EditProjectDi
{isUploadingIcon ? 'Uploading...' : 'Upload Custom Icon'}
</Button>
<p className="text-xs text-muted-foreground mt-1">
PNG, JPG, GIF or WebP. Max 5MB.
PNG, JPG, GIF or WebP. Max 2MB.
</p>
</div>
</div>

View File

@@ -3,7 +3,7 @@
*/
import { useCallback } from 'react';
import { Bell, Check, Trash2 } from 'lucide-react';
import { Bell, Check, Trash2, ExternalLink } from 'lucide-react';
import { useNavigate } from '@tanstack/react-router';
import { useNotificationsStore } from '@/store/notifications-store';
import { useLoadNotifications, useNotificationEvents } from '@/hooks/use-notification-events';

View File

@@ -59,7 +59,7 @@ interface ThemeButtonProps {
/** Handler for pointer leave events (used to clear preview) */
onPointerLeave: (e: React.PointerEvent) => void;
/** Handler for click events (used to select theme) */
onClick: (e: React.MouseEvent) => void;
onClick: () => void;
}
/**
@@ -77,7 +77,6 @@ const ThemeButton = memo(function ThemeButton({
const Icon = option.icon;
return (
<button
type="button"
onPointerEnter={onPointerEnter}
onPointerLeave={onPointerLeave}
onClick={onClick}
@@ -146,10 +145,7 @@ const ThemeColumn = memo(function ThemeColumn({
isSelected={selectedTheme === option.value}
onPointerEnter={() => onPreviewEnter(option.value)}
onPointerLeave={onPreviewLeave}
onClick={(e) => {
e.stopPropagation();
onSelect(option.value);
}}
onClick={() => onSelect(option.value)}
/>
))}
</div>
@@ -197,11 +193,13 @@ export function ProjectContextMenu({
const {
moveProjectToTrash,
theme: globalTheme,
setTheme,
setProjectTheme,
setPreviewTheme,
} = useAppStore();
const [showRemoveDialog, setShowRemoveDialog] = useState(false);
const [showThemeSubmenu, setShowThemeSubmenu] = useState(false);
const [removeConfirmed, setRemoveConfirmed] = useState(false);
const themeSubmenuRef = useRef<HTMLDivElement>(null);
const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -319,24 +317,13 @@ export function ProjectContextMenu({
const handleThemeSelect = useCallback(
(value: ThemeMode | typeof USE_GLOBAL_THEME) => {
// Clear any pending close timeout to prevent race conditions
if (closeTimeoutRef.current) {
clearTimeout(closeTimeoutRef.current);
closeTimeoutRef.current = null;
}
// Close menu first
setShowThemeSubmenu(false);
onClose();
// Then apply theme changes
setPreviewTheme(null);
const isUsingGlobal = value === USE_GLOBAL_THEME;
// Only set project theme - don't change global theme
// The UI uses getEffectiveTheme() which handles: previewTheme ?? projectTheme ?? globalTheme
setTheme(isUsingGlobal ? globalTheme : value);
setProjectTheme(project.id, isUsingGlobal ? null : value);
setShowThemeSubmenu(false);
},
[onClose, project.id, setPreviewTheme, setProjectTheme]
[globalTheme, project.id, setPreviewTheme, setProjectTheme, setTheme]
);
const handleConfirmRemove = useCallback(() => {
@@ -344,6 +331,7 @@ export function ProjectContextMenu({
toast.success('Project removed', {
description: `${project.name} has been removed from your projects list`,
});
setRemoveConfirmed(true);
}, [moveProjectToTrash, project.id, project.name]);
const handleDialogClose = useCallback(
@@ -352,6 +340,8 @@ export function ProjectContextMenu({
// Close the context menu when dialog closes (whether confirmed or cancelled)
// This prevents the context menu from reappearing after dialog interaction
if (!isOpen) {
// Reset confirmation state
setRemoveConfirmed(false);
// Always close the context menu when dialog closes
onClose();
}
@@ -440,13 +430,9 @@ export function ProjectContextMenu({
<div className="p-2">
{/* Use Global Option */}
<button
type="button"
onPointerEnter={() => handlePreviewEnter(globalTheme)}
onPointerLeave={handlePreviewLeave}
onClick={(e) => {
e.stopPropagation();
handleThemeSelect(USE_GLOBAL_THEME);
}}
onClick={() => handleThemeSelect(USE_GLOBAL_THEME)}
className={cn(
'w-full flex items-center gap-2 px-3 py-2 rounded-md',
'text-sm font-medium text-left',

View File

@@ -1,4 +1,3 @@
import { useState } from 'react';
import { Folder, LucideIcon } from 'lucide-react';
import * as LucideIcons from 'lucide-react';
import { cn, sanitizeForTestId } from '@/lib/utils';
@@ -20,8 +19,6 @@ export function ProjectSwitcherItem({
onClick,
onContextMenu,
}: ProjectSwitcherItemProps) {
const [imageError, setImageError] = useState(false);
// Convert index to hotkey label: 0 -> "1", 1 -> "2", ..., 8 -> "9", 9 -> "0"
const hotkeyLabel =
hotkeyIndex !== undefined && hotkeyIndex >= 0 && hotkeyIndex <= 9
@@ -38,7 +35,7 @@ export function ProjectSwitcherItem({
};
const IconComponent = getIconComponent();
const hasCustomIcon = !!project.customIconPath && !imageError;
const hasCustomIcon = !!project.customIconPath;
// Combine project.id with sanitized name for uniqueness and readability
// Format: project-switcher-{id}-{sanitizedName}
@@ -77,7 +74,6 @@ export function ProjectSwitcherItem({
'w-8 h-8 rounded-lg object-cover transition-all duration-200',
isActive ? 'ring-1 ring-brand-500/50' : 'group-hover:scale-110'
)}
onError={() => setImageError(true)}
/>
) : (
<IconComponent

View File

@@ -100,8 +100,14 @@ export function ProjectSelectorWithOptions({
const { sensors, handleDragEnd } = useDragAndDrop({ projects, reorderProjects });
const { globalTheme, setProjectTheme, setPreviewTheme, handlePreviewEnter, handlePreviewLeave } =
useProjectTheme();
const {
globalTheme,
setTheme,
setProjectTheme,
setPreviewTheme,
handlePreviewEnter,
handlePreviewLeave,
} = useProjectTheme();
if (!sidebarOpen || projects.length === 0) {
return null;
@@ -275,8 +281,11 @@ export function ProjectSelectorWithOptions({
onValueChange={(value) => {
if (currentProject) {
setPreviewTheme(null);
// Only set project theme - don't change global theme
// The UI uses getEffectiveTheme() which handles: previewTheme ?? projectTheme ?? globalTheme
if (value !== '') {
setTheme(value as ThemeMode);
} else {
setTheme(globalTheme);
}
setProjectTheme(
currentProject.id,
value === '' ? null : (value as ThemeMode)

View File

@@ -5,7 +5,7 @@ import { formatShortcut } from '@/store/app-store';
import { Activity, Settings, BookOpen, MessageSquare, ExternalLink } from 'lucide-react';
import { useOSDetection } from '@/hooks/use-os-detection';
import { getElectronAPI } from '@/lib/electron';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
function getOSAbbreviation(os: string): string {
switch (os) {
@@ -72,14 +72,68 @@ export function SidebarFooter({
<div className="flex flex-col items-center py-2 px-2 gap-1">
{/* Running Agents */}
{!hideRunningAgents && (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => navigate({ to: '/running-agents' })}
className={cn(
'relative flex items-center justify-center w-10 h-10 rounded-xl',
'transition-all duration-200 ease-out titlebar-no-drag',
isActiveRoute('running-agents')
? [
'bg-gradient-to-r from-brand-500/20 via-brand-500/15 to-brand-600/10',
'text-foreground border border-brand-500/30',
'shadow-md shadow-brand-500/10',
]
: [
'text-muted-foreground hover:text-foreground',
'hover:bg-accent/50 border border-transparent hover:border-border/40',
]
)}
data-testid="running-agents-link"
>
<Activity
className={cn(
'w-[18px] h-[18px]',
isActiveRoute('running-agents') && 'text-brand-500'
)}
/>
{runningAgentsCount > 0 && (
<span
className={cn(
'absolute -top-1 -right-1 flex items-center justify-center',
'min-w-4 h-4 px-1 text-[9px] font-bold rounded-full',
'bg-brand-500 text-white shadow-sm'
)}
>
{runningAgentsCount > 99 ? '99' : runningAgentsCount}
</span>
)}
</button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
Running Agents
{runningAgentsCount > 0 && (
<span className="ml-2 px-1.5 py-0.5 bg-brand-500 text-white rounded-full text-[10px]">
{runningAgentsCount}
</span>
)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Settings */}
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => navigate({ to: '/running-agents' })}
onClick={() => navigate({ to: '/settings' })}
className={cn(
'relative flex items-center justify-center w-10 h-10 rounded-xl',
'flex items-center justify-center w-10 h-10 rounded-xl',
'transition-all duration-200 ease-out titlebar-no-drag',
isActiveRoute('running-agents')
isActiveRoute('settings')
? [
'bg-gradient-to-r from-brand-500/20 via-brand-500/15 to-brand-600/10',
'text-foreground border border-brand-500/30',
@@ -90,115 +144,72 @@ export function SidebarFooter({
'hover:bg-accent/50 border border-transparent hover:border-border/40',
]
)}
data-testid="running-agents-link"
data-testid="settings-button"
>
<Activity
<Settings
className={cn(
'w-[18px] h-[18px]',
isActiveRoute('running-agents') && 'text-brand-500'
isActiveRoute('settings') && 'text-brand-500'
)}
/>
{runningAgentsCount > 0 && (
<span
className={cn(
'absolute -top-1 -right-1 flex items-center justify-center',
'min-w-4 h-4 px-1 text-[9px] font-bold rounded-full',
'bg-brand-500 text-white shadow-sm'
)}
>
{runningAgentsCount > 99 ? '99' : runningAgentsCount}
</span>
)}
</button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
Running Agents
{runningAgentsCount > 0 && (
<span className="ml-2 px-1.5 py-0.5 bg-brand-500 text-white rounded-full text-[10px]">
{runningAgentsCount}
</span>
)}
Global Settings
<span className="ml-2 px-1.5 py-0.5 bg-muted rounded text-[10px] font-mono text-muted-foreground">
{formatShortcut(shortcuts.settings, true)}
</span>
</TooltipContent>
</Tooltip>
)}
{/* Settings */}
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => navigate({ to: '/settings' })}
className={cn(
'flex items-center justify-center w-10 h-10 rounded-xl',
'transition-all duration-200 ease-out titlebar-no-drag',
isActiveRoute('settings')
? [
'bg-gradient-to-r from-brand-500/20 via-brand-500/15 to-brand-600/10',
'text-foreground border border-brand-500/30',
'shadow-md shadow-brand-500/10',
]
: [
'text-muted-foreground hover:text-foreground',
'hover:bg-accent/50 border border-transparent hover:border-border/40',
]
)}
data-testid="settings-button"
>
<Settings
className={cn('w-[18px] h-[18px]', isActiveRoute('settings') && 'text-brand-500')}
/>
</button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
Global Settings
<span className="ml-2 px-1.5 py-0.5 bg-muted rounded text-[10px] font-mono text-muted-foreground">
{formatShortcut(shortcuts.settings, true)}
</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
{/* Documentation */}
{!hideWiki && (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={handleWikiClick}
className={cn(
'flex items-center justify-center w-10 h-10 rounded-xl',
'text-muted-foreground hover:text-foreground',
'hover:bg-accent/50 border border-transparent hover:border-border/40',
'transition-all duration-200 ease-out titlebar-no-drag'
)}
data-testid="documentation-button"
>
<BookOpen className="w-[18px] h-[18px]" />
</button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
Documentation
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Feedback */}
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={handleWikiClick}
onClick={handleFeedbackClick}
className={cn(
'flex items-center justify-center w-10 h-10 rounded-xl',
'text-muted-foreground hover:text-foreground',
'hover:bg-accent/50 border border-transparent hover:border-border/40',
'transition-all duration-200 ease-out titlebar-no-drag'
)}
data-testid="documentation-button"
data-testid="feedback-button"
>
<BookOpen className="w-[18px] h-[18px]" />
<MessageSquare className="w-[18px] h-[18px]" />
</button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
Documentation
Feedback
</TooltipContent>
</Tooltip>
)}
{/* Feedback */}
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={handleFeedbackClick}
className={cn(
'flex items-center justify-center w-10 h-10 rounded-xl',
'text-muted-foreground hover:text-foreground',
'hover:bg-accent/50 border border-transparent hover:border-border/40',
'transition-all duration-200 ease-out titlebar-no-drag'
)}
data-testid="feedback-button"
>
<MessageSquare className="w-[18px] h-[18px]" />
</button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
Feedback
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
);

View File

@@ -15,7 +15,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
interface SidebarHeaderProps {
sidebarOpen: boolean;
@@ -92,74 +92,78 @@ export function SidebarHeader({
isMac && isElectron() && 'pt-[10px]'
)}
>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={handleLogoClick}
className="group flex flex-col items-center"
data-testid="logo-button"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
role="img"
aria-label="Automaker Logo"
className="size-8 group-hover:rotate-12 transition-transform duration-300 ease-out"
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={handleLogoClick}
className="group flex flex-col items-center"
data-testid="logo-button"
>
<defs>
<linearGradient
id="bg-collapsed"
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-collapsed)" />
<g
fill="none"
stroke="#FFFFFF"
strokeWidth="20"
strokeLinecap="round"
strokeLinejoin="round"
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
role="img"
aria-label="Automaker Logo"
className="size-8 group-hover:rotate-12 transition-transform duration-300 ease-out"
>
<path d="M92 92 L52 128 L92 164" />
<path d="M144 72 L116 184" />
<path d="M164 92 L204 128 L164 164" />
</g>
</svg>
</button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
Go to Dashboard
</TooltipContent>
</Tooltip>
<defs>
<linearGradient
id="bg-collapsed"
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-collapsed)" />
<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>
</button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
Go to Dashboard
</TooltipContent>
</Tooltip>
</TooltipProvider>
{/* Collapsed project icon with dropdown */}
{currentProject && (
<>
<div className="w-full h-px bg-border/40 my-2" />
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button
onContextMenu={(e) => onProjectContextMenu(currentProject, e)}
className="p-1 rounded-lg hover:bg-accent/50 transition-colors"
data-testid="collapsed-project-button"
>
{renderProjectIcon(currentProject)}
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{currentProject.name}
</TooltipContent>
</Tooltip>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button
onContextMenu={(e) => onProjectContextMenu(currentProject, e)}
className="p-1 rounded-lg hover:bg-accent/50 transition-colors"
data-testid="collapsed-project-button"
>
{renderProjectIcon(currentProject)}
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{currentProject.name}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<DropdownMenuContent
align="start"
side="right"

View File

@@ -1,14 +1,10 @@
import { useCallback, useEffect, useRef } from 'react';
import { useState, useCallback, useEffect, useRef } from 'react';
import type { NavigateOptions } from '@tanstack/react-router';
import { ChevronDown, Wrench, Github, Folder } from 'lucide-react';
import * as LucideIcons from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { ChevronDown, Wrench, Github } from 'lucide-react';
import { cn } from '@/lib/utils';
import { formatShortcut, useAppStore } from '@/store/app-store';
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
import { formatShortcut } from '@/store/app-store';
import type { NavSection } from '../types';
import type { Project } from '@/lib/electron';
import type { SidebarStyle } from '@automaker/types';
import { Spinner } from '@/components/ui/spinner';
import {
DropdownMenu,
@@ -16,7 +12,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
// Map section labels to icons
const sectionIcons: Record<string, React.ComponentType<{ className?: string }>> = {
@@ -27,7 +23,6 @@ const sectionIcons: Record<string, React.ComponentType<{ className?: string }>>
interface SidebarNavigationProps {
currentProject: Project | null;
sidebarOpen: boolean;
sidebarStyle: SidebarStyle;
navSections: NavSection[];
isActiveRoute: (id: string) => boolean;
navigate: (opts: NavigateOptions) => void;
@@ -37,7 +32,6 @@ interface SidebarNavigationProps {
export function SidebarNavigation({
currentProject,
sidebarOpen,
sidebarStyle,
navSections,
isActiveRoute,
navigate,
@@ -45,26 +39,21 @@ export function SidebarNavigation({
}: SidebarNavigationProps) {
const navRef = useRef<HTMLElement>(null);
// Get collapsed state from store (persisted across restarts)
const { collapsedNavSections, setCollapsedNavSections, toggleNavSection } = useAppStore();
// Track collapsed state for each collapsible section
const [collapsedSections, setCollapsedSections] = useState<Record<string, boolean>>({});
// Initialize collapsed state when sections change (e.g., GitHub section appears)
// Only set defaults for sections that don't have a persisted state
useEffect(() => {
let hasNewSections = false;
const updated = { ...collapsedNavSections };
navSections.forEach((section) => {
if (section.collapsible && section.label && !(section.label in updated)) {
updated[section.label] = section.defaultCollapsed ?? false;
hasNewSections = true;
}
setCollapsedSections((prev) => {
const updated = { ...prev };
navSections.forEach((section) => {
if (section.collapsible && section.label && !(section.label in updated)) {
updated[section.label] = section.defaultCollapsed ?? false;
}
});
return updated;
});
if (hasNewSections) {
setCollapsedNavSections(updated);
}
}, [navSections, collapsedNavSections, setCollapsedNavSections]);
}, [navSections]);
// Check scroll state
const checkScrollState = useCallback(() => {
@@ -88,7 +77,14 @@ export function SidebarNavigation({
nav.removeEventListener('scroll', checkScrollState);
resizeObserver.disconnect();
};
}, [checkScrollState, collapsedNavSections]);
}, [checkScrollState, collapsedSections]);
const toggleSection = useCallback((label: string) => {
setCollapsedSections((prev) => ({
...prev,
[label]: !prev[label],
}));
}, []);
// Filter sections: always show non-project sections, only show project sections when project exists
const visibleSections = navSections.filter((section) => {
@@ -100,50 +96,11 @@ export function SidebarNavigation({
return !!currentProject;
});
// Get the icon component for the current project
const getProjectIcon = (): LucideIcon => {
if (currentProject?.icon && currentProject.icon in LucideIcons) {
return (LucideIcons as unknown as Record<string, LucideIcon>)[currentProject.icon];
}
return Folder;
};
const ProjectIcon = getProjectIcon();
const hasCustomIcon = !!currentProject?.customIconPath;
return (
<nav
ref={navRef}
className={cn(
'flex-1 overflow-y-auto scrollbar-hide px-3 pb-2',
// Add top padding in discord mode since there's no header
sidebarStyle === 'discord' ? 'pt-3' : 'mt-1'
)}
>
{/* Project name display for classic/discord mode */}
{sidebarStyle === 'discord' && currentProject && sidebarOpen && (
<div className="mb-3">
<div className="flex items-center gap-2.5 px-3 py-2">
{hasCustomIcon ? (
<img
src={getAuthenticatedImageUrl(currentProject.customIconPath!, currentProject.path)}
alt={currentProject.name}
className="w-5 h-5 rounded object-cover"
/>
) : (
<ProjectIcon className="w-5 h-5 text-brand-500 shrink-0" />
)}
<span className="text-sm font-medium text-foreground truncate">
{currentProject.name}
</span>
</div>
<div className="h-px bg-border/40 mx-1 mt-1" />
</div>
)}
<nav ref={navRef} className={cn('flex-1 overflow-y-auto scrollbar-hide px-3 pb-2 mt-1')}>
{/* Navigation sections */}
{visibleSections.map((section, sectionIdx) => {
const isCollapsed = section.label ? collapsedNavSections[section.label] : false;
const isCollapsed = section.label ? collapsedSections[section.label] : false;
const isCollapsible = section.collapsible && section.label && sidebarOpen;
const SectionIcon = section.label ? sectionIcons[section.label] : null;
@@ -153,37 +110,21 @@ export function SidebarNavigation({
{/* Section Label - clickable if collapsible (expanded sidebar) */}
{section.label && sidebarOpen && (
<button
onClick={() => isCollapsible && toggleNavSection(section.label!)}
onClick={() => isCollapsible && toggleSection(section.label!)}
className={cn(
'group flex items-center w-full px-3 py-1.5 mb-1 rounded-md',
'transition-all duration-200 ease-out',
isCollapsible
? [
'cursor-pointer',
'hover:bg-accent/50 hover:text-foreground',
'border border-transparent hover:border-border/40',
]
: 'cursor-default'
'flex items-center w-full px-3 mb-1.5',
isCollapsible && 'cursor-pointer hover:text-foreground'
)}
disabled={!isCollapsible}
>
<span
className={cn(
'text-[10px] font-semibold uppercase tracking-widest transition-colors duration-200',
isCollapsible
? 'text-muted-foreground/70 group-hover:text-foreground'
: 'text-muted-foreground/70'
)}
>
<span className="text-[10px] font-semibold text-muted-foreground/70 uppercase tracking-widest">
{section.label}
</span>
{isCollapsible && (
<ChevronDown
className={cn(
'w-3 h-3 ml-auto transition-all duration-200',
isCollapsed
? '-rotate-90 text-muted-foreground/50 group-hover:text-muted-foreground'
: 'text-muted-foreground/50 group-hover:text-muted-foreground'
'w-3 h-3 ml-auto text-muted-foreground/50 transition-transform duration-200',
isCollapsed && '-rotate-90'
)}
/>
)}
@@ -193,25 +134,27 @@ export function SidebarNavigation({
{/* Section icon with dropdown (collapsed sidebar) */}
{section.label && !sidebarOpen && SectionIcon && section.collapsible && isCollapsed && (
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button
className={cn(
'group flex items-center justify-center w-full py-2 rounded-lg',
'text-muted-foreground hover:text-foreground',
'hover:bg-accent/50 border border-transparent hover:border-border/40',
'transition-all duration-200 ease-out'
)}
>
<SectionIcon className="w-[18px] h-[18px]" />
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{section.label}
</TooltipContent>
</Tooltip>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button
className={cn(
'group flex items-center justify-center w-full py-2 rounded-lg',
'text-muted-foreground hover:text-foreground',
'hover:bg-accent/50 border border-transparent hover:border-border/40',
'transition-all duration-200 ease-out'
)}
>
<SectionIcon className="w-[18px] h-[18px]" />
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{section.label}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<DropdownMenuContent side="right" align="start" sideOffset={8} className="w-48">
{section.items.map((item) => {
const ItemIcon = item.icon;

View File

@@ -9,15 +9,19 @@ export const ThemeMenuItem = memo(function ThemeMenuItem({
}: ThemeMenuItemProps) {
const Icon = option.icon;
return (
<DropdownMenuRadioItem
value={option.value}
data-testid={`project-theme-${option.value}`}
className="text-xs py-1.5"
<div
key={option.value}
onPointerEnter={() => onPreviewEnter(option.value)}
onPointerLeave={onPreviewLeave}
>
<Icon className="w-3.5 h-3.5 mr-1.5" style={{ color: option.color }} />
<span>{option.label}</span>
</DropdownMenuRadioItem>
<DropdownMenuRadioItem
value={option.value}
data-testid={`project-theme-${option.value}`}
className="text-xs py-1.5"
>
<Icon className="w-3.5 h-3.5 mr-1.5" style={{ color: option.color }} />
<span>{option.label}</span>
</DropdownMenuRadioItem>
</div>
);
});

View File

@@ -53,7 +53,6 @@ export function Sidebar() {
trashedProjects,
currentProject,
sidebarOpen,
sidebarStyle,
mobileSidebarHidden,
projectHistory,
upsertAndSetCurrentProject,
@@ -382,21 +381,17 @@ export function Sidebar() {
)}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Only show header in unified mode - in discord mode, ProjectSwitcher has the logo */}
{sidebarStyle === 'unified' && (
<SidebarHeader
sidebarOpen={sidebarOpen}
currentProject={currentProject}
onNewProject={handleNewProject}
onOpenFolder={handleOpenFolder}
onProjectContextMenu={handleContextMenu}
/>
)}
<SidebarHeader
sidebarOpen={sidebarOpen}
currentProject={currentProject}
onNewProject={handleNewProject}
onOpenFolder={handleOpenFolder}
onProjectContextMenu={handleContextMenu}
/>
<SidebarNavigation
currentProject={currentProject}
sidebarOpen={sidebarOpen}
sidebarStyle={sidebarStyle}
navSections={navSections}
isActiveRoute={isActiveRoute}
navigate={navigate}

View File

@@ -1,4 +1,8 @@
import * as React from 'react';
import { Settings2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { useAppStore } from '@/store/app-store';
import type { ModelAlias, CursorModelId, PhaseModelKey, PhaseModelEntry } from '@automaker/types';
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
@@ -70,6 +74,12 @@ export function ModelOverrideTrigger({
lg: 'h-10 w-10',
};
const iconSizes = {
sm: 'w-3.5 h-3.5',
md: 'w-4 h-4',
lg: 'w-5 h-5',
};
// For icon variant, wrap PhaseModelSelector and hide text/chevron with CSS
if (variant === 'icon') {
return (

View File

@@ -37,6 +37,16 @@ function normalizeEntry(entry: PhaseModelEntry | string): PhaseModelEntry {
return entry;
}
/**
* Extract model string from PhaseModelEntry or string
*/
function extractModel(entry: PhaseModelEntry | string): ModelId {
if (typeof entry === 'string') {
return entry as ModelId;
}
return entry.model;
}
/**
* Hook for managing model overrides per phase
*

View File

@@ -3,7 +3,7 @@ import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
import { Spinner, type SpinnerVariant } from '@/components/ui/spinner';
import { Spinner } from '@/components/ui/spinner';
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all duration-200 cursor-pointer disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive active:scale-[0.98]",
@@ -37,19 +37,9 @@ const buttonVariants = cva(
}
);
/** Button variants that have colored backgrounds requiring foreground spinner color */
const COLORED_BACKGROUND_VARIANTS = new Set<string>(['default', 'destructive']);
/** Get spinner variant based on button variant - use foreground for colored backgrounds */
function getSpinnerVariant(
buttonVariant: VariantProps<typeof buttonVariants>['variant']
): SpinnerVariant {
const variant = buttonVariant ?? 'default';
if (COLORED_BACKGROUND_VARIANTS.has(variant)) {
return 'foreground';
}
// outline, secondary, ghost, link, animated-outline use standard backgrounds
return 'primary';
// Loading spinner component
function ButtonSpinner({ className }: { className?: string }) {
return <Spinner size="sm" className={className} />;
}
function Button({
@@ -67,7 +57,6 @@ function Button({
loading?: boolean;
}) {
const isDisabled = disabled || loading;
const spinnerVariant = getSpinnerVariant(variant);
// Special handling for animated-outline variant
if (variant === 'animated-outline' && !asChild) {
@@ -94,7 +83,7 @@ function Button({
size === 'icon' && 'p-0 gap-0'
)}
>
{loading && <Spinner size="sm" variant={spinnerVariant} />}
{loading && <ButtonSpinner />}
{children}
</span>
</button>
@@ -110,7 +99,7 @@ function Button({
disabled={isDisabled}
{...props}
>
{loading && <Spinner size="sm" variant={spinnerVariant} />}
{loading && <ButtonSpinner />}
{children}
</Comp>
);

View File

@@ -1,3 +1,4 @@
import * as React from 'react';
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
const Collapsible = CollapsiblePrimitive.Root;

View File

@@ -479,7 +479,7 @@ export function GitDiffPanel({
<div className="flex flex-col items-center justify-center gap-2 py-8 text-muted-foreground">
<AlertCircle className="w-5 h-5 text-amber-500" />
<span className="text-sm">{error}</span>
<Button variant="ghost" size="sm" onClick={() => void loadDiffs()} className="mt-2">
<Button variant="ghost" size="sm" onClick={loadDiffs} className="mt-2">
<RefreshCw className="w-4 h-4 mr-2" />
Retry
</Button>
@@ -550,12 +550,7 @@ export function GitDiffPanel({
>
Collapse All
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => void loadDiffs()}
className="text-xs h-7"
>
<Button variant="ghost" size="sm" onClick={loadDiffs} className="text-xs h-7">
<RefreshCw className="w-3 h-3 mr-1" />
Refresh
</Button>

View File

@@ -7,7 +7,7 @@ import {
} from '@/store/app-store';
import type { KeyboardShortcuts } from '@/store/app-store';
import { cn } from '@/lib/utils';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { CheckCircle2, X, RotateCcw, Edit2 } from 'lucide-react';
@@ -305,52 +305,54 @@ export function KeyboardMap({ onKeySelect, selectedKey, className }: KeyboardMap
};
return (
<div className={cn('space-y-4', className)} data-testid="keyboard-map">
{/* Legend */}
<div className="flex flex-wrap gap-4 justify-center text-xs">
{Object.entries(CATEGORY_COLORS).map(([key, colors]) => (
<div key={key} className="flex items-center gap-2">
<div className={cn('w-4 h-4 rounded border', colors.bg, colors.border)} />
<span className={colors.text}>{colors.label}</span>
<TooltipProvider>
<div className={cn('space-y-4', className)} data-testid="keyboard-map">
{/* Legend */}
<div className="flex flex-wrap gap-4 justify-center text-xs">
{Object.entries(CATEGORY_COLORS).map(([key, colors]) => (
<div key={key} className="flex items-center gap-2">
<div className={cn('w-4 h-4 rounded border', colors.bg, colors.border)} />
<span className={colors.text}>{colors.label}</span>
</div>
))}
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-sidebar-accent/10 border border-sidebar-border" />
<span className="text-muted-foreground">Available</span>
</div>
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-yellow-500" />
<span className="text-yellow-400">Modified</span>
</div>
))}
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-sidebar-accent/10 border border-sidebar-border" />
<span className="text-muted-foreground">Available</span>
</div>
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-yellow-500" />
<span className="text-yellow-400">Modified</span>
{/* Keyboard layout */}
<div className="flex flex-col items-center gap-1.5 p-4 rounded-xl bg-sidebar-accent/5 border border-sidebar-border">
{KEYBOARD_ROWS.map((row, rowIndex) => (
<div key={rowIndex} className="flex gap-1.5 justify-center">
{row.map(renderKey)}
</div>
))}
</div>
{/* Stats */}
<div className="flex justify-center gap-6 text-xs text-muted-foreground">
<span>
<strong className="text-foreground">{Object.keys(keyboardShortcuts).length}</strong>{' '}
shortcuts configured
</span>
<span>
<strong className="text-foreground">{Object.keys(keyToShortcuts).length}</strong> keys
in use
</span>
<span>
<strong className="text-foreground">
{KEYBOARD_ROWS.flat().length - Object.keys(keyToShortcuts).length}
</strong>{' '}
keys available
</span>
</div>
</div>
{/* Keyboard layout */}
<div className="flex flex-col items-center gap-1.5 p-4 rounded-xl bg-sidebar-accent/5 border border-sidebar-border">
{KEYBOARD_ROWS.map((row, rowIndex) => (
<div key={rowIndex} className="flex gap-1.5 justify-center">
{row.map(renderKey)}
</div>
))}
</div>
{/* Stats */}
<div className="flex justify-center gap-6 text-xs text-muted-foreground">
<span>
<strong className="text-foreground">{Object.keys(keyboardShortcuts).length}</strong>{' '}
shortcuts configured
</span>
<span>
<strong className="text-foreground">{Object.keys(keyToShortcuts).length}</strong> keys in
use
</span>
<span>
<strong className="text-foreground">
{KEYBOARD_ROWS.flat().length - Object.keys(keyToShortcuts).length}
</strong>{' '}
keys available
</span>
</div>
</div>
</TooltipProvider>
);
}
@@ -506,194 +508,196 @@ export function ShortcutReferencePanel({ editable = false }: ShortcutReferencePa
};
return (
<div className="space-y-4" data-testid="shortcut-reference-panel">
{editable && (
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
onClick={() => resetKeyboardShortcuts()}
className="gap-2 text-xs"
data-testid="reset-all-shortcuts-button"
>
<RotateCcw className="w-3 h-3" />
Reset All to Defaults
</Button>
</div>
)}
{Object.entries(groupedShortcuts).map(([category, shortcuts]) => {
const colors = CATEGORY_COLORS[category as keyof typeof CATEGORY_COLORS];
return (
<div key={category} className="space-y-2">
<h4 className={cn('text-sm font-semibold', colors.text)}>{colors.label}</h4>
<div className="grid grid-cols-2 gap-2">
{shortcuts.map(({ key, label, value }) => {
const isModified = mergedShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key];
const isEditing = editingShortcut === key;
return (
<div
key={key}
className={cn(
'flex items-center justify-between p-2 rounded-lg bg-sidebar-accent/10 border transition-colors',
isEditing ? 'border-brand-500' : 'border-sidebar-border',
editable && !isEditing && 'hover:bg-sidebar-accent/20 cursor-pointer'
)}
onClick={() => editable && !isEditing && handleStartEdit(key)}
data-testid={`shortcut-row-${key}`}
>
<span className="text-sm text-foreground">{label}</span>
<div className="flex items-center gap-2">
{isEditing ? (
<div
className="flex items-center gap-2"
onClick={(e) => e.stopPropagation()}
>
{/* Modifier checkboxes */}
<div className="flex items-center gap-1.5 text-xs">
<div className="flex items-center gap-1">
<Checkbox
id={`mod-cmd-${key}`}
checked={modifiers.cmdCtrl}
onCheckedChange={(checked) =>
handleModifierChange('cmdCtrl', !!checked, key)
}
className="h-3.5 w-3.5"
/>
<Label
htmlFor={`mod-cmd-${key}`}
className="text-xs text-muted-foreground cursor-pointer"
>
{isMac ? '⌘' : 'Ctrl'}
</Label>
</div>
<div className="flex items-center gap-1">
<Checkbox
id={`mod-alt-${key}`}
checked={modifiers.alt}
onCheckedChange={(checked) =>
handleModifierChange('alt', !!checked, key)
}
className="h-3.5 w-3.5"
/>
<Label
htmlFor={`mod-alt-${key}`}
className="text-xs text-muted-foreground cursor-pointer"
>
{isMac ? '⌥' : 'Alt'}
</Label>
</div>
<div className="flex items-center gap-1">
<Checkbox
id={`mod-shift-${key}`}
checked={modifiers.shift}
onCheckedChange={(checked) =>
handleModifierChange('shift', !!checked, key)
}
className="h-3.5 w-3.5"
/>
<Label
htmlFor={`mod-shift-${key}`}
className="text-xs text-muted-foreground cursor-pointer"
>
</Label>
</div>
</div>
<span className="text-muted-foreground">+</span>
<Input
value={keyValue}
onChange={(e) => handleKeyChange(e.target.value, key)}
onKeyDown={handleKeyDown}
className={cn(
'w-12 h-7 text-center font-mono text-xs uppercase',
shortcutError && 'border-red-500 focus-visible:ring-red-500'
)}
placeholder="Key"
maxLength={1}
autoFocus
data-testid={`edit-shortcut-input-${key}`}
/>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 hover:bg-green-500/20 hover:text-green-400"
onClick={(e) => {
e.stopPropagation();
handleSaveShortcut();
}}
disabled={!!shortcutError || !keyValue}
data-testid={`save-shortcut-${key}`}
>
<CheckCircle2 className="w-4 h-4" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 hover:bg-red-500/20 hover:text-red-400"
onClick={(e) => {
e.stopPropagation();
handleCancelEdit();
}}
data-testid={`cancel-shortcut-${key}`}
>
<X className="w-4 h-4" />
</Button>
</div>
) : (
<>
<kbd
className={cn(
'px-2 py-1 text-xs font-mono rounded border',
colors.bg,
colors.border,
colors.text
)}
>
{formatShortcut(value, true)}
</kbd>
{isModified && editable && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0 hover:bg-yellow-500/20 hover:text-yellow-400"
onClick={(e) => {
e.stopPropagation();
handleResetShortcut(key);
}}
data-testid={`reset-shortcut-${key}`}
>
<RotateCcw className="w-3 h-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Reset to default ({DEFAULT_KEYBOARD_SHORTCUTS[key]})
</TooltipContent>
</Tooltip>
)}
{isModified && !editable && (
<span className="w-2 h-2 rounded-full bg-yellow-500" />
)}
{editable && !isModified && (
<Edit2 className="w-3 h-3 text-muted-foreground opacity-0 group-hover:opacity-100" />
)}
</>
)}
</div>
</div>
);
})}
</div>
{editingShortcut &&
shortcutError &&
SHORTCUT_CATEGORIES[editingShortcut] === category && (
<p className="text-xs text-red-400 mt-1">{shortcutError}</p>
)}
<TooltipProvider>
<div className="space-y-4" data-testid="shortcut-reference-panel">
{editable && (
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
onClick={() => resetKeyboardShortcuts()}
className="gap-2 text-xs"
data-testid="reset-all-shortcuts-button"
>
<RotateCcw className="w-3 h-3" />
Reset All to Defaults
</Button>
</div>
);
})}
</div>
)}
{Object.entries(groupedShortcuts).map(([category, shortcuts]) => {
const colors = CATEGORY_COLORS[category as keyof typeof CATEGORY_COLORS];
return (
<div key={category} className="space-y-2">
<h4 className={cn('text-sm font-semibold', colors.text)}>{colors.label}</h4>
<div className="grid grid-cols-2 gap-2">
{shortcuts.map(({ key, label, value }) => {
const isModified = mergedShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key];
const isEditing = editingShortcut === key;
return (
<div
key={key}
className={cn(
'flex items-center justify-between p-2 rounded-lg bg-sidebar-accent/10 border transition-colors',
isEditing ? 'border-brand-500' : 'border-sidebar-border',
editable && !isEditing && 'hover:bg-sidebar-accent/20 cursor-pointer'
)}
onClick={() => editable && !isEditing && handleStartEdit(key)}
data-testid={`shortcut-row-${key}`}
>
<span className="text-sm text-foreground">{label}</span>
<div className="flex items-center gap-2">
{isEditing ? (
<div
className="flex items-center gap-2"
onClick={(e) => e.stopPropagation()}
>
{/* Modifier checkboxes */}
<div className="flex items-center gap-1.5 text-xs">
<div className="flex items-center gap-1">
<Checkbox
id={`mod-cmd-${key}`}
checked={modifiers.cmdCtrl}
onCheckedChange={(checked) =>
handleModifierChange('cmdCtrl', !!checked, key)
}
className="h-3.5 w-3.5"
/>
<Label
htmlFor={`mod-cmd-${key}`}
className="text-xs text-muted-foreground cursor-pointer"
>
{isMac ? '⌘' : 'Ctrl'}
</Label>
</div>
<div className="flex items-center gap-1">
<Checkbox
id={`mod-alt-${key}`}
checked={modifiers.alt}
onCheckedChange={(checked) =>
handleModifierChange('alt', !!checked, key)
}
className="h-3.5 w-3.5"
/>
<Label
htmlFor={`mod-alt-${key}`}
className="text-xs text-muted-foreground cursor-pointer"
>
{isMac ? '⌥' : 'Alt'}
</Label>
</div>
<div className="flex items-center gap-1">
<Checkbox
id={`mod-shift-${key}`}
checked={modifiers.shift}
onCheckedChange={(checked) =>
handleModifierChange('shift', !!checked, key)
}
className="h-3.5 w-3.5"
/>
<Label
htmlFor={`mod-shift-${key}`}
className="text-xs text-muted-foreground cursor-pointer"
>
</Label>
</div>
</div>
<span className="text-muted-foreground">+</span>
<Input
value={keyValue}
onChange={(e) => handleKeyChange(e.target.value, key)}
onKeyDown={handleKeyDown}
className={cn(
'w-12 h-7 text-center font-mono text-xs uppercase',
shortcutError && 'border-red-500 focus-visible:ring-red-500'
)}
placeholder="Key"
maxLength={1}
autoFocus
data-testid={`edit-shortcut-input-${key}`}
/>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 hover:bg-green-500/20 hover:text-green-400"
onClick={(e) => {
e.stopPropagation();
handleSaveShortcut();
}}
disabled={!!shortcutError || !keyValue}
data-testid={`save-shortcut-${key}`}
>
<CheckCircle2 className="w-4 h-4" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 hover:bg-red-500/20 hover:text-red-400"
onClick={(e) => {
e.stopPropagation();
handleCancelEdit();
}}
data-testid={`cancel-shortcut-${key}`}
>
<X className="w-4 h-4" />
</Button>
</div>
) : (
<>
<kbd
className={cn(
'px-2 py-1 text-xs font-mono rounded border',
colors.bg,
colors.border,
colors.text
)}
>
{formatShortcut(value, true)}
</kbd>
{isModified && editable && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0 hover:bg-yellow-500/20 hover:text-yellow-400"
onClick={(e) => {
e.stopPropagation();
handleResetShortcut(key);
}}
data-testid={`reset-shortcut-${key}`}
>
<RotateCcw className="w-3 h-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Reset to default ({DEFAULT_KEYBOARD_SHORTCUTS[key]})
</TooltipContent>
</Tooltip>
)}
{isModified && !editable && (
<span className="w-2 h-2 rounded-full bg-yellow-500" />
)}
{editable && !isModified && (
<Edit2 className="w-3 h-3 text-muted-foreground opacity-0 group-hover:opacity-100" />
)}
</>
)}
</div>
</div>
);
})}
</div>
{editingShortcut &&
shortcutError &&
SHORTCUT_CATEGORIES[editingShortcut] === category && (
<p className="text-xs text-red-400 mt-1">{shortcutError}</p>
)}
</div>
);
})}
</div>
</TooltipProvider>
);
}

View File

@@ -1,4 +1,4 @@
import { useState, useMemo, useRef } from 'react';
import { useState, useMemo, useEffect, useRef } from 'react';
import {
ChevronDown,
ChevronRight,
@@ -21,6 +21,7 @@ import {
X,
Filter,
Circle,
Play,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';

View File

@@ -116,8 +116,9 @@ const PROVIDER_ICON_DEFINITIONS: Record<ProviderIconKey, ProviderIconDefinition>
},
copilot: {
viewBox: '0 0 98 96',
// Official GitHub Octocat logo mark (theme-aware via currentColor)
// Official GitHub Octocat logo mark
path: 'M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z',
fill: '#ffffff',
},
};

View File

@@ -1,8 +1,7 @@
import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
export type SpinnerSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
export type SpinnerVariant = 'primary' | 'foreground' | 'muted';
type SpinnerSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
const sizeClasses: Record<SpinnerSize, string> = {
xs: 'h-3 w-3',
@@ -12,17 +11,9 @@ const sizeClasses: Record<SpinnerSize, string> = {
xl: 'h-8 w-8',
};
const variantClasses: Record<SpinnerVariant, string> = {
primary: 'text-primary',
foreground: 'text-primary-foreground',
muted: 'text-muted-foreground',
};
interface SpinnerProps {
/** Size of the spinner */
size?: SpinnerSize;
/** Color variant - use 'foreground' when on primary backgrounds */
variant?: SpinnerVariant;
/** Additional class names */
className?: string;
}
@@ -30,12 +21,11 @@ interface SpinnerProps {
/**
* Themed spinner component using the primary brand color.
* Use this for all loading indicators throughout the app for consistency.
* Use variant='foreground' when placing on primary-colored backgrounds.
*/
export function Spinner({ size = 'md', variant = 'primary', className }: SpinnerProps) {
export function Spinner({ size = 'md', className }: SpinnerProps) {
return (
<Loader2
className={cn(sizeClasses[size], 'animate-spin', variantClasses[variant], className)}
className={cn(sizeClasses[size], 'animate-spin text-primary', className)}
aria-hidden="true"
/>
);

View File

@@ -9,7 +9,6 @@ import { Check, Circle, ChevronDown, ChevronRight, FileCode } from 'lucide-react
import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron';
import type { AutoModeEvent } from '@/types/electron';
import type { Feature, ParsedTask } from '@automaker/types';
import { Badge } from '@/components/ui/badge';
interface TaskInfo {
@@ -37,7 +36,7 @@ export function TaskProgressPanel({
const [tasks, setTasks] = useState<TaskInfo[]>([]);
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
const [isLoading, setIsLoading] = useState(true);
const [, setCurrentTaskId] = useState<string | null>(null);
const [currentTaskId, setCurrentTaskId] = useState<string | null>(null);
// Load initial tasks from feature's planSpec
const loadInitialTasks = useCallback(async () => {
@@ -54,29 +53,26 @@ export function TaskProgressPanel({
}
const result = await api.features.get(projectPath, featureId);
const feature = (result as { success: boolean; feature?: Feature }).feature;
const feature: any = (result as any).feature;
if (result.success && feature?.planSpec?.tasks) {
const planSpec = feature.planSpec;
const planTasks = planSpec.tasks; // Already guarded by the if condition above
const planSpec = feature.planSpec as any;
const planTasks = planSpec.tasks;
const currentId = planSpec.currentTaskId;
const completedCount = planSpec.tasksCompleted || 0;
// Convert planSpec tasks to TaskInfo with proper status
// planTasks is guaranteed to be defined due to the if condition check
const initialTasks: TaskInfo[] = (planTasks as ParsedTask[]).map(
(t: ParsedTask, index: number) => ({
id: t.id,
description: t.description,
filePath: t.filePath,
phase: t.phase,
status:
index < completedCount
? ('completed' as const)
: t.id === currentId
? ('in_progress' as const)
: ('pending' as const),
})
);
const initialTasks: TaskInfo[] = planTasks.map((t: any, index: number) => ({
id: t.id,
description: t.description,
filePath: t.filePath,
phase: t.phase,
status:
index < completedCount
? ('completed' as const)
: t.id === currentId
? ('in_progress' as const)
: ('pending' as const),
}));
setTasks(initialTasks);
setCurrentTaskId(currentId || null);
@@ -240,7 +236,7 @@ export function TaskProgressPanel({
<div className="absolute left-[2.35rem] top-4 bottom-8 w-px bg-linear-to-b from-border/80 via-border/40 to-transparent" />
<div className="space-y-5">
{tasks.map((task, _index) => {
{tasks.map((task, index) => {
const isActive = task.status === 'in_progress';
const isCompleted = task.status === 'completed';
const isPending = task.status === 'pending';
@@ -265,7 +261,7 @@ export function TaskProgressPanel({
)}
>
{isCompleted && <Check className="h-3.5 w-3.5" />}
{isActive && <Spinner size="xs" variant="foreground" />}
{isActive && <Spinner size="xs" />}
{isPending && <Circle className="h-2 w-2 fill-current opacity-50" />}
</div>

View File

@@ -25,6 +25,8 @@ type UsageError = {
message: string;
};
// Fixed refresh interval (45 seconds)
const REFRESH_INTERVAL_SECONDS = 45;
const CLAUDE_SESSION_WINDOW_HOURS = 5;
// Helper to format reset time for Codex
@@ -227,6 +229,15 @@ export function UsagePopover() {
// Calculate max percentage for header button
const claudeSessionPercentage = claudeUsage?.sessionPercentage || 0;
const codexMaxPercentage = codexUsage?.rateLimits
? Math.max(
codexUsage.rateLimits.primary?.usedPercent || 0,
codexUsage.rateLimits.secondary?.usedPercent || 0
)
: 0;
const isStale = activeTab === 'claude' ? isClaudeStale : isCodexStale;
const getProgressBarColor = (percentage: number) => {
if (percentage >= 80) return 'bg-red-500';
if (percentage >= 50) return 'bg-yellow-500';

View File

@@ -5,7 +5,17 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/com
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Terminal, CheckCircle, XCircle, Play, File, Pencil, Wrench } from 'lucide-react';
import {
FileText,
FolderOpen,
Terminal,
CheckCircle,
XCircle,
Play,
File,
Pencil,
Wrench,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import { getElectronAPI } from '@/lib/electron';
@@ -19,6 +29,13 @@ interface ToolResult {
timestamp: Date;
}
interface ToolExecution {
tool: string;
input: string;
result: ToolResult | null;
isRunning: boolean;
}
export function AgentToolsView() {
const { currentProject } = useAppStore();
const api = getElectronAPI();

View File

@@ -63,6 +63,7 @@ export function AgentView() {
sendMessage,
clearHistory,
stopExecution,
error: agentError,
serverQueue,
addToServerQueue,
removeFromServerQueue,

View File

@@ -1,7 +1,7 @@
import { useCallback, useState } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { useQueryClient } from '@tanstack/react-query';
import { useAppStore, FileTreeNode, ProjectAnalysis, Feature } from '@/store/app-store';
import { useAppStore, FileTreeNode, ProjectAnalysis } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import { queryKeys } from '@/lib/query-keys';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -640,14 +640,14 @@ ${Object.entries(projectAnalysis.filesByExtension)
}
for (const detectedFeature of detectedFeatures) {
const newFeature: Feature = {
await api.features.create(currentProject.path, {
id: generateUUID(),
category: detectedFeature.category,
description: detectedFeature.description,
status: 'backlog',
// Initialize with empty steps so the object satisfies the Feature type
steps: [],
};
await api.features.create(currentProject.path, newFeature);
} as any);
}
// Invalidate React Query cache to sync UI

View File

@@ -1,6 +1,6 @@
import { useEffect, useState, useCallback, useMemo } from 'react';
// @ts-nocheck
import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
import { createLogger } from '@automaker/utils/logger';
import type { PointerEvent as ReactPointerEvent } from 'react';
import {
DndContext,
PointerSensor,
@@ -8,8 +8,7 @@ import {
useSensors,
rectIntersection,
pointerWithin,
type CollisionDetection,
type Collision,
type PointerEvent as DndPointerEvent,
} from '@dnd-kit/core';
// Custom pointer sensor that ignores drag events from within dialogs
@@ -17,7 +16,7 @@ class DialogAwarePointerSensor extends PointerSensor {
static activators = [
{
eventName: 'onPointerDown' as const,
handler: ({ nativeEvent: event }: ReactPointerEvent) => {
handler: ({ nativeEvent: event }: { nativeEvent: DndPointerEvent }) => {
// Don't start drag if the event originated from inside a dialog
if ((event.target as Element)?.closest?.('[role="dialog"]')) {
return false;
@@ -30,13 +29,16 @@ class DialogAwarePointerSensor extends PointerSensor {
import { useAppStore, Feature } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import { getHttpApiClient } from '@/lib/http-api-client';
import type { BacklogPlanResult, FeatureStatusWithPipeline } from '@automaker/types';
import type { AutoModeEvent } from '@/types/electron';
import type { ModelAlias, CursorModelId, BacklogPlanResult } from '@automaker/types';
import { pathsEqual } from '@/lib/utils';
import { toast } from 'sonner';
import { getBlockingDependencies } from '@automaker/dependency-resolver';
import { BoardBackgroundModal } from '@/components/dialogs/board-background-modal';
import { Spinner } from '@/components/ui/spinner';
import { useShallow } from 'zustand/react/shallow';
import { useAutoMode } from '@/hooks/use-auto-mode';
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
import { useWindowState } from '@/hooks/use-window-state';
// Board-view specific imports
import { BoardHeader } from './board-view/board-header';
@@ -95,6 +97,8 @@ const logger = createLogger('Board');
export function BoardView() {
const {
currentProject,
maxConcurrency: legacyMaxConcurrency,
setMaxConcurrency: legacySetMaxConcurrency,
defaultSkipTests,
specCreatingForProject,
setSpecCreatingForProject,
@@ -105,6 +109,9 @@ export function BoardView() {
setCurrentWorktree,
getWorktrees,
setWorktrees,
useWorktrees,
enableDependencyBlocking,
skipVerificationInAutoMode,
planUseSelectedWorktreeBranch,
addFeatureUseSelectedWorktreeBranch,
isPrimaryWorktreeBranch,
@@ -113,6 +120,8 @@ export function BoardView() {
} = useAppStore(
useShallow((state) => ({
currentProject: state.currentProject,
maxConcurrency: state.maxConcurrency,
setMaxConcurrency: state.setMaxConcurrency,
defaultSkipTests: state.defaultSkipTests,
specCreatingForProject: state.specCreatingForProject,
setSpecCreatingForProject: state.setSpecCreatingForProject,
@@ -123,6 +132,9 @@ export function BoardView() {
setCurrentWorktree: state.setCurrentWorktree,
getWorktrees: state.getWorktrees,
setWorktrees: state.setWorktrees,
useWorktrees: state.useWorktrees,
enableDependencyBlocking: state.enableDependencyBlocking,
skipVerificationInAutoMode: state.skipVerificationInAutoMode,
planUseSelectedWorktreeBranch: state.planUseSelectedWorktreeBranch,
addFeatureUseSelectedWorktreeBranch: state.addFeatureUseSelectedWorktreeBranch,
isPrimaryWorktreeBranch: state.isPrimaryWorktreeBranch,
@@ -139,9 +151,12 @@ export function BoardView() {
// Subscribe to worktreePanelVisibleByProject to trigger re-renders when it changes
const worktreePanelVisibleByProject = useAppStore((state) => state.worktreePanelVisibleByProject);
// Subscribe to showInitScriptIndicatorByProject to trigger re-renders when it changes
useAppStore((state) => state.showInitScriptIndicatorByProject);
const showInitScriptIndicatorByProject = useAppStore(
(state) => state.showInitScriptIndicatorByProject
);
const getShowInitScriptIndicator = useAppStore((state) => state.getShowInitScriptIndicator);
const getDefaultDeleteBranch = useAppStore((state) => state.getDefaultDeleteBranch);
const shortcuts = useKeyboardShortcutsConfig();
const {
features: hookFeatures,
isLoading,
@@ -172,9 +187,13 @@ export function BoardView() {
const [showCreatePRDialog, setShowCreatePRDialog] = useState(false);
const [showCreateBranchDialog, setShowCreateBranchDialog] = useState(false);
const [showPullResolveConflictsDialog, setShowPullResolveConflictsDialog] = useState(false);
const [selectedWorktreeForAction, setSelectedWorktreeForAction] = useState<WorktreeInfo | null>(
null
);
const [selectedWorktreeForAction, setSelectedWorktreeForAction] = useState<{
path: string;
branch: string;
isMain: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
} | null>(null);
const [worktreeRefreshKey, setWorktreeRefreshKey] = useState(0);
// Backlog plan dialog state
@@ -345,12 +364,12 @@ export function BoardView() {
}, [currentProject, worktreeRefreshKey]);
// Custom collision detection that prioritizes specific drop targets (cards, worktrees) over columns
const collisionDetectionStrategy = useCallback((args: Parameters<CollisionDetection>[0]) => {
const collisionDetectionStrategy = useCallback((args: any) => {
const pointerCollisions = pointerWithin(args);
// Priority 1: Specific drop targets (cards for dependency links, worktrees)
// These need to be detected even if they are inside a column
const specificTargetCollisions = pointerCollisions.filter((collision: Collision) => {
const specificTargetCollisions = pointerCollisions.filter((collision: any) => {
const id = String(collision.id);
return id.startsWith('card-drop-') || id.startsWith('worktree-drop-');
});
@@ -360,7 +379,7 @@ export function BoardView() {
}
// Priority 2: Columns
const columnCollisions = pointerCollisions.filter((collision: Collision) =>
const columnCollisions = pointerCollisions.filter((collision: any) =>
COLUMNS.some((col) => col.id === collision.id)
);
@@ -414,29 +433,19 @@ export function BoardView() {
// Get the branch for the currently selected worktree
// Find the worktree that matches the current selection, or use main worktree
const selectedWorktree = useMemo((): WorktreeInfo | undefined => {
let found;
const selectedWorktree = useMemo(() => {
if (currentWorktreePath === null) {
// Primary worktree selected - find the main worktree
found = worktrees.find((w) => w.isMain);
return worktrees.find((w) => w.isMain);
} else {
// Specific worktree selected - find it by path
found = worktrees.find((w) => !w.isMain && pathsEqual(w.path, currentWorktreePath));
return worktrees.find((w) => !w.isMain && pathsEqual(w.path, currentWorktreePath));
}
if (!found) return undefined;
// Ensure all required WorktreeInfo fields are present
return {
...found,
isCurrent:
found.isCurrent ??
(currentWorktreePath !== null ? pathsEqual(found.path, currentWorktreePath) : found.isMain),
hasWorktree: found.hasWorktree ?? true,
};
}, [worktrees, currentWorktreePath]);
// Auto mode hook - pass current worktree to get worktree-specific state
// Must be after selectedWorktree is defined
const autoMode = useAutoMode(selectedWorktree);
const autoMode = useAutoMode(selectedWorktree ?? undefined);
// Get runningTasks from the hook (scoped to current project/worktree)
const runningAutoTasks = autoMode.runningTasks;
// Get worktree-specific maxConcurrency from the hook
@@ -454,16 +463,6 @@ export function BoardView() {
const selectedWorktreeBranch =
currentWorktreeBranch || worktrees.find((w) => w.isMain)?.branch || 'main';
// Aggregate running auto tasks across all worktrees for this project
const autoModeByWorktree = useAppStore((state) => state.autoModeByWorktree);
const runningAutoTasksAllWorktrees = useMemo(() => {
if (!currentProject?.id) return [];
const prefix = `${currentProject.id}::`;
return Object.entries(autoModeByWorktree)
.filter(([key]) => key.startsWith(prefix))
.flatMap(([, state]) => state.runningTasks ?? []);
}, [autoModeByWorktree, currentProject?.id]);
// Get in-progress features for keyboard shortcuts (needed before actions hook)
// Must be after runningAutoTasks is defined
const inProgressFeaturesForShortcuts = useMemo(() => {
@@ -526,6 +525,8 @@ export function BoardView() {
handleMoveBackToInProgress,
handleOpenFollowUp,
handleSendFollowUp,
handleCommitFeature,
handleMergeFeature,
handleCompleteFeature,
handleUnarchiveFeature,
handleViewOutput,
@@ -965,27 +966,28 @@ export function BoardView() {
const api = getElectronAPI();
if (!api?.backlogPlan) return;
const unsubscribe = api.backlogPlan.onEvent((data: unknown) => {
const event = data as { type: string; result?: BacklogPlanResult; error?: string };
if (event.type === 'backlog_plan_complete') {
setIsGeneratingPlan(false);
if (event.result && event.result.changes?.length > 0) {
setPendingBacklogPlan(event.result);
toast.success('Plan ready! Click to review.', {
duration: 10000,
action: {
label: 'Review',
onClick: () => setShowPlanDialog(true),
},
});
} else {
toast.info('No changes generated. Try again with a different prompt.');
const unsubscribe = api.backlogPlan.onEvent(
(event: { type: string; result?: BacklogPlanResult; error?: string }) => {
if (event.type === 'backlog_plan_complete') {
setIsGeneratingPlan(false);
if (event.result && event.result.changes?.length > 0) {
setPendingBacklogPlan(event.result);
toast.success('Plan ready! Click to review.', {
duration: 10000,
action: {
label: 'Review',
onClick: () => setShowPlanDialog(true),
},
});
} else {
toast.info('No changes generated. Try again with a different prompt.');
}
} else if (event.type === 'backlog_plan_error') {
setIsGeneratingPlan(false);
toast.error(`Plan generation failed: ${event.error}`);
}
} else if (event.type === 'backlog_plan_error') {
setIsGeneratingPlan(false);
toast.error(`Plan generation failed: ${event.error}`);
}
});
);
return unsubscribe;
}, []);
@@ -1097,10 +1099,10 @@ export function BoardView() {
// Build columnFeaturesMap for ListView
// pipelineConfig is now from usePipelineConfig React Query hook at the top
const columnFeaturesMap = useMemo(() => {
const columns = getColumnsWithPipeline(pipelineConfig ?? null);
const columns = getColumnsWithPipeline(pipelineConfig);
const map: Record<string, typeof hookFeatures> = {};
for (const column of columns) {
map[column.id] = getColumnFeatures(column.id as FeatureStatusWithPipeline);
map[column.id] = getColumnFeatures(column.id as any);
}
return map;
}, [pipelineConfig, getColumnFeatures]);
@@ -1370,7 +1372,7 @@ export function BoardView() {
setWorktreeRefreshKey((k) => k + 1);
}}
onRemovedWorktrees={handleRemovedWorktrees}
runningFeatureIds={runningAutoTasksAllWorktrees}
runningFeatureIds={runningAutoTasks}
branchCardCounts={branchCardCounts}
features={hookFeatures.map((f) => ({
id: f.id,
@@ -1450,13 +1452,14 @@ export function BoardView() {
onAddFeature={() => setShowAddDialog(true)}
onShowCompletedModal={() => setShowCompletedModal(true)}
completedCount={completedFeatures.length}
pipelineConfig={pipelineConfig ?? null}
pipelineConfig={pipelineConfig}
onOpenPipelineSettings={() => setShowPipelineSettings(true)}
isSelectionMode={isSelectionMode}
selectionTarget={selectionTarget}
selectedFeatureIds={selectedFeatureIds}
onToggleFeatureSelection={toggleFeatureSelection}
onToggleSelectionMode={toggleSelectionMode}
viewMode={viewMode}
isDragging={activeFeature !== null}
onAiSuggest={() => setShowPlanDialog(true)}
className="transition-opacity duration-200"
@@ -1609,7 +1612,7 @@ export function BoardView() {
open={showPipelineSettings}
onClose={() => setShowPipelineSettings(false)}
projectPath={currentProject.path}
pipelineConfig={pipelineConfig ?? null}
pipelineConfig={pipelineConfig}
onSave={async (config) => {
const api = getHttpApiClient();
const result = await api.pipeline.saveConfig(currentProject.path, config);

View File

@@ -1,4 +1,4 @@
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { ImageIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
@@ -18,22 +18,24 @@ export function BoardControls({ isMounted, onShowBoardBackground }: BoardControl
);
return (
<div className="flex items-center gap-2">
{/* Board Background Button */}
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onShowBoardBackground}
className={buttonClass}
data-testid="board-background-button"
>
<ImageIcon className="w-4 h-4" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Board Background Settings</p>
</TooltipContent>
</Tooltip>
</div>
<TooltipProvider>
<div className="flex items-center gap-2">
{/* Board Background Button */}
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onShowBoardBackground}
className={buttonClass}
data-testid="board-background-button"
>
<ImageIcon className="w-4 h-4" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Board Background Settings</p>
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
);
}

View File

@@ -1,7 +1,13 @@
import { memo, useEffect, useState, useMemo, useRef } from 'react';
import { Feature, ThinkingLevel, ReasoningEffort, ParsedTask } from '@/store/app-store';
import { Feature, ThinkingLevel, ParsedTask } from '@/store/app-store';
import type { ReasoningEffort } from '@automaker/types';
import { getProviderFromModel } from '@/lib/utils';
import { parseAgentContext, formatModelName, DEFAULT_MODEL } from '@/lib/agent-context-parser';
import {
AgentTaskInfo,
parseAgentContext,
formatModelName,
DEFAULT_MODEL,
} from '@/lib/agent-context-parser';
import { cn } from '@/lib/utils';
import type { AutoModeEvent } from '@/types/electron';
import { Brain, ListTodo, Sparkles, Expand, CheckCircle2, Circle, Wrench } from 'lucide-react';
@@ -289,8 +295,7 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
// Agent Info Panel for non-backlog cards
// Show panel if we have agentInfo OR planSpec.tasks (for spec/full mode)
// Note: hasPlanSpecTasks is already defined above and includes freshPlanSpec
// (The backlog case was already handled above and returned early)
if (agentInfo || hasPlanSpecTasks) {
if (feature.status !== 'backlog' && (agentInfo || hasPlanSpecTasks)) {
return (
<>
<div className="mb-3 space-y-2 overflow-hidden">

View File

@@ -1,4 +1,4 @@
// @ts-nocheck - optional callback prop typing with feature status narrowing
// @ts-nocheck
import { memo } from 'react';
import { Feature } from '@/store/app-store';
import { Button } from '@/components/ui/button';
@@ -36,7 +36,7 @@ interface CardActionsProps {
export const CardActions = memo(function CardActions({
feature,
isCurrentAutoTask,
hasContext: _hasContext,
hasContext,
shortcutKey,
isSelectionMode = false,
onEdit,

View File

@@ -1,8 +1,8 @@
// @ts-nocheck - badge component prop variations with conditional rendering
// @ts-nocheck
import { memo, useEffect, useMemo, useState } from 'react';
import { Feature, useAppStore } from '@/store/app-store';
import { cn } from '@/lib/utils';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { AlertCircle, Lock, Hand, Sparkles, SkipForward } from 'lucide-react';
import { getBlockingDependencies } from '@automaker/dependency-resolver';
import { useShallow } from 'zustand/react/shallow';
@@ -28,22 +28,24 @@ export const CardBadges = memo(function CardBadges({ feature }: CardBadgesProps)
return (
<div className="flex flex-wrap items-center gap-1.5 px-3 pt-1.5 min-h-[24px]">
{/* Error badge */}
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
uniformBadgeClass,
'bg-[var(--status-error-bg)] border-[var(--status-error)]/40 text-[var(--status-error)]'
)}
data-testid={`error-badge-${feature.id}`}
>
<AlertCircle className="w-3.5 h-3.5" />
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs max-w-[250px]">
<p>{feature.error}</p>
</TooltipContent>
</Tooltip>
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
uniformBadgeClass,
'bg-[var(--status-error-bg)] border-[var(--status-error)]/40 text-[var(--status-error)]'
)}
data-testid={`error-badge-${feature.id}`}
>
<AlertCircle className="w-3.5 h-3.5" />
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs max-w-[250px]">
<p>{feature.error}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
});
@@ -98,11 +100,13 @@ export const PriorityBadges = memo(function PriorityBadges({
return;
}
// eslint-disable-next-line no-undef
const interval = setInterval(() => {
setCurrentTime(Date.now());
}, 1000);
return () => {
// eslint-disable-next-line no-undef
clearInterval(interval);
};
}, [feature.justFinishedAt, feature.status, currentTime]);
@@ -134,137 +138,147 @@ export const PriorityBadges = memo(function PriorityBadges({
<div className="absolute top-2 left-2 flex items-center gap-1">
{/* Priority badge */}
{feature.priority && (
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
uniformBadgeClass,
feature.priority === 1 &&
'bg-[var(--status-error-bg)] border-[var(--status-error)]/40 text-[var(--status-error)]',
feature.priority === 2 &&
'bg-[var(--status-warning-bg)] border-[var(--status-warning)]/40 text-[var(--status-warning)]',
feature.priority === 3 &&
'bg-[var(--status-info-bg)] border-[var(--status-info)]/40 text-[var(--status-info)]'
)}
data-testid={`priority-badge-${feature.id}`}
>
<span className="font-bold text-xs">
{feature.priority === 1 ? 'H' : feature.priority === 2 ? 'M' : 'L'}
</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
<p>
{feature.priority === 1
? 'High Priority'
: feature.priority === 2
? 'Medium Priority'
: 'Low Priority'}
</p>
</TooltipContent>
</Tooltip>
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
uniformBadgeClass,
feature.priority === 1 &&
'bg-[var(--status-error-bg)] border-[var(--status-error)]/40 text-[var(--status-error)]',
feature.priority === 2 &&
'bg-[var(--status-warning-bg)] border-[var(--status-warning)]/40 text-[var(--status-warning)]',
feature.priority === 3 &&
'bg-[var(--status-info-bg)] border-[var(--status-info)]/40 text-[var(--status-info)]'
)}
data-testid={`priority-badge-${feature.id}`}
>
<span className="font-bold text-xs">
{feature.priority === 1 ? 'H' : feature.priority === 2 ? 'M' : 'L'}
</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
<p>
{feature.priority === 1
? 'High Priority'
: feature.priority === 2
? 'Medium Priority'
: 'Low Priority'}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Manual verification badge */}
{showManualVerification && (
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
uniformBadgeClass,
'bg-[var(--status-warning-bg)] border-[var(--status-warning)]/40 text-[var(--status-warning)]'
)}
data-testid={`skip-tests-badge-${feature.id}`}
>
<Hand className="w-3.5 h-3.5" />
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
<p>Manual verification required</p>
</TooltipContent>
</Tooltip>
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
uniformBadgeClass,
'bg-[var(--status-warning-bg)] border-[var(--status-warning)]/40 text-[var(--status-warning)]'
)}
data-testid={`skip-tests-badge-${feature.id}`}
>
<Hand className="w-3.5 h-3.5" />
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
<p>Manual verification required</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Blocked badge */}
{isBlocked && (
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
uniformBadgeClass,
'bg-orange-500/20 border-orange-500/50 text-orange-500'
)}
data-testid={`blocked-badge-${feature.id}`}
>
<Lock className="w-3.5 h-3.5" />
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs max-w-[250px]">
<p className="font-medium mb-1">
Blocked by {blockingDependencies.length} incomplete{' '}
{blockingDependencies.length === 1 ? 'dependency' : 'dependencies'}
</p>
<p className="text-muted-foreground">
{blockingDependencies
.map((depId) => {
const dep = features.find((f) => f.id === depId);
return dep?.description || depId;
})
.join(', ')}
</p>
</TooltipContent>
</Tooltip>
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
uniformBadgeClass,
'bg-orange-500/20 border-orange-500/50 text-orange-500'
)}
data-testid={`blocked-badge-${feature.id}`}
>
<Lock className="w-3.5 h-3.5" />
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs max-w-[250px]">
<p className="font-medium mb-1">
Blocked by {blockingDependencies.length} incomplete{' '}
{blockingDependencies.length === 1 ? 'dependency' : 'dependencies'}
</p>
<p className="text-muted-foreground">
{blockingDependencies
.map((depId) => {
const dep = features.find((f) => f.id === depId);
return dep?.description || depId;
})
.join(', ')}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Just Finished badge */}
{isJustFinished && (
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
uniformBadgeClass,
'bg-[var(--status-success-bg)] border-[var(--status-success)]/40 text-[var(--status-success)] animate-pulse'
)}
data-testid={`just-finished-badge-${feature.id}`}
>
<Sparkles className="w-3.5 h-3.5" />
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
<p>Agent just finished working on this feature</p>
</TooltipContent>
</Tooltip>
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
uniformBadgeClass,
'bg-[var(--status-success-bg)] border-[var(--status-success)]/40 text-[var(--status-success)] animate-pulse'
)}
data-testid={`just-finished-badge-${feature.id}`}
>
<Sparkles className="w-3.5 h-3.5" />
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
<p>Agent just finished working on this feature</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Pipeline exclusion badge */}
{hasPipelineExclusions && (
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
uniformBadgeClass,
allPipelinesExcluded
? 'bg-violet-500/20 border-violet-500/50 text-violet-500'
: 'bg-violet-500/10 border-violet-500/30 text-violet-400'
)}
data-testid={`pipeline-exclusion-badge-${feature.id}`}
>
<SkipForward className="w-3.5 h-3.5" />
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs max-w-[250px]">
<p className="font-medium mb-1">
{allPipelinesExcluded
? 'All pipelines skipped'
: `${excludedStepCount} of ${totalPipelineSteps} pipeline${totalPipelineSteps !== 1 ? 's' : ''} skipped`}
</p>
<p className="text-muted-foreground">
{allPipelinesExcluded
? 'This feature will skip all custom pipeline steps'
: 'Some custom pipeline steps will be skipped for this feature'}
</p>
</TooltipContent>
</Tooltip>
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
uniformBadgeClass,
allPipelinesExcluded
? 'bg-violet-500/20 border-violet-500/50 text-violet-500'
: 'bg-violet-500/10 border-violet-500/30 text-violet-400'
)}
data-testid={`pipeline-exclusion-badge-${feature.id}`}
>
<SkipForward className="w-3.5 h-3.5" />
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs max-w-[250px]">
<p className="font-medium mb-1">
{allPipelinesExcluded
? 'All pipelines skipped'
: `${excludedStepCount} of ${totalPipelineSteps} pipeline${totalPipelineSteps !== 1 ? 's' : ''} skipped`}
</p>
<p className="text-muted-foreground">
{allPipelinesExcluded
? 'This feature will skip all custom pipeline steps'
: 'Some custom pipeline steps will be skipped for this feature'}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
);

View File

@@ -1,4 +1,4 @@
// @ts-nocheck - content section prop typing with feature data extraction
// @ts-nocheck
import { memo } from 'react';
import { Feature } from '@/store/app-store';
import { GitBranch, GitPullRequest, ExternalLink } from 'lucide-react';

View File

@@ -1,4 +1,4 @@
// @ts-nocheck - header component props with optional handlers and status variants
// @ts-nocheck
import { memo, useState } from 'react';
import { Feature } from '@/store/app-store';
import { cn } from '@/lib/utils';

View File

@@ -1,4 +1,4 @@
// @ts-nocheck - dnd-kit draggable/droppable ref combination type incompatibilities
// @ts-nocheck
import React, { memo, useLayoutEffect, useState, useCallback } from 'react';
import { useDraggable, useDroppable } from '@dnd-kit/core';
import { cn } from '@/lib/utils';

View File

@@ -1,4 +1,4 @@
// @ts-nocheck - dialog state typing with feature summary extraction
// @ts-nocheck
import { Feature } from '@/store/app-store';
import { AgentTaskInfo } from '@/lib/agent-context-parser';
import {

View File

@@ -78,9 +78,7 @@ export const KanbanColumn = memo(function KanbanColumn({
)}
>
<div className={cn('w-2.5 h-2.5 rounded-full shrink-0', colorClass)} />
<h3 className="font-semibold text-sm text-foreground/90 flex-1 tracking-tight whitespace-nowrap">
{title}
</h3>
<h3 className="font-semibold text-sm text-foreground/90 flex-1 tracking-tight">{title}</h3>
{headerAction}
<span className="text-xs font-medium text-muted-foreground/80 bg-muted/50 px-2 py-0.5 rounded-md tabular-nums">
{count}

View File

@@ -132,7 +132,7 @@ const SortableColumnHeader = memo(function SortableColumnHeader({
)}
data-testid={`list-header-${column.id}`}
>
<span className="whitespace-nowrap truncate">{column.label}</span>
<span>{column.label}</span>
<SortIcon column={column.id} sortConfig={sortConfig} />
</div>
);
@@ -156,7 +156,7 @@ const StaticColumnHeader = memo(function StaticColumnHeader({ column }: { column
)}
data-testid={`list-header-${column.id}`}
>
<span className="whitespace-nowrap truncate">{column.label}</span>
<span>{column.label}</span>
</div>
);
});

View File

@@ -1,7 +1,9 @@
// @ts-nocheck - BaseFeature index signature causes property access type errors
// 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, TooltipTrigger } from '@/components/ui/tooltip';
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';
@@ -147,27 +149,29 @@ const IndicatorBadges = memo(function IndicatorBadges({
return (
<div className="flex items-center gap-1 ml-2">
{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 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>
);
});

View File

@@ -8,7 +8,7 @@ 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 { getStatusOrder } from './status-badge';
import { getStatusLabel, getStatusOrder } from './status-badge';
import { getColumnsWithPipeline } from '../../constants';
import type { SortConfig, SortColumn } from '../../hooks/use-list-view-state';

View File

@@ -1,5 +1,6 @@
// @ts-nocheck - feature data building with conditional fields and model type inference
// @ts-nocheck
import { useState, useEffect, useRef } from 'react';
import { createLogger } from '@automaker/utils/logger';
import {
Dialog,
DialogContent,
@@ -26,10 +27,18 @@ import { useNavigate } from '@tanstack/react-router';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import { modelSupportsThinking } from '@/lib/utils';
import { useAppStore, ThinkingLevel, FeatureImage, PlanningMode, Feature } from '@/store/app-store';
import type { ReasoningEffort, PhaseModelEntry, AgentModel } from '@automaker/types';
import { supportsReasoningEffort } from '@automaker/types';
import {
useAppStore,
ModelAlias,
ThinkingLevel,
FeatureImage,
PlanningMode,
Feature,
} from '@/store/app-store';
import type { ReasoningEffort, PhaseModelEntry, AgentModel } from '@automaker/types';
import { supportsReasoningEffort, isClaudeModel } from '@automaker/types';
import {
TestingTabContent,
PrioritySelector,
WorkModeSelector,
PlanningModeSelect,
@@ -41,13 +50,15 @@ import {
} from '../shared';
import type { WorkMode } from '../shared';
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import {
getAncestors,
formatAncestorContextForPrompt,
type AncestorContext,
} from '@automaker/dependency-resolver';
const logger = createLogger('AddFeatureDialog');
/**
* Determines the default work mode based on global settings and current worktree selection.
*
@@ -168,6 +179,9 @@ export function AddFeatureDialog({
// Model selection state
const [modelEntry, setModelEntry] = useState<PhaseModelEntry>({ model: 'claude-opus' });
// Check if current model supports planning mode (Claude/Anthropic only)
const modelSupportsPlanningMode = isClaudeModel(modelEntry.model);
// Planning mode state
const [planningMode, setPlanningMode] = useState<PlanningMode>('skip');
const [requirePlanApproval, setRequirePlanApproval] = useState(false);
@@ -256,13 +270,6 @@ export function AddFeatureDialog({
allFeatures,
]);
// Clear requirePlanApproval when planning mode is skip or lite
useEffect(() => {
if (planningMode === 'skip' || planningMode === 'lite') {
setRequirePlanApproval(false);
}
}, [planningMode]);
const handleModelChange = (entry: PhaseModelEntry) => {
setModelEntry(entry);
};
@@ -521,24 +528,26 @@ export function AddFeatureDialog({
<Cpu className="w-4 h-4 text-muted-foreground" />
<span>AI & Execution</span>
</div>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => {
onOpenChange(false);
navigate({ to: '/settings', search: { view: 'defaults' } });
}}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<Settings2 className="w-3.5 h-3.5" />
<span>Edit Defaults</span>
</button>
</TooltipTrigger>
<TooltipContent>
<p>Change default model and planning settings for new features</p>
</TooltipContent>
</Tooltip>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => {
onOpenChange(false);
navigate({ to: '/settings', search: { view: 'defaults' } });
}}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<Settings2 className="w-3.5 h-3.5" />
<span>Edit Defaults</span>
</button>
</TooltipTrigger>
<TooltipContent>
<p>Change default model and planning settings for new features</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="space-y-1.5">
@@ -553,13 +562,41 @@ export function AddFeatureDialog({
<div className="grid gap-3 grid-cols-2">
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Planning</Label>
<PlanningModeSelect
mode={planningMode}
onModeChange={setPlanningMode}
testIdPrefix="add-feature-planning"
compact
/>
<Label
className={cn(
'text-xs text-muted-foreground',
!modelSupportsPlanningMode && 'opacity-50'
)}
>
Planning
</Label>
{modelSupportsPlanningMode ? (
<PlanningModeSelect
mode={planningMode}
onModeChange={setPlanningMode}
testIdPrefix="add-feature-planning"
compact
/>
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div>
<PlanningModeSelect
mode="skip"
onModeChange={() => {}}
testIdPrefix="add-feature-planning"
compact
disabled
/>
</div>
</TooltipTrigger>
<TooltipContent>
<p>Planning modes are only available for Claude Provider</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Options</Label>
@@ -583,14 +620,20 @@ export function AddFeatureDialog({
id="add-feature-require-approval"
checked={requirePlanApproval}
onCheckedChange={(checked) => setRequirePlanApproval(!!checked)}
disabled={planningMode === 'skip' || planningMode === 'lite'}
data-testid="add-feature-planning-require-approval-checkbox"
disabled={
!modelSupportsPlanningMode ||
planningMode === 'skip' ||
planningMode === 'lite'
}
data-testid="add-feature-require-approval-checkbox"
/>
<Label
htmlFor="add-feature-require-approval"
className={cn(
'text-xs font-normal',
planningMode === 'skip' || planningMode === 'lite'
!modelSupportsPlanningMode ||
planningMode === 'skip' ||
planningMode === 'lite'
? 'cursor-not-allowed text-muted-foreground'
: 'cursor-pointer'
)}

View File

@@ -17,7 +17,6 @@ import { useAppStore } from '@/store/app-store';
import { extractSummary } from '@/lib/log-parser';
import { useAgentOutput } from '@/hooks/queries';
import type { AutoModeEvent } from '@/types/electron';
import type { BacklogPlanEvent } from '@automaker/types';
interface AgentOutputModalProps {
open: boolean;
@@ -49,16 +48,18 @@ export function AgentOutputModal({
const isBacklogPlan = featureId.startsWith('backlog-plan:');
// Resolve project path - prefer prop, fallback to window.__currentProject
const resolvedProjectPath = projectPathProp || window.__currentProject?.path || '';
const resolvedProjectPath = projectPathProp || (window as any).__currentProject?.path || '';
// Track additional content from WebSocket events (appended to query data)
const [streamedContent, setStreamedContent] = useState<string>('');
const [viewMode, setViewMode] = useState<ViewMode | null>(null);
// Use React Query for initial output loading
const { data: initialOutput = '', isLoading } = useAgentOutput(resolvedProjectPath, featureId, {
enabled: open && !!resolvedProjectPath,
});
const { data: initialOutput = '', isLoading } = useAgentOutput(
resolvedProjectPath,
featureId,
open && !!resolvedProjectPath
);
// Reset streamed content when modal opens or featureId changes
useEffect(() => {
@@ -261,8 +262,7 @@ export function AgentOutputModal({
const api = getElectronAPI();
if (!api?.backlogPlan) return;
const unsubscribe = api.backlogPlan.onEvent((data: unknown) => {
const event = data as BacklogPlanEvent;
const unsubscribe = api.backlogPlan.onEvent((event: any) => {
if (!event?.type) return;
let newContent = '';
@@ -282,7 +282,7 @@ export function AgentOutputModal({
}
if (newContent) {
setStreamedContent((prev) => prev + newContent);
setOutput((prev) => `${prev}${newContent}`);
}
});

View File

@@ -1,4 +1,4 @@
// @ts-nocheck - completed features filtering and grouping with status transitions
// @ts-nocheck
import {
Dialog,
DialogContent,

View File

@@ -1,4 +1,4 @@
// @ts-nocheck - dependency tree visualization with recursive feature relationships
// @ts-nocheck
import { useState, useEffect } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Feature } from '@/store/app-store';

View File

@@ -1,5 +1,6 @@
// @ts-nocheck - form state management with partial feature updates and validation
// @ts-nocheck
import { useState, useEffect } from 'react';
import { createLogger } from '@automaker/utils/logger';
import {
Dialog,
DialogContent,
@@ -25,10 +26,11 @@ import { GitBranch, Cpu, FolderKanban, Settings2 } from 'lucide-react';
import { useNavigate } from '@tanstack/react-router';
import { toast } from 'sonner';
import { cn, modelSupportsThinking } from '@/lib/utils';
import { Feature, ModelAlias, ThinkingLevel, PlanningMode } from '@/store/app-store';
import { Feature, ModelAlias, ThinkingLevel, useAppStore, PlanningMode } from '@/store/app-store';
import type { ReasoningEffort, PhaseModelEntry, DescriptionHistoryEntry } from '@automaker/types';
import { migrateModelId } from '@automaker/types';
import {
TestingTabContent,
PrioritySelector,
WorkModeSelector,
PlanningModeSelect,
@@ -39,9 +41,11 @@ import {
} from '../shared';
import type { WorkMode } from '../shared';
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { DependencyTreeDialog } from './dependency-tree-dialog';
import { supportsReasoningEffort } from '@automaker/types';
import { isClaudeModel, supportsReasoningEffort } from '@automaker/types';
const logger = createLogger('EditFeatureDialog');
interface EditFeatureDialogProps {
feature: Feature | null;
@@ -115,6 +119,9 @@ export function EditFeatureDialog({
reasoningEffort: feature?.reasoningEffort || 'none',
}));
// Check if current model supports planning mode (Claude/Anthropic only)
const modelSupportsPlanningMode = isClaudeModel(modelEntry.model);
// Track the source of description changes for history
const [descriptionChangeSource, setDescriptionChangeSource] = useState<
{ source: 'enhance'; mode: EnhancementMode } | 'edit' | null
@@ -187,13 +194,6 @@ export function EditFeatureDialog({
}
}, [feature, allFeatures]);
// Clear requirePlanApproval when planning mode is skip or lite
useEffect(() => {
if (planningMode === 'skip' || planningMode === 'lite') {
setRequirePlanApproval(false);
}
}, [planningMode]);
const handleModelChange = (entry: PhaseModelEntry) => {
setModelEntry(entry);
};
@@ -420,24 +420,26 @@ export function EditFeatureDialog({
<Cpu className="w-4 h-4 text-muted-foreground" />
<span>AI & Execution</span>
</div>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => {
onClose();
navigate({ to: '/settings', search: { view: 'defaults' } });
}}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<Settings2 className="w-3.5 h-3.5" />
<span>Edit Defaults</span>
</button>
</TooltipTrigger>
<TooltipContent>
<p>Change default model and planning settings for new features</p>
</TooltipContent>
</Tooltip>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => {
onClose();
navigate({ to: '/settings', search: { view: 'defaults' } });
}}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<Settings2 className="w-3.5 h-3.5" />
<span>Edit Defaults</span>
</button>
</TooltipTrigger>
<TooltipContent>
<p>Change default model and planning settings for new features</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="space-y-1.5">
@@ -452,13 +454,41 @@ export function EditFeatureDialog({
<div className="grid gap-3 grid-cols-2">
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Planning</Label>
<PlanningModeSelect
mode={planningMode}
onModeChange={setPlanningMode}
testIdPrefix="edit-feature-planning"
compact
/>
<Label
className={cn(
'text-xs text-muted-foreground',
!modelSupportsPlanningMode && 'opacity-50'
)}
>
Planning
</Label>
{modelSupportsPlanningMode ? (
<PlanningModeSelect
mode={planningMode}
onModeChange={setPlanningMode}
testIdPrefix="edit-feature-planning"
compact
/>
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div>
<PlanningModeSelect
mode="skip"
onModeChange={() => {}}
testIdPrefix="edit-feature-planning"
compact
disabled
/>
</div>
</TooltipTrigger>
<TooltipContent>
<p>Planning modes are only available for Claude Provider</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Options</Label>
@@ -484,14 +514,20 @@ export function EditFeatureDialog({
id="edit-feature-require-approval"
checked={requirePlanApproval}
onCheckedChange={(checked) => setRequirePlanApproval(!!checked)}
disabled={planningMode === 'skip' || planningMode === 'lite'}
disabled={
!modelSupportsPlanningMode ||
planningMode === 'skip' ||
planningMode === 'lite'
}
data-testid="edit-feature-require-approval-checkbox"
/>
<Label
htmlFor="edit-feature-require-approval"
className={cn(
'text-xs font-normal',
planningMode === 'skip' || planningMode === 'lite'
!modelSupportsPlanningMode ||
planningMode === 'skip' ||
planningMode === 'lite'
? 'cursor-not-allowed text-muted-foreground'
: 'cursor-pointer'
)}

View File

@@ -1,3 +1,5 @@
import { useState } from 'react';
import { createLogger } from '@automaker/utils/logger';
import {
Dialog,
DialogContent,
@@ -16,7 +18,14 @@ import {
} from '@/components/ui/description-image-dropzone';
import { MessageSquare } from 'lucide-react';
import { Feature } from '@/store/app-store';
import { EnhanceWithAI, EnhancementHistoryButton, type BaseHistoryEntry } from '../shared';
import {
EnhanceWithAI,
EnhancementHistoryButton,
type EnhancementMode,
type BaseHistoryEntry,
} from '../shared';
const logger = createLogger('FollowUpDialog');
/**
* A single entry in the follow-up prompt history

View File

@@ -11,6 +11,7 @@ import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { AlertCircle } from 'lucide-react';
import { modelSupportsThinking } from '@/lib/utils';
import { Feature, ModelAlias, ThinkingLevel, PlanningMode } from '@/store/app-store';
import {
TestingTabContent,
@@ -21,8 +22,9 @@ import {
} from '../shared';
import type { WorkMode } from '../shared';
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
import type { PhaseModelEntry } from '@automaker/types';
import { isCursorModel, isClaudeModel, type PhaseModelEntry } from '@automaker/types';
import { cn } from '@/lib/utils';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
interface MassEditDialogProps {
open: boolean;
@@ -197,13 +199,6 @@ export function MassEditDialog({
}
}, [open, selectedFeatures]);
// Clear requirePlanApproval when planning mode is skip or lite
useEffect(() => {
if (planningMode === 'skip' || planningMode === 'lite') {
setRequirePlanApproval(false);
}
}, [planningMode]);
const handleApply = async () => {
const updates: Partial<Feature> = {};
@@ -239,6 +234,9 @@ export function MassEditDialog({
};
const hasAnyApply = Object.values(applyState).some(Boolean);
const isCurrentModelCursor = isCursorModel(model);
const modelAllowsThinking = !isCurrentModelCursor && modelSupportsThinking(model);
const modelSupportsPlanningMode = isClaudeModel(model);
return (
<Dialog open={open} onOpenChange={(open) => !open && onClose()}>
@@ -278,30 +276,64 @@ export function MassEditDialog({
<div className="border-t border-border" />
{/* Planning Mode */}
<FieldWrapper
label="Planning Mode"
isMixed={mixedValues.planningMode || mixedValues.requirePlanApproval}
willApply={applyState.planningMode || applyState.requirePlanApproval}
onApplyChange={(apply) =>
setApplyState((prev) => ({
...prev,
planningMode: apply,
requirePlanApproval: apply,
}))
}
>
<PlanningModeSelect
mode={planningMode}
onModeChange={(newMode) => {
setPlanningMode(newMode);
// Auto-suggest approval based on mode, but user can override
setRequirePlanApproval(newMode === 'spec' || newMode === 'full');
}}
requireApproval={requirePlanApproval}
onRequireApprovalChange={setRequirePlanApproval}
testIdPrefix="mass-edit-planning"
/>
</FieldWrapper>
{modelSupportsPlanningMode ? (
<FieldWrapper
label="Planning Mode"
isMixed={mixedValues.planningMode || mixedValues.requirePlanApproval}
willApply={applyState.planningMode || applyState.requirePlanApproval}
onApplyChange={(apply) =>
setApplyState((prev) => ({
...prev,
planningMode: apply,
requirePlanApproval: apply,
}))
}
>
<PlanningModeSelect
mode={planningMode}
onModeChange={(newMode) => {
setPlanningMode(newMode);
// Auto-suggest approval based on mode, but user can override
setRequirePlanApproval(newMode === 'spec' || newMode === 'full');
}}
requireApproval={requirePlanApproval}
onRequireApprovalChange={setRequirePlanApproval}
testIdPrefix="mass-edit-planning"
/>
</FieldWrapper>
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
'p-3 rounded-lg border transition-colors border-border bg-muted/20 opacity-50 cursor-not-allowed'
)}
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Checkbox checked={false} disabled className="opacity-50" />
<Label className="text-sm font-medium text-muted-foreground">
Planning Mode
</Label>
</div>
</div>
<div className="opacity-50 pointer-events-none">
<PlanningModeSelect
mode="skip"
onModeChange={() => {}}
testIdPrefix="mass-edit-planning"
disabled
/>
</div>
</div>
</TooltipTrigger>
<TooltipContent>
<p>Planning modes are only available for Claude Provider</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Priority */}
<FieldWrapper

View File

@@ -330,7 +330,7 @@ export function MergeWorktreeDialog({
>
{isLoading ? (
<>
<Spinner size="sm" variant="foreground" className="mr-2" />
<Spinner size="sm" className="mr-2" />
Merging...
</>
) : (

View File

@@ -1,150 +0,0 @@
export const commitTemplate = {
id: 'commit',
name: 'Commit Changes',
colorClass: 'bg-purple-500/20',
instructions: `## Commit Changes Step
# ⚠️ CRITICAL REQUIREMENT: YOU MUST COMMIT ALL CHANGES USING CONVENTIONAL COMMIT FORMAT ⚠️
**THIS IS NOT OPTIONAL. YOU MUST CREATE AND EXECUTE A GIT COMMIT WITH ALL CHANGES.**
This step requires you to:
1. **REVIEW** all changes made in this feature
2. **CREATE** a conventional commit message
3. **EXECUTE** the git commit command
**You cannot complete this step by only reviewing changes. You MUST execute the git commit command.**
---
### Phase 1: Review Phase
Review all changes made in this feature:
- Review all modified files using \`git status\` and \`git diff\`
- Identify the scope and nature of changes
- Determine the appropriate conventional commit type
- Identify any breaking changes that need to be documented
---
### Phase 2: Commit Phase - ⚠️ MANDATORY ACTION REQUIRED ⚠️
**YOU MUST NOW CREATE AND EXECUTE A GIT COMMIT WITH ALL CHANGES.**
**This is not optional. You must stage all changes and commit them using conventional commit format.**
#### Conventional Commit Format
Follow this format for your commit message:
\`\`\`
<type>(<scope>): <subject>
<body>
<footer>
\`\`\`
#### Commit Types (choose the most appropriate):
- **feat**: A new feature
- **fix**: A bug fix
- **docs**: Documentation only changes
- **style**: Code style changes (formatting, missing semicolons, etc.)
- **refactor**: Code refactoring without changing functionality
- **perf**: Performance improvements
- **test**: Adding or updating tests
- **chore**: Changes to build process, dependencies, or tooling
- **ci**: Changes to CI configuration
- **build**: Changes to build system or dependencies
#### Scope (optional but recommended):
- Component/module name (e.g., \`ui\`, \`server\`, \`auth\`)
- Feature area (e.g., \`board\`, \`pipeline\`, \`agent\`)
- Package name (e.g., \`@automaker/types\`)
#### Subject:
- Use imperative mood: "add" not "added" or "adds"
- First letter lowercase
- No period at the end
- Maximum 72 characters
#### Body (optional but recommended for significant changes):
- Explain the "what" and "why" of the change
- Reference related issues or PRs
- Separate from subject with blank line
- Wrap at 72 characters
#### Footer (optional):
- Breaking changes: \`BREAKING CHANGE: <description>\`
- Issue references: \`Closes #123\`, \`Fixes #456\`
#### Action Steps (You MUST complete these):
1. **Stage All Changes** - PREPARE FOR COMMIT:
- ✅ Run \`git add .\` or \`git add -A\` to stage all changes
- ✅ Verify staged changes with \`git status\`
- ✅ Ensure all relevant changes are staged
2. **Create Commit Message** - FOLLOW CONVENTIONAL COMMIT FORMAT:
- ✅ Determine the appropriate commit type based on changes
- ✅ Identify the scope (component/module/feature)
- ✅ Write a clear, imperative subject line
- ✅ Add a body explaining the changes (if significant)
- ✅ Include breaking changes in footer if applicable
- ✅ Reference related issues if applicable
3. **Execute Commit** - COMMIT THE CHANGES:
- ✅ Run \`git commit -m "<type>(<scope>): <subject>" -m "<body>"\` or use a multi-line commit message
- ✅ Verify the commit was created with \`git log -1\`
- ✅ **EXECUTE THE ACTUAL GIT COMMIT COMMAND**
#### Example Commit Messages:
\`\`\`
feat(ui): add pipeline step commit template
Add a new pipeline step template for committing changes using
conventional commit format. This ensures all commits follow
a consistent pattern for better changelog generation.
Closes #123
\`\`\`
\`\`\`
fix(server): resolve agent session timeout issue
The agent session was timing out prematurely due to incorrect
WebSocket heartbeat configuration. Updated heartbeat interval
to match server expectations.
Fixes #456
\`\`\`
\`\`\`
refactor(pipeline): extract step template logic
Extract step template loading and validation into separate
utility functions to improve code organization and testability.
\`\`\`
---
### Summary Required
After completing BOTH review AND commit phases, provide:
- A summary of all changes that were committed
- **The exact commit message that was used (this proves you executed the commit)**
- The commit hash (if available)
- Any notes about the commit (breaking changes, related issues, etc.)
---
# ⚠️ FINAL REMINDER ⚠️
**Reviewing changes without committing is INCOMPLETE and UNACCEPTABLE.**
**You MUST stage all changes and execute a git commit command.**
**You MUST use conventional commit format for the commit message.**
**You MUST show evidence of the commit execution in your summary.**
**This step is only complete when changes have been committed to git.**`,
};

View File

@@ -4,7 +4,6 @@ import { uxReviewTemplate } from './ux-review';
import { testingTemplate } from './testing';
import { documentationTemplate } from './documentation';
import { optimizationTemplate } from './optimization';
import { commitTemplate } from './commit';
export interface PipelineStepTemplate {
id: string;
@@ -20,7 +19,6 @@ export const STEP_TEMPLATES: PipelineStepTemplate[] = [
testingTemplate,
documentationTemplate,
optimizationTemplate,
commitTemplate,
];
// Helper to get template color class

View File

@@ -210,7 +210,7 @@ export function PlanApprovalDialog({
className="bg-green-600 hover:bg-green-700 text-white"
>
{isLoading ? (
<Spinner size="sm" variant="foreground" className="mr-2" />
<Spinner size="sm" className="mr-2" />
) : (
<Check className="w-4 h-4 mr-2" />
)}

View File

@@ -23,7 +23,14 @@ import { getHttpApiClient } from '@/lib/http-api-client';
import { toast } from 'sonner';
import { GitMerge, RefreshCw, AlertTriangle } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import type { WorktreeInfo } from '../worktree-panel/types';
interface WorktreeInfo {
path: string;
branch: string;
isMain: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
}
interface RemoteBranch {
name: string;
@@ -42,7 +49,7 @@ interface PullResolveConflictsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
worktree: WorktreeInfo | null;
onConfirm: (worktree: WorktreeInfo, remoteBranch: string) => void | Promise<void>;
onConfirm: (worktree: WorktreeInfo, remoteBranch: string) => void;
}
export function PullResolveConflictsDialog({

View File

@@ -1,4 +1,4 @@
// @ts-nocheck - feature update logic with partial updates and image/file handling
// @ts-nocheck
import { useCallback } from 'react';
import {
Feature,

View File

@@ -1,4 +1,4 @@
// @ts-nocheck - column filtering logic with dependency resolution and status mapping
// @ts-nocheck
import { useMemo, useCallback } from 'react';
import { Feature, useAppStore } from '@/store/app-store';
import {
@@ -51,6 +51,7 @@ export function useBoardColumnFeatures({
// Determine the effective worktree path and branch for filtering
// If currentWorktreePath is null, we're on the main worktree
const effectiveWorktreePath = currentWorktreePath || projectPath;
// Use the branch name from the selected worktree
// If we're selecting main (currentWorktreePath is null), currentWorktreeBranch
// should contain the main branch's actual name, defaulting to "main"

View File

@@ -23,7 +23,7 @@ interface UseBoardDragDropProps {
export function useBoardDragDrop({
features,
currentProject: _currentProject,
currentProject,
runningAutoTasks,
persistFeatureUpdate,
handleStartImplementation,
@@ -128,9 +128,10 @@ export function useBoardDragDrop({
const targetBranch = worktreeData.branch;
const currentBranch = draggedFeature.branchName;
// For main worktree, set branchName to undefined to indicate it should use main
// For main worktree, set branchName to null to indicate it should use main
// (must use null not undefined so it serializes to JSON for the API call)
// For other worktrees, set branchName to the target branch
const newBranchName: string | undefined = worktreeData.isMain ? undefined : targetBranch;
const newBranchName = worktreeData.isMain ? null : targetBranch;
// If already on the same branch, nothing to do
// For main worktree: feature with null/undefined branchName is already on main

View File

@@ -1,16 +1,15 @@
import { useEffect, useRef } from 'react';
import { getElectronAPI } from '@/lib/electron';
import { createLogger } from '@automaker/utils/logger';
import type { Feature } from '@/store/app-store';
const logger = createLogger('BoardEffects');
interface UseBoardEffectsProps {
currentProject: { path: string; id: string; name?: string } | null;
currentProject: { path: string; id: string } | null;
specCreatingForProject: string | null;
setSpecCreatingForProject: (path: string | null) => void;
checkContextExists: (featureId: string) => Promise<boolean>;
features: Feature[];
features: any[];
isLoading: boolean;
featuresWithContext: Set<string>;
setFeaturesWithContext: (set: Set<string>) => void;
@@ -34,10 +33,10 @@ export function useBoardEffects({
// Make current project available globally for modal
useEffect(() => {
if (currentProject) {
window.__currentProject = currentProject;
(window as any).__currentProject = currentProject;
}
return () => {
window.__currentProject = null;
(window as any).__currentProject = null;
};
}, [currentProject]);

View File

@@ -185,8 +185,8 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
features,
isLoading,
persistedCategories,
loadFeatures: async () => {
await queryClient.invalidateQueries({
loadFeatures: () => {
queryClient.invalidateQueries({
queryKey: queryKeys.features.all(currentProject?.path ?? ''),
});
},

View File

@@ -1,6 +1,5 @@
import { useCallback } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import type { Feature as ApiFeature } from '@automaker/types';
import { Feature } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
@@ -49,14 +48,14 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
feature: result.feature,
});
if (result.success && result.feature) {
const updatedFeature = result.feature as Feature;
updateFeature(updatedFeature.id, updatedFeature as Partial<Feature>);
const updatedFeature = result.feature;
updateFeature(updatedFeature.id, updatedFeature);
queryClient.setQueryData<Feature[]>(
queryKeys.features.all(currentProject.path),
(features) => {
if (!features) return features;
return features.map((feature) =>
feature.id === updatedFeature.id ? { ...feature, ...updatedFeature } : feature
feature.id === updatedFeature.id ? updatedFeature : feature
);
}
);
@@ -86,9 +85,9 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
return;
}
const result = await api.features.create(currentProject.path, feature as ApiFeature);
const result = await api.features.create(currentProject.path, feature);
if (result.success && result.feature) {
updateFeature(result.feature.id, result.feature as Partial<Feature>);
updateFeature(result.feature.id, result.feature);
// Invalidate React Query cache to sync UI
queryClient.invalidateQueries({
queryKey: queryKeys.features.all(currentProject.path),

View File

@@ -6,15 +6,13 @@ import {
useEffect,
type RefObject,
type ReactNode,
type UIEvent,
} from 'react';
import { DragOverlay } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { Button } from '@/components/ui/button';
import { KanbanColumn, KanbanCard, EmptyStateCard } from './components';
import { Feature, useAppStore, formatShortcut } from '@/store/app-store';
import { Archive, Settings2, CheckSquare, GripVertical, Plus, CheckCircle2 } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Archive, Settings2, CheckSquare, GripVertical, Plus } from 'lucide-react';
import { useResponsiveKanban } from '@/hooks/use-responsive-kanban';
import { getColumnsWithPipeline, type ColumnId } from './constants';
import type { PipelineConfig } from '@automaker/types';
@@ -80,7 +78,7 @@ const REDUCED_CARD_OPACITY_PERCENT = 85;
type VirtualListItem = { id: string };
interface VirtualListState<Item extends VirtualListItem> {
contentRef: RefObject<HTMLDivElement | null>;
contentRef: RefObject<HTMLDivElement>;
onScroll: (event: UIEvent<HTMLDivElement>) => void;
itemIds: string[];
visibleItems: Item[];
@@ -361,44 +359,32 @@ export function KanbanBoard({
column.id === 'verified' ? (
<div className="flex items-center gap-1">
{columnFeatures.length > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={onArchiveAllVerified}
data-testid="archive-all-verified-button"
>
<CheckCircle2 className="w-3.5 h-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Complete All</p>
</TooltipContent>
</Tooltip>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={onArchiveAllVerified}
data-testid="archive-all-verified-button"
>
<Archive className="w-3 h-3 mr-1" />
Complete All
</Button>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 relative"
onClick={onShowCompletedModal}
data-testid="completed-features-button"
>
<Archive className="w-3.5 h-3.5 text-muted-foreground" />
{completedCount > 0 && (
<span className="absolute -top-1 -right-1 bg-brand-500 text-white text-[8px] font-bold rounded-full w-3.5 h-3.5 flex items-center justify-center">
{completedCount > 99 ? '99+' : completedCount}
</span>
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Completed Features ({completedCount})</p>
</TooltipContent>
</Tooltip>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 relative"
onClick={onShowCompletedModal}
title={`Completed Features (${completedCount})`}
data-testid="completed-features-button"
>
<Archive className="w-3.5 h-3.5 text-muted-foreground" />
{completedCount > 0 && (
<span className="absolute -top-1 -right-1 bg-brand-500 text-white text-[8px] font-bold rounded-full w-3.5 h-3.5 flex items-center justify-center">
{completedCount > 99 ? '99+' : completedCount}
</span>
)}
</Button>
</div>
) : column.id === 'backlog' ? (
<div className="flex items-center gap-1">

View File

@@ -1,3 +1,4 @@
import type { ModelAlias } from '@/store/app-store';
import type { ModelProvider, ThinkingLevel, ReasoningEffort } from '@automaker/types';
import {
CURSOR_MODEL_MAP,

View File

@@ -1,12 +1,14 @@
// @ts-nocheck
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Brain, AlertTriangle } from 'lucide-react';
import { AnthropicIcon, CursorIcon, OpenAIIcon } from '@/components/ui/provider-icon';
import { cn } from '@/lib/utils';
import type { ModelAlias } from '@/store/app-store';
import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
import { getModelProvider } from '@automaker/types';
import type { ModelProvider, CursorModelId } from '@automaker/types';
import { getModelProvider, PROVIDER_PREFIXES, stripProviderPrefix } from '@automaker/types';
import type { ModelProvider } from '@automaker/types';
import { CLAUDE_MODELS, CURSOR_MODELS, ModelOption } from './model-constants';
import { useEffect } from 'react';
import { Spinner } from '@/components/ui/spinner';
@@ -39,7 +41,6 @@ export function ModelSelector({
const isCursorAvailable = cursorCliStatus?.installed && cursorCliStatus?.auth?.authenticated;
// Check if Codex CLI is available
// @ts-expect-error - codexCliStatus uses CliStatus type but should use CodexCliStatus which has auth
const isCodexAvailable = codexCliStatus?.installed && codexCliStatus?.auth?.authenticated;
// Fetch Codex models on mount
@@ -75,8 +76,8 @@ export function ModelSelector({
// Check both the full ID (for GPT models) and the unprefixed version (for non-GPT models)
const unprefixedId = model.id.startsWith('cursor-') ? model.id.slice(7) : model.id;
return (
enabledCursorModels.includes(model.id as CursorModelId) ||
enabledCursorModels.includes(unprefixedId as CursorModelId)
enabledCursorModels.includes(model.id as any) ||
enabledCursorModels.includes(unprefixedId as any)
);
});

View File

@@ -12,6 +12,7 @@ import {
GitBranch,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import { XtermLogViewer, type XtermLogViewerRef } from '@/components/ui/xterm-log-viewer';
import { useDevServerLogs } from '../hooks/use-dev-server-logs';
import type { WorktreeInfo } from '../types';

View File

@@ -1,16 +1,5 @@
export { BranchSwitchDropdown } from './branch-switch-dropdown';
export { DevServerLogsPanel } from './dev-server-logs-panel';
export { WorktreeActionsDropdown } from './worktree-actions-dropdown';
export { WorktreeDropdown } from './worktree-dropdown';
export type { WorktreeDropdownProps } from './worktree-dropdown';
export { WorktreeDropdownItem } from './worktree-dropdown-item';
export type { WorktreeDropdownItemProps } from './worktree-dropdown-item';
export {
truncateBranchName,
getPRBadgeStyles,
getChangesBadgeStyles,
getTestStatusStyles,
} from './worktree-indicator-utils';
export type { TestStatus } from './worktree-indicator-utils';
export { WorktreeMobileDropdown } from './worktree-mobile-dropdown';
export { WorktreeTab } from './worktree-tab';

View File

@@ -1,5 +1,5 @@
import type { ReactElement, ReactNode } from 'react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
interface TooltipWrapperProps {
/** The element to wrap with a tooltip */
@@ -29,14 +29,16 @@ export function TooltipWrapper({
}
return (
<Tooltip>
<TooltipTrigger asChild>
{/* The div wrapper is necessary for tooltips to work on disabled elements */}
<div>{children}</div>
</TooltipTrigger>
<TooltipContent side={side}>
<p>{tooltipContent}</p>
</TooltipContent>
</Tooltip>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
{/* The div wrapper is necessary for tooltips to work on disabled elements */}
<div>{children}</div>
</TooltipTrigger>
<TooltipContent side={side}>
<p>{tooltipContent}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}

View File

@@ -161,7 +161,7 @@ export function WorktreeActionsDropdown({
: null;
// Get available terminals for the "Open In Terminal" submenu
const { terminals } = useAvailableTerminals();
const { terminals, hasExternalTerminals } = useAvailableTerminals();
// Use shared hook for effective default terminal (null = integrated terminal)
const effectiveDefaultTerminal = useEffectiveDefaultTerminal(terminals);
@@ -319,7 +319,7 @@ export function WorktreeActionsDropdown({
<DropdownMenuItem onClick={() => onToggleAutoMode(worktree)} className="text-xs">
<span className="flex items-center mr-2">
<Zap className="w-3.5 h-3.5 text-yellow-500" />
<span className="ml-1.5 h-2 w-2 rounded-full bg-green-500 animate-pulse" />
<span className="ml-0.5 h-2 w-2 rounded-full bg-green-500 animate-pulse" />
</span>
Stop Auto Mode
</DropdownMenuItem>

View File

@@ -1,200 +0,0 @@
import { DropdownMenuItem } from '@/components/ui/dropdown-menu';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Check, CircleDot, Globe, GitPullRequest, FlaskConical } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import type { WorktreeInfo, DevServerInfo, TestSessionInfo } from '../types';
import {
truncateBranchName,
getPRBadgeStyles,
getChangesBadgeStyles,
getTestStatusStyles,
} from './worktree-indicator-utils';
/**
* Maximum characters for branch name before truncation in dropdown items.
* Set to 28 to accommodate longer names in the wider dropdown menu while
* still fitting comfortably with all status indicators.
*/
const MAX_ITEM_BRANCH_NAME_LENGTH = 28;
export interface WorktreeDropdownItemProps {
/** The worktree to display */
worktree: WorktreeInfo;
/** Whether this worktree is currently selected */
isSelected: boolean;
/** Whether this worktree has running features/processes */
isRunning: boolean;
/** Number of cards associated with this worktree's branch */
cardCount?: number;
/** Whether the dev server is running for this worktree */
devServerRunning?: boolean;
/** Dev server information if running */
devServerInfo?: DevServerInfo;
/** Whether auto-mode is running for this worktree */
isAutoModeRunning?: boolean;
/** Whether tests are running for this worktree */
isTestRunning?: boolean;
/** Test session info for this worktree */
testSessionInfo?: TestSessionInfo;
/** Callback when the worktree is selected */
onSelect: () => void;
}
/**
* A dropdown menu item component for displaying an individual worktree entry.
*
* Features:
* - Selection indicator (checkmark when selected)
* - Running status indicator (spinner)
* - Branch name with tooltip for long names
* - Main branch badge
* - Dev server status indicator
* - Auto mode indicator
* - Test status indicator
* - Card count badge
* - Uncommitted changes indicator
* - PR status badge
*/
export function WorktreeDropdownItem({
worktree,
isSelected,
isRunning,
cardCount,
devServerRunning,
devServerInfo,
isAutoModeRunning = false,
isTestRunning = false,
testSessionInfo,
onSelect,
}: WorktreeDropdownItemProps) {
const { hasChanges, changedFilesCount, pr } = worktree;
// Truncate long branch names using shared utility
const { truncated: truncatedBranch, isTruncated: isBranchNameTruncated } = truncateBranchName(
worktree.branch,
MAX_ITEM_BRANCH_NAME_LENGTH
);
const branchNameElement = (
<span className={cn('font-mono text-xs truncate', isSelected && 'font-medium')}>
{truncatedBranch}
</span>
);
return (
<DropdownMenuItem
onSelect={onSelect}
className={cn('flex items-center gap-2 cursor-pointer pr-2', isSelected && 'bg-accent')}
aria-current={isSelected ? 'true' : undefined}
>
<div className="flex items-center gap-2 flex-1 min-w-0">
{/* Selection indicator */}
{isSelected ? (
<Check className="w-3.5 h-3.5 shrink-0 text-primary" />
) : (
<div className="w-3.5 h-3.5 shrink-0" />
)}
{/* Running indicator */}
{isRunning && <Spinner size="xs" className="shrink-0" />}
{/* Branch name with optional tooltip */}
{isBranchNameTruncated ? (
<Tooltip>
<TooltipTrigger asChild>{branchNameElement}</TooltipTrigger>
<TooltipContent>
<p className="font-mono text-xs">{worktree.branch}</p>
</TooltipContent>
</Tooltip>
) : (
branchNameElement
)}
{/* Main badge */}
{worktree.isMain && (
<span className="text-[10px] px-1 py-0.5 rounded bg-muted text-muted-foreground shrink-0">
main
</span>
)}
</div>
{/* Right side indicators - ordered consistently with dropdown trigger */}
<div className="flex items-center gap-1.5 shrink-0">
{/* Card count badge */}
{cardCount !== undefined && cardCount > 0 && (
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
{cardCount}
</span>
)}
{/* Uncommitted changes indicator */}
{hasChanges && (
<span
className={cn(
'inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded border',
getChangesBadgeStyles()
)}
title={`${changedFilesCount ?? 'Some'} uncommitted file${changedFilesCount !== 1 ? 's' : ''}`}
>
<CircleDot className="w-2.5 h-2.5 mr-0.5" />
{changedFilesCount ?? '!'}
</span>
)}
{/* Dev server indicator */}
{devServerRunning && (
<span
className="inline-flex items-center justify-center h-4 w-4 text-green-500"
title={`Dev server running on port ${devServerInfo?.port}`}
>
<Globe className="w-3 h-3" />
</span>
)}
{/* Test running indicator */}
{isTestRunning && (
<span
className="inline-flex items-center justify-center h-4 w-4 text-blue-500"
title="Tests Running"
>
<FlaskConical className="w-3 h-3 animate-pulse" />
</span>
)}
{/* Last test result indicator (when not running) */}
{!isTestRunning && testSessionInfo && (
<span
className={cn(
'inline-flex items-center justify-center h-4 w-4',
getTestStatusStyles(testSessionInfo.status)
)}
title={`Last test: ${testSessionInfo.status}`}
>
<FlaskConical className="w-3 h-3" />
</span>
)}
{/* Auto mode indicator */}
{isAutoModeRunning && (
<span className="flex items-center justify-center h-4 px-0.5" title="Auto Mode Running">
<span className="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
</span>
)}
{/* PR indicator */}
{pr && (
<span
className={cn(
'inline-flex items-center gap-0.5 h-4 px-1 text-[10px] font-medium rounded border',
getPRBadgeStyles(pr.state)
)}
title={`PR #${pr.number}: ${pr.title}`}
>
<GitPullRequest className="w-2.5 h-2.5" />#{pr.number}
</span>
)}
</div>
</DropdownMenuItem>
);
}

View File

@@ -1,479 +0,0 @@
import { useMemo } from 'react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuGroup,
} from '@/components/ui/dropdown-menu';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import {
GitBranch,
ChevronDown,
CircleDot,
Globe,
GitPullRequest,
FlaskConical,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import type {
WorktreeInfo,
BranchInfo,
DevServerInfo,
PRInfo,
GitRepoStatus,
TestSessionInfo,
} from '../types';
import { WorktreeDropdownItem } from './worktree-dropdown-item';
import { BranchSwitchDropdown } from './branch-switch-dropdown';
import { WorktreeActionsDropdown } from './worktree-actions-dropdown';
import {
truncateBranchName,
getPRBadgeStyles,
getChangesBadgeStyles,
getTestStatusStyles,
} from './worktree-indicator-utils';
export interface WorktreeDropdownProps {
/** List of all worktrees to display in the dropdown */
worktrees: WorktreeInfo[];
/** Function to check if a worktree is currently selected */
isWorktreeSelected: (worktree: WorktreeInfo) => boolean;
/** Function to check if a worktree has running features/processes */
hasRunningFeatures: (worktree: WorktreeInfo) => boolean;
/** Whether worktree activation is in progress */
isActivating: boolean;
/** Map of branch names to card counts */
branchCardCounts?: Record<string, number>;
/** Function to check if dev server is running for a worktree */
isDevServerRunning: (worktree: WorktreeInfo) => boolean;
/** Function to get dev server info for a worktree */
getDevServerInfo: (worktree: WorktreeInfo) => DevServerInfo | undefined;
/** Function to check if auto-mode is running for a worktree */
isAutoModeRunningForWorktree: (worktree: WorktreeInfo) => boolean;
/** Function to check if tests are running for a worktree */
isTestRunningForWorktree: (worktree: WorktreeInfo) => boolean;
/** Function to get test session info for a worktree */
getTestSessionInfo: (worktree: WorktreeInfo) => TestSessionInfo | undefined;
/** Callback when a worktree is selected */
onSelectWorktree: (worktree: WorktreeInfo) => void;
// Branch switching props
branches: BranchInfo[];
filteredBranches: BranchInfo[];
branchFilter: string;
isLoadingBranches: boolean;
isSwitching: boolean;
onBranchDropdownOpenChange: (worktree: WorktreeInfo) => (open: boolean) => void;
onBranchFilterChange: (value: string) => void;
onSwitchBranch: (worktree: WorktreeInfo, branchName: string) => void;
onCreateBranch: (worktree: WorktreeInfo) => void;
// Action dropdown props
isPulling: boolean;
isPushing: boolean;
isStartingDevServer: boolean;
aheadCount: number;
behindCount: number;
hasRemoteBranch: boolean;
gitRepoStatus: GitRepoStatus;
hasTestCommand: boolean;
isStartingTests: boolean;
hasInitScript: boolean;
onActionsDropdownOpenChange: (worktree: WorktreeInfo) => (open: boolean) => void;
onPull: (worktree: WorktreeInfo) => void;
onPush: (worktree: WorktreeInfo) => void;
onPushNewBranch: (worktree: WorktreeInfo) => void;
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void;
onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void;
onViewChanges: (worktree: WorktreeInfo) => void;
onDiscardChanges: (worktree: WorktreeInfo) => void;
onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void;
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
onResolveConflicts: (worktree: WorktreeInfo) => void;
onMerge: (worktree: WorktreeInfo) => void;
onDeleteWorktree: (worktree: WorktreeInfo) => void;
onStartDevServer: (worktree: WorktreeInfo) => void;
onStopDevServer: (worktree: WorktreeInfo) => void;
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
onViewDevServerLogs: (worktree: WorktreeInfo) => void;
onRunInitScript: (worktree: WorktreeInfo) => void;
onToggleAutoMode: (worktree: WorktreeInfo) => void;
onStartTests: (worktree: WorktreeInfo) => void;
onStopTests: (worktree: WorktreeInfo) => void;
onViewTestLogs: (worktree: WorktreeInfo) => void;
}
/**
* Maximum characters for branch name before truncation in the dropdown trigger.
* Set to 24 to keep the trigger compact while showing enough context for identification.
*/
const MAX_TRIGGER_BRANCH_NAME_LENGTH = 24;
/**
* A dropdown component for displaying and switching between worktrees.
* Used when there are 3+ worktrees to avoid horizontal tab wrapping.
*
* Features:
* - Compact dropdown trigger showing current worktree with indicators
* - Grouped display (main branch + worktrees)
* - Full status indicators (PR, dev server, auto mode, changes)
* - Branch switch dropdown integration
* - Actions dropdown integration
* - Tooltip for truncated branch names
*/
export function WorktreeDropdown({
worktrees,
isWorktreeSelected,
hasRunningFeatures,
isActivating,
branchCardCounts,
isDevServerRunning,
getDevServerInfo,
isAutoModeRunningForWorktree,
isTestRunningForWorktree,
getTestSessionInfo,
onSelectWorktree,
// Branch switching props
branches,
filteredBranches,
branchFilter,
isLoadingBranches,
isSwitching,
onBranchDropdownOpenChange,
onBranchFilterChange,
onSwitchBranch,
onCreateBranch,
// Action dropdown props
isPulling,
isPushing,
isStartingDevServer,
aheadCount,
behindCount,
hasRemoteBranch,
gitRepoStatus,
hasTestCommand,
isStartingTests,
hasInitScript,
onActionsDropdownOpenChange,
onPull,
onPush,
onPushNewBranch,
onOpenInEditor,
onOpenInIntegratedTerminal,
onOpenInExternalTerminal,
onViewChanges,
onDiscardChanges,
onCommit,
onCreatePR,
onAddressPRComments,
onResolveConflicts,
onMerge,
onDeleteWorktree,
onStartDevServer,
onStopDevServer,
onOpenDevServerUrl,
onViewDevServerLogs,
onRunInitScript,
onToggleAutoMode,
onStartTests,
onStopTests,
onViewTestLogs,
}: WorktreeDropdownProps) {
// Find the currently selected worktree to display in the trigger
const selectedWorktree = worktrees.find((w) => isWorktreeSelected(w));
const displayBranch = selectedWorktree?.branch || 'Select worktree';
const { truncated: truncatedBranch, isTruncated: isBranchNameTruncated } = truncateBranchName(
displayBranch,
MAX_TRIGGER_BRANCH_NAME_LENGTH
);
// Separate main worktree from others for grouping
const mainWorktree = worktrees.find((w) => w.isMain);
const otherWorktrees = worktrees.filter((w) => !w.isMain);
// Get status info for selected worktree - memoized to prevent unnecessary recalculations
const selectedStatus = useMemo(() => {
if (!selectedWorktree) {
return {
devServerRunning: false,
devServerInfo: undefined,
autoModeRunning: false,
isRunning: false,
testRunning: false,
testSessionInfo: undefined,
};
}
return {
devServerRunning: isDevServerRunning(selectedWorktree),
devServerInfo: getDevServerInfo(selectedWorktree),
autoModeRunning: isAutoModeRunningForWorktree(selectedWorktree),
isRunning: hasRunningFeatures(selectedWorktree),
testRunning: isTestRunningForWorktree(selectedWorktree),
testSessionInfo: getTestSessionInfo(selectedWorktree),
};
}, [
selectedWorktree,
isDevServerRunning,
getDevServerInfo,
isAutoModeRunningForWorktree,
hasRunningFeatures,
isTestRunningForWorktree,
getTestSessionInfo,
]);
// Build trigger button with all indicators - memoized for performance
const triggerButton = useMemo(
() => (
<Button
variant="outline"
size="sm"
className={cn(
'h-7 px-3 gap-1.5 font-mono text-xs bg-secondary/50 hover:bg-secondary min-w-0 border-r-0 rounded-r-none'
)}
disabled={isActivating}
>
{/* Running/Activating indicator */}
{(selectedStatus.isRunning || isActivating) && <Spinner size="xs" className="shrink-0" />}
{/* Branch icon */}
<GitBranch className="w-3.5 h-3.5 shrink-0" />
{/* Branch name with optional tooltip */}
<span className="truncate max-w-[150px]">{truncatedBranch}</span>
{/* Card count badge */}
{selectedWorktree &&
branchCardCounts?.[selectedWorktree.branch] !== undefined &&
branchCardCounts[selectedWorktree.branch] > 0 && (
<span className="inline-flex items-center justify-center h-4 min-w-4 px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border shrink-0">
{branchCardCounts[selectedWorktree.branch]}
</span>
)}
{/* Uncommitted changes indicator */}
{selectedWorktree?.hasChanges && (
<span
className={cn(
'inline-flex items-center justify-center h-4 min-w-4 px-1 text-[10px] font-medium rounded border shrink-0',
getChangesBadgeStyles()
)}
>
<CircleDot className="w-2.5 h-2.5 mr-0.5" />
{selectedWorktree.changedFilesCount ?? '!'}
</span>
)}
{/* Dev server indicator */}
{selectedStatus.devServerRunning && (
<span
className="inline-flex items-center justify-center h-4 w-4 text-green-500 shrink-0"
title={`Dev server running on port ${selectedStatus.devServerInfo?.port}`}
>
<Globe className="w-3 h-3" />
</span>
)}
{/* Test running indicator */}
{selectedStatus.testRunning && (
<span
className="inline-flex items-center justify-center h-4 w-4 text-blue-500 shrink-0"
title="Tests Running"
>
<FlaskConical className="w-3 h-3 animate-pulse" />
</span>
)}
{/* Last test result indicator (when not running) */}
{!selectedStatus.testRunning && selectedStatus.testSessionInfo && (
<span
className={cn(
'inline-flex items-center justify-center h-4 w-4 shrink-0',
getTestStatusStyles(selectedStatus.testSessionInfo.status)
)}
title={`Last test: ${selectedStatus.testSessionInfo.status}`}
>
<FlaskConical className="w-3 h-3" />
</span>
)}
{/* Auto mode indicator */}
{selectedStatus.autoModeRunning && (
<span
className="flex items-center justify-center h-4 px-0.5 shrink-0"
title="Auto Mode Running"
>
<span className="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
</span>
)}
{/* PR badge */}
{selectedWorktree?.pr && (
<span
className={cn(
'inline-flex items-center gap-0.5 h-4 px-1 text-[10px] font-medium rounded border shrink-0',
getPRBadgeStyles(selectedWorktree.pr.state)
)}
>
<GitPullRequest className="w-2.5 h-2.5" />#{selectedWorktree.pr.number}
</span>
)}
{/* Dropdown chevron */}
<ChevronDown className="w-3 h-3 shrink-0 ml-auto" />
</Button>
),
[isActivating, selectedStatus, truncatedBranch, selectedWorktree, branchCardCounts]
);
// Wrap trigger button with dropdown trigger first to ensure ref is passed correctly
const dropdownTrigger = <DropdownMenuTrigger asChild>{triggerButton}</DropdownMenuTrigger>;
const triggerWithTooltip = isBranchNameTruncated ? (
<Tooltip>
<TooltipTrigger asChild>{dropdownTrigger}</TooltipTrigger>
<TooltipContent>
<p className="font-mono text-xs">{displayBranch}</p>
</TooltipContent>
</Tooltip>
) : (
dropdownTrigger
);
return (
<div className="flex items-center">
<DropdownMenu>
{triggerWithTooltip}
<DropdownMenuContent
align="start"
className="w-80 max-h-96 overflow-y-auto"
aria-label="Worktree selection"
>
{/* Main worktree section */}
{mainWorktree && (
<>
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
Main Branch
</DropdownMenuLabel>
<WorktreeDropdownItem
worktree={mainWorktree}
isSelected={isWorktreeSelected(mainWorktree)}
isRunning={hasRunningFeatures(mainWorktree)}
cardCount={branchCardCounts?.[mainWorktree.branch]}
devServerRunning={isDevServerRunning(mainWorktree)}
devServerInfo={getDevServerInfo(mainWorktree)}
isAutoModeRunning={isAutoModeRunningForWorktree(mainWorktree)}
isTestRunning={isTestRunningForWorktree(mainWorktree)}
testSessionInfo={getTestSessionInfo(mainWorktree)}
onSelect={() => onSelectWorktree(mainWorktree)}
/>
</>
)}
{/* Other worktrees section */}
{otherWorktrees.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
Worktrees ({otherWorktrees.length})
</DropdownMenuLabel>
<DropdownMenuGroup>
{otherWorktrees.map((worktree) => (
<WorktreeDropdownItem
key={worktree.path}
worktree={worktree}
isSelected={isWorktreeSelected(worktree)}
isRunning={hasRunningFeatures(worktree)}
cardCount={branchCardCounts?.[worktree.branch]}
devServerRunning={isDevServerRunning(worktree)}
devServerInfo={getDevServerInfo(worktree)}
isAutoModeRunning={isAutoModeRunningForWorktree(worktree)}
isTestRunning={isTestRunningForWorktree(worktree)}
testSessionInfo={getTestSessionInfo(worktree)}
onSelect={() => onSelectWorktree(worktree)}
/>
))}
</DropdownMenuGroup>
</>
)}
{/* Empty state */}
{worktrees.length === 0 && (
<div className="px-2 py-4 text-center text-sm text-muted-foreground">
No worktrees available
</div>
)}
</DropdownMenuContent>
</DropdownMenu>
{/* Branch switch dropdown for main branch (only when main is selected) */}
{selectedWorktree?.isMain && (
<BranchSwitchDropdown
worktree={selectedWorktree}
isSelected={true}
branches={branches}
filteredBranches={filteredBranches}
branchFilter={branchFilter}
isLoadingBranches={isLoadingBranches}
isSwitching={isSwitching}
onOpenChange={onBranchDropdownOpenChange(selectedWorktree)}
onFilterChange={onBranchFilterChange}
onSwitchBranch={onSwitchBranch}
onCreateBranch={onCreateBranch}
/>
)}
{/* Actions dropdown for the selected worktree */}
{selectedWorktree && (
<WorktreeActionsDropdown
worktree={selectedWorktree}
isSelected={true}
aheadCount={aheadCount}
behindCount={behindCount}
hasRemoteBranch={hasRemoteBranch}
isPulling={isPulling}
isPushing={isPushing}
isStartingDevServer={isStartingDevServer}
isDevServerRunning={isDevServerRunning(selectedWorktree)}
devServerInfo={getDevServerInfo(selectedWorktree)}
gitRepoStatus={gitRepoStatus}
isAutoModeRunning={isAutoModeRunningForWorktree(selectedWorktree)}
hasTestCommand={hasTestCommand}
isStartingTests={isStartingTests}
isTestRunning={isTestRunningForWorktree(selectedWorktree)}
testSessionInfo={getTestSessionInfo(selectedWorktree)}
onOpenChange={onActionsDropdownOpenChange(selectedWorktree)}
onPull={onPull}
onPush={onPush}
onPushNewBranch={onPushNewBranch}
onOpenInEditor={onOpenInEditor}
onOpenInIntegratedTerminal={onOpenInIntegratedTerminal}
onOpenInExternalTerminal={onOpenInExternalTerminal}
onViewChanges={onViewChanges}
onDiscardChanges={onDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}
onResolveConflicts={onResolveConflicts}
onMerge={onMerge}
onDeleteWorktree={onDeleteWorktree}
onStartDevServer={onStartDevServer}
onStopDevServer={onStopDevServer}
onOpenDevServerUrl={onOpenDevServerUrl}
onViewDevServerLogs={onViewDevServerLogs}
onRunInitScript={onRunInitScript}
onToggleAutoMode={onToggleAutoMode}
onStartTests={onStartTests}
onStopTests={onStopTests}
onViewTestLogs={onViewTestLogs}
hasInitScript={hasInitScript}
/>
)}
</div>
);
}

View File

@@ -1,70 +0,0 @@
/**
* Shared utility functions for worktree indicator styling and formatting.
* These utilities ensure consistent appearance across WorktreeTab, WorktreeDropdown,
* and WorktreeDropdownItem components.
*/
import type { PRInfo } from '../types';
/**
* Truncates a branch name if it exceeds the maximum length.
* @param branchName - The full branch name
* @param maxLength - Maximum characters before truncation
* @returns Object with truncated name and whether truncation occurred
*/
export function truncateBranchName(
branchName: string,
maxLength: number
): { truncated: string; isTruncated: boolean } {
const isTruncated = branchName.length > maxLength;
const truncated = isTruncated ? `${branchName.slice(0, maxLength)}...` : branchName;
return { truncated, isTruncated };
}
/**
* Returns the appropriate CSS classes for a PR badge based on PR state.
* @param state - The PR state (OPEN, MERGED, or CLOSED)
* @returns CSS class string for the badge
*/
export function getPRBadgeStyles(state: PRInfo['state']): string {
switch (state) {
case 'OPEN':
return 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30';
case 'MERGED':
return 'bg-purple-500/15 text-purple-600 dark:text-purple-400 border-purple-500/30';
case 'CLOSED':
default:
return 'bg-rose-500/15 text-rose-600 dark:text-rose-400 border-rose-500/30';
}
}
/**
* Returns the CSS classes for the uncommitted changes badge.
* This is a constant style used across all worktree components.
*/
export function getChangesBadgeStyles(): string {
return 'bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30';
}
/** Possible test session status values */
export type TestStatus = 'pending' | 'running' | 'passed' | 'failed' | 'cancelled';
/**
* Returns the CSS classes for a test status indicator based on test result.
* @param status - The test session status
* @returns CSS class string for the indicator color
*/
export function getTestStatusStyles(status: TestStatus): string {
switch (status) {
case 'passed':
return 'text-green-500';
case 'failed':
return 'text-red-500';
case 'running':
return 'text-blue-500';
case 'pending':
case 'cancelled':
default:
return 'text-muted-foreground';
}
}

View File

@@ -3,7 +3,7 @@ import { Button } from '@/components/ui/button';
import { Globe, CircleDot, GitPullRequest } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { useDroppable } from '@dnd-kit/core';
import type {
WorktreeInfo,
@@ -260,10 +260,8 @@ export function WorktreeTab({
aria-label={worktree.branch}
data-testid={`worktree-branch-${worktree.branch}`}
>
{isRunning && <Spinner size="xs" variant={isSelected ? 'foreground' : 'primary'} />}
{isActivating && !isRunning && (
<Spinner size="xs" variant={isSelected ? 'foreground' : 'primary'} />
)}
{isRunning && <Spinner size="xs" />}
{isActivating && !isRunning && <Spinner size="xs" />}
{worktree.branch}
{cardCount !== undefined && cardCount > 0 && (
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
@@ -271,27 +269,29 @@ export function WorktreeTab({
</span>
)}
{hasChanges && (
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
'inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded border',
isSelected
? 'bg-amber-500 text-amber-950 border-amber-400'
: 'bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30'
)}
>
<CircleDot className="w-2.5 h-2.5 mr-0.5" />
{changedFilesCount ?? '!'}
</span>
</TooltipTrigger>
<TooltipContent>
<p>
{changedFilesCount ?? 'Some'} uncommitted file
{changedFilesCount !== 1 ? 's' : ''}
</p>
</TooltipContent>
</Tooltip>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
'inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded border',
isSelected
? 'bg-amber-500 text-amber-950 border-amber-400'
: 'bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30'
)}
>
<CircleDot className="w-2.5 h-2.5 mr-0.5" />
{changedFilesCount ?? '!'}
</span>
</TooltipTrigger>
<TooltipContent>
<p>
{changedFilesCount ?? 'Some'} uncommitted file
{changedFilesCount !== 1 ? 's' : ''}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{prBadge}
</Button>
@@ -327,10 +327,8 @@ export function WorktreeTab({
: 'Click to switch to this branch'
}
>
{isRunning && <Spinner size="xs" variant={isSelected ? 'foreground' : 'primary'} />}
{isActivating && !isRunning && (
<Spinner size="xs" variant={isSelected ? 'foreground' : 'primary'} />
)}
{isRunning && <Spinner size="xs" />}
{isActivating && !isRunning && <Spinner size="xs" />}
{worktree.branch}
{cardCount !== undefined && cardCount > 0 && (
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
@@ -338,72 +336,78 @@ export function WorktreeTab({
</span>
)}
{hasChanges && (
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
'inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded border',
isSelected
? 'bg-amber-500 text-amber-950 border-amber-400'
: 'bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30'
)}
>
<CircleDot className="w-2.5 h-2.5 mr-0.5" />
{changedFilesCount ?? '!'}
</span>
</TooltipTrigger>
<TooltipContent>
<p>
{changedFilesCount ?? 'Some'} uncommitted file
{changedFilesCount !== 1 ? 's' : ''}
</p>
</TooltipContent>
</Tooltip>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
'inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded border',
isSelected
? 'bg-amber-500 text-amber-950 border-amber-400'
: 'bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30'
)}
>
<CircleDot className="w-2.5 h-2.5 mr-0.5" />
{changedFilesCount ?? '!'}
</span>
</TooltipTrigger>
<TooltipContent>
<p>
{changedFilesCount ?? 'Some'} uncommitted file
{changedFilesCount !== 1 ? 's' : ''}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{prBadge}
</Button>
)}
{isDevServerRunning && (
<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>
<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>
)}
{isAutoModeRunning && (
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
'flex items-center justify-center h-7 px-1.5 rounded-none border-r-0',
isSelected ? 'bg-primary text-primary-foreground' : 'bg-secondary/50'
)}
>
<span className="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
</span>
</TooltipTrigger>
<TooltipContent>
<p>Auto Mode Running</p>
</TooltipContent>
</Tooltip>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
'flex items-center justify-center h-7 px-1.5 rounded-none border-r-0',
isSelected ? 'bg-primary text-primary-foreground' : 'bg-secondary/50'
)}
>
<span className="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
</span>
</TooltipTrigger>
<TooltipContent>
<p>Auto Mode Running</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<WorktreeActionsDropdown

View File

@@ -26,9 +26,6 @@ export function useAvailableEditors() {
const { mutate: refreshMutate, isPending: isRefreshing } = useMutation({
mutationFn: async () => {
const api = getElectronAPI();
if (!api.worktree) {
throw new Error('Worktree API not available');
}
const result = await api.worktree.refreshEditors();
if (!result.success) {
throw new Error(result.error || 'Failed to refresh editors');

View File

@@ -95,20 +95,12 @@ export function useWorktrees({
);
// fetchWorktrees for backward compatibility - now just triggers a refetch
// The silent option is accepted but not used (React Query handles loading states)
// Returns removed worktrees array if any were detected, undefined otherwise
const fetchWorktrees = useCallback(
async (_options?: {
silent?: boolean;
}): Promise<Array<{ path: string; branch: string }> | undefined> => {
await queryClient.invalidateQueries({
queryKey: queryKeys.worktrees.all(projectPath),
});
const result = await refetch();
return result.data?.removedWorktrees;
},
[projectPath, queryClient, refetch]
);
const fetchWorktrees = useCallback(async () => {
await queryClient.invalidateQueries({
queryKey: queryKeys.worktrees.all(projectPath),
});
return refetch();
}, [projectPath, queryClient, refetch]);
const currentWorktreePath = currentWorktree?.path ?? null;
const selectedWorktree = currentWorktreePath

View File

@@ -28,7 +28,6 @@ import {
WorktreeMobileDropdown,
WorktreeActionsDropdown,
BranchSwitchDropdown,
WorktreeDropdown,
} from './components';
import { useAppStore } from '@/store/app-store';
import { ViewWorktreeChangesDialog, PushToRemoteDialog, MergeWorktreeDialog } from '../dialogs';
@@ -37,9 +36,6 @@ import { TestLogsPanel } from '@/components/ui/test-logs-panel';
import { Undo2 } from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
/** Threshold for switching from tabs to dropdown layout (number of worktrees) */
const WORKTREE_DROPDOWN_THRESHOLD = 3;
export function WorktreePanel({
projectPath,
onCreateWorktree,
@@ -383,13 +379,13 @@ export function WorktreePanel({
const isMobile = useIsMobile();
// Periodic interval check (30 seconds) to detect branch changes on disk
// Reduced polling to lessen repeated worktree list calls while keeping UI reasonably fresh
// 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);
useEffect(() => {
intervalRef.current = setInterval(() => {
fetchWorktrees({ silent: true });
}, 30000);
}, 5000);
return () => {
if (intervalRef.current) {
@@ -514,7 +510,7 @@ export function WorktreePanel({
} else {
toast.error(result.error || 'Failed to push changes');
}
} catch {
} catch (error) {
toast.error('Failed to push changes');
}
},
@@ -716,43 +712,30 @@ export function WorktreePanel({
);
}
// Use dropdown layout when worktree count meets or exceeds the threshold
const useDropdownLayout = worktrees.length >= WORKTREE_DROPDOWN_THRESHOLD;
// Desktop view: full tabs layout or dropdown layout depending on worktree count
// 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" />
<span className="text-sm text-muted-foreground mr-2">
{useDropdownLayout ? 'Worktree:' : 'Branch:'}
</span>
<span className="text-sm text-muted-foreground mr-2">Branch:</span>
{/* Dropdown layout for 3+ worktrees */}
{useDropdownLayout ? (
<>
<WorktreeDropdown
worktrees={worktrees}
isWorktreeSelected={isWorktreeSelected}
hasRunningFeatures={hasRunningFeatures}
<div className="flex items-center gap-2">
{mainWorktree && (
<WorktreeTab
key={mainWorktree.path}
worktree={mainWorktree}
cardCount={branchCardCounts?.[mainWorktree.branch]}
hasChanges={mainWorktree.hasChanges}
changedFilesCount={mainWorktree.changedFilesCount}
isSelected={isWorktreeSelected(mainWorktree)}
isRunning={hasRunningFeatures(mainWorktree)}
isActivating={isActivating}
branchCardCounts={branchCardCounts}
isDevServerRunning={isDevServerRunning}
getDevServerInfo={getDevServerInfo}
isAutoModeRunningForWorktree={isAutoModeRunningForWorktree}
isTestRunningForWorktree={isTestRunningForWorktree}
getTestSessionInfo={getTestSessionInfo}
onSelectWorktree={handleSelectWorktree}
// Branch switching props
isDevServerRunning={isDevServerRunning(mainWorktree)}
devServerInfo={getDevServerInfo(mainWorktree)}
branches={branches}
filteredBranches={filteredBranches}
branchFilter={branchFilter}
isLoadingBranches={isLoadingBranches}
isSwitching={isSwitching}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange}
onBranchFilterChange={setBranchFilter}
onSwitchBranch={handleSwitchBranch}
onCreateBranch={onCreateBranch}
// Action dropdown props
isPulling={isPulling}
isPushing={isPushing}
isStartingDevServer={isStartingDevServer}
@@ -760,10 +743,16 @@ export function WorktreePanel({
behindCount={behindCount}
hasRemoteBranch={hasRemoteBranch}
gitRepoStatus={gitRepoStatus}
hasTestCommand={hasTestCommand}
isAutoModeRunning={isAutoModeRunningForWorktree(mainWorktree)}
isStartingTests={isStartingTests}
hasInitScript={hasInitScript}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange}
isTestRunning={isTestRunningForWorktree(mainWorktree)}
testSessionInfo={getTestSessionInfo(mainWorktree)}
onSelectWorktree={handleSelectWorktree}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(mainWorktree)}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(mainWorktree)}
onBranchFilterChange={setBranchFilter}
onSwitchBranch={handleSwitchBranch}
onCreateBranch={onCreateBranch}
onPull={handlePull}
onPush={handlePush}
onPushNewBranch={handlePushNewBranch}
@@ -787,206 +776,111 @@ export function WorktreePanel({
onStartTests={handleStartTests}
onStopTests={handleStopTests}
onViewTestLogs={handleViewTestLogs}
hasInitScript={hasInitScript}
hasTestCommand={hasTestCommand}
/>
)}
</div>
{useWorktreesEnabled && (
<>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={onCreateWorktree}
title="Create new worktree"
>
<Plus className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={async () => {
const removedWorktrees = await fetchWorktrees();
if (removedWorktrees && removedWorktrees.length > 0 && onRemovedWorktrees) {
onRemovedWorktrees(removedWorktrees);
}
}}
disabled={isLoading}
title="Refresh worktrees"
>
{isLoading ? <Spinner size="xs" /> : <RefreshCw className="w-3.5 h-3.5" />}
</Button>
</>
)}
</>
) : (
/* Standard tabs layout for 1-2 worktrees */
{/* Worktrees section - only show if enabled */}
{useWorktreesEnabled && (
<>
<div className="flex items-center gap-2">
{mainWorktree && (
<WorktreeTab
key={mainWorktree.path}
worktree={mainWorktree}
cardCount={branchCardCounts?.[mainWorktree.branch]}
hasChanges={mainWorktree.hasChanges}
changedFilesCount={mainWorktree.changedFilesCount}
isSelected={isWorktreeSelected(mainWorktree)}
isRunning={hasRunningFeatures(mainWorktree)}
isActivating={isActivating}
isDevServerRunning={isDevServerRunning(mainWorktree)}
devServerInfo={getDevServerInfo(mainWorktree)}
branches={branches}
filteredBranches={filteredBranches}
branchFilter={branchFilter}
isLoadingBranches={isLoadingBranches}
isSwitching={isSwitching}
isPulling={isPulling}
isPushing={isPushing}
isStartingDevServer={isStartingDevServer}
aheadCount={aheadCount}
behindCount={behindCount}
hasRemoteBranch={hasRemoteBranch}
gitRepoStatus={gitRepoStatus}
isAutoModeRunning={isAutoModeRunningForWorktree(mainWorktree)}
isStartingTests={isStartingTests}
isTestRunning={isTestRunningForWorktree(mainWorktree)}
testSessionInfo={getTestSessionInfo(mainWorktree)}
onSelectWorktree={handleSelectWorktree}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(mainWorktree)}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(mainWorktree)}
onBranchFilterChange={setBranchFilter}
onSwitchBranch={handleSwitchBranch}
onCreateBranch={onCreateBranch}
onPull={handlePull}
onPush={handlePush}
onPushNewBranch={handlePushNewBranch}
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal}
onViewChanges={handleViewChanges}
onDiscardChanges={handleDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}
onResolveConflicts={onResolveConflicts}
onMerge={handleMerge}
onDeleteWorktree={onDeleteWorktree}
onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
onViewDevServerLogs={handleViewDevServerLogs}
onRunInitScript={handleRunInitScript}
onToggleAutoMode={handleToggleAutoMode}
onStartTests={handleStartTests}
onStopTests={handleStopTests}
onViewTestLogs={handleViewTestLogs}
hasInitScript={hasInitScript}
hasTestCommand={hasTestCommand}
/>
)}
<div className="w-px h-5 bg-border mx-2" />
<GitBranch className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground mr-2">Worktrees:</span>
<div className="flex items-center gap-2 flex-wrap">
{nonMainWorktrees.map((worktree) => {
const cardCount = branchCardCounts?.[worktree.branch];
return (
<WorktreeTab
key={worktree.path}
worktree={worktree}
cardCount={cardCount}
hasChanges={worktree.hasChanges}
changedFilesCount={worktree.changedFilesCount}
isSelected={isWorktreeSelected(worktree)}
isRunning={hasRunningFeatures(worktree)}
isActivating={isActivating}
isDevServerRunning={isDevServerRunning(worktree)}
devServerInfo={getDevServerInfo(worktree)}
branches={branches}
filteredBranches={filteredBranches}
branchFilter={branchFilter}
isLoadingBranches={isLoadingBranches}
isSwitching={isSwitching}
isPulling={isPulling}
isPushing={isPushing}
isStartingDevServer={isStartingDevServer}
aheadCount={aheadCount}
behindCount={behindCount}
hasRemoteBranch={hasRemoteBranch}
gitRepoStatus={gitRepoStatus}
isAutoModeRunning={isAutoModeRunningForWorktree(worktree)}
isStartingTests={isStartingTests}
isTestRunning={isTestRunningForWorktree(worktree)}
testSessionInfo={getTestSessionInfo(worktree)}
onSelectWorktree={handleSelectWorktree}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)}
onBranchFilterChange={setBranchFilter}
onSwitchBranch={handleSwitchBranch}
onCreateBranch={onCreateBranch}
onPull={handlePull}
onPush={handlePush}
onPushNewBranch={handlePushNewBranch}
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal}
onViewChanges={handleViewChanges}
onDiscardChanges={handleDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}
onResolveConflicts={onResolveConflicts}
onMerge={handleMerge}
onDeleteWorktree={onDeleteWorktree}
onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
onViewDevServerLogs={handleViewDevServerLogs}
onRunInitScript={handleRunInitScript}
onToggleAutoMode={handleToggleAutoMode}
onStartTests={handleStartTests}
onStopTests={handleStopTests}
onViewTestLogs={handleViewTestLogs}
hasInitScript={hasInitScript}
hasTestCommand={hasTestCommand}
/>
);
})}
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={onCreateWorktree}
title="Create new worktree"
>
<Plus className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={async () => {
const removedWorktrees = await fetchWorktrees();
if (removedWorktrees && removedWorktrees.length > 0 && onRemovedWorktrees) {
onRemovedWorktrees(removedWorktrees);
}
}}
disabled={isLoading}
title="Refresh worktrees"
>
{isLoading ? <Spinner size="xs" /> : <RefreshCw className="w-3.5 h-3.5" />}
</Button>
</div>
{/* Worktrees section - only show if enabled and not using dropdown layout */}
{useWorktreesEnabled && (
<>
<div className="w-px h-5 bg-border mx-2" />
<GitBranch className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground mr-2">Worktrees:</span>
<div className="flex items-center gap-2 flex-wrap">
{nonMainWorktrees.map((worktree) => {
const cardCount = branchCardCounts?.[worktree.branch];
return (
<WorktreeTab
key={worktree.path}
worktree={worktree}
cardCount={cardCount}
hasChanges={worktree.hasChanges}
changedFilesCount={worktree.changedFilesCount}
isSelected={isWorktreeSelected(worktree)}
isRunning={hasRunningFeatures(worktree)}
isActivating={isActivating}
isDevServerRunning={isDevServerRunning(worktree)}
devServerInfo={getDevServerInfo(worktree)}
branches={branches}
filteredBranches={filteredBranches}
branchFilter={branchFilter}
isLoadingBranches={isLoadingBranches}
isSwitching={isSwitching}
isPulling={isPulling}
isPushing={isPushing}
isStartingDevServer={isStartingDevServer}
aheadCount={aheadCount}
behindCount={behindCount}
hasRemoteBranch={hasRemoteBranch}
gitRepoStatus={gitRepoStatus}
isAutoModeRunning={isAutoModeRunningForWorktree(worktree)}
isStartingTests={isStartingTests}
isTestRunning={isTestRunningForWorktree(worktree)}
testSessionInfo={getTestSessionInfo(worktree)}
onSelectWorktree={handleSelectWorktree}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)}
onBranchFilterChange={setBranchFilter}
onSwitchBranch={handleSwitchBranch}
onCreateBranch={onCreateBranch}
onPull={handlePull}
onPush={handlePush}
onPushNewBranch={handlePushNewBranch}
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal}
onViewChanges={handleViewChanges}
onDiscardChanges={handleDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}
onResolveConflicts={onResolveConflicts}
onMerge={handleMerge}
onDeleteWorktree={onDeleteWorktree}
onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
onViewDevServerLogs={handleViewDevServerLogs}
onRunInitScript={handleRunInitScript}
onToggleAutoMode={handleToggleAutoMode}
onStartTests={handleStartTests}
onStopTests={handleStopTests}
onViewTestLogs={handleViewTestLogs}
hasInitScript={hasInitScript}
hasTestCommand={hasTestCommand}
/>
);
})}
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={onCreateWorktree}
title="Create new worktree"
>
<Plus className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={async () => {
const removedWorktrees = await fetchWorktrees();
if (removedWorktrees && removedWorktrees.length > 0 && onRemovedWorktrees) {
onRemovedWorktrees(removedWorktrees);
}
}}
disabled={isLoading}
title="Refresh worktrees"
>
{isLoading ? <Spinner size="xs" /> : <RefreshCw className="w-3.5 h-3.5" />}
</Button>
</div>
</>
)}
</>
)}

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