mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 14:22:02 +00:00
Compare commits
6 Commits
main
...
stefandevo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3f3f5fbd1 | ||
|
|
f007ca2c80 | ||
|
|
8efd14c580 | ||
|
|
86e3892c66 | ||
|
|
8ffe69feb1 | ||
|
|
2ceab3d65e |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@automaker/server",
|
||||
"version": "0.13.0",
|
||||
"version": "0.12.0",
|
||||
"description": "Backend server for Automaker - provides API for both web and Electron modes",
|
||||
"author": "AutoMaker Team",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
|
||||
@@ -546,10 +546,10 @@ export async function getPhaseModelWithOverrides(
|
||||
}
|
||||
}
|
||||
|
||||
// If no phase model found, use per-phase default
|
||||
// If no phase model found, use a default
|
||||
if (!phaseModel) {
|
||||
phaseModel = DEFAULT_PHASE_MODELS[phase] || { model: 'sonnet' };
|
||||
logger.debug(`${logPrefix} No ${phase} configured, using default: ${phaseModel.model}`);
|
||||
phaseModel = { model: 'sonnet' };
|
||||
logger.debug(`${logPrefix} No ${phase} configured, using default: sonnet`);
|
||||
}
|
||||
|
||||
// Resolve provider if providerId is set
|
||||
|
||||
@@ -1042,7 +1042,7 @@ export class OpencodeProvider extends CliProvider {
|
||||
'lm studio': 'lmstudio',
|
||||
lmstudio: 'lmstudio',
|
||||
opencode: 'opencode',
|
||||
'z.ai coding plan': 'zai-coding-plan',
|
||||
'z.ai coding plan': 'z-ai',
|
||||
'z.ai': 'z-ai',
|
||||
};
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
getAutoLoadClaudeMdSetting,
|
||||
getPromptCustomization,
|
||||
getPhaseModelWithOverrides,
|
||||
getProviderByModelId,
|
||||
} from '../../lib/settings-helpers.js';
|
||||
|
||||
const logger = createLogger('Suggestions');
|
||||
@@ -187,22 +186,8 @@ ${prompts.suggestions.baseTemplate}`;
|
||||
});
|
||||
model = resolved.model;
|
||||
thinkingLevel = resolved.thinkingLevel;
|
||||
|
||||
// Try to find a provider for this model (e.g., GLM, MiniMax models)
|
||||
if (settingsService) {
|
||||
const providerResult = await getProviderByModelId(
|
||||
modelOverride,
|
||||
settingsService,
|
||||
'[Suggestions]'
|
||||
);
|
||||
provider = providerResult.provider;
|
||||
// Use resolved model from provider if available (maps to Claude model)
|
||||
if (providerResult.resolvedModel) {
|
||||
model = providerResult.resolvedModel;
|
||||
}
|
||||
credentials = providerResult.credentials ?? (await settingsService.getCredentials());
|
||||
}
|
||||
// If no settingsService, credentials remains undefined (initialized above)
|
||||
// For overrides, just get credentials without a specific provider
|
||||
credentials = await settingsService?.getCredentials();
|
||||
} else if (settingsService) {
|
||||
// Use settings-based model with provider info
|
||||
const phaseResult = await getPhaseModelWithOverrides(
|
||||
|
||||
@@ -168,7 +168,7 @@ export function createGenerateCommitMessageHandler(
|
||||
worktreePath,
|
||||
'[GenerateCommitMessage]'
|
||||
);
|
||||
const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
|
||||
const { model } = resolvePhaseModel(phaseModelEntry);
|
||||
|
||||
logger.info(
|
||||
`Using model for commit message: ${model}`,
|
||||
@@ -199,7 +199,6 @@ export function createGenerateCommitMessageHandler(
|
||||
maxTurns: 1,
|
||||
allowedTools: [],
|
||||
readOnly: true,
|
||||
thinkingLevel, // Pass thinking level for extended thinking support
|
||||
claudeCompatibleProvider, // Pass provider for alternative endpoint configuration
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
});
|
||||
|
||||
@@ -75,21 +75,6 @@ import { getNotificationService } from './notification-service.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
/**
|
||||
* Get the current branch name for a git repository
|
||||
* @param projectPath - Path to the git repository
|
||||
* @returns The current branch name, or null if not in a git repo or on detached HEAD
|
||||
*/
|
||||
async function getCurrentBranch(projectPath: string): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await execAsync('git branch --show-current', { cwd: projectPath });
|
||||
const branch = stdout.trim();
|
||||
return branch || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// PlanningMode type is imported from @automaker/types
|
||||
|
||||
interface ParsedTask {
|
||||
@@ -651,7 +636,7 @@ export class AutoModeService {
|
||||
iterationCount++;
|
||||
try {
|
||||
// Count running features for THIS project/worktree only
|
||||
const projectRunningCount = await this.getRunningCountForWorktree(projectPath, branchName);
|
||||
const projectRunningCount = this.getRunningCountForWorktree(projectPath, branchName);
|
||||
|
||||
// Check if we have capacity for this project/worktree
|
||||
if (projectRunningCount >= projectState.config.maxConcurrency) {
|
||||
@@ -744,24 +729,20 @@ export class AutoModeService {
|
||||
/**
|
||||
* Get count of running features for a specific worktree
|
||||
* @param projectPath - The project path
|
||||
* @param branchName - The branch name, or null for main worktree (features without branchName or matching primary branch)
|
||||
* @param branchName - The branch name, or null for main worktree (features without branchName or with "main")
|
||||
*/
|
||||
private async getRunningCountForWorktree(
|
||||
projectPath: string,
|
||||
branchName: string | null
|
||||
): Promise<number> {
|
||||
// Get the actual primary branch name for the project
|
||||
const primaryBranch = await getCurrentBranch(projectPath);
|
||||
|
||||
private getRunningCountForWorktree(projectPath: string, branchName: string | null): number {
|
||||
const normalizedBranch = branchName === 'main' ? null : branchName;
|
||||
let count = 0;
|
||||
for (const [, feature] of this.runningFeatures) {
|
||||
// Filter by project path AND branchName to get accurate worktree-specific count
|
||||
const featureBranch = feature.branchName ?? null;
|
||||
if (branchName === null) {
|
||||
// Main worktree: match features with branchName === null OR branchName matching primary branch
|
||||
const isPrimaryBranch =
|
||||
featureBranch === null || (primaryBranch && featureBranch === primaryBranch);
|
||||
if (feature.projectPath === projectPath && isPrimaryBranch) {
|
||||
if (normalizedBranch === null) {
|
||||
// Main worktree: match features with branchName === null OR branchName === "main"
|
||||
if (
|
||||
feature.projectPath === projectPath &&
|
||||
(featureBranch === null || featureBranch === 'main')
|
||||
) {
|
||||
count++;
|
||||
}
|
||||
} else {
|
||||
@@ -810,7 +791,7 @@ export class AutoModeService {
|
||||
// Remove from map
|
||||
this.autoLoopsByProject.delete(worktreeKey);
|
||||
|
||||
return await this.getRunningCountForWorktree(projectPath, branchName);
|
||||
return this.getRunningCountForWorktree(projectPath, branchName);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1045,7 +1026,7 @@ export class AutoModeService {
|
||||
const maxAgents = await this.resolveMaxConcurrency(projectPath, branchName);
|
||||
|
||||
// Get current running count for this worktree
|
||||
const currentAgents = await this.getRunningCountForWorktree(projectPath, branchName);
|
||||
const currentAgents = this.getRunningCountForWorktree(projectPath, branchName);
|
||||
|
||||
return {
|
||||
hasCapacity: currentAgents < maxAgents,
|
||||
@@ -2976,10 +2957,6 @@ Format your response as a structured markdown document.`;
|
||||
// Features are stored in .automaker directory
|
||||
const featuresDir = getFeaturesDir(projectPath);
|
||||
|
||||
// Get the actual primary branch name for the project (e.g., "main", "master", "develop")
|
||||
// This is needed to correctly match features when branchName is null (main worktree)
|
||||
const primaryBranch = await getCurrentBranch(projectPath);
|
||||
|
||||
try {
|
||||
const entries = await secureFs.readdir(featuresDir, {
|
||||
withFileTypes: true,
|
||||
@@ -3019,21 +2996,17 @@ Format your response as a structured markdown document.`;
|
||||
(feature.planSpec.tasksCompleted ?? 0) < (feature.planSpec.tasksTotal ?? 0))
|
||||
) {
|
||||
// Filter by branchName:
|
||||
// - If branchName is null (main worktree), include features with:
|
||||
// - branchName === null, OR
|
||||
// - branchName === primaryBranch (e.g., "main", "master", "develop")
|
||||
// - If branchName is null (main worktree), include features with branchName === null OR branchName === "main"
|
||||
// - If branchName is set, only include features with matching branchName
|
||||
const featureBranch = feature.branchName ?? null;
|
||||
if (branchName === null) {
|
||||
// Main worktree: include features without branchName OR with branchName matching primary branch
|
||||
// This handles repos where the primary branch is named something other than "main"
|
||||
const isPrimaryBranch =
|
||||
featureBranch === null || (primaryBranch && featureBranch === primaryBranch);
|
||||
if (isPrimaryBranch) {
|
||||
// Main worktree: include features without branchName OR with branchName === "main"
|
||||
// This handles both correct (null) and legacy ("main") cases
|
||||
if (featureBranch === null || featureBranch === 'main') {
|
||||
pendingFeatures.push(feature);
|
||||
} else {
|
||||
logger.debug(
|
||||
`[loadPendingFeatures] Filtering out feature ${feature.id} (branchName: ${featureBranch}, primaryBranch: ${primaryBranch}) for main worktree`
|
||||
`[loadPendingFeatures] Filtering out feature ${feature.id} (branchName: ${featureBranch}) for main worktree`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -41,7 +41,7 @@ import type { FeatureLoader } from './feature-loader.js';
|
||||
import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js';
|
||||
import { resolveModelString } from '@automaker/model-resolver';
|
||||
import { stripProviderPrefix } from '@automaker/types';
|
||||
import { getPromptCustomization, getProviderByModelId } from '../lib/settings-helpers.js';
|
||||
import { getPromptCustomization } from '../lib/settings-helpers.js';
|
||||
|
||||
const logger = createLogger('IdeationService');
|
||||
|
||||
@@ -208,27 +208,7 @@ export class IdeationService {
|
||||
);
|
||||
|
||||
// Resolve model alias to canonical identifier (with prefix)
|
||||
let modelId = resolveModelString(options?.model ?? 'sonnet');
|
||||
|
||||
// Try to find a provider for this model (e.g., GLM, MiniMax models)
|
||||
let claudeCompatibleProvider: import('@automaker/types').ClaudeCompatibleProvider | undefined;
|
||||
let credentials = await this.settingsService?.getCredentials();
|
||||
|
||||
if (this.settingsService && options?.model) {
|
||||
const providerResult = await getProviderByModelId(
|
||||
options.model,
|
||||
this.settingsService,
|
||||
'[IdeationService]'
|
||||
);
|
||||
if (providerResult.provider) {
|
||||
claudeCompatibleProvider = providerResult.provider;
|
||||
// Use resolved model from provider if available (maps to Claude model)
|
||||
if (providerResult.resolvedModel) {
|
||||
modelId = providerResult.resolvedModel;
|
||||
}
|
||||
credentials = providerResult.credentials ?? credentials;
|
||||
}
|
||||
}
|
||||
const modelId = resolveModelString(options?.model ?? 'sonnet');
|
||||
|
||||
// Create SDK options
|
||||
const sdkOptions = createChatOptions({
|
||||
@@ -243,6 +223,9 @@ export class IdeationService {
|
||||
// Strip provider prefix - providers need bare model IDs
|
||||
const bareModel = stripProviderPrefix(modelId);
|
||||
|
||||
// Get credentials for API calls (uses hardcoded model, no phase setting)
|
||||
const credentials = await this.settingsService?.getCredentials();
|
||||
|
||||
const executeOptions: ExecuteOptions = {
|
||||
prompt: message,
|
||||
model: bareModel,
|
||||
@@ -252,7 +235,6 @@ export class IdeationService {
|
||||
maxTurns: 1, // Single turn for ideation
|
||||
abortController: activeSession.abortController!,
|
||||
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined,
|
||||
claudeCompatibleProvider, // Pass provider for alternative endpoint configuration
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
};
|
||||
|
||||
|
||||
@@ -1311,317 +1311,4 @@ describe('opencode-provider.ts', () => {
|
||||
expect(args[modelIndex + 1]).toBe('provider/model-v1.2.3-beta');
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// parseProvidersOutput Tests
|
||||
// ==========================================================================
|
||||
|
||||
describe('parseProvidersOutput', () => {
|
||||
// Helper function to access private method
|
||||
function parseProviders(output: string) {
|
||||
return (
|
||||
provider as unknown as {
|
||||
parseProvidersOutput: (output: string) => Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
authenticated: boolean;
|
||||
authMethod?: 'oauth' | 'api_key';
|
||||
}>;
|
||||
}
|
||||
).parseProvidersOutput(output);
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// Critical Fix Validation
|
||||
// =======================================================================
|
||||
|
||||
describe('Critical Fix Validation', () => {
|
||||
it('should map "z.ai coding plan" to "zai-coding-plan" (NOT "z-ai")', () => {
|
||||
const output = '● z.ai coding plan oauth';
|
||||
const result = parseProviders(output);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('zai-coding-plan');
|
||||
expect(result[0].name).toBe('z.ai coding plan');
|
||||
expect(result[0].authMethod).toBe('oauth');
|
||||
});
|
||||
|
||||
it('should map "z.ai" to "z-ai" (different from coding plan)', () => {
|
||||
const output = '● z.ai api';
|
||||
const result = parseProviders(output);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('z-ai');
|
||||
expect(result[0].name).toBe('z.ai');
|
||||
expect(result[0].authMethod).toBe('api_key');
|
||||
});
|
||||
|
||||
it('should distinguish between "z.ai coding plan" and "z.ai"', () => {
|
||||
const output = '● z.ai coding plan oauth\n● z.ai api';
|
||||
const result = parseProviders(output);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].id).toBe('zai-coding-plan');
|
||||
expect(result[0].name).toBe('z.ai coding plan');
|
||||
expect(result[1].id).toBe('z-ai');
|
||||
expect(result[1].name).toBe('z.ai');
|
||||
});
|
||||
});
|
||||
|
||||
// =======================================================================
|
||||
// Provider Name Mapping
|
||||
// =======================================================================
|
||||
|
||||
describe('Provider Name Mapping', () => {
|
||||
it('should map all 12 providers correctly', () => {
|
||||
const output = `● anthropic oauth
|
||||
● github copilot oauth
|
||||
● google api
|
||||
● openai api
|
||||
● openrouter api
|
||||
● azure api
|
||||
● amazon bedrock oauth
|
||||
● ollama api
|
||||
● lm studio api
|
||||
● opencode oauth
|
||||
● z.ai coding plan oauth
|
||||
● z.ai api`;
|
||||
|
||||
const result = parseProviders(output);
|
||||
|
||||
expect(result).toHaveLength(12);
|
||||
expect(result.map((p) => p.id)).toEqual([
|
||||
'anthropic',
|
||||
'github-copilot',
|
||||
'google',
|
||||
'openai',
|
||||
'openrouter',
|
||||
'azure',
|
||||
'amazon-bedrock',
|
||||
'ollama',
|
||||
'lmstudio',
|
||||
'opencode',
|
||||
'zai-coding-plan',
|
||||
'z-ai',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle case-insensitive provider names and preserve original casing', () => {
|
||||
const output = '● Anthropic api\n● OPENAI oauth\n● GitHub Copilot oauth';
|
||||
const result = parseProviders(output);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0].id).toBe('anthropic');
|
||||
expect(result[0].name).toBe('Anthropic'); // Preserves casing
|
||||
expect(result[1].id).toBe('openai');
|
||||
expect(result[1].name).toBe('OPENAI'); // Preserves casing
|
||||
expect(result[2].id).toBe('github-copilot');
|
||||
expect(result[2].name).toBe('GitHub Copilot'); // Preserves casing
|
||||
});
|
||||
|
||||
it('should handle multi-word provider names with spaces', () => {
|
||||
const output = '● Amazon Bedrock oauth\n● LM Studio api\n● GitHub Copilot oauth';
|
||||
const result = parseProviders(output);
|
||||
|
||||
expect(result[0].id).toBe('amazon-bedrock');
|
||||
expect(result[0].name).toBe('Amazon Bedrock');
|
||||
expect(result[1].id).toBe('lmstudio');
|
||||
expect(result[1].name).toBe('LM Studio');
|
||||
expect(result[2].id).toBe('github-copilot');
|
||||
expect(result[2].name).toBe('GitHub Copilot');
|
||||
});
|
||||
});
|
||||
|
||||
// =======================================================================
|
||||
// Duplicate Aliases
|
||||
// =======================================================================
|
||||
|
||||
describe('Duplicate Aliases', () => {
|
||||
it('should map provider aliases to the same ID', () => {
|
||||
// Test copilot variants
|
||||
const copilot1 = parseProviders('● copilot oauth');
|
||||
const copilot2 = parseProviders('● github copilot oauth');
|
||||
expect(copilot1[0].id).toBe('github-copilot');
|
||||
expect(copilot2[0].id).toBe('github-copilot');
|
||||
|
||||
// Test bedrock variants
|
||||
const bedrock1 = parseProviders('● bedrock oauth');
|
||||
const bedrock2 = parseProviders('● amazon bedrock oauth');
|
||||
expect(bedrock1[0].id).toBe('amazon-bedrock');
|
||||
expect(bedrock2[0].id).toBe('amazon-bedrock');
|
||||
|
||||
// Test lmstudio variants
|
||||
const lm1 = parseProviders('● lmstudio api');
|
||||
const lm2 = parseProviders('● lm studio api');
|
||||
expect(lm1[0].id).toBe('lmstudio');
|
||||
expect(lm2[0].id).toBe('lmstudio');
|
||||
});
|
||||
});
|
||||
|
||||
// =======================================================================
|
||||
// Authentication Methods
|
||||
// =======================================================================
|
||||
|
||||
describe('Authentication Methods', () => {
|
||||
it('should detect oauth and api_key auth methods', () => {
|
||||
const output = '● anthropic oauth\n● openai api\n● google api_key';
|
||||
const result = parseProviders(output);
|
||||
|
||||
expect(result[0].authMethod).toBe('oauth');
|
||||
expect(result[1].authMethod).toBe('api_key');
|
||||
expect(result[2].authMethod).toBe('api_key');
|
||||
});
|
||||
|
||||
it('should set authenticated to true and handle case-insensitive auth methods', () => {
|
||||
const output = '● anthropic OAuth\n● openai API';
|
||||
const result = parseProviders(output);
|
||||
|
||||
expect(result[0].authenticated).toBe(true);
|
||||
expect(result[0].authMethod).toBe('oauth');
|
||||
expect(result[1].authenticated).toBe(true);
|
||||
expect(result[1].authMethod).toBe('api_key');
|
||||
});
|
||||
|
||||
it('should return undefined authMethod for unknown auth types', () => {
|
||||
const output = '● anthropic unknown-auth';
|
||||
const result = parseProviders(output);
|
||||
|
||||
expect(result[0].authenticated).toBe(true);
|
||||
expect(result[0].authMethod).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// =======================================================================
|
||||
// ANSI Escape Sequences
|
||||
// =======================================================================
|
||||
|
||||
describe('ANSI Escape Sequences', () => {
|
||||
it('should strip ANSI color codes from output', () => {
|
||||
const output = '\x1b[32m● anthropic oauth\x1b[0m';
|
||||
const result = parseProviders(output);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('anthropic');
|
||||
expect(result[0].name).toBe('anthropic');
|
||||
});
|
||||
|
||||
it('should handle complex ANSI sequences and codes in provider names', () => {
|
||||
const output =
|
||||
'\x1b[1;32m●\x1b[0m \x1b[33mgit\x1b[32mhub\x1b[0m copilot\x1b[0m \x1b[36moauth\x1b[0m';
|
||||
const result = parseProviders(output);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('github-copilot');
|
||||
});
|
||||
});
|
||||
|
||||
// =======================================================================
|
||||
// Edge Cases
|
||||
// =======================================================================
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should return empty array for empty output or no ● symbols', () => {
|
||||
expect(parseProviders('')).toEqual([]);
|
||||
expect(parseProviders('anthropic oauth\nopenai api')).toEqual([]);
|
||||
expect(parseProviders('No authenticated providers')).toEqual([]);
|
||||
});
|
||||
|
||||
it('should skip malformed lines with ● but insufficient content', () => {
|
||||
const output = '●\n● \n● anthropic\n● openai api';
|
||||
const result = parseProviders(output);
|
||||
|
||||
// Only the last line has both provider name and auth method
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('openai');
|
||||
});
|
||||
|
||||
it('should use fallback for unknown providers (spaces to hyphens)', () => {
|
||||
const output = '● unknown provider name oauth';
|
||||
const result = parseProviders(output);
|
||||
|
||||
expect(result[0].id).toBe('unknown-provider-name');
|
||||
expect(result[0].name).toBe('unknown provider name');
|
||||
});
|
||||
|
||||
it('should handle extra whitespace and mixed case', () => {
|
||||
const output = '● AnThRoPiC oauth';
|
||||
const result = parseProviders(output);
|
||||
|
||||
expect(result[0].id).toBe('anthropic');
|
||||
expect(result[0].name).toBe('AnThRoPiC');
|
||||
});
|
||||
|
||||
it('should handle multiple ● symbols on same line', () => {
|
||||
const output = '● ● anthropic oauth';
|
||||
const result = parseProviders(output);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('anthropic');
|
||||
});
|
||||
|
||||
it('should handle different newline formats and trailing newlines', () => {
|
||||
const outputUnix = '● anthropic oauth\n● openai api';
|
||||
const outputWindows = '● anthropic oauth\r\n● openai api\r\n\r\n';
|
||||
|
||||
const resultUnix = parseProviders(outputUnix);
|
||||
const resultWindows = parseProviders(outputWindows);
|
||||
|
||||
expect(resultUnix).toHaveLength(2);
|
||||
expect(resultWindows).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should handle provider names with numbers and special characters', () => {
|
||||
const output = '● gpt-4o api';
|
||||
const result = parseProviders(output);
|
||||
|
||||
expect(result[0].id).toBe('gpt-4o');
|
||||
expect(result[0].name).toBe('gpt-4o');
|
||||
});
|
||||
});
|
||||
|
||||
// =======================================================================
|
||||
// Real-world CLI Output
|
||||
// =======================================================================
|
||||
|
||||
describe('Real-world CLI Output', () => {
|
||||
it('should parse CLI output with box drawing characters and decorations', () => {
|
||||
const output = `┌─────────────────────────────────────────────────┐
|
||||
│ Authenticated Providers │
|
||||
├─────────────────────────────────────────────────┤
|
||||
● anthropic oauth
|
||||
● openai api
|
||||
└─────────────────────────────────────────────────┘`;
|
||||
|
||||
const result = parseProviders(output);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].id).toBe('anthropic');
|
||||
expect(result[1].id).toBe('openai');
|
||||
});
|
||||
|
||||
it('should parse output with ANSI colors and box characters', () => {
|
||||
const output = `\x1b[1m┌─────────────────────────────────────────────────┐\x1b[0m
|
||||
\x1b[1m│ Authenticated Providers │\x1b[0m
|
||||
\x1b[1m├─────────────────────────────────────────────────┤\x1b[0m
|
||||
\x1b[32m●\x1b[0m \x1b[33manthropic\x1b[0m \x1b[36moauth\x1b[0m
|
||||
\x1b[32m●\x1b[0m \x1b[33mgoogle\x1b[0m \x1b[36mapi\x1b[0m
|
||||
\x1b[1m└─────────────────────────────────────────────────┘\x1b[0m`;
|
||||
|
||||
const result = parseProviders(output);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].id).toBe('anthropic');
|
||||
expect(result[1].id).toBe('google');
|
||||
});
|
||||
|
||||
it('should handle "no authenticated providers" message', () => {
|
||||
const output = `┌─────────────────────────────────────────────────┐
|
||||
│ No authenticated providers found │
|
||||
└─────────────────────────────────────────────────┘`;
|
||||
|
||||
const result = parseProviders(output);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@automaker/ui",
|
||||
"version": "0.13.0",
|
||||
"version": "0.12.0",
|
||||
"description": "An autonomous AI development studio that helps you build software faster using AI-powered agents",
|
||||
"homepage": "https://github.com/AutoMaker-Org/automaker",
|
||||
"repository": {
|
||||
|
||||
@@ -45,8 +45,6 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
||||
setCardBorderOpacity,
|
||||
setHideScrollbar,
|
||||
clearBoardBackground,
|
||||
persistSettings,
|
||||
getCurrentSettings,
|
||||
} = useBoardBackgroundSettings();
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
@@ -57,31 +55,12 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
||||
const backgroundSettings =
|
||||
(currentProject && boardBackgroundByProject[currentProject.path]) || defaultBackgroundSettings;
|
||||
|
||||
// Local state for sliders during dragging (avoids store updates during drag)
|
||||
const [localCardOpacity, setLocalCardOpacity] = useState(backgroundSettings.cardOpacity);
|
||||
const [localColumnOpacity, setLocalColumnOpacity] = useState(backgroundSettings.columnOpacity);
|
||||
const [localCardBorderOpacity, setLocalCardBorderOpacity] = useState(
|
||||
backgroundSettings.cardBorderOpacity
|
||||
);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
// Sync local state with store when not dragging (e.g., on modal open or external changes)
|
||||
useEffect(() => {
|
||||
if (!isDragging) {
|
||||
setLocalCardOpacity(backgroundSettings.cardOpacity);
|
||||
setLocalColumnOpacity(backgroundSettings.columnOpacity);
|
||||
setLocalCardBorderOpacity(backgroundSettings.cardBorderOpacity);
|
||||
}
|
||||
}, [
|
||||
isDragging,
|
||||
backgroundSettings.cardOpacity,
|
||||
backgroundSettings.columnOpacity,
|
||||
backgroundSettings.cardBorderOpacity,
|
||||
]);
|
||||
|
||||
const cardOpacity = backgroundSettings.cardOpacity;
|
||||
const columnOpacity = backgroundSettings.columnOpacity;
|
||||
const columnBorderEnabled = backgroundSettings.columnBorderEnabled;
|
||||
const cardGlassmorphism = backgroundSettings.cardGlassmorphism;
|
||||
const cardBorderEnabled = backgroundSettings.cardBorderEnabled;
|
||||
const cardBorderOpacity = backgroundSettings.cardBorderOpacity;
|
||||
const hideScrollbar = backgroundSettings.hideScrollbar;
|
||||
const imageVersion = backgroundSettings.imageVersion;
|
||||
|
||||
@@ -219,40 +198,21 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
||||
}
|
||||
}, [currentProject, clearBoardBackground]);
|
||||
|
||||
// Live update local state during drag (modal-only, no store update)
|
||||
const handleCardOpacityChange = useCallback((value: number[]) => {
|
||||
setIsDragging(true);
|
||||
setLocalCardOpacity(value[0]);
|
||||
}, []);
|
||||
|
||||
// Update store and persist when slider is released
|
||||
const handleCardOpacityCommit = useCallback(
|
||||
(value: number[]) => {
|
||||
// Live update opacity when sliders change (with persistence)
|
||||
const handleCardOpacityChange = useCallback(
|
||||
async (value: number[]) => {
|
||||
if (!currentProject) return;
|
||||
setIsDragging(false);
|
||||
setCardOpacity(currentProject.path, value[0]);
|
||||
const current = getCurrentSettings(currentProject.path);
|
||||
persistSettings(currentProject.path, { ...current, cardOpacity: value[0] });
|
||||
await setCardOpacity(currentProject.path, value[0]);
|
||||
},
|
||||
[currentProject, setCardOpacity, getCurrentSettings, persistSettings]
|
||||
[currentProject, setCardOpacity]
|
||||
);
|
||||
|
||||
// Live update local state during drag (modal-only, no store update)
|
||||
const handleColumnOpacityChange = useCallback((value: number[]) => {
|
||||
setIsDragging(true);
|
||||
setLocalColumnOpacity(value[0]);
|
||||
}, []);
|
||||
|
||||
// Update store and persist when slider is released
|
||||
const handleColumnOpacityCommit = useCallback(
|
||||
(value: number[]) => {
|
||||
const handleColumnOpacityChange = useCallback(
|
||||
async (value: number[]) => {
|
||||
if (!currentProject) return;
|
||||
setIsDragging(false);
|
||||
setColumnOpacity(currentProject.path, value[0]);
|
||||
const current = getCurrentSettings(currentProject.path);
|
||||
persistSettings(currentProject.path, { ...current, columnOpacity: value[0] });
|
||||
await setColumnOpacity(currentProject.path, value[0]);
|
||||
},
|
||||
[currentProject, setColumnOpacity, getCurrentSettings, persistSettings]
|
||||
[currentProject, setColumnOpacity]
|
||||
);
|
||||
|
||||
const handleColumnBorderToggle = useCallback(
|
||||
@@ -279,22 +239,12 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
||||
[currentProject, setCardBorderEnabled]
|
||||
);
|
||||
|
||||
// Live update local state during drag (modal-only, no store update)
|
||||
const handleCardBorderOpacityChange = useCallback((value: number[]) => {
|
||||
setIsDragging(true);
|
||||
setLocalCardBorderOpacity(value[0]);
|
||||
}, []);
|
||||
|
||||
// Update store and persist when slider is released
|
||||
const handleCardBorderOpacityCommit = useCallback(
|
||||
(value: number[]) => {
|
||||
const handleCardBorderOpacityChange = useCallback(
|
||||
async (value: number[]) => {
|
||||
if (!currentProject) return;
|
||||
setIsDragging(false);
|
||||
setCardBorderOpacity(currentProject.path, value[0]);
|
||||
const current = getCurrentSettings(currentProject.path);
|
||||
persistSettings(currentProject.path, { ...current, cardBorderOpacity: value[0] });
|
||||
await setCardBorderOpacity(currentProject.path, value[0]);
|
||||
},
|
||||
[currentProject, setCardBorderOpacity, getCurrentSettings, persistSettings]
|
||||
[currentProject, setCardBorderOpacity]
|
||||
);
|
||||
|
||||
const handleHideScrollbarToggle = useCallback(
|
||||
@@ -428,12 +378,11 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Card Opacity</Label>
|
||||
<span className="text-sm text-muted-foreground">{localCardOpacity}%</span>
|
||||
<span className="text-sm text-muted-foreground">{cardOpacity}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[localCardOpacity]}
|
||||
value={[cardOpacity]}
|
||||
onValueChange={handleCardOpacityChange}
|
||||
onValueCommit={handleCardOpacityCommit}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
@@ -444,12 +393,11 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Column Opacity</Label>
|
||||
<span className="text-sm text-muted-foreground">{localColumnOpacity}%</span>
|
||||
<span className="text-sm text-muted-foreground">{columnOpacity}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[localColumnOpacity]}
|
||||
value={[columnOpacity]}
|
||||
onValueChange={handleColumnOpacityChange}
|
||||
onValueCommit={handleColumnOpacityCommit}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
@@ -498,12 +446,11 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Card Border Opacity</Label>
|
||||
<span className="text-sm text-muted-foreground">{localCardBorderOpacity}%</span>
|
||||
<span className="text-sm text-muted-foreground">{cardBorderOpacity}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[localCardBorderOpacity]}
|
||||
value={[cardBorderOpacity]}
|
||||
onValueChange={handleCardBorderOpacityChange}
|
||||
onValueCommit={handleCardBorderOpacityCommit}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
|
||||
@@ -636,8 +636,10 @@ export function BoardView() {
|
||||
const result = await api.features.bulkUpdate(currentProject.path, featureIds, finalUpdates);
|
||||
|
||||
if (result.success) {
|
||||
// Invalidate React Query cache to refetch features with server-updated values
|
||||
loadFeatures();
|
||||
// Update local state
|
||||
featureIds.forEach((featureId) => {
|
||||
updateFeature(featureId, finalUpdates);
|
||||
});
|
||||
toast.success(`Updated ${result.updatedCount} features`);
|
||||
exitSelectionMode();
|
||||
} else {
|
||||
@@ -653,7 +655,7 @@ export function BoardView() {
|
||||
[
|
||||
currentProject,
|
||||
selectedFeatureIds,
|
||||
loadFeatures,
|
||||
updateFeature,
|
||||
exitSelectionMode,
|
||||
getPrimaryWorktreeBranch,
|
||||
addAndSelectWorktree,
|
||||
@@ -781,8 +783,10 @@ export function BoardView() {
|
||||
const result = await api.features.bulkUpdate(currentProject.path, featureIds, updates);
|
||||
|
||||
if (result.success) {
|
||||
// Invalidate React Query cache to refetch features with server-updated values
|
||||
loadFeatures();
|
||||
// Update local state for all features
|
||||
featureIds.forEach((featureId) => {
|
||||
updateFeature(featureId, updates);
|
||||
});
|
||||
toast.success(`Verified ${result.updatedCount} features`);
|
||||
exitSelectionMode();
|
||||
} else {
|
||||
@@ -794,7 +798,7 @@ export function BoardView() {
|
||||
logger.error('Bulk verify failed:', error);
|
||||
toast.error('Failed to verify features');
|
||||
}
|
||||
}, [currentProject, selectedFeatureIds, loadFeatures, exitSelectionMode]);
|
||||
}, [currentProject, selectedFeatureIds, updateFeature, exitSelectionMode]);
|
||||
|
||||
// Handler for addressing PR comments - creates a feature and starts it automatically
|
||||
const handleAddressPRComments = useCallback(
|
||||
|
||||
@@ -142,8 +142,7 @@ export function BoardHeader({
|
||||
onConcurrencyChange={onConcurrencyChange}
|
||||
isAutoModeRunning={isAutoModeRunning}
|
||||
onAutoModeToggle={onAutoModeToggle}
|
||||
skipVerificationInAutoMode={skipVerificationInAutoMode}
|
||||
onSkipVerificationChange={setSkipVerificationInAutoMode}
|
||||
onOpenAutoModeSettings={() => {}}
|
||||
onOpenPlanDialog={onOpenPlanDialog}
|
||||
showClaudeUsage={showClaudeUsage}
|
||||
showCodexUsage={showCodexUsage}
|
||||
|
||||
@@ -180,10 +180,8 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
'kanban-card-content h-full relative',
|
||||
reduceEffects ? 'shadow-none' : 'shadow-sm',
|
||||
'transition-all duration-200 ease-out',
|
||||
// Disable hover translate for in-progress cards to prevent gap showing gradient
|
||||
isInteractive &&
|
||||
!reduceEffects &&
|
||||
!isCurrentAutoTask &&
|
||||
'hover:-translate-y-0.5 hover:shadow-md hover:shadow-black/10 bg-transparent',
|
||||
!glassmorphism && 'backdrop-blur-[0px]!',
|
||||
!isCurrentAutoTask &&
|
||||
|
||||
@@ -35,10 +35,10 @@ export const LIST_COLUMNS: ColumnDef[] = [
|
||||
},
|
||||
{
|
||||
id: 'priority',
|
||||
label: 'Priority',
|
||||
label: '',
|
||||
sortable: true,
|
||||
width: 'w-20',
|
||||
minWidth: 'min-w-[60px]',
|
||||
width: 'w-18',
|
||||
minWidth: 'min-w-[16px]',
|
||||
align: 'center',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -128,7 +128,6 @@ export function MassEditDialog({
|
||||
// Field values
|
||||
const [model, setModel] = useState<ModelAlias>('claude-sonnet');
|
||||
const [thinkingLevel, setThinkingLevel] = useState<ThinkingLevel>('none');
|
||||
const [providerId, setProviderId] = useState<string | undefined>(undefined);
|
||||
const [planningMode, setPlanningMode] = useState<PlanningMode>('skip');
|
||||
const [requirePlanApproval, setRequirePlanApproval] = useState(false);
|
||||
const [priority, setPriority] = useState(2);
|
||||
@@ -163,7 +162,6 @@ export function MassEditDialog({
|
||||
});
|
||||
setModel(getInitialValue(selectedFeatures, 'model', 'claude-sonnet') as ModelAlias);
|
||||
setThinkingLevel(getInitialValue(selectedFeatures, 'thinkingLevel', 'none') as ThinkingLevel);
|
||||
setProviderId(undefined); // Features don't store providerId, but we track it after selection
|
||||
setPlanningMode(getInitialValue(selectedFeatures, 'planningMode', 'skip') as PlanningMode);
|
||||
setRequirePlanApproval(getInitialValue(selectedFeatures, 'requirePlanApproval', false));
|
||||
setPriority(getInitialValue(selectedFeatures, 'priority', 2));
|
||||
@@ -228,11 +226,10 @@ export function MassEditDialog({
|
||||
Select a specific model configuration
|
||||
</p>
|
||||
<PhaseModelSelector
|
||||
value={{ model, thinkingLevel, providerId }}
|
||||
value={{ model, thinkingLevel }}
|
||||
onChange={(entry: PhaseModelEntry) => {
|
||||
setModel(entry.model as ModelAlias);
|
||||
setThinkingLevel(entry.thinkingLevel || 'none');
|
||||
setProviderId(entry.providerId);
|
||||
// Auto-enable model and thinking level for apply state
|
||||
setApplyState((prev) => ({
|
||||
...prev,
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
HeaderActionsPanel,
|
||||
HeaderActionsPanelTrigger,
|
||||
} from '@/components/ui/header-actions-panel';
|
||||
import { Bot, Wand2, GitBranch, Zap, FastForward } from 'lucide-react';
|
||||
import { Bot, Wand2, Settings2, GitBranch, Zap } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { MobileUsageBar } from './mobile-usage-bar';
|
||||
|
||||
@@ -23,8 +23,7 @@ interface HeaderMobileMenuProps {
|
||||
// Auto mode
|
||||
isAutoModeRunning: boolean;
|
||||
onAutoModeToggle: (enabled: boolean) => void;
|
||||
skipVerificationInAutoMode: boolean;
|
||||
onSkipVerificationChange: (value: boolean) => void;
|
||||
onOpenAutoModeSettings: () => void;
|
||||
// Plan button
|
||||
onOpenPlanDialog: () => void;
|
||||
// Usage bar visibility
|
||||
@@ -42,8 +41,7 @@ export function HeaderMobileMenu({
|
||||
onConcurrencyChange,
|
||||
isAutoModeRunning,
|
||||
onAutoModeToggle,
|
||||
skipVerificationInAutoMode,
|
||||
onSkipVerificationChange,
|
||||
onOpenAutoModeSettings,
|
||||
onOpenPlanDialog,
|
||||
showClaudeUsage,
|
||||
showCodexUsage,
|
||||
@@ -68,23 +66,29 @@ export function HeaderMobileMenu({
|
||||
Controls
|
||||
</span>
|
||||
|
||||
{/* Auto Mode Section */}
|
||||
<div className="rounded-lg border border-border/50 overflow-hidden">
|
||||
{/* Auto Mode Toggle */}
|
||||
<div
|
||||
className="flex items-center justify-between p-3 cursor-pointer hover:bg-accent/50 transition-colors"
|
||||
onClick={() => onAutoModeToggle(!isAutoModeRunning)}
|
||||
data-testid="mobile-auto-mode-toggle-container"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap
|
||||
className={cn(
|
||||
'w-4 h-4',
|
||||
isAutoModeRunning ? 'text-yellow-500' : 'text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
<span className="text-sm font-medium">Auto Mode</span>
|
||||
</div>
|
||||
{/* Auto Mode Toggle */}
|
||||
<div
|
||||
className="flex items-center justify-between p-3 cursor-pointer hover:bg-accent/50 rounded-lg border border-border/50 transition-colors"
|
||||
onClick={() => onAutoModeToggle(!isAutoModeRunning)}
|
||||
data-testid="mobile-auto-mode-toggle-container"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap
|
||||
className={cn(
|
||||
'w-4 h-4',
|
||||
isAutoModeRunning ? 'text-yellow-500' : 'text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
<span className="text-sm font-medium">Auto Mode</span>
|
||||
<span
|
||||
className="text-[10px] font-medium text-muted-foreground bg-muted/60 px-1.5 py-0.5 rounded"
|
||||
data-testid="mobile-auto-mode-max-concurrency"
|
||||
title="Max concurrent agents"
|
||||
>
|
||||
{maxConcurrency}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="mobile-auto-mode-toggle"
|
||||
checked={isAutoModeRunning}
|
||||
@@ -92,51 +96,17 @@ export function HeaderMobileMenu({
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
data-testid="mobile-auto-mode-toggle"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Skip Verification Toggle */}
|
||||
<div
|
||||
className="flex items-center justify-between p-3 pl-9 cursor-pointer hover:bg-accent/50 border-t border-border/30 transition-colors"
|
||||
onClick={() => onSkipVerificationChange(!skipVerificationInAutoMode)}
|
||||
data-testid="mobile-skip-verification-toggle-container"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FastForward className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">Skip Verification</span>
|
||||
</div>
|
||||
<Switch
|
||||
id="mobile-skip-verification-toggle"
|
||||
checked={skipVerificationInAutoMode}
|
||||
onCheckedChange={onSkipVerificationChange}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
data-testid="mobile-skip-verification-toggle"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Concurrency Control */}
|
||||
<div
|
||||
className="p-3 pl-9 border-t border-border/30"
|
||||
data-testid="mobile-concurrency-control"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Bot className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">Max Agents</span>
|
||||
<span
|
||||
className="text-sm text-muted-foreground ml-auto"
|
||||
data-testid="mobile-concurrency-value"
|
||||
>
|
||||
{runningAgentsCount}/{maxConcurrency}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[maxConcurrency]}
|
||||
onValueChange={(value) => onConcurrencyChange(value[0])}
|
||||
min={1}
|
||||
max={10}
|
||||
step={1}
|
||||
className="w-full"
|
||||
data-testid="mobile-concurrency-slider"
|
||||
/>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenAutoModeSettings();
|
||||
}}
|
||||
className="p-1 rounded hover:bg-accent/50 transition-colors"
|
||||
title="Auto Mode Settings"
|
||||
data-testid="mobile-auto-mode-settings-button"
|
||||
>
|
||||
<Settings2 className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -159,6 +129,32 @@ export function HeaderMobileMenu({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Concurrency Control */}
|
||||
<div
|
||||
className="p-3 rounded-lg border border-border/50"
|
||||
data-testid="mobile-concurrency-control"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Bot className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Max Agents</span>
|
||||
<span
|
||||
className="text-sm text-muted-foreground ml-auto"
|
||||
data-testid="mobile-concurrency-value"
|
||||
>
|
||||
{runningAgentsCount}/{maxConcurrency}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[maxConcurrency]}
|
||||
onValueChange={(value) => onConcurrencyChange(value[0])}
|
||||
min={1}
|
||||
max={10}
|
||||
step={1}
|
||||
className="w-full"
|
||||
data-testid="mobile-concurrency-slider"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Plan Button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -487,15 +487,7 @@ export function useBoardActions({
|
||||
const handleStartImplementation = useCallback(
|
||||
async (feature: Feature) => {
|
||||
// Check capacity for the feature's specific worktree, not the current view
|
||||
// Normalize the branch name: if the feature's branch is the primary worktree branch,
|
||||
// treat it as null (main worktree) to match how running tasks are stored
|
||||
const rawBranchName = feature.branchName ?? null;
|
||||
const featureBranchName =
|
||||
currentProject?.path &&
|
||||
rawBranchName &&
|
||||
isPrimaryWorktreeBranch(currentProject.path, rawBranchName)
|
||||
? null
|
||||
: rawBranchName;
|
||||
const featureBranchName = feature.branchName ?? null;
|
||||
const featureWorktreeState = currentProject
|
||||
? getAutoModeState(currentProject.id, featureBranchName)
|
||||
: null;
|
||||
@@ -575,7 +567,6 @@ export function useBoardActions({
|
||||
handleRunFeature,
|
||||
currentProject,
|
||||
getAutoModeState,
|
||||
isPrimaryWorktreeBranch,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -128,22 +128,15 @@ export function useBoardDragDrop({
|
||||
const targetBranch = worktreeData.branch;
|
||||
const currentBranch = draggedFeature.branchName;
|
||||
|
||||
// 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 = 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
|
||||
// For other worktrees: compare branch names directly
|
||||
const isAlreadyOnTarget = worktreeData.isMain
|
||||
? !currentBranch // null or undefined means already on main
|
||||
: currentBranch === targetBranch;
|
||||
|
||||
if (isAlreadyOnTarget) {
|
||||
if (currentBranch === targetBranch) {
|
||||
return;
|
||||
}
|
||||
|
||||
// For main worktree, set branchName to undefined/null to indicate it should use main
|
||||
// For other worktrees, set branchName to the target branch
|
||||
const newBranchName = worktreeData.isMain ? undefined : targetBranch;
|
||||
|
||||
// Update feature's branchName
|
||||
updateFeature(featureId, { branchName: newBranchName });
|
||||
await persistFeatureUpdate(featureId, { branchName: newBranchName });
|
||||
|
||||
@@ -17,8 +17,11 @@ export function useRunningFeatures({ runningFeatureIds, features }: UseRunningFe
|
||||
|
||||
// Match by branchName only (worktreePath is no longer stored)
|
||||
if (feature.branchName) {
|
||||
// Check if branch names match - this handles both main worktree (any primary branch name)
|
||||
// and feature worktrees
|
||||
// Special case: if feature is on 'main' branch, it belongs to main worktree
|
||||
// irrespective of whether the branch name matches exactly (it should, but strict equality might fail if refs differ)
|
||||
if (worktree.isMain && feature.branchName === 'main') {
|
||||
return true;
|
||||
}
|
||||
return worktree.branch === feature.branchName;
|
||||
}
|
||||
|
||||
|
||||
@@ -104,10 +104,10 @@ export function ProjectClaudeSection({ project }: ProjectClaudeSectionProps) {
|
||||
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Active Provider for This Project</Label>
|
||||
<Label className="text-sm font-medium">Active Profile for This Project</Label>
|
||||
<Select value={selectValue} onValueChange={handleChange}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select provider" />
|
||||
<SelectValue placeholder="Select profile" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="global">
|
||||
|
||||
@@ -115,14 +115,11 @@ function PhaseOverrideItem({
|
||||
}
|
||||
}
|
||||
}
|
||||
// Default to model ID for built-in models (both short aliases and canonical IDs)
|
||||
// Default to model ID for built-in models
|
||||
const modelMap: Record<string, string> = {
|
||||
haiku: 'Claude Haiku',
|
||||
sonnet: 'Claude Sonnet',
|
||||
opus: 'Claude Opus',
|
||||
'claude-haiku': 'Claude Haiku',
|
||||
'claude-sonnet': 'Claude Sonnet',
|
||||
'claude-opus': 'Claude Opus',
|
||||
};
|
||||
return modelMap[entry.model] || entry.model;
|
||||
};
|
||||
|
||||
@@ -415,44 +415,6 @@ export function PhaseModelSelector({
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Check ClaudeCompatibleProvider models by model ID only (when providerId is not set)
|
||||
// This handles cases where features store model ID but not providerId
|
||||
for (const provider of enabledProviders) {
|
||||
const providerModel = provider.models?.find((m) => m.id === selectedModel);
|
||||
if (providerModel) {
|
||||
// Count providers of same type to determine if we need provider name suffix
|
||||
const sameTypeCount = enabledProviders.filter(
|
||||
(p) => p.providerType === provider.providerType
|
||||
).length;
|
||||
const suffix = sameTypeCount > 1 ? ` (${provider.name})` : '';
|
||||
// Add thinking level to label if not 'none'
|
||||
const thinkingLabel =
|
||||
selectedThinkingLevel !== 'none'
|
||||
? ` (${THINKING_LEVEL_LABELS[selectedThinkingLevel]} Thinking)`
|
||||
: '';
|
||||
// Get icon based on provider type
|
||||
const getIconForProviderType = () => {
|
||||
switch (provider.providerType) {
|
||||
case 'glm':
|
||||
return GlmIcon;
|
||||
case 'minimax':
|
||||
return MiniMaxIcon;
|
||||
case 'openrouter':
|
||||
return OpenRouterIcon;
|
||||
default:
|
||||
return getProviderIconForModel(providerModel.id) || OpenRouterIcon;
|
||||
}
|
||||
};
|
||||
return {
|
||||
id: selectedModel,
|
||||
label: `${providerModel.displayName}${suffix}${thinkingLabel}`,
|
||||
description: provider.name,
|
||||
provider: 'claude-compatible' as const,
|
||||
icon: getIconForProviderType(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [
|
||||
selectedModel,
|
||||
|
||||
@@ -52,7 +52,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
|
||||
// Generate unique ID for providers
|
||||
function generateProviderId(): string {
|
||||
return `provider-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
|
||||
return `provider-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
// Mask API key for display (show first 4 + last 4 chars)
|
||||
@@ -219,16 +219,11 @@ export function ApiProfilesSection() {
|
||||
mapsToClaudeModel: m.mapsToClaudeModel,
|
||||
}));
|
||||
|
||||
// Preserve enabled state when editing, default to true for new providers
|
||||
const existingProvider = editingProviderId
|
||||
? claudeCompatibleProviders.find((p) => p.id === editingProviderId)
|
||||
: undefined;
|
||||
|
||||
const providerData: ClaudeCompatibleProvider = {
|
||||
id: editingProviderId ?? generateProviderId(),
|
||||
name: formData.name.trim(),
|
||||
providerType: formData.providerType,
|
||||
enabled: existingProvider?.enabled ?? true,
|
||||
enabled: true,
|
||||
baseUrl: formData.baseUrl.trim(),
|
||||
// For fixed providers, always use inline
|
||||
apiKeySource: isFixedProvider ? 'inline' : formData.apiKeySource,
|
||||
|
||||
@@ -67,25 +67,9 @@ export function FileBrowserProvider({ children }: { children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
// No-op fallback for HMR transitions when context temporarily becomes unavailable
|
||||
const hmrFallback: FileBrowserContextValue = {
|
||||
openFileBrowser: async () => {
|
||||
console.warn('[HMR] FileBrowserContext not available, returning null');
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
export function useFileBrowser() {
|
||||
const context = useContext(FileBrowserContext);
|
||||
// During HMR, the context can temporarily be null as modules reload.
|
||||
// Instead of crashing the app, return a safe no-op fallback that will
|
||||
// be replaced once the provider re-mounts.
|
||||
if (!context) {
|
||||
if (import.meta.hot) {
|
||||
// In development with HMR active, gracefully degrade
|
||||
return hmrFallback;
|
||||
}
|
||||
// In production, this indicates a real bug - throw to help debug
|
||||
throw new Error('useFileBrowser must be used within FileBrowserProvider');
|
||||
}
|
||||
return context;
|
||||
|
||||
@@ -39,9 +39,6 @@ export function useUpdateGlobalSettings(options: UpdateGlobalSettingsOptions = {
|
||||
return useMutation({
|
||||
mutationFn: async (settings: Record<string, unknown>) => {
|
||||
const api = getElectronAPI();
|
||||
if (!api.settings) {
|
||||
throw new Error('Settings API not available');
|
||||
}
|
||||
// Use updateGlobal for partial updates
|
||||
const result = await api.settings.updateGlobal(settings);
|
||||
if (!result.success) {
|
||||
@@ -69,43 +66,33 @@ export function useUpdateGlobalSettings(options: UpdateGlobalSettingsOptions = {
|
||||
* @param projectPath - Optional path to the project (can also pass via mutation variables)
|
||||
* @returns Mutation for updating project settings
|
||||
*/
|
||||
interface ProjectSettingsWithPath {
|
||||
projectPath: string;
|
||||
settings: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function useUpdateProjectSettings(projectPath?: string) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (variables: Record<string, unknown> | ProjectSettingsWithPath) => {
|
||||
mutationFn: async (
|
||||
variables:
|
||||
| Record<string, unknown>
|
||||
| { projectPath: string; settings: Record<string, unknown> }
|
||||
) => {
|
||||
// Support both call patterns:
|
||||
// 1. useUpdateProjectSettings(projectPath) then mutate(settings)
|
||||
// 2. useUpdateProjectSettings() then mutate({ projectPath, settings })
|
||||
let path: string;
|
||||
let settings: Record<string, unknown>;
|
||||
|
||||
if (
|
||||
typeof variables === 'object' &&
|
||||
'projectPath' in variables &&
|
||||
'settings' in variables &&
|
||||
typeof variables.projectPath === 'string' &&
|
||||
typeof variables.settings === 'object'
|
||||
) {
|
||||
if ('projectPath' in variables && 'settings' in variables) {
|
||||
path = variables.projectPath;
|
||||
settings = variables.settings as Record<string, unknown>;
|
||||
settings = variables.settings;
|
||||
} else if (projectPath) {
|
||||
path = projectPath;
|
||||
settings = variables as Record<string, unknown>;
|
||||
settings = variables;
|
||||
} else {
|
||||
throw new Error('Project path is required');
|
||||
}
|
||||
|
||||
const api = getElectronAPI();
|
||||
if (!api.settings) {
|
||||
throw new Error('Settings API not available');
|
||||
}
|
||||
const result = await api.settings.updateProject(path, settings);
|
||||
const result = await api.settings.setProject(path, settings);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to update project settings');
|
||||
}
|
||||
@@ -135,12 +122,9 @@ export function useSaveCredentials() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (credentials: { anthropic?: string; google?: string; openai?: string }) => {
|
||||
mutationFn: async (credentials: Record<string, string>) => {
|
||||
const api = getElectronAPI();
|
||||
if (!api.settings) {
|
||||
throw new Error('Settings API not available');
|
||||
}
|
||||
const result = await api.settings.updateCredentials({ apiKeys: credentials });
|
||||
const result = await api.settings.setCredentials(credentials);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to save credentials');
|
||||
}
|
||||
|
||||
@@ -77,7 +77,6 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
getWorktreeKey,
|
||||
getMaxConcurrencyForWorktree,
|
||||
setMaxConcurrencyForWorktree,
|
||||
isPrimaryWorktreeBranch,
|
||||
} = useAppStore(
|
||||
useShallow((state) => ({
|
||||
autoModeByWorktree: state.autoModeByWorktree,
|
||||
@@ -91,7 +90,6 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
getWorktreeKey: state.getWorktreeKey,
|
||||
getMaxConcurrencyForWorktree: state.getMaxConcurrencyForWorktree,
|
||||
setMaxConcurrencyForWorktree: state.setMaxConcurrencyForWorktree,
|
||||
isPrimaryWorktreeBranch: state.isPrimaryWorktreeBranch,
|
||||
}))
|
||||
);
|
||||
|
||||
@@ -199,20 +197,8 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
}
|
||||
|
||||
// Extract branchName from event, defaulting to null (main worktree)
|
||||
const rawEventBranchName: string | null =
|
||||
'branchName' in event && event.branchName !== undefined ? event.branchName : null;
|
||||
|
||||
// Get projectPath for worktree lookup
|
||||
const eventProjectPath = 'projectPath' in event ? event.projectPath : currentProject?.path;
|
||||
|
||||
// Normalize branchName: convert primary worktree branch to null for consistent key lookup
|
||||
// This handles cases where the main branch is named something other than 'main' (e.g., 'master', 'develop')
|
||||
const eventBranchName: string | null =
|
||||
eventProjectPath &&
|
||||
rawEventBranchName &&
|
||||
isPrimaryWorktreeBranch(eventProjectPath, rawEventBranchName)
|
||||
? null
|
||||
: rawEventBranchName;
|
||||
'branchName' in event && event.branchName !== undefined ? event.branchName : null;
|
||||
|
||||
// Skip event if we couldn't determine the project
|
||||
if (!eventProjectId) {
|
||||
@@ -507,7 +493,6 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
currentProject?.path,
|
||||
getMaxConcurrencyForWorktree,
|
||||
setMaxConcurrencyForWorktree,
|
||||
isPrimaryWorktreeBranch,
|
||||
]);
|
||||
|
||||
// Start auto mode - calls backend to start the auto loop for this worktree
|
||||
|
||||
@@ -5,10 +5,6 @@ import { useUpdateProjectSettings } from '@/hooks/mutations';
|
||||
/**
|
||||
* Hook for managing board background settings with automatic persistence to server.
|
||||
* Uses React Query mutation for server persistence with automatic error handling.
|
||||
*
|
||||
* For sliders, the modal uses local state during dragging and calls:
|
||||
* - setCardOpacity/setColumnOpacity/setCardBorderOpacity to update store on commit
|
||||
* - persistSettings directly to save to server on commit
|
||||
*/
|
||||
export function useBoardBackgroundSettings() {
|
||||
const store = useAppStore();
|
||||
@@ -69,20 +65,22 @@ export function useBoardBackgroundSettings() {
|
||||
[store, persistSettings, getCurrentSettings]
|
||||
);
|
||||
|
||||
// Update store (called on slider commit to update the board view)
|
||||
const setCardOpacity = useCallback(
|
||||
(projectPath: string, opacity: number) => {
|
||||
async (projectPath: string, opacity: number) => {
|
||||
const current = getCurrentSettings(projectPath);
|
||||
store.setCardOpacity(projectPath, opacity);
|
||||
await persistSettings(projectPath, { ...current, cardOpacity: opacity });
|
||||
},
|
||||
[store]
|
||||
[store, persistSettings, getCurrentSettings]
|
||||
);
|
||||
|
||||
// Update store (called on slider commit to update the board view)
|
||||
const setColumnOpacity = useCallback(
|
||||
(projectPath: string, opacity: number) => {
|
||||
async (projectPath: string, opacity: number) => {
|
||||
const current = getCurrentSettings(projectPath);
|
||||
store.setColumnOpacity(projectPath, opacity);
|
||||
await persistSettings(projectPath, { ...current, columnOpacity: opacity });
|
||||
},
|
||||
[store]
|
||||
[store, persistSettings, getCurrentSettings]
|
||||
);
|
||||
|
||||
const setColumnBorderEnabled = useCallback(
|
||||
@@ -121,12 +119,16 @@ export function useBoardBackgroundSettings() {
|
||||
[store, persistSettings, getCurrentSettings]
|
||||
);
|
||||
|
||||
// Update store (called on slider commit to update the board view)
|
||||
const setCardBorderOpacity = useCallback(
|
||||
(projectPath: string, opacity: number) => {
|
||||
async (projectPath: string, opacity: number) => {
|
||||
const current = getCurrentSettings(projectPath);
|
||||
store.setCardBorderOpacity(projectPath, opacity);
|
||||
await persistSettings(projectPath, {
|
||||
...current,
|
||||
cardBorderOpacity: opacity,
|
||||
});
|
||||
},
|
||||
[store]
|
||||
[store, persistSettings, getCurrentSettings]
|
||||
);
|
||||
|
||||
const setHideScrollbar = useCallback(
|
||||
@@ -168,6 +170,5 @@ export function useBoardBackgroundSettings() {
|
||||
setHideScrollbar,
|
||||
clearBoardBackground,
|
||||
getCurrentSettings,
|
||||
persistSettings,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -208,13 +208,12 @@ export function parseLocalStorageSettings(): Partial<GlobalSettings> | null {
|
||||
worktreePanelCollapsed === 'true' || (state.worktreePanelCollapsed as boolean),
|
||||
lastProjectDir: lastProjectDir || (state.lastProjectDir as string),
|
||||
recentFolders: recentFolders ? JSON.parse(recentFolders) : (state.recentFolders as string[]),
|
||||
// Claude API Profiles (legacy)
|
||||
// Claude API Profiles
|
||||
claudeApiProfiles: (state.claudeApiProfiles as GlobalSettings['claudeApiProfiles']) ?? [],
|
||||
activeClaudeApiProfileId:
|
||||
(state.activeClaudeApiProfileId as GlobalSettings['activeClaudeApiProfileId']) ?? null,
|
||||
// Claude Compatible Providers (new system)
|
||||
claudeCompatibleProviders:
|
||||
(state.claudeCompatibleProviders as GlobalSettings['claudeCompatibleProviders']) ?? [],
|
||||
// Event hooks
|
||||
eventHooks: state.eventHooks as GlobalSettings['eventHooks'],
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to parse localStorage settings:', error);
|
||||
@@ -349,16 +348,6 @@ export function mergeSettings(
|
||||
merged.activeClaudeApiProfileId = localSettings.activeClaudeApiProfileId;
|
||||
}
|
||||
|
||||
// Claude Compatible Providers - preserve from localStorage if server is empty
|
||||
if (
|
||||
(!serverSettings.claudeCompatibleProviders ||
|
||||
serverSettings.claudeCompatibleProviders.length === 0) &&
|
||||
localSettings.claudeCompatibleProviders &&
|
||||
localSettings.claudeCompatibleProviders.length > 0
|
||||
) {
|
||||
merged.claudeCompatibleProviders = localSettings.claudeCompatibleProviders;
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
|
||||
@@ -895,15 +895,12 @@ function RootLayoutContent() {
|
||||
}
|
||||
|
||||
function RootLayout() {
|
||||
// Hide devtools on compact screens (mobile/tablet) to avoid overlap with sidebar settings
|
||||
const isCompact = useIsCompact();
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<FileBrowserProvider>
|
||||
<RootLayoutContent />
|
||||
</FileBrowserProvider>
|
||||
{SHOW_QUERY_DEVTOOLS && !isCompact ? (
|
||||
{SHOW_QUERY_DEVTOOLS ? (
|
||||
<ReactQueryDevtools initialIsOpen={false} buttonPosition="bottom-left" />
|
||||
) : null}
|
||||
</QueryClientProvider>
|
||||
|
||||
@@ -18,13 +18,7 @@ import {
|
||||
|
||||
const TEST_TEMP_DIR = createTempDirPath('list-view-priority-test');
|
||||
|
||||
// TODO: This test is skipped because setupRealProject only sets localStorage,
|
||||
// but the server's settings.json (set by setup-e2e-fixtures.mjs) takes precedence
|
||||
// with localStorageMigrated: true. The test creates features in a temp directory,
|
||||
// but the server loads from the E2E Test Project fixture path.
|
||||
// Fix: Either modify setupRealProject to also update server settings, or
|
||||
// have the test add features through the UI instead of on disk.
|
||||
test.describe.skip('List View Priority Column', () => {
|
||||
test.describe('List View Priority Column', () => {
|
||||
let projectPath: string;
|
||||
const projectName = `test-project-${Date.now()}`;
|
||||
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
/**
|
||||
* String utility functions for common text operations
|
||||
*/
|
||||
|
||||
/**
|
||||
* Truncate a string to a maximum length, adding an ellipsis if truncated
|
||||
* @param str - The string to truncate
|
||||
* @param maxLength - Maximum length of the result (including ellipsis)
|
||||
* @param ellipsis - The ellipsis string to use (default: '...')
|
||||
* @returns The truncated string
|
||||
*/
|
||||
export function truncate(str: string, maxLength: number, ellipsis: string = '...'): string {
|
||||
if (maxLength < ellipsis.length) {
|
||||
throw new Error(
|
||||
`maxLength (${maxLength}) must be at least the length of ellipsis (${ellipsis.length})`
|
||||
);
|
||||
}
|
||||
|
||||
if (str.length <= maxLength) {
|
||||
return str;
|
||||
}
|
||||
|
||||
return str.slice(0, maxLength - ellipsis.length) + ellipsis;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a string to kebab-case (e.g., "Hello World" -> "hello-world")
|
||||
* @param str - The string to convert
|
||||
* @returns The kebab-case string
|
||||
*/
|
||||
export function toKebabCase(str: string): string {
|
||||
return str
|
||||
.replace(/([a-z])([A-Z])/g, '$1-$2') // camelCase -> camel-Case
|
||||
.replace(/[\s_]+/g, '-') // spaces and underscores -> hyphens
|
||||
.replace(/[^a-zA-Z0-9-]/g, '') // remove non-alphanumeric (except hyphens)
|
||||
.replace(/-+/g, '-') // collapse multiple hyphens
|
||||
.replace(/^-|-$/g, '') // remove leading/trailing hyphens
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a string to camelCase (e.g., "hello-world" -> "helloWorld")
|
||||
* @param str - The string to convert
|
||||
* @returns The camelCase string
|
||||
*/
|
||||
export function toCamelCase(str: string): string {
|
||||
return str
|
||||
.replace(/[^a-zA-Z0-9\s_-]/g, '') // remove special characters
|
||||
.replace(/[-_\s]+(.)?/g, (_, char) => (char ? char.toUpperCase() : ''))
|
||||
.replace(/^[A-Z]/, (char) => char.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a string to PascalCase (e.g., "hello-world" -> "HelloWorld")
|
||||
* @param str - The string to convert
|
||||
* @returns The PascalCase string
|
||||
*/
|
||||
export function toPascalCase(str: string): string {
|
||||
const camel = toCamelCase(str);
|
||||
return camel.charAt(0).toUpperCase() + camel.slice(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Capitalize the first letter of a string
|
||||
* @param str - The string to capitalize
|
||||
* @returns The string with first letter capitalized
|
||||
*/
|
||||
export function capitalize(str: string): string {
|
||||
if (str.length === 0) {
|
||||
return str;
|
||||
}
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove duplicate whitespace from a string, preserving single spaces
|
||||
* @param str - The string to clean
|
||||
* @returns The string with duplicate whitespace removed
|
||||
*/
|
||||
export function collapseWhitespace(str: string): string {
|
||||
return str.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string is empty or contains only whitespace
|
||||
* @param str - The string to check
|
||||
* @returns True if the string is blank
|
||||
*/
|
||||
export function isBlank(str: string | null | undefined): boolean {
|
||||
return str === null || str === undefined || str.trim().length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string is not empty and contains non-whitespace characters
|
||||
* @param str - The string to check
|
||||
* @returns True if the string is not blank
|
||||
*/
|
||||
export function isNotBlank(str: string | null | undefined): boolean {
|
||||
return !isBlank(str);
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely parse a string to an integer, returning a default value on failure
|
||||
* @param str - The string to parse
|
||||
* @param defaultValue - The default value if parsing fails (default: 0)
|
||||
* @returns The parsed integer or the default value
|
||||
*/
|
||||
export function safeParseInt(str: string | null | undefined, defaultValue: number = 0): number {
|
||||
if (isBlank(str)) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
const parsed = parseInt(str!, 10);
|
||||
return isNaN(parsed) ? defaultValue : parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a slug from a string (URL-friendly identifier)
|
||||
* @param str - The string to convert to a slug
|
||||
* @param maxLength - Optional maximum length for the slug
|
||||
* @returns The slugified string
|
||||
*/
|
||||
export function slugify(str: string, maxLength?: number): string {
|
||||
let slug = str
|
||||
.toLowerCase()
|
||||
.normalize('NFD') // Normalize unicode characters
|
||||
.replace(/[\u0300-\u036f]/g, '') // Remove diacritics
|
||||
.replace(/[^a-z0-9\s-]/g, '') // Remove non-alphanumeric
|
||||
.replace(/\s+/g, '-') // Replace spaces with hyphens
|
||||
.replace(/-+/g, '-') // Collapse multiple hyphens
|
||||
.replace(/^-|-$/g, ''); // Remove leading/trailing hyphens
|
||||
|
||||
if (maxLength !== undefined && slug.length > maxLength) {
|
||||
// Truncate at word boundary if possible
|
||||
slug = slug.slice(0, maxLength);
|
||||
const lastHyphen = slug.lastIndexOf('-');
|
||||
if (lastHyphen > maxLength * 0.5) {
|
||||
slug = slug.slice(0, lastHyphen);
|
||||
}
|
||||
slug = slug.replace(/-$/g, ''); // Remove trailing hyphen after truncation
|
||||
}
|
||||
|
||||
return slug;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape special regex characters in a string
|
||||
* @param str - The string to escape
|
||||
* @returns The escaped string safe for use in a RegExp
|
||||
*/
|
||||
export function escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Pluralize a word based on count
|
||||
* @param word - The singular form of the word
|
||||
* @param count - The count to base pluralization on
|
||||
* @param pluralForm - Optional custom plural form (default: word + 's')
|
||||
* @returns The word in singular or plural form
|
||||
*/
|
||||
export function pluralize(word: string, count: number, pluralForm?: string): string {
|
||||
if (count === 1) {
|
||||
return word;
|
||||
}
|
||||
return pluralForm || `${word}s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a count with its associated word (e.g., "1 item", "3 items")
|
||||
* @param count - The count
|
||||
* @param singular - The singular form of the word
|
||||
* @param plural - Optional custom plural form
|
||||
* @returns Formatted string with count and word
|
||||
*/
|
||||
export function formatCount(count: number, singular: string, plural?: string): string {
|
||||
return `${count} ${pluralize(singular, count, plural)}`;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "automaker",
|
||||
"version": "0.13.0",
|
||||
"version": "0.12.0rc",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=22.0.0 <23.0.0"
|
||||
|
||||
Reference in New Issue
Block a user