mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-02 20:43:36 +00:00
Compare commits
6 Commits
main
...
stefandevo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3f3f5fbd1 | ||
|
|
f007ca2c80 | ||
|
|
8efd14c580 | ||
|
|
86e3892c66 | ||
|
|
8ffe69feb1 | ||
|
|
2ceab3d65e |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@automaker/server",
|
"name": "@automaker/server",
|
||||||
"version": "0.13.0",
|
"version": "0.12.0",
|
||||||
"description": "Backend server for Automaker - provides API for both web and Electron modes",
|
"description": "Backend server for Automaker - provides API for both web and Electron modes",
|
||||||
"author": "AutoMaker Team",
|
"author": "AutoMaker Team",
|
||||||
"license": "SEE LICENSE IN LICENSE",
|
"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) {
|
if (!phaseModel) {
|
||||||
phaseModel = DEFAULT_PHASE_MODELS[phase] || { model: 'sonnet' };
|
phaseModel = { model: 'sonnet' };
|
||||||
logger.debug(`${logPrefix} No ${phase} configured, using default: ${phaseModel.model}`);
|
logger.debug(`${logPrefix} No ${phase} configured, using default: sonnet`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve provider if providerId is set
|
// Resolve provider if providerId is set
|
||||||
|
|||||||
@@ -1042,7 +1042,7 @@ export class OpencodeProvider extends CliProvider {
|
|||||||
'lm studio': 'lmstudio',
|
'lm studio': 'lmstudio',
|
||||||
lmstudio: 'lmstudio',
|
lmstudio: 'lmstudio',
|
||||||
opencode: 'opencode',
|
opencode: 'opencode',
|
||||||
'z.ai coding plan': 'zai-coding-plan',
|
'z.ai coding plan': 'z-ai',
|
||||||
'z.ai': 'z-ai',
|
'z.ai': 'z-ai',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
getAutoLoadClaudeMdSetting,
|
getAutoLoadClaudeMdSetting,
|
||||||
getPromptCustomization,
|
getPromptCustomization,
|
||||||
getPhaseModelWithOverrides,
|
getPhaseModelWithOverrides,
|
||||||
getProviderByModelId,
|
|
||||||
} from '../../lib/settings-helpers.js';
|
} from '../../lib/settings-helpers.js';
|
||||||
|
|
||||||
const logger = createLogger('Suggestions');
|
const logger = createLogger('Suggestions');
|
||||||
@@ -187,22 +186,8 @@ ${prompts.suggestions.baseTemplate}`;
|
|||||||
});
|
});
|
||||||
model = resolved.model;
|
model = resolved.model;
|
||||||
thinkingLevel = resolved.thinkingLevel;
|
thinkingLevel = resolved.thinkingLevel;
|
||||||
|
// For overrides, just get credentials without a specific provider
|
||||||
// Try to find a provider for this model (e.g., GLM, MiniMax models)
|
credentials = await settingsService?.getCredentials();
|
||||||
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)
|
|
||||||
} else if (settingsService) {
|
} else if (settingsService) {
|
||||||
// Use settings-based model with provider info
|
// Use settings-based model with provider info
|
||||||
const phaseResult = await getPhaseModelWithOverrides(
|
const phaseResult = await getPhaseModelWithOverrides(
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ export function createGenerateCommitMessageHandler(
|
|||||||
worktreePath,
|
worktreePath,
|
||||||
'[GenerateCommitMessage]'
|
'[GenerateCommitMessage]'
|
||||||
);
|
);
|
||||||
const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
|
const { model } = resolvePhaseModel(phaseModelEntry);
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`Using model for commit message: ${model}`,
|
`Using model for commit message: ${model}`,
|
||||||
@@ -199,7 +199,6 @@ export function createGenerateCommitMessageHandler(
|
|||||||
maxTurns: 1,
|
maxTurns: 1,
|
||||||
allowedTools: [],
|
allowedTools: [],
|
||||||
readOnly: true,
|
readOnly: true,
|
||||||
thinkingLevel, // Pass thinking level for extended thinking support
|
|
||||||
claudeCompatibleProvider, // Pass provider for alternative endpoint configuration
|
claudeCompatibleProvider, // Pass provider for alternative endpoint configuration
|
||||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -75,21 +75,6 @@ import { getNotificationService } from './notification-service.js';
|
|||||||
|
|
||||||
const execAsync = promisify(exec);
|
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
|
// PlanningMode type is imported from @automaker/types
|
||||||
|
|
||||||
interface ParsedTask {
|
interface ParsedTask {
|
||||||
@@ -651,7 +636,7 @@ export class AutoModeService {
|
|||||||
iterationCount++;
|
iterationCount++;
|
||||||
try {
|
try {
|
||||||
// Count running features for THIS project/worktree only
|
// 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
|
// Check if we have capacity for this project/worktree
|
||||||
if (projectRunningCount >= projectState.config.maxConcurrency) {
|
if (projectRunningCount >= projectState.config.maxConcurrency) {
|
||||||
@@ -744,24 +729,20 @@ export class AutoModeService {
|
|||||||
/**
|
/**
|
||||||
* Get count of running features for a specific worktree
|
* Get count of running features for a specific worktree
|
||||||
* @param projectPath - The project path
|
* @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(
|
private getRunningCountForWorktree(projectPath: string, branchName: string | null): number {
|
||||||
projectPath: string,
|
const normalizedBranch = branchName === 'main' ? null : branchName;
|
||||||
branchName: string | null
|
|
||||||
): Promise<number> {
|
|
||||||
// Get the actual primary branch name for the project
|
|
||||||
const primaryBranch = await getCurrentBranch(projectPath);
|
|
||||||
|
|
||||||
let count = 0;
|
let count = 0;
|
||||||
for (const [, feature] of this.runningFeatures) {
|
for (const [, feature] of this.runningFeatures) {
|
||||||
// Filter by project path AND branchName to get accurate worktree-specific count
|
// Filter by project path AND branchName to get accurate worktree-specific count
|
||||||
const featureBranch = feature.branchName ?? null;
|
const featureBranch = feature.branchName ?? null;
|
||||||
if (branchName === null) {
|
if (normalizedBranch === null) {
|
||||||
// Main worktree: match features with branchName === null OR branchName matching primary branch
|
// Main worktree: match features with branchName === null OR branchName === "main"
|
||||||
const isPrimaryBranch =
|
if (
|
||||||
featureBranch === null || (primaryBranch && featureBranch === primaryBranch);
|
feature.projectPath === projectPath &&
|
||||||
if (feature.projectPath === projectPath && isPrimaryBranch) {
|
(featureBranch === null || featureBranch === 'main')
|
||||||
|
) {
|
||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -810,7 +791,7 @@ export class AutoModeService {
|
|||||||
// Remove from map
|
// Remove from map
|
||||||
this.autoLoopsByProject.delete(worktreeKey);
|
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);
|
const maxAgents = await this.resolveMaxConcurrency(projectPath, branchName);
|
||||||
|
|
||||||
// Get current running count for this worktree
|
// Get current running count for this worktree
|
||||||
const currentAgents = await this.getRunningCountForWorktree(projectPath, branchName);
|
const currentAgents = this.getRunningCountForWorktree(projectPath, branchName);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hasCapacity: currentAgents < maxAgents,
|
hasCapacity: currentAgents < maxAgents,
|
||||||
@@ -2976,10 +2957,6 @@ Format your response as a structured markdown document.`;
|
|||||||
// Features are stored in .automaker directory
|
// Features are stored in .automaker directory
|
||||||
const featuresDir = getFeaturesDir(projectPath);
|
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 {
|
try {
|
||||||
const entries = await secureFs.readdir(featuresDir, {
|
const entries = await secureFs.readdir(featuresDir, {
|
||||||
withFileTypes: true,
|
withFileTypes: true,
|
||||||
@@ -3019,21 +2996,17 @@ Format your response as a structured markdown document.`;
|
|||||||
(feature.planSpec.tasksCompleted ?? 0) < (feature.planSpec.tasksTotal ?? 0))
|
(feature.planSpec.tasksCompleted ?? 0) < (feature.planSpec.tasksTotal ?? 0))
|
||||||
) {
|
) {
|
||||||
// Filter by branchName:
|
// Filter by branchName:
|
||||||
// - If branchName is null (main worktree), include features with:
|
// - If branchName is null (main worktree), include features with branchName === null OR branchName === "main"
|
||||||
// - branchName === null, OR
|
|
||||||
// - branchName === primaryBranch (e.g., "main", "master", "develop")
|
|
||||||
// - If branchName is set, only include features with matching branchName
|
// - If branchName is set, only include features with matching branchName
|
||||||
const featureBranch = feature.branchName ?? null;
|
const featureBranch = feature.branchName ?? null;
|
||||||
if (branchName === null) {
|
if (branchName === null) {
|
||||||
// Main worktree: include features without branchName OR with branchName matching primary branch
|
// Main worktree: include features without branchName OR with branchName === "main"
|
||||||
// This handles repos where the primary branch is named something other than "main"
|
// This handles both correct (null) and legacy ("main") cases
|
||||||
const isPrimaryBranch =
|
if (featureBranch === null || featureBranch === 'main') {
|
||||||
featureBranch === null || (primaryBranch && featureBranch === primaryBranch);
|
|
||||||
if (isPrimaryBranch) {
|
|
||||||
pendingFeatures.push(feature);
|
pendingFeatures.push(feature);
|
||||||
} else {
|
} else {
|
||||||
logger.debug(
|
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 {
|
} else {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ import type { FeatureLoader } from './feature-loader.js';
|
|||||||
import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js';
|
import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js';
|
||||||
import { resolveModelString } from '@automaker/model-resolver';
|
import { resolveModelString } from '@automaker/model-resolver';
|
||||||
import { stripProviderPrefix } from '@automaker/types';
|
import { stripProviderPrefix } from '@automaker/types';
|
||||||
import { getPromptCustomization, getProviderByModelId } from '../lib/settings-helpers.js';
|
import { getPromptCustomization } from '../lib/settings-helpers.js';
|
||||||
|
|
||||||
const logger = createLogger('IdeationService');
|
const logger = createLogger('IdeationService');
|
||||||
|
|
||||||
@@ -208,27 +208,7 @@ export class IdeationService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Resolve model alias to canonical identifier (with prefix)
|
// Resolve model alias to canonical identifier (with prefix)
|
||||||
let modelId = resolveModelString(options?.model ?? 'sonnet');
|
const 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create SDK options
|
// Create SDK options
|
||||||
const sdkOptions = createChatOptions({
|
const sdkOptions = createChatOptions({
|
||||||
@@ -243,6 +223,9 @@ export class IdeationService {
|
|||||||
// Strip provider prefix - providers need bare model IDs
|
// Strip provider prefix - providers need bare model IDs
|
||||||
const bareModel = stripProviderPrefix(modelId);
|
const bareModel = stripProviderPrefix(modelId);
|
||||||
|
|
||||||
|
// Get credentials for API calls (uses hardcoded model, no phase setting)
|
||||||
|
const credentials = await this.settingsService?.getCredentials();
|
||||||
|
|
||||||
const executeOptions: ExecuteOptions = {
|
const executeOptions: ExecuteOptions = {
|
||||||
prompt: message,
|
prompt: message,
|
||||||
model: bareModel,
|
model: bareModel,
|
||||||
@@ -252,7 +235,6 @@ export class IdeationService {
|
|||||||
maxTurns: 1, // Single turn for ideation
|
maxTurns: 1, // Single turn for ideation
|
||||||
abortController: activeSession.abortController!,
|
abortController: activeSession.abortController!,
|
||||||
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined,
|
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined,
|
||||||
claudeCompatibleProvider, // Pass provider for alternative endpoint configuration
|
|
||||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
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');
|
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",
|
"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",
|
"description": "An autonomous AI development studio that helps you build software faster using AI-powered agents",
|
||||||
"homepage": "https://github.com/AutoMaker-Org/automaker",
|
"homepage": "https://github.com/AutoMaker-Org/automaker",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -45,8 +45,6 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
|||||||
setCardBorderOpacity,
|
setCardBorderOpacity,
|
||||||
setHideScrollbar,
|
setHideScrollbar,
|
||||||
clearBoardBackground,
|
clearBoardBackground,
|
||||||
persistSettings,
|
|
||||||
getCurrentSettings,
|
|
||||||
} = useBoardBackgroundSettings();
|
} = useBoardBackgroundSettings();
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
@@ -57,31 +55,12 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
|||||||
const backgroundSettings =
|
const backgroundSettings =
|
||||||
(currentProject && boardBackgroundByProject[currentProject.path]) || defaultBackgroundSettings;
|
(currentProject && boardBackgroundByProject[currentProject.path]) || defaultBackgroundSettings;
|
||||||
|
|
||||||
// Local state for sliders during dragging (avoids store updates during drag)
|
const cardOpacity = backgroundSettings.cardOpacity;
|
||||||
const [localCardOpacity, setLocalCardOpacity] = useState(backgroundSettings.cardOpacity);
|
const columnOpacity = backgroundSettings.columnOpacity;
|
||||||
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 columnBorderEnabled = backgroundSettings.columnBorderEnabled;
|
const columnBorderEnabled = backgroundSettings.columnBorderEnabled;
|
||||||
const cardGlassmorphism = backgroundSettings.cardGlassmorphism;
|
const cardGlassmorphism = backgroundSettings.cardGlassmorphism;
|
||||||
const cardBorderEnabled = backgroundSettings.cardBorderEnabled;
|
const cardBorderEnabled = backgroundSettings.cardBorderEnabled;
|
||||||
|
const cardBorderOpacity = backgroundSettings.cardBorderOpacity;
|
||||||
const hideScrollbar = backgroundSettings.hideScrollbar;
|
const hideScrollbar = backgroundSettings.hideScrollbar;
|
||||||
const imageVersion = backgroundSettings.imageVersion;
|
const imageVersion = backgroundSettings.imageVersion;
|
||||||
|
|
||||||
@@ -219,40 +198,21 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
|||||||
}
|
}
|
||||||
}, [currentProject, clearBoardBackground]);
|
}, [currentProject, clearBoardBackground]);
|
||||||
|
|
||||||
// Live update local state during drag (modal-only, no store update)
|
// Live update opacity when sliders change (with persistence)
|
||||||
const handleCardOpacityChange = useCallback((value: number[]) => {
|
const handleCardOpacityChange = useCallback(
|
||||||
setIsDragging(true);
|
async (value: number[]) => {
|
||||||
setLocalCardOpacity(value[0]);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Update store and persist when slider is released
|
|
||||||
const handleCardOpacityCommit = useCallback(
|
|
||||||
(value: number[]) => {
|
|
||||||
if (!currentProject) return;
|
if (!currentProject) return;
|
||||||
setIsDragging(false);
|
await setCardOpacity(currentProject.path, value[0]);
|
||||||
setCardOpacity(currentProject.path, value[0]);
|
|
||||||
const current = getCurrentSettings(currentProject.path);
|
|
||||||
persistSettings(currentProject.path, { ...current, cardOpacity: value[0] });
|
|
||||||
},
|
},
|
||||||
[currentProject, setCardOpacity, getCurrentSettings, persistSettings]
|
[currentProject, setCardOpacity]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Live update local state during drag (modal-only, no store update)
|
const handleColumnOpacityChange = useCallback(
|
||||||
const handleColumnOpacityChange = useCallback((value: number[]) => {
|
async (value: number[]) => {
|
||||||
setIsDragging(true);
|
|
||||||
setLocalColumnOpacity(value[0]);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Update store and persist when slider is released
|
|
||||||
const handleColumnOpacityCommit = useCallback(
|
|
||||||
(value: number[]) => {
|
|
||||||
if (!currentProject) return;
|
if (!currentProject) return;
|
||||||
setIsDragging(false);
|
await setColumnOpacity(currentProject.path, value[0]);
|
||||||
setColumnOpacity(currentProject.path, value[0]);
|
|
||||||
const current = getCurrentSettings(currentProject.path);
|
|
||||||
persistSettings(currentProject.path, { ...current, columnOpacity: value[0] });
|
|
||||||
},
|
},
|
||||||
[currentProject, setColumnOpacity, getCurrentSettings, persistSettings]
|
[currentProject, setColumnOpacity]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleColumnBorderToggle = useCallback(
|
const handleColumnBorderToggle = useCallback(
|
||||||
@@ -279,22 +239,12 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
|||||||
[currentProject, setCardBorderEnabled]
|
[currentProject, setCardBorderEnabled]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Live update local state during drag (modal-only, no store update)
|
const handleCardBorderOpacityChange = useCallback(
|
||||||
const handleCardBorderOpacityChange = useCallback((value: number[]) => {
|
async (value: number[]) => {
|
||||||
setIsDragging(true);
|
|
||||||
setLocalCardBorderOpacity(value[0]);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Update store and persist when slider is released
|
|
||||||
const handleCardBorderOpacityCommit = useCallback(
|
|
||||||
(value: number[]) => {
|
|
||||||
if (!currentProject) return;
|
if (!currentProject) return;
|
||||||
setIsDragging(false);
|
await setCardBorderOpacity(currentProject.path, value[0]);
|
||||||
setCardBorderOpacity(currentProject.path, value[0]);
|
|
||||||
const current = getCurrentSettings(currentProject.path);
|
|
||||||
persistSettings(currentProject.path, { ...current, cardBorderOpacity: value[0] });
|
|
||||||
},
|
},
|
||||||
[currentProject, setCardBorderOpacity, getCurrentSettings, persistSettings]
|
[currentProject, setCardBorderOpacity]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleHideScrollbarToggle = useCallback(
|
const handleHideScrollbarToggle = useCallback(
|
||||||
@@ -428,12 +378,11 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label>Card Opacity</Label>
|
<Label>Card Opacity</Label>
|
||||||
<span className="text-sm text-muted-foreground">{localCardOpacity}%</span>
|
<span className="text-sm text-muted-foreground">{cardOpacity}%</span>
|
||||||
</div>
|
</div>
|
||||||
<Slider
|
<Slider
|
||||||
value={[localCardOpacity]}
|
value={[cardOpacity]}
|
||||||
onValueChange={handleCardOpacityChange}
|
onValueChange={handleCardOpacityChange}
|
||||||
onValueCommit={handleCardOpacityCommit}
|
|
||||||
min={0}
|
min={0}
|
||||||
max={100}
|
max={100}
|
||||||
step={1}
|
step={1}
|
||||||
@@ -444,12 +393,11 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label>Column Opacity</Label>
|
<Label>Column Opacity</Label>
|
||||||
<span className="text-sm text-muted-foreground">{localColumnOpacity}%</span>
|
<span className="text-sm text-muted-foreground">{columnOpacity}%</span>
|
||||||
</div>
|
</div>
|
||||||
<Slider
|
<Slider
|
||||||
value={[localColumnOpacity]}
|
value={[columnOpacity]}
|
||||||
onValueChange={handleColumnOpacityChange}
|
onValueChange={handleColumnOpacityChange}
|
||||||
onValueCommit={handleColumnOpacityCommit}
|
|
||||||
min={0}
|
min={0}
|
||||||
max={100}
|
max={100}
|
||||||
step={1}
|
step={1}
|
||||||
@@ -498,12 +446,11 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label>Card Border Opacity</Label>
|
<Label>Card Border Opacity</Label>
|
||||||
<span className="text-sm text-muted-foreground">{localCardBorderOpacity}%</span>
|
<span className="text-sm text-muted-foreground">{cardBorderOpacity}%</span>
|
||||||
</div>
|
</div>
|
||||||
<Slider
|
<Slider
|
||||||
value={[localCardBorderOpacity]}
|
value={[cardBorderOpacity]}
|
||||||
onValueChange={handleCardBorderOpacityChange}
|
onValueChange={handleCardBorderOpacityChange}
|
||||||
onValueCommit={handleCardBorderOpacityCommit}
|
|
||||||
min={0}
|
min={0}
|
||||||
max={100}
|
max={100}
|
||||||
step={1}
|
step={1}
|
||||||
|
|||||||
@@ -636,8 +636,10 @@ export function BoardView() {
|
|||||||
const result = await api.features.bulkUpdate(currentProject.path, featureIds, finalUpdates);
|
const result = await api.features.bulkUpdate(currentProject.path, featureIds, finalUpdates);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// Invalidate React Query cache to refetch features with server-updated values
|
// Update local state
|
||||||
loadFeatures();
|
featureIds.forEach((featureId) => {
|
||||||
|
updateFeature(featureId, finalUpdates);
|
||||||
|
});
|
||||||
toast.success(`Updated ${result.updatedCount} features`);
|
toast.success(`Updated ${result.updatedCount} features`);
|
||||||
exitSelectionMode();
|
exitSelectionMode();
|
||||||
} else {
|
} else {
|
||||||
@@ -653,7 +655,7 @@ export function BoardView() {
|
|||||||
[
|
[
|
||||||
currentProject,
|
currentProject,
|
||||||
selectedFeatureIds,
|
selectedFeatureIds,
|
||||||
loadFeatures,
|
updateFeature,
|
||||||
exitSelectionMode,
|
exitSelectionMode,
|
||||||
getPrimaryWorktreeBranch,
|
getPrimaryWorktreeBranch,
|
||||||
addAndSelectWorktree,
|
addAndSelectWorktree,
|
||||||
@@ -781,8 +783,10 @@ export function BoardView() {
|
|||||||
const result = await api.features.bulkUpdate(currentProject.path, featureIds, updates);
|
const result = await api.features.bulkUpdate(currentProject.path, featureIds, updates);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// Invalidate React Query cache to refetch features with server-updated values
|
// Update local state for all features
|
||||||
loadFeatures();
|
featureIds.forEach((featureId) => {
|
||||||
|
updateFeature(featureId, updates);
|
||||||
|
});
|
||||||
toast.success(`Verified ${result.updatedCount} features`);
|
toast.success(`Verified ${result.updatedCount} features`);
|
||||||
exitSelectionMode();
|
exitSelectionMode();
|
||||||
} else {
|
} else {
|
||||||
@@ -794,7 +798,7 @@ export function BoardView() {
|
|||||||
logger.error('Bulk verify failed:', error);
|
logger.error('Bulk verify failed:', error);
|
||||||
toast.error('Failed to verify features');
|
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
|
// Handler for addressing PR comments - creates a feature and starts it automatically
|
||||||
const handleAddressPRComments = useCallback(
|
const handleAddressPRComments = useCallback(
|
||||||
|
|||||||
@@ -142,8 +142,7 @@ export function BoardHeader({
|
|||||||
onConcurrencyChange={onConcurrencyChange}
|
onConcurrencyChange={onConcurrencyChange}
|
||||||
isAutoModeRunning={isAutoModeRunning}
|
isAutoModeRunning={isAutoModeRunning}
|
||||||
onAutoModeToggle={onAutoModeToggle}
|
onAutoModeToggle={onAutoModeToggle}
|
||||||
skipVerificationInAutoMode={skipVerificationInAutoMode}
|
onOpenAutoModeSettings={() => {}}
|
||||||
onSkipVerificationChange={setSkipVerificationInAutoMode}
|
|
||||||
onOpenPlanDialog={onOpenPlanDialog}
|
onOpenPlanDialog={onOpenPlanDialog}
|
||||||
showClaudeUsage={showClaudeUsage}
|
showClaudeUsage={showClaudeUsage}
|
||||||
showCodexUsage={showCodexUsage}
|
showCodexUsage={showCodexUsage}
|
||||||
|
|||||||
@@ -180,10 +180,8 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
'kanban-card-content h-full relative',
|
'kanban-card-content h-full relative',
|
||||||
reduceEffects ? 'shadow-none' : 'shadow-sm',
|
reduceEffects ? 'shadow-none' : 'shadow-sm',
|
||||||
'transition-all duration-200 ease-out',
|
'transition-all duration-200 ease-out',
|
||||||
// Disable hover translate for in-progress cards to prevent gap showing gradient
|
|
||||||
isInteractive &&
|
isInteractive &&
|
||||||
!reduceEffects &&
|
!reduceEffects &&
|
||||||
!isCurrentAutoTask &&
|
|
||||||
'hover:-translate-y-0.5 hover:shadow-md hover:shadow-black/10 bg-transparent',
|
'hover:-translate-y-0.5 hover:shadow-md hover:shadow-black/10 bg-transparent',
|
||||||
!glassmorphism && 'backdrop-blur-[0px]!',
|
!glassmorphism && 'backdrop-blur-[0px]!',
|
||||||
!isCurrentAutoTask &&
|
!isCurrentAutoTask &&
|
||||||
|
|||||||
@@ -35,10 +35,10 @@ export const LIST_COLUMNS: ColumnDef[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'priority',
|
id: 'priority',
|
||||||
label: 'Priority',
|
label: '',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
width: 'w-20',
|
width: 'w-18',
|
||||||
minWidth: 'min-w-[60px]',
|
minWidth: 'min-w-[16px]',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -128,7 +128,6 @@ export function MassEditDialog({
|
|||||||
// Field values
|
// Field values
|
||||||
const [model, setModel] = useState<ModelAlias>('claude-sonnet');
|
const [model, setModel] = useState<ModelAlias>('claude-sonnet');
|
||||||
const [thinkingLevel, setThinkingLevel] = useState<ThinkingLevel>('none');
|
const [thinkingLevel, setThinkingLevel] = useState<ThinkingLevel>('none');
|
||||||
const [providerId, setProviderId] = useState<string | undefined>(undefined);
|
|
||||||
const [planningMode, setPlanningMode] = useState<PlanningMode>('skip');
|
const [planningMode, setPlanningMode] = useState<PlanningMode>('skip');
|
||||||
const [requirePlanApproval, setRequirePlanApproval] = useState(false);
|
const [requirePlanApproval, setRequirePlanApproval] = useState(false);
|
||||||
const [priority, setPriority] = useState(2);
|
const [priority, setPriority] = useState(2);
|
||||||
@@ -163,7 +162,6 @@ export function MassEditDialog({
|
|||||||
});
|
});
|
||||||
setModel(getInitialValue(selectedFeatures, 'model', 'claude-sonnet') as ModelAlias);
|
setModel(getInitialValue(selectedFeatures, 'model', 'claude-sonnet') as ModelAlias);
|
||||||
setThinkingLevel(getInitialValue(selectedFeatures, 'thinkingLevel', 'none') as ThinkingLevel);
|
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);
|
setPlanningMode(getInitialValue(selectedFeatures, 'planningMode', 'skip') as PlanningMode);
|
||||||
setRequirePlanApproval(getInitialValue(selectedFeatures, 'requirePlanApproval', false));
|
setRequirePlanApproval(getInitialValue(selectedFeatures, 'requirePlanApproval', false));
|
||||||
setPriority(getInitialValue(selectedFeatures, 'priority', 2));
|
setPriority(getInitialValue(selectedFeatures, 'priority', 2));
|
||||||
@@ -228,11 +226,10 @@ export function MassEditDialog({
|
|||||||
Select a specific model configuration
|
Select a specific model configuration
|
||||||
</p>
|
</p>
|
||||||
<PhaseModelSelector
|
<PhaseModelSelector
|
||||||
value={{ model, thinkingLevel, providerId }}
|
value={{ model, thinkingLevel }}
|
||||||
onChange={(entry: PhaseModelEntry) => {
|
onChange={(entry: PhaseModelEntry) => {
|
||||||
setModel(entry.model as ModelAlias);
|
setModel(entry.model as ModelAlias);
|
||||||
setThinkingLevel(entry.thinkingLevel || 'none');
|
setThinkingLevel(entry.thinkingLevel || 'none');
|
||||||
setProviderId(entry.providerId);
|
|
||||||
// Auto-enable model and thinking level for apply state
|
// Auto-enable model and thinking level for apply state
|
||||||
setApplyState((prev) => ({
|
setApplyState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
HeaderActionsPanel,
|
HeaderActionsPanel,
|
||||||
HeaderActionsPanelTrigger,
|
HeaderActionsPanelTrigger,
|
||||||
} from '@/components/ui/header-actions-panel';
|
} 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 { cn } from '@/lib/utils';
|
||||||
import { MobileUsageBar } from './mobile-usage-bar';
|
import { MobileUsageBar } from './mobile-usage-bar';
|
||||||
|
|
||||||
@@ -23,8 +23,7 @@ interface HeaderMobileMenuProps {
|
|||||||
// Auto mode
|
// Auto mode
|
||||||
isAutoModeRunning: boolean;
|
isAutoModeRunning: boolean;
|
||||||
onAutoModeToggle: (enabled: boolean) => void;
|
onAutoModeToggle: (enabled: boolean) => void;
|
||||||
skipVerificationInAutoMode: boolean;
|
onOpenAutoModeSettings: () => void;
|
||||||
onSkipVerificationChange: (value: boolean) => void;
|
|
||||||
// Plan button
|
// Plan button
|
||||||
onOpenPlanDialog: () => void;
|
onOpenPlanDialog: () => void;
|
||||||
// Usage bar visibility
|
// Usage bar visibility
|
||||||
@@ -42,8 +41,7 @@ export function HeaderMobileMenu({
|
|||||||
onConcurrencyChange,
|
onConcurrencyChange,
|
||||||
isAutoModeRunning,
|
isAutoModeRunning,
|
||||||
onAutoModeToggle,
|
onAutoModeToggle,
|
||||||
skipVerificationInAutoMode,
|
onOpenAutoModeSettings,
|
||||||
onSkipVerificationChange,
|
|
||||||
onOpenPlanDialog,
|
onOpenPlanDialog,
|
||||||
showClaudeUsage,
|
showClaudeUsage,
|
||||||
showCodexUsage,
|
showCodexUsage,
|
||||||
@@ -68,23 +66,29 @@ export function HeaderMobileMenu({
|
|||||||
Controls
|
Controls
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Auto Mode Section */}
|
{/* Auto Mode Toggle */}
|
||||||
<div className="rounded-lg border border-border/50 overflow-hidden">
|
<div
|
||||||
{/* Auto Mode Toggle */}
|
className="flex items-center justify-between p-3 cursor-pointer hover:bg-accent/50 rounded-lg border border-border/50 transition-colors"
|
||||||
<div
|
onClick={() => onAutoModeToggle(!isAutoModeRunning)}
|
||||||
className="flex items-center justify-between p-3 cursor-pointer hover:bg-accent/50 transition-colors"
|
data-testid="mobile-auto-mode-toggle-container"
|
||||||
onClick={() => onAutoModeToggle(!isAutoModeRunning)}
|
>
|
||||||
data-testid="mobile-auto-mode-toggle-container"
|
<div className="flex items-center gap-2">
|
||||||
>
|
<Zap
|
||||||
<div className="flex items-center gap-2">
|
className={cn(
|
||||||
<Zap
|
'w-4 h-4',
|
||||||
className={cn(
|
isAutoModeRunning ? 'text-yellow-500' : 'text-muted-foreground'
|
||||||
'w-4 h-4',
|
)}
|
||||||
isAutoModeRunning ? 'text-yellow-500' : 'text-muted-foreground'
|
/>
|
||||||
)}
|
<span className="text-sm font-medium">Auto Mode</span>
|
||||||
/>
|
<span
|
||||||
<span className="text-sm font-medium">Auto Mode</span>
|
className="text-[10px] font-medium text-muted-foreground bg-muted/60 px-1.5 py-0.5 rounded"
|
||||||
</div>
|
data-testid="mobile-auto-mode-max-concurrency"
|
||||||
|
title="Max concurrent agents"
|
||||||
|
>
|
||||||
|
{maxConcurrency}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<Switch
|
<Switch
|
||||||
id="mobile-auto-mode-toggle"
|
id="mobile-auto-mode-toggle"
|
||||||
checked={isAutoModeRunning}
|
checked={isAutoModeRunning}
|
||||||
@@ -92,51 +96,17 @@ export function HeaderMobileMenu({
|
|||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
data-testid="mobile-auto-mode-toggle"
|
data-testid="mobile-auto-mode-toggle"
|
||||||
/>
|
/>
|
||||||
</div>
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
{/* Skip Verification Toggle */}
|
e.stopPropagation();
|
||||||
<div
|
onOpenAutoModeSettings();
|
||||||
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)}
|
className="p-1 rounded hover:bg-accent/50 transition-colors"
|
||||||
data-testid="mobile-skip-verification-toggle-container"
|
title="Auto Mode Settings"
|
||||||
>
|
data-testid="mobile-auto-mode-settings-button"
|
||||||
<div className="flex items-center gap-2">
|
>
|
||||||
<FastForward className="w-4 h-4 text-muted-foreground" />
|
<Settings2 className="w-4 h-4 text-muted-foreground" />
|
||||||
<span className="text-sm text-muted-foreground">Skip Verification</span>
|
</button>
|
||||||
</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"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -159,6 +129,32 @@ export function HeaderMobileMenu({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 */}
|
{/* Plan Button */}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -487,15 +487,7 @@ export function useBoardActions({
|
|||||||
const handleStartImplementation = useCallback(
|
const handleStartImplementation = useCallback(
|
||||||
async (feature: Feature) => {
|
async (feature: Feature) => {
|
||||||
// Check capacity for the feature's specific worktree, not the current view
|
// 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,
|
const featureBranchName = feature.branchName ?? null;
|
||||||
// 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 featureWorktreeState = currentProject
|
const featureWorktreeState = currentProject
|
||||||
? getAutoModeState(currentProject.id, featureBranchName)
|
? getAutoModeState(currentProject.id, featureBranchName)
|
||||||
: null;
|
: null;
|
||||||
@@ -575,7 +567,6 @@ export function useBoardActions({
|
|||||||
handleRunFeature,
|
handleRunFeature,
|
||||||
currentProject,
|
currentProject,
|
||||||
getAutoModeState,
|
getAutoModeState,
|
||||||
isPrimaryWorktreeBranch,
|
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -128,22 +128,15 @@ export function useBoardDragDrop({
|
|||||||
const targetBranch = worktreeData.branch;
|
const targetBranch = worktreeData.branch;
|
||||||
const currentBranch = draggedFeature.branchName;
|
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
|
// If already on the same branch, nothing to do
|
||||||
// For main worktree: feature with null/undefined branchName is already on main
|
if (currentBranch === targetBranch) {
|
||||||
// For other worktrees: compare branch names directly
|
|
||||||
const isAlreadyOnTarget = worktreeData.isMain
|
|
||||||
? !currentBranch // null or undefined means already on main
|
|
||||||
: currentBranch === targetBranch;
|
|
||||||
|
|
||||||
if (isAlreadyOnTarget) {
|
|
||||||
return;
|
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
|
// Update feature's branchName
|
||||||
updateFeature(featureId, { branchName: newBranchName });
|
updateFeature(featureId, { branchName: newBranchName });
|
||||||
await persistFeatureUpdate(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)
|
// Match by branchName only (worktreePath is no longer stored)
|
||||||
if (feature.branchName) {
|
if (feature.branchName) {
|
||||||
// Check if branch names match - this handles both main worktree (any primary branch name)
|
// Special case: if feature is on 'main' branch, it belongs to main worktree
|
||||||
// and feature worktrees
|
// 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;
|
return worktree.branch === feature.branchName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -104,10 +104,10 @@ export function ProjectClaudeSection({ project }: ProjectClaudeSectionProps) {
|
|||||||
|
|
||||||
<div className="p-6 space-y-4">
|
<div className="p-6 space-y-4">
|
||||||
<div className="space-y-2">
|
<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}>
|
<Select value={selectValue} onValueChange={handleChange}>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue placeholder="Select provider" />
|
<SelectValue placeholder="Select profile" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="global">
|
<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> = {
|
const modelMap: Record<string, string> = {
|
||||||
haiku: 'Claude Haiku',
|
haiku: 'Claude Haiku',
|
||||||
sonnet: 'Claude Sonnet',
|
sonnet: 'Claude Sonnet',
|
||||||
opus: 'Claude Opus',
|
opus: 'Claude Opus',
|
||||||
'claude-haiku': 'Claude Haiku',
|
|
||||||
'claude-sonnet': 'Claude Sonnet',
|
|
||||||
'claude-opus': 'Claude Opus',
|
|
||||||
};
|
};
|
||||||
return modelMap[entry.model] || entry.model;
|
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;
|
return null;
|
||||||
}, [
|
}, [
|
||||||
selectedModel,
|
selectedModel,
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
|
|
||||||
// Generate unique ID for providers
|
// Generate unique ID for providers
|
||||||
function generateProviderId(): string {
|
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)
|
// Mask API key for display (show first 4 + last 4 chars)
|
||||||
@@ -219,16 +219,11 @@ export function ApiProfilesSection() {
|
|||||||
mapsToClaudeModel: m.mapsToClaudeModel,
|
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 = {
|
const providerData: ClaudeCompatibleProvider = {
|
||||||
id: editingProviderId ?? generateProviderId(),
|
id: editingProviderId ?? generateProviderId(),
|
||||||
name: formData.name.trim(),
|
name: formData.name.trim(),
|
||||||
providerType: formData.providerType,
|
providerType: formData.providerType,
|
||||||
enabled: existingProvider?.enabled ?? true,
|
enabled: true,
|
||||||
baseUrl: formData.baseUrl.trim(),
|
baseUrl: formData.baseUrl.trim(),
|
||||||
// For fixed providers, always use inline
|
// For fixed providers, always use inline
|
||||||
apiKeySource: isFixedProvider ? 'inline' : formData.apiKeySource,
|
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() {
|
export function useFileBrowser() {
|
||||||
const context = useContext(FileBrowserContext);
|
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 (!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');
|
throw new Error('useFileBrowser must be used within FileBrowserProvider');
|
||||||
}
|
}
|
||||||
return context;
|
return context;
|
||||||
|
|||||||
@@ -39,9 +39,6 @@ export function useUpdateGlobalSettings(options: UpdateGlobalSettingsOptions = {
|
|||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (settings: Record<string, unknown>) => {
|
mutationFn: async (settings: Record<string, unknown>) => {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
if (!api.settings) {
|
|
||||||
throw new Error('Settings API not available');
|
|
||||||
}
|
|
||||||
// Use updateGlobal for partial updates
|
// Use updateGlobal for partial updates
|
||||||
const result = await api.settings.updateGlobal(settings);
|
const result = await api.settings.updateGlobal(settings);
|
||||||
if (!result.success) {
|
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)
|
* @param projectPath - Optional path to the project (can also pass via mutation variables)
|
||||||
* @returns Mutation for updating project settings
|
* @returns Mutation for updating project settings
|
||||||
*/
|
*/
|
||||||
interface ProjectSettingsWithPath {
|
|
||||||
projectPath: string;
|
|
||||||
settings: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpdateProjectSettings(projectPath?: string) {
|
export function useUpdateProjectSettings(projectPath?: string) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
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:
|
// Support both call patterns:
|
||||||
// 1. useUpdateProjectSettings(projectPath) then mutate(settings)
|
// 1. useUpdateProjectSettings(projectPath) then mutate(settings)
|
||||||
// 2. useUpdateProjectSettings() then mutate({ projectPath, settings })
|
// 2. useUpdateProjectSettings() then mutate({ projectPath, settings })
|
||||||
let path: string;
|
let path: string;
|
||||||
let settings: Record<string, unknown>;
|
let settings: Record<string, unknown>;
|
||||||
|
|
||||||
if (
|
if ('projectPath' in variables && 'settings' in variables) {
|
||||||
typeof variables === 'object' &&
|
|
||||||
'projectPath' in variables &&
|
|
||||||
'settings' in variables &&
|
|
||||||
typeof variables.projectPath === 'string' &&
|
|
||||||
typeof variables.settings === 'object'
|
|
||||||
) {
|
|
||||||
path = variables.projectPath;
|
path = variables.projectPath;
|
||||||
settings = variables.settings as Record<string, unknown>;
|
settings = variables.settings;
|
||||||
} else if (projectPath) {
|
} else if (projectPath) {
|
||||||
path = projectPath;
|
path = projectPath;
|
||||||
settings = variables as Record<string, unknown>;
|
settings = variables;
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Project path is required');
|
throw new Error('Project path is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
if (!api.settings) {
|
const result = await api.settings.setProject(path, settings);
|
||||||
throw new Error('Settings API not available');
|
|
||||||
}
|
|
||||||
const result = await api.settings.updateProject(path, settings);
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new Error(result.error || 'Failed to update project settings');
|
throw new Error(result.error || 'Failed to update project settings');
|
||||||
}
|
}
|
||||||
@@ -135,12 +122,9 @@ export function useSaveCredentials() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (credentials: { anthropic?: string; google?: string; openai?: string }) => {
|
mutationFn: async (credentials: Record<string, string>) => {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
if (!api.settings) {
|
const result = await api.settings.setCredentials(credentials);
|
||||||
throw new Error('Settings API not available');
|
|
||||||
}
|
|
||||||
const result = await api.settings.updateCredentials({ apiKeys: credentials });
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new Error(result.error || 'Failed to save credentials');
|
throw new Error(result.error || 'Failed to save credentials');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,7 +77,6 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
|||||||
getWorktreeKey,
|
getWorktreeKey,
|
||||||
getMaxConcurrencyForWorktree,
|
getMaxConcurrencyForWorktree,
|
||||||
setMaxConcurrencyForWorktree,
|
setMaxConcurrencyForWorktree,
|
||||||
isPrimaryWorktreeBranch,
|
|
||||||
} = useAppStore(
|
} = useAppStore(
|
||||||
useShallow((state) => ({
|
useShallow((state) => ({
|
||||||
autoModeByWorktree: state.autoModeByWorktree,
|
autoModeByWorktree: state.autoModeByWorktree,
|
||||||
@@ -91,7 +90,6 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
|||||||
getWorktreeKey: state.getWorktreeKey,
|
getWorktreeKey: state.getWorktreeKey,
|
||||||
getMaxConcurrencyForWorktree: state.getMaxConcurrencyForWorktree,
|
getMaxConcurrencyForWorktree: state.getMaxConcurrencyForWorktree,
|
||||||
setMaxConcurrencyForWorktree: state.setMaxConcurrencyForWorktree,
|
setMaxConcurrencyForWorktree: state.setMaxConcurrencyForWorktree,
|
||||||
isPrimaryWorktreeBranch: state.isPrimaryWorktreeBranch,
|
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -199,20 +197,8 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Extract branchName from event, defaulting to null (main worktree)
|
// 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 =
|
const eventBranchName: string | null =
|
||||||
eventProjectPath &&
|
'branchName' in event && event.branchName !== undefined ? event.branchName : null;
|
||||||
rawEventBranchName &&
|
|
||||||
isPrimaryWorktreeBranch(eventProjectPath, rawEventBranchName)
|
|
||||||
? null
|
|
||||||
: rawEventBranchName;
|
|
||||||
|
|
||||||
// Skip event if we couldn't determine the project
|
// Skip event if we couldn't determine the project
|
||||||
if (!eventProjectId) {
|
if (!eventProjectId) {
|
||||||
@@ -507,7 +493,6 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
|||||||
currentProject?.path,
|
currentProject?.path,
|
||||||
getMaxConcurrencyForWorktree,
|
getMaxConcurrencyForWorktree,
|
||||||
setMaxConcurrencyForWorktree,
|
setMaxConcurrencyForWorktree,
|
||||||
isPrimaryWorktreeBranch,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Start auto mode - calls backend to start the auto loop for this worktree
|
// 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.
|
* Hook for managing board background settings with automatic persistence to server.
|
||||||
* Uses React Query mutation for server persistence with automatic error handling.
|
* 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() {
|
export function useBoardBackgroundSettings() {
|
||||||
const store = useAppStore();
|
const store = useAppStore();
|
||||||
@@ -69,20 +65,22 @@ export function useBoardBackgroundSettings() {
|
|||||||
[store, persistSettings, getCurrentSettings]
|
[store, persistSettings, getCurrentSettings]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update store (called on slider commit to update the board view)
|
|
||||||
const setCardOpacity = useCallback(
|
const setCardOpacity = useCallback(
|
||||||
(projectPath: string, opacity: number) => {
|
async (projectPath: string, opacity: number) => {
|
||||||
|
const current = getCurrentSettings(projectPath);
|
||||||
store.setCardOpacity(projectPath, opacity);
|
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(
|
const setColumnOpacity = useCallback(
|
||||||
(projectPath: string, opacity: number) => {
|
async (projectPath: string, opacity: number) => {
|
||||||
|
const current = getCurrentSettings(projectPath);
|
||||||
store.setColumnOpacity(projectPath, opacity);
|
store.setColumnOpacity(projectPath, opacity);
|
||||||
|
await persistSettings(projectPath, { ...current, columnOpacity: opacity });
|
||||||
},
|
},
|
||||||
[store]
|
[store, persistSettings, getCurrentSettings]
|
||||||
);
|
);
|
||||||
|
|
||||||
const setColumnBorderEnabled = useCallback(
|
const setColumnBorderEnabled = useCallback(
|
||||||
@@ -121,12 +119,16 @@ export function useBoardBackgroundSettings() {
|
|||||||
[store, persistSettings, getCurrentSettings]
|
[store, persistSettings, getCurrentSettings]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update store (called on slider commit to update the board view)
|
|
||||||
const setCardBorderOpacity = useCallback(
|
const setCardBorderOpacity = useCallback(
|
||||||
(projectPath: string, opacity: number) => {
|
async (projectPath: string, opacity: number) => {
|
||||||
|
const current = getCurrentSettings(projectPath);
|
||||||
store.setCardBorderOpacity(projectPath, opacity);
|
store.setCardBorderOpacity(projectPath, opacity);
|
||||||
|
await persistSettings(projectPath, {
|
||||||
|
...current,
|
||||||
|
cardBorderOpacity: opacity,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[store]
|
[store, persistSettings, getCurrentSettings]
|
||||||
);
|
);
|
||||||
|
|
||||||
const setHideScrollbar = useCallback(
|
const setHideScrollbar = useCallback(
|
||||||
@@ -168,6 +170,5 @@ export function useBoardBackgroundSettings() {
|
|||||||
setHideScrollbar,
|
setHideScrollbar,
|
||||||
clearBoardBackground,
|
clearBoardBackground,
|
||||||
getCurrentSettings,
|
getCurrentSettings,
|
||||||
persistSettings,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -208,13 +208,12 @@ export function parseLocalStorageSettings(): Partial<GlobalSettings> | null {
|
|||||||
worktreePanelCollapsed === 'true' || (state.worktreePanelCollapsed as boolean),
|
worktreePanelCollapsed === 'true' || (state.worktreePanelCollapsed as boolean),
|
||||||
lastProjectDir: lastProjectDir || (state.lastProjectDir as string),
|
lastProjectDir: lastProjectDir || (state.lastProjectDir as string),
|
||||||
recentFolders: recentFolders ? JSON.parse(recentFolders) : (state.recentFolders as string[]),
|
recentFolders: recentFolders ? JSON.parse(recentFolders) : (state.recentFolders as string[]),
|
||||||
// Claude API Profiles (legacy)
|
// Claude API Profiles
|
||||||
claudeApiProfiles: (state.claudeApiProfiles as GlobalSettings['claudeApiProfiles']) ?? [],
|
claudeApiProfiles: (state.claudeApiProfiles as GlobalSettings['claudeApiProfiles']) ?? [],
|
||||||
activeClaudeApiProfileId:
|
activeClaudeApiProfileId:
|
||||||
(state.activeClaudeApiProfileId as GlobalSettings['activeClaudeApiProfileId']) ?? null,
|
(state.activeClaudeApiProfileId as GlobalSettings['activeClaudeApiProfileId']) ?? null,
|
||||||
// Claude Compatible Providers (new system)
|
// Event hooks
|
||||||
claudeCompatibleProviders:
|
eventHooks: state.eventHooks as GlobalSettings['eventHooks'],
|
||||||
(state.claudeCompatibleProviders as GlobalSettings['claudeCompatibleProviders']) ?? [],
|
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to parse localStorage settings:', error);
|
logger.error('Failed to parse localStorage settings:', error);
|
||||||
@@ -349,16 +348,6 @@ export function mergeSettings(
|
|||||||
merged.activeClaudeApiProfileId = localSettings.activeClaudeApiProfileId;
|
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;
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -895,15 +895,12 @@ function RootLayoutContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function RootLayout() {
|
function RootLayout() {
|
||||||
// Hide devtools on compact screens (mobile/tablet) to avoid overlap with sidebar settings
|
|
||||||
const isCompact = useIsCompact();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<FileBrowserProvider>
|
<FileBrowserProvider>
|
||||||
<RootLayoutContent />
|
<RootLayoutContent />
|
||||||
</FileBrowserProvider>
|
</FileBrowserProvider>
|
||||||
{SHOW_QUERY_DEVTOOLS && !isCompact ? (
|
{SHOW_QUERY_DEVTOOLS ? (
|
||||||
<ReactQueryDevtools initialIsOpen={false} buttonPosition="bottom-left" />
|
<ReactQueryDevtools initialIsOpen={false} buttonPosition="bottom-left" />
|
||||||
) : null}
|
) : null}
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
|||||||
@@ -18,13 +18,7 @@ import {
|
|||||||
|
|
||||||
const TEST_TEMP_DIR = createTempDirPath('list-view-priority-test');
|
const TEST_TEMP_DIR = createTempDirPath('list-view-priority-test');
|
||||||
|
|
||||||
// TODO: This test is skipped because setupRealProject only sets localStorage,
|
test.describe('List View Priority Column', () => {
|
||||||
// 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', () => {
|
|
||||||
let projectPath: string;
|
let projectPath: string;
|
||||||
const projectName = `test-project-${Date.now()}`;
|
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",
|
"name": "automaker",
|
||||||
"version": "0.13.0",
|
"version": "0.12.0rc",
|
||||||
"private": true,
|
"private": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.0.0 <23.0.0"
|
"node": ">=22.0.0 <23.0.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user