Compare commits

..

6 Commits

Author SHA1 Message Date
Stefan de Vogelaere
e3f3f5fbd1 test: fix server tests for provider model passthrough behavior
- Update model-resolver.test.ts to expect unknown models to pass through
  unchanged (supports ClaudeCompatibleProvider models like GLM-4.7)
- Remove warning expectations for unknown models (valid for providers)
- Add missing getCredentials and getGlobalSettings mocks to
  ideation-service.test.ts for settingsService
2026-01-20 20:23:29 +01:00
Stefan de Vogelaere
f007ca2c80 refactor: simplify getPhaseModelWithOverrides calls per code review
Address code review feedback on PR #629:
- Make settingsService parameter optional in getPhaseModelWithOverrides
- Function now handles undefined settingsService gracefully by returning defaults
- Remove redundant ternary checks in 4 call sites:
  - apps/server/src/routes/context/routes/describe-file.ts
  - apps/server/src/routes/context/routes/describe-image.ts
  - apps/server/src/routes/worktree/routes/generate-commit-message.ts
  - apps/server/src/services/auto-mode-service.ts
- Remove unused DEFAULT_PHASE_MODELS imports where applicable
2026-01-20 20:16:25 +01:00
Stefan de Vogelaere
8efd14c580 fix: update tests for new model resolver passthrough behavior
1. model-resolver tests:
   - Unknown models now pass through unchanged (provider model support)
   - Removed expectations for warnings on unknown models
   - Updated case sensitivity and edge case tests accordingly
   - Added tests for provider-like model names (GLM-4.7, MiniMax-M2.1)

2. atomic-writer tests:
   - Updated regex to match new temp file format with random suffix
   - Format changed from .tmp.{timestamp} to .tmp.{timestamp}.{hex}
2026-01-20 20:09:52 +01:00
Stefan de Vogelaere
86e3892c66 fix: atomic writer race condition and bulk replace reset to defaults
1. AtomicWriter Race Condition Fix (libs/utils/src/atomic-writer.ts):
   - Changed temp file naming from Date.now() to Date.now() + random hex
   - Uses crypto.randomBytes(4).toString('hex') for uniqueness
   - Prevents ENOENT errors when multiple concurrent writes happen
     within the same millisecond

2. Bulk Replace "Anthropic Direct" Reset (both dialogs):
   - When selecting "Anthropic Direct", now uses DEFAULT_PHASE_MODELS
   - Properly resets thinking levels and other settings to defaults
   - Added thinkingLevel to the change detection comparison
   - Affects both global and project-level bulk replace dialogs
2026-01-20 19:55:13 +01:00
Stefan de Vogelaere
8ffe69feb1 merge: integrate v0.13.0rc with React Query refactor
Resolved conflict in use-project-settings-loader.ts:
- Keep React Query approach from upstream
- Add phaseModelOverrides loading for provider model persistence
- Update both currentProject and projects array to keep in sync
2026-01-20 19:36:30 +01:00
Stefan de Vogelaere
2ceab3d65e feat: refactor Claude API Profiles to Claude Compatible Providers
- Rename ClaudeApiProfile to ClaudeCompatibleProvider with models[] array
- Each ProviderModel has mapsToClaudeModel field for Claude tier mapping
- Add providerType field for provider-specific icons (glm, minimax, openrouter)
- Add thinking level support for provider models in phase selectors
- Show all mapped Claude models per provider model (e.g., "Maps to Haiku, Sonnet, Opus")
- Add Bulk Replace feature to switch all phases to a provider at once
- Hide Bulk Replace button when no providers are enabled
- Fix project-level phaseModelOverrides not persisting after refresh
- Fix deleting last provider not persisting (remove empty array guard)
- Add getProviderByModelId() helper for all SDK routes
- Update all routes to pass provider config for provider models
- Update terminology from "profiles" to "providers" throughout UI
- Update documentation to reflect new provider system
2026-01-20 19:13:34 +01:00
32 changed files with 180 additions and 916 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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": {

View File

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

View File

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

View File

@@ -142,8 +142,7 @@ export function BoardHeader({
onConcurrencyChange={onConcurrencyChange}
isAutoModeRunning={isAutoModeRunning}
onAutoModeToggle={onAutoModeToggle}
skipVerificationInAutoMode={skipVerificationInAutoMode}
onSkipVerificationChange={setSkipVerificationInAutoMode}
onOpenAutoModeSettings={() => {}}
onOpenPlanDialog={onOpenPlanDialog}
showClaudeUsage={showClaudeUsage}
showCodexUsage={showCodexUsage}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "automaker",
"version": "0.13.0",
"version": "0.12.0rc",
"private": true,
"engines": {
"node": ">=22.0.0 <23.0.0"