fix: improve GLM presets, ideation auth, and Insights env (#648)

* fix: improve api profile presets and ideation auth

Add GLM presets and improve profile dialog layout.
Align ideation auth flow with API profiles.
Expand Insights env setup and add tests.

* github: pass api profile env to python runners

* test: clean up github runner env temp dirs

* refactor: centralize Claude.md env helper

* fix: repair insights env return

* test: fix typecheck and vitest sentry mocks

* refactor: share sentry mocks and types

---------

Co-authored-by: StillKnotKnown <stillknotknown@users.noreply.github.com>
Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com>
This commit is contained in:
StillKnotKnown
2026-01-05 13:05:14 +02:00
committed by GitHub
parent 1e3e8bda1d
commit 6fb2d48433
29 changed files with 1508 additions and 236 deletions
@@ -0,0 +1 @@
export * from './sentry-electron-shared';
@@ -0,0 +1 @@
export * from './sentry-electron-shared';
@@ -0,0 +1,26 @@
export type SentryErrorEvent = Record<string, unknown>;
export type SentryScope = {
setContext: (key: string, value: Record<string, unknown>) => void;
};
export type SentryInitOptions = {
beforeSend?: (event: SentryErrorEvent) => SentryErrorEvent | null;
tracesSampleRate?: number;
profilesSampleRate?: number;
dsn?: string;
environment?: string;
release?: string;
debug?: boolean;
enabled?: boolean;
};
export function init(_options: SentryInitOptions): void {}
export function captureException(_error: Error): void {}
export function withScope(callback: (scope: SentryScope) => void): void {
callback({
setContext: () => {}
});
}
+8
View File
@@ -28,6 +28,14 @@ Object.defineProperty(global, 'localStorage', {
value: localStorageMock
});
// Mock scrollIntoView for Radix Select in jsdom
if (typeof HTMLElement !== 'undefined' && !HTMLElement.prototype.scrollIntoView) {
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
value: vi.fn(),
writable: true
});
}
// Test data directory for isolated file operations
export const TEST_DATA_DIR = '/tmp/auto-claude-ui-tests';
@@ -0,0 +1,99 @@
/**
* @vitest-environment node
*/
import path from 'path';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { InsightsConfig } from '../insights/config';
vi.mock('electron', () => ({
app: {
getAppPath: () => '/app',
getPath: () => '/tmp',
isPackaged: false
}
}));
vi.mock('../rate-limit-detector', () => ({
getProfileEnv: () => ({ CLAUDE_CODE_OAUTH_TOKEN: 'oauth-token' })
}));
const mockGetApiProfileEnv = vi.fn();
vi.mock('../services/profile', () => ({
getAPIProfileEnv: (...args: unknown[]) => mockGetApiProfileEnv(...args)
}));
const mockGetPythonEnv = vi.fn();
vi.mock('../python-env-manager', () => ({
pythonEnvManager: {
getPythonEnv: () => mockGetPythonEnv()
}
}));
describe('InsightsConfig', () => {
const originalEnv = { ...process.env };
beforeEach(() => {
process.env = { ...originalEnv, TEST_ENV: 'ok' };
mockGetApiProfileEnv.mockResolvedValue({
ANTHROPIC_BASE_URL: 'https://api.z.ai',
ANTHROPIC_AUTH_TOKEN: 'key'
});
mockGetPythonEnv.mockReturnValue({ PYTHONPATH: '/site-packages' });
});
afterEach(() => {
process.env = { ...originalEnv };
vi.clearAllMocks();
vi.restoreAllMocks();
});
it('should build process env with python and profile settings', async () => {
const config = new InsightsConfig();
vi.spyOn(config, 'loadAutoBuildEnv').mockReturnValue({ CUSTOM_ENV: '1' });
vi.spyOn(config, 'getAutoBuildSourcePath').mockReturnValue('/backend');
const env = await config.getProcessEnv();
expect(env.TEST_ENV).toBe('ok');
expect(env.CUSTOM_ENV).toBe('1');
expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBe('oauth-token');
expect(env.ANTHROPIC_BASE_URL).toBe('https://api.z.ai');
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('key');
expect(env.PYTHONPATH).toBe(['/site-packages', '/backend'].join(path.delimiter));
});
it('should clear ANTHROPIC env vars in OAuth mode when no API profile is set', async () => {
const config = new InsightsConfig();
mockGetApiProfileEnv.mockResolvedValue({});
process.env = {
...originalEnv,
ANTHROPIC_AUTH_TOKEN: 'stale-token',
ANTHROPIC_BASE_URL: 'https://stale.example'
};
const env = await config.getProcessEnv();
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('');
expect(env.ANTHROPIC_BASE_URL).toBe('');
});
it('should set PYTHONPATH only to auto-build path when python env has none', async () => {
const config = new InsightsConfig();
mockGetPythonEnv.mockReturnValue({});
vi.spyOn(config, 'getAutoBuildSourcePath').mockReturnValue('/backend');
const env = await config.getProcessEnv();
expect(env.PYTHONPATH).toBe('/backend');
});
it('should keep PYTHONPATH from python env when auto-build path is missing', async () => {
const config = new InsightsConfig();
mockGetPythonEnv.mockReturnValue({ PYTHONPATH: '/site-packages' });
vi.spyOn(config, 'getAutoBuildSourcePath').mockReturnValue(null);
const env = await config.getProcessEnv();
expect(env.PYTHONPATH).toBe('/site-packages');
});
});
+35 -5
View File
@@ -2,8 +2,10 @@ import path from 'path';
import { existsSync, readFileSync } from 'fs';
import { app } from 'electron';
import { getProfileEnv } from '../rate-limit-detector';
import { getAPIProfileEnv } from '../services/profile';
import { getOAuthModeClearVars } from '../agent/env-utils';
import { pythonEnvManager, getConfiguredPythonPath } from '../python-env-manager';
import { getValidatedPythonPath } from '../python-detector';
import { getConfiguredPythonPath, pythonEnvManager } from '../python-env-manager';
import { getAugmentedEnv } from '../env-utils';
import { getEffectiveSourcePath } from '../updater/path-resolver';
@@ -105,23 +107,51 @@ export class InsightsConfig {
* Get complete environment for process execution
* Includes system env, auto-claude env, and active Claude profile
*/
getProcessEnv(): Record<string, string> {
async getProcessEnv(): Promise<Record<string, string>> {
const autoBuildEnv = this.loadAutoBuildEnv();
const profileEnv = getProfileEnv();
// Get Python environment (PYTHONPATH for bundled packages like python-dotenv)
const apiProfileEnv = await getAPIProfileEnv();
const oauthModeClearVars = getOAuthModeClearVars(apiProfileEnv);
const pythonEnv = pythonEnvManager.getPythonEnv();
const autoBuildSource = this.getAutoBuildSourcePath();
const pythonPathParts = (pythonEnv.PYTHONPATH ?? '')
.split(path.delimiter)
.map((entry) => entry.trim())
.filter(Boolean)
.map((entry) => path.resolve(entry));
if (autoBuildSource) {
const normalizedAutoBuildSource = path.resolve(autoBuildSource);
const autoBuildComparator = process.platform === 'win32'
? normalizedAutoBuildSource.toLowerCase()
: normalizedAutoBuildSource;
const hasAutoBuildSource = pythonPathParts.some((entry) => {
const candidate = process.platform === 'win32' ? entry.toLowerCase() : entry;
return candidate === autoBuildComparator;
});
if (!hasAutoBuildSource) {
pythonPathParts.push(normalizedAutoBuildSource);
}
}
const combinedPythonPath = pythonPathParts.join(path.delimiter);
// Use getAugmentedEnv() to ensure common tool paths (claude, dotnet, etc.)
// are available even when app is launched from Finder/Dock
// are available even when app is launched from Finder/Dock.
const augmentedEnv = getAugmentedEnv();
return {
...augmentedEnv,
...pythonEnv, // Include PYTHONPATH for bundled site-packages
...autoBuildEnv,
...oauthModeClearVars,
...profileEnv,
...apiProfileEnv,
PYTHONUNBUFFERED: '1',
PYTHONIOENCODING: 'utf-8',
PYTHONUTF8: '1'
PYTHONUTF8: '1',
...(combinedPythonPath ? { PYTHONPATH: combinedPythonPath } : {})
};
}
}
@@ -85,7 +85,7 @@ export class InsightsExecutor extends EventEmitter {
} as InsightsChatStatus);
// Get process environment
const processEnv = this.config.getProcessEnv();
const processEnv = await this.config.getProcessEnv();
// Write conversation history to temp file to avoid Windows command-line length limit
const historyFile = path.join(
@@ -0,0 +1,256 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import type { Project } from '../../../../shared/types';
import { IPC_CHANNELS } from '../../../../shared/constants';
import type { BrowserWindow } from 'electron';
import type { AgentManager } from '../../../agent/agent-manager';
import type { createIPCCommunicators as createIPCCommunicatorsType } from '../utils/ipc-communicator';
const mockIpcMain = vi.hoisted(() => {
class HoistedMockIpcMain {
handlers = new Map<string, Function>();
listeners = new Map<string, Function>();
handle(channel: string, handler: Function): void {
this.handlers.set(channel, handler);
}
on(channel: string, listener: Function): void {
this.listeners.set(channel, listener);
}
async invokeHandler(channel: string, ...args: unknown[]): Promise<unknown> {
const handler = this.handlers.get(channel);
if (!handler) {
throw new Error(`No handler for channel: ${channel}`);
}
return handler({}, ...args);
}
async emit(channel: string, ...args: unknown[]): Promise<void> {
const listener = this.listeners.get(channel);
if (!listener) {
throw new Error(`No listener for channel: ${channel}`);
}
await listener({}, ...args);
}
reset(): void {
this.handlers.clear();
this.listeners.clear();
}
}
return new HoistedMockIpcMain();
});
const mockRunPythonSubprocess = vi.fn();
const mockValidateGitHubModule = vi.fn();
const mockGetRunnerEnv = vi.fn();
type CreateIPCCommunicators = typeof createIPCCommunicatorsType;
const mockCreateIPCCommunicators = vi.fn(
(..._args: Parameters<CreateIPCCommunicators>) => ({
sendProgress: vi.fn(),
sendComplete: vi.fn(),
sendError: vi.fn(),
})
) as unknown as CreateIPCCommunicators;
const projectRef: { current: Project | null } = { current: null };
const tempDirs: string[] = [];
vi.mock('electron', () => ({
ipcMain: mockIpcMain,
BrowserWindow: class {},
}));
vi.mock('../../../agent/agent-manager', () => ({
AgentManager: class {
startSpecCreation = vi.fn();
},
}));
vi.mock('../utils/ipc-communicator', () => ({
createIPCCommunicators: (...args: Parameters<CreateIPCCommunicators>) =>
mockCreateIPCCommunicators(...args),
}));
vi.mock('../utils/project-middleware', () => ({
withProjectOrNull: async (_projectId: string, handler: (project: Project) => Promise<unknown>) => {
if (!projectRef.current) {
return null;
}
return handler(projectRef.current);
},
}));
vi.mock('../utils/subprocess-runner', () => ({
runPythonSubprocess: (...args: unknown[]) => mockRunPythonSubprocess(...args),
validateGitHubModule: (...args: unknown[]) => mockValidateGitHubModule(...args),
getPythonPath: () => '/tmp/python',
getRunnerPath: () => '/tmp/runner.py',
buildRunnerArgs: (_runnerPath: string, _projectPath: string, command: string, args: string[] = []) => [
'runner.py',
command,
...args,
],
}));
vi.mock('../utils/runner-env', () => ({
getRunnerEnv: (...args: unknown[]) => mockGetRunnerEnv(...args),
}));
vi.mock('../utils', () => ({
getGitHubConfig: vi.fn(() => null),
githubFetch: vi.fn(),
}));
vi.mock('../../../settings-utils', () => ({
readSettingsFile: vi.fn(() => ({})),
}));
function createMockWindow(): BrowserWindow {
return { webContents: { send: vi.fn() } } as unknown as BrowserWindow;
}
function createProject(): Project {
const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'github-env-test-'));
tempDirs.push(projectPath);
return {
id: 'project-1',
name: 'Test Project',
path: projectPath,
autoBuildPath: '.auto-claude',
settings: {
model: 'default',
memoryBackend: 'file',
linearSync: false,
notifications: {
onTaskComplete: false,
onTaskFailed: false,
onReviewNeeded: false,
sound: false,
},
graphitiMcpEnabled: false,
useClaudeMd: true,
},
createdAt: new Date(),
updatedAt: new Date(),
};
}
describe('GitHub runner env usage', () => {
beforeEach(() => {
vi.clearAllMocks();
mockIpcMain.reset();
projectRef.current = createProject();
mockValidateGitHubModule.mockResolvedValue({ valid: true, backendPath: '/tmp/backend' });
mockGetRunnerEnv.mockResolvedValue({ ANTHROPIC_AUTH_TOKEN: 'token' });
});
afterEach(() => {
for (const dir of tempDirs) {
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors for already-removed temp dirs.
}
}
tempDirs.length = 0;
});
it('passes runner env to PR review subprocess', async () => {
const { registerPRHandlers } = await import('../pr-handlers');
mockRunPythonSubprocess.mockReturnValue({
process: { pid: 123 },
promise: Promise.resolve({
success: true,
exitCode: 0,
stdout: '',
stderr: '',
data: {
prNumber: 123,
repo: 'test/repo',
success: true,
findings: [],
summary: '',
overallStatus: 'comment',
reviewedAt: new Date().toISOString(),
},
}),
});
registerPRHandlers(() => createMockWindow());
await mockIpcMain.emit(IPC_CHANNELS.GITHUB_PR_REVIEW, projectRef.current?.id, 123);
expect(mockGetRunnerEnv).toHaveBeenCalledWith({ USE_CLAUDE_MD: 'true' });
expect(mockRunPythonSubprocess).toHaveBeenCalledWith(
expect.objectContaining({
env: { ANTHROPIC_AUTH_TOKEN: 'token' },
})
);
});
it('passes runner env to triage subprocess', async () => {
const { registerTriageHandlers } = await import('../triage-handlers');
mockRunPythonSubprocess.mockReturnValue({
process: { pid: 124 },
promise: Promise.resolve({
success: true,
exitCode: 0,
stdout: '',
stderr: '',
data: [],
}),
});
registerTriageHandlers(() => createMockWindow());
await mockIpcMain.emit(IPC_CHANNELS.GITHUB_TRIAGE_RUN, projectRef.current?.id);
expect(mockGetRunnerEnv).toHaveBeenCalledWith();
expect(mockRunPythonSubprocess).toHaveBeenCalledWith(
expect.objectContaining({
env: { ANTHROPIC_AUTH_TOKEN: 'token' },
})
);
});
it('passes runner env to autofix analyze preview subprocess', async () => {
const { registerAutoFixHandlers } = await import('../autofix-handlers');
const { AgentManager: MockedAgentManager } = await import('../../../agent/agent-manager');
mockRunPythonSubprocess.mockReturnValue({
process: { pid: 125 },
promise: Promise.resolve({
success: true,
exitCode: 0,
stdout: '',
stderr: '',
data: {
totalIssues: 0,
primaryIssue: null,
proposedBatches: [],
singleIssues: [],
},
}),
});
const agentManager: AgentManager = new MockedAgentManager();
const getMainWindow: () => BrowserWindow | null = () => createMockWindow();
registerAutoFixHandlers(agentManager, getMainWindow);
await mockIpcMain.emit(IPC_CHANNELS.GITHUB_AUTOFIX_ANALYZE_PREVIEW, projectRef.current?.id);
expect(mockGetRunnerEnv).toHaveBeenCalledWith();
expect(mockRunPythonSubprocess).toHaveBeenCalledWith(
expect.objectContaining({
env: { ANTHROPIC_AUTH_TOKEN: 'token' },
})
);
});
});
@@ -28,6 +28,7 @@ import {
parseJSONFromOutput,
} from './utils/subprocess-runner';
import { AgentManager } from '../../agent/agent-manager';
import { getRunnerEnv } from './utils/runner-env';
// Debug logging
const { debug: debugLog } = createContextLogger('GitHub AutoFix');
@@ -277,11 +278,13 @@ async function checkNewIssues(project: Project): Promise<Array<{number: number}>
const backendPath = validation.backendPath!;
const args = buildRunnerArgs(getRunnerPath(backendPath), project.path, 'check-new');
const subprocessEnv = await getRunnerEnv();
const { promise } = runPythonSubprocess<Array<{number: number}>>({
pythonPath: getPythonPath(backendPath),
args,
cwd: backendPath,
env: subprocessEnv,
onComplete: (stdout) => {
return parseJSONFromOutput<Array<{number: number}>>(stdout);
},
@@ -607,6 +610,7 @@ export function registerAutoFixHandlers(
const backendPath = validation.backendPath!;
const additionalArgs = issueNumbers && issueNumbers.length > 0 ? issueNumbers.map(n => n.toString()) : [];
const args = buildRunnerArgs(getRunnerPath(backendPath), project.path, 'batch-issues', additionalArgs);
const subprocessEnv = await getRunnerEnv();
debugLog('Spawning batch process', { args });
@@ -614,6 +618,7 @@ export function registerAutoFixHandlers(
pythonPath: getPythonPath(backendPath),
args,
cwd: backendPath,
env: subprocessEnv,
onProgress: (percent, message) => {
sendProgress({
phase: 'batching',
@@ -728,12 +733,14 @@ export function registerAutoFixHandlers(
}
const args = buildRunnerArgs(getRunnerPath(backendPath), project.path, 'analyze-preview', additionalArgs);
const subprocessEnv = await getRunnerEnv();
debugLog('Spawning analyze-preview process', { args });
const { promise } = runPythonSubprocess<AnalyzePreviewResult>({
pythonPath: getPythonPath(backendPath),
args,
cwd: backendPath,
env: subprocessEnv,
onProgress: (percent, message) => {
sendProgress({ phase: 'analyzing', progress: percent, message });
},
@@ -20,6 +20,7 @@ import type { Project, AppSettings } from '../../../shared/types';
import { createContextLogger } from './utils/logger';
import { withProjectOrNull } from './utils/project-middleware';
import { createIPCCommunicators } from './utils/ipc-communicator';
import { getRunnerEnv } from './utils/runner-env';
import {
runPythonSubprocess,
getPythonPath,
@@ -70,6 +71,13 @@ function getReviewKey(projectId: string, prNumber: number): string {
return `${projectId}:${prNumber}`;
}
/**
* Returns env vars for Claude.md usage; enabled unless explicitly opted out.
*/
function getClaudeMdEnv(project: Project): Record<string, string> | undefined {
return project.settings?.useClaudeMd !== false ? { USE_CLAUDE_MD: 'true' } : undefined;
}
/**
* PR review finding from AI analysis
*/
@@ -630,10 +638,9 @@ async function runPRReview(
const logCollector = new PRLogCollector(project, prNumber, repo, false);
// Build environment with project settings
const subprocessEnv: Record<string, string> = {};
if (project.settings?.useClaudeMd !== false) {
subprocessEnv['USE_CLAUDE_MD'] = 'true';
}
const subprocessEnv = await getRunnerEnv(
getClaudeMdEnv(project)
);
const { process: childProcess, promise } = runPythonSubprocess<PRReviewResult>({
pythonPath: getPythonPath(backendPath),
@@ -1491,10 +1498,9 @@ export function registerPRHandlers(
const logCollector = new PRLogCollector(project, prNumber, repo, true);
// Build environment with project settings
const followupEnv: Record<string, string> = {};
if (project.settings?.useClaudeMd !== false) {
followupEnv['USE_CLAUDE_MD'] = 'true';
}
const followupEnv = await getRunnerEnv(
getClaudeMdEnv(project)
);
const { process: childProcess, promise } = runPythonSubprocess<PRReviewResult>({
pythonPath: getPythonPath(backendPath),
@@ -19,6 +19,7 @@ import type { Project, AppSettings } from '../../../shared/types';
import { createContextLogger } from './utils/logger';
import { withProjectOrNull } from './utils/project-middleware';
import { createIPCCommunicators } from './utils/ipc-communicator';
import { getRunnerEnv } from './utils/runner-env';
import {
runPythonSubprocess,
getPythonPath,
@@ -254,10 +255,13 @@ async function runTriage(
debugLog('Spawning triage process', { args, model, thinkingLevel });
const subprocessEnv = await getRunnerEnv();
const { promise } = runPythonSubprocess<TriageResult[]>({
pythonPath: getPythonPath(backendPath),
args,
cwd: backendPath,
env: subprocessEnv,
onProgress: (percent, message) => {
debugLog('Progress update', { percent, message });
sendProgress({
@@ -0,0 +1,55 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const mockGetAPIProfileEnv = vi.fn();
const mockGetOAuthModeClearVars = vi.fn();
vi.mock('../../../../services/profile', () => ({
getAPIProfileEnv: (...args: unknown[]) => mockGetAPIProfileEnv(...args),
}));
vi.mock('../../../../agent/env-utils', () => ({
getOAuthModeClearVars: (...args: unknown[]) => mockGetOAuthModeClearVars(...args),
}));
import { getRunnerEnv } from '../runner-env';
describe('getRunnerEnv', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('merges API profile env with OAuth clear vars', async () => {
mockGetAPIProfileEnv.mockResolvedValue({
ANTHROPIC_AUTH_TOKEN: 'token',
ANTHROPIC_BASE_URL: 'https://api.example.com',
});
mockGetOAuthModeClearVars.mockReturnValue({
ANTHROPIC_AUTH_TOKEN: '',
});
const result = await getRunnerEnv();
expect(mockGetOAuthModeClearVars).toHaveBeenCalledWith({
ANTHROPIC_AUTH_TOKEN: 'token',
ANTHROPIC_BASE_URL: 'https://api.example.com',
});
expect(result).toEqual({
ANTHROPIC_AUTH_TOKEN: '',
ANTHROPIC_BASE_URL: 'https://api.example.com',
});
});
it('includes extra env values', async () => {
mockGetAPIProfileEnv.mockResolvedValue({
ANTHROPIC_AUTH_TOKEN: 'token',
});
mockGetOAuthModeClearVars.mockReturnValue({});
const result = await getRunnerEnv({ USE_CLAUDE_MD: 'true' });
expect(result).toEqual({
ANTHROPIC_AUTH_TOKEN: 'token',
USE_CLAUDE_MD: 'true',
});
});
});
@@ -0,0 +1,15 @@
import { getOAuthModeClearVars } from '../../../agent/env-utils';
import { getAPIProfileEnv } from '../../../services/profile';
export async function getRunnerEnv(
extraEnv?: Record<string, string>
): Promise<Record<string, string>> {
const apiProfileEnv = await getAPIProfileEnv();
const oauthModeClearVars = getOAuthModeClearVars(apiProfileEnv);
return {
...apiProfileEnv,
...oauthModeClearVars,
...extraEnv,
};
}
@@ -0,0 +1,173 @@
/**
* Unit tests for useIdeation hook
*
* @vitest-environment jsdom
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import type {
IdeationConfig,
IdeationGenerationStatus,
IdeationType
} from '../../../../../shared/types';
import { useIdeation } from '../useIdeation';
const mockGenerateIdeation = vi.hoisted(() => vi.fn());
const mockRefreshIdeation = vi.hoisted(() => vi.fn());
const mockAppendIdeation = vi.hoisted(() => vi.fn());
const mockLoadIdeation = vi.hoisted(() => vi.fn());
const mockSetupListeners = vi.hoisted(() => vi.fn(() => () => {}));
const mockAuthState = vi.hoisted(() => ({
hasToken: true as boolean | null,
isLoading: false,
error: null as string | null,
checkAuth: vi.fn()
}));
vi.mock('../useIdeationAuth', () => ({
useIdeationAuth: () => mockAuthState
}));
vi.mock('../../../../stores/task-store', () => ({
loadTasks: vi.fn()
}));
vi.mock('../../../../stores/ideation-store', () => {
const state = {
session: null,
generationStatus: {} as IdeationGenerationStatus,
isGenerating: false,
config: {
enabledTypes: [],
includeRoadmapContext: false,
includeKanbanContext: false,
maxIdeasPerType: 3
} as IdeationConfig,
logs: [],
typeStates: {},
selectedIds: new Set()
};
return {
useIdeationStore: (selector: (s: typeof state) => unknown) => selector(state),
loadIdeation: mockLoadIdeation,
generateIdeation: mockGenerateIdeation,
refreshIdeation: mockRefreshIdeation,
stopIdeation: vi.fn(),
appendIdeation: mockAppendIdeation,
dismissAllIdeasForProject: vi.fn(),
deleteMultipleIdeasForProject: vi.fn(),
getIdeasByType: vi.fn(() => []),
getActiveIdeas: vi.fn(() => []),
getArchivedIdeas: vi.fn(() => []),
getIdeationSummary: vi.fn(() => ({ totalIdeas: 0, byType: {}, byStatus: {} })),
setupIdeationListeners: mockSetupListeners
};
});
describe('useIdeation', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should set up and clean up listeners on unmount', () => {
const cleanupFn = vi.fn();
mockSetupListeners.mockReturnValueOnce(cleanupFn);
const { unmount } = renderHook(() => useIdeation('project-1'));
expect(mockLoadIdeation).toHaveBeenCalledWith('project-1');
unmount();
expect(cleanupFn).toHaveBeenCalled();
});
it('should prompt for env config when token is missing', () => {
mockAuthState.hasToken = false;
mockAuthState.isLoading = false;
const { result } = renderHook(() => useIdeation('project-1'));
act(() => {
result.current.handleGenerate();
});
expect(result.current.showEnvConfigModal).toBe(true);
expect(mockGenerateIdeation).not.toHaveBeenCalled();
});
it('should generate when token is present', () => {
mockAuthState.hasToken = true;
mockAuthState.isLoading = false;
const { result } = renderHook(() => useIdeation('project-1'));
act(() => {
result.current.handleGenerate();
});
expect(result.current.showEnvConfigModal).toBe(false);
expect(mockGenerateIdeation).toHaveBeenCalledWith('project-1');
});
it('should retry generate after env is configured', () => {
mockAuthState.hasToken = false;
mockAuthState.isLoading = false;
const { result } = renderHook(() => useIdeation('project-1'));
act(() => {
result.current.handleGenerate();
});
act(() => {
result.current.handleEnvConfigured();
});
expect(mockAuthState.checkAuth).toHaveBeenCalled();
expect(mockGenerateIdeation).toHaveBeenCalledWith('project-1');
});
it('should retry refresh after env is configured', () => {
mockAuthState.hasToken = false;
mockAuthState.isLoading = false;
const { result } = renderHook(() => useIdeation('project-1'));
act(() => {
result.current.handleRefresh();
});
act(() => {
result.current.handleEnvConfigured();
});
expect(mockAuthState.checkAuth).toHaveBeenCalled();
expect(mockRefreshIdeation).toHaveBeenCalledWith('project-1');
});
it('should append ideas after env is configured', () => {
mockAuthState.hasToken = false;
mockAuthState.isLoading = false;
const { result } = renderHook(() => useIdeation('project-1'));
const typesToAdd = ['code_improvements'] as IdeationType[];
act(() => {
result.current.setTypesToAdd(typesToAdd);
});
act(() => {
result.current.handleAddMoreIdeas();
});
act(() => {
result.current.handleEnvConfigured();
});
expect(mockAuthState.checkAuth).toHaveBeenCalled();
expect(mockAppendIdeation).toHaveBeenCalledWith('project-1', typesToAdd);
expect(result.current.typesToAdd).toHaveLength(0);
});
});
@@ -18,6 +18,7 @@ import { useSettingsStore } from '../../../../stores/settings-store';
// Mock checkSourceToken function
const mockCheckSourceToken = vi.fn();
const mockGetApiProfiles = vi.fn();
describe('useIdeationAuth', () => {
beforeEach(() => {
@@ -37,6 +38,7 @@ describe('useIdeationAuth', () => {
// Setup window.electronAPI mock
if (window.electronAPI) {
window.electronAPI.checkSourceToken = mockCheckSourceToken;
window.electronAPI.getAPIProfiles = mockGetApiProfiles;
}
// Default mock implementation - has source token
@@ -44,6 +46,15 @@ describe('useIdeationAuth', () => {
success: true,
data: { hasToken: true, sourcePath: '/mock/auto-claude' }
});
mockGetApiProfiles.mockResolvedValue({
success: true,
data: {
profiles: [],
activeProfileId: null,
version: 1
}
});
});
afterEach(() => {
@@ -187,6 +198,62 @@ describe('useIdeationAuth', () => {
expect(result.current.hasToken).toBe(true);
});
it('should fall back to IPC profiles when store activeProfileId is missing', async () => {
mockCheckSourceToken.mockResolvedValue({
success: true,
data: { hasToken: false }
});
mockGetApiProfiles.mockResolvedValue({
success: true,
data: {
profiles: [{
id: 'profile-1',
name: 'Custom API',
baseUrl: 'https://api.anthropic.com',
apiKey: 'sk-ant-test-key',
createdAt: Date.now(),
updatedAt: Date.now()
}],
activeProfileId: 'profile-1',
version: 1
}
});
useSettingsStore.setState({
activeProfileId: null
});
const { result } = renderHook(() => useIdeationAuth());
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(mockGetApiProfiles).toHaveBeenCalled();
expect(result.current.hasToken).toBe(true);
});
it('should not call IPC profiles when store activeProfileId is set', async () => {
mockCheckSourceToken.mockResolvedValue({
success: true,
data: { hasToken: false }
});
useSettingsStore.setState({
activeProfileId: 'profile-1'
});
const { result } = renderHook(() => useIdeationAuth());
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(mockGetApiProfiles).not.toHaveBeenCalled();
expect(result.current.hasToken).toBe(true);
});
it('should return hasToken false when no API profile is active', async () => {
mockCheckSourceToken.mockResolvedValue({
success: true,
@@ -379,8 +446,6 @@ describe('useIdeationAuth', () => {
data: { hasToken: false }
});
const { result } = renderHook(() => useIdeationAuth());
// Initial state - active profile
useSettingsStore.setState({
profiles: [{
@@ -394,6 +459,8 @@ describe('useIdeationAuth', () => {
activeProfileId: 'profile-1'
});
const { result } = renderHook(() => useIdeationAuth());
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
@@ -15,7 +15,7 @@ import {
setupIdeationListeners
} from '../../../stores/ideation-store';
import { loadTasks } from '../../../stores/task-store';
import { useClaudeTokenCheck } from '../../EnvConfigModal';
import { useIdeationAuth } from './useIdeationAuth';
import type { Idea, IdeationType } from '../../../../shared/types';
import { ALL_IDEATION_TYPES } from '../constants';
@@ -49,7 +49,7 @@ export function useIdeation(projectId: string, options: UseIdeationOptions = {})
const [showAddMoreDialog, setShowAddMoreDialog] = useState(false);
const [typesToAdd, setTypesToAdd] = useState<IdeationType[]>([]);
const { hasToken, isLoading: isCheckingToken, checkToken } = useClaudeTokenCheck();
const { hasToken, isLoading: isCheckingToken, checkAuth } = useIdeationAuth();
// Set up IPC listeners and load ideation on mount
useEffect(() => {
@@ -85,7 +85,7 @@ export function useIdeation(projectId: string, options: UseIdeationOptions = {})
};
const handleEnvConfigured = () => {
checkToken();
checkAuth();
if (pendingAction === 'generate') {
generateIdeation(projectId);
} else if (pendingAction === 'refresh') {
@@ -21,6 +21,24 @@ export function useIdeationAuth() {
// Get active API profile info from settings store
const activeProfileId = useSettingsStore((state) => state.activeProfileId);
const resolveHasAPIProfile = async (profileId?: string | null): Promise<boolean> => {
// Trust the store when it's already populated to avoid extra IPC calls; fallback to IPC only when empty.
if (profileId && profileId !== '') {
return true;
}
try {
const profilesResult = await window.electronAPI.getAPIProfiles();
return Boolean(
profilesResult.success &&
profilesResult.data?.activeProfileId &&
profilesResult.data.activeProfileId !== ''
);
} catch {
return false;
}
};
useEffect(() => {
const performCheck = async () => {
setIsLoading(true);
@@ -31,11 +49,10 @@ export function useIdeationAuth() {
const sourceTokenResult = await window.electronAPI.checkSourceToken();
const hasSourceOAuthToken = sourceTokenResult.success && sourceTokenResult.data?.hasToken;
// Check if active API profile is configured
const hasAPIProfile = Boolean(activeProfileId && activeProfileId !== '');
const hasAPIProfile = await resolveHasAPIProfile(activeProfileId);
// Auth is valid if either source token or API profile exists
setHasToken(hasSourceOAuthToken || hasAPIProfile);
setHasToken(Boolean(hasSourceOAuthToken || hasAPIProfile));
} catch (err) {
setHasToken(false);
setError(err instanceof Error ? err.message : 'Unknown error');
@@ -55,8 +72,10 @@ export function useIdeationAuth() {
try {
const sourceTokenResult = await window.electronAPI.checkSourceToken();
const hasSourceOAuthToken = sourceTokenResult.success && sourceTokenResult.data?.hasToken;
const hasAPIProfile = Boolean(activeProfileId && activeProfileId !== '');
setHasToken(hasSourceOAuthToken || hasAPIProfile);
const hasAPIProfile = await resolveHasAPIProfile(activeProfileId);
setHasToken(Boolean(hasSourceOAuthToken || hasAPIProfile));
} catch (err) {
setHasToken(false);
setError(err instanceof Error ? err.message : 'Unknown error');
@@ -8,6 +8,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import '../../../shared/i18n';
import { ModelSearchableSelect } from './ModelSearchableSelect';
import { useSettingsStore } from '../../stores/settings-store';
@@ -132,6 +133,31 @@ describe('ModelSearchableSelect', () => {
});
});
it('should render dropdown above the input', async () => {
mockDiscoverModels.mockResolvedValue([
{ id: 'claude-3-5-sonnet-20241022', display_name: 'Claude Sonnet 3.5' }
]);
render(
<ModelSearchableSelect
value=""
onChange={mockOnChange}
baseUrl="https://api.anthropic.com"
apiKey="sk-test-key-12chars"
/>
);
const input = screen.getByPlaceholderText('Select a model or type manually');
fireEvent.focus(input);
await waitFor(() => {
expect(screen.getByTestId('model-select-dropdown')).toBeInTheDocument();
});
const dropdown = screen.getByTestId('model-select-dropdown');
expect(dropdown).toHaveClass('bottom-full');
});
it('should select model and close dropdown', async () => {
mockDiscoverModels.mockResolvedValue([
{ id: 'claude-3-5-sonnet-20241022', display_name: 'Claude Sonnet 3.5' }
@@ -355,4 +381,3 @@ describe('ModelSearchableSelect', () => {
});
});
});
@@ -17,6 +17,7 @@
*/
import { useState, useEffect, useRef } from 'react';
import { Loader2, AlertCircle, ChevronDown, Search, Check, Info } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { cn } from '../../lib/utils';
@@ -57,12 +58,14 @@ interface ModelSearchableSelectProps {
export function ModelSearchableSelect({
value,
onChange,
placeholder = 'Select a model or type manually',
placeholder,
baseUrl,
apiKey,
disabled = false,
className
}: ModelSearchableSelectProps) {
const { t } = useTranslation();
const resolvedPlaceholder = placeholder ?? t('settings:modelSelect.placeholder');
const discoverModels = useSettingsStore((state) => state.discoverModels);
// Dropdown open state
const [isOpen, setIsOpen] = useState(false);
@@ -229,7 +232,9 @@ export function ModelSearchableSelect({
handleOpen();
}
}}
placeholder={modelDiscoveryNotSupported ? 'Enter model name (e.g., claude-3-5-sonnet-20241022)' : placeholder}
placeholder={modelDiscoveryNotSupported
? t('settings:modelSelect.placeholderManual')
: resolvedPlaceholder}
disabled={disabled}
className="pr-10"
/>
@@ -254,7 +259,10 @@ export function ModelSearchableSelect({
{/* Dropdown panel - only show when we have models to display */}
{isOpen && !isLoading && !modelDiscoveryNotSupported && models.length > 0 && (
<div className="absolute z-50 w-full mt-1 bg-background border rounded-md shadow-lg max-h-60 overflow-hidden flex flex-col">
<div
className="absolute z-50 w-full bottom-full mb-1 bg-background border rounded-md shadow-lg max-h-60 overflow-hidden flex flex-col"
data-testid="model-select-dropdown"
>
{/* Search input */}
<div className="p-2 border-b">
<div className="relative">
@@ -262,7 +270,7 @@ export function ModelSearchableSelect({
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search models..."
placeholder={t('settings:modelSelect.searchPlaceholder')}
className="pl-8"
autoFocus
/>
@@ -273,7 +281,7 @@ export function ModelSearchableSelect({
<div className="flex-1 overflow-y-auto py-1">
{filteredModels.length === 0 ? (
<div className="p-3 text-center text-sm text-muted-foreground">
No models match your search
{t('settings:modelSelect.noResults')}
</div>
) : (
filteredModels.map((model) => (
@@ -304,7 +312,7 @@ export function ModelSearchableSelect({
{modelDiscoveryNotSupported && (
<p className="text-sm text-muted-foreground mt-1 flex items-center gap-1">
<Info className="h-3 w-3" />
Model discovery not available. Enter model name manually.
{t('settings:modelSelect.discoveryNotAvailable')}
</p>
)}
{error && !modelDiscoveryNotSupported && (
@@ -10,6 +10,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import '../../../shared/i18n';
import { ProfileEditDialog } from './ProfileEditDialog';
import type { APIProfile } from '@shared/types/profile';
@@ -269,6 +270,64 @@ describe('ProfileEditDialog - Create Mode', () => {
expect(apiKeyInput).toHaveAttribute('type', 'password');
expect(apiKeyInput).not.toBeDisabled();
});
it('should apply preset values in create mode', async () => {
render(
<ProfileEditDialog
open={true}
onOpenChange={mockOnOpenChange}
/>
);
const presetTrigger = screen.getByLabelText(/preset/i);
fireEvent.keyDown(presetTrigger, { key: 'ArrowDown', code: 'ArrowDown' });
const glmGlobalOption = await screen.findByRole('option', { name: 'GLM (Global)' });
fireEvent.click(glmGlobalOption);
expect(screen.getByLabelText(/base url/i)).toHaveValue('https://api.z.ai/api/anthropic');
expect(screen.getByLabelText(/name/i)).toHaveValue('GLM (Global)');
});
it('should not overwrite name when applying a preset', async () => {
render(
<ProfileEditDialog
open={true}
onOpenChange={mockOnOpenChange}
/>
);
const nameInput = screen.getByLabelText(/name/i);
fireEvent.change(nameInput, { target: { value: 'My Custom Name' } });
const presetTrigger = screen.getByLabelText(/preset/i);
fireEvent.keyDown(presetTrigger, { key: 'ArrowDown', code: 'ArrowDown' });
const groqOption = await screen.findByRole('option', { name: 'Groq' });
fireEvent.click(groqOption);
expect(screen.getByLabelText(/name/i)).toHaveValue('My Custom Name');
expect(screen.getByLabelText(/base url/i)).toHaveValue('https://api.groq.com/openai/v1');
});
it('should move focus to Base URL after selecting a preset', async () => {
render(
<ProfileEditDialog
open={true}
onOpenChange={mockOnOpenChange}
/>
);
const presetTrigger = screen.getByLabelText(/preset/i);
fireEvent.keyDown(presetTrigger, { key: 'ArrowDown', code: 'ArrowDown' });
const anthropicOption = await screen.findByRole('option', { name: 'Anthropic' });
fireEvent.click(anthropicOption);
await waitFor(() => {
expect(screen.getByLabelText(/base url/i)).toHaveFocus();
});
});
});
describe('ProfileEditDialog - Validation', () => {
@@ -15,6 +15,7 @@
*/
import { useState, useEffect, useRef } from 'react';
import { Loader2, AlertCircle, CheckCircle2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import {
Dialog,
DialogContent,
@@ -26,12 +27,14 @@ import {
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
import { useSettingsStore } from '../../stores/settings-store';
import { ModelSearchableSelect } from './ModelSearchableSelect';
import { useToast } from '../../hooks/use-toast';
import { isValidUrl, isValidApiKey } from '../../lib/profile-utils';
import type { APIProfile, ProfileFormData, TestConnectionResult } from '@shared/types/profile';
import { maskApiKey } from '../../lib/profile-utils';
import { API_PROVIDER_PRESETS } from '../../../shared/constants';
interface ProfileEditDialogProps {
/** Whether the dialog is open */
@@ -45,6 +48,7 @@ interface ProfileEditDialogProps {
}
export function ProfileEditDialog({ open, onOpenChange, onSaved, profile }: ProfileEditDialogProps) {
const { t } = useTranslation();
const {
saveProfile,
updateProfile,
@@ -67,6 +71,7 @@ export function ProfileEditDialog({ open, onOpenChange, onSaved, profile }: Prof
const [haikuModel, setHaikuModel] = useState('');
const [sonnetModel, setSonnetModel] = useState('');
const [opusModel, setOpusModel] = useState('');
const [presetId, setPresetId] = useState<string>('');
// API key change state (for edit mode)
const [isChangingApiKey, setIsChangingApiKey] = useState(false);
@@ -78,6 +83,7 @@ export function ProfileEditDialog({ open, onOpenChange, onSaved, profile }: Prof
// AbortController ref for test connection cleanup
const abortControllerRef = useRef<AbortController | null>(null);
const baseUrlInputRef = useRef<HTMLInputElement | null>(null);
// Local state for auto-hiding test result display
const [showTestResult, setShowTestResult] = useState(false);
@@ -116,6 +122,7 @@ export function ProfileEditDialog({ open, onOpenChange, onSaved, profile }: Prof
setSonnetModel(profile.models?.sonnet || '');
setOpusModel(profile.models?.opus || '');
setIsChangingApiKey(false);
setPresetId('');
} else {
// Reset to empty form for create mode
setName('');
@@ -126,6 +133,7 @@ export function ProfileEditDialog({ open, onOpenChange, onSaved, profile }: Prof
setSonnetModel('');
setOpusModel('');
setIsChangingApiKey(false);
setPresetId('');
}
// Clear validation errors
setNameError(null);
@@ -137,13 +145,23 @@ export function ProfileEditDialog({ open, onOpenChange, onSaved, profile }: Prof
}
}, [open]);
const applyPreset = (id: string) => {
const preset = API_PROVIDER_PRESETS.find((item) => item.id === id);
if (!preset) return;
setPresetId(id);
setBaseUrl(preset.baseUrl);
if (!name.trim()) {
setName(t(preset.labelKey));
}
};
// Validate form
const validateForm = (): boolean => {
let isValid = true;
// Name validation
if (!name.trim()) {
setNameError('Name is required');
setNameError(t('settings:apiProfiles.validation.nameRequired'));
isValid = false;
} else {
setNameError(null);
@@ -151,10 +169,10 @@ export function ProfileEditDialog({ open, onOpenChange, onSaved, profile }: Prof
// Base URL validation
if (!baseUrl.trim()) {
setUrlError('Base URL is required');
setUrlError(t('settings:apiProfiles.validation.baseUrlRequired'));
isValid = false;
} else if (!isValidUrl(baseUrl)) {
setUrlError('Invalid URL format (must be http:// or https://)');
setUrlError(t('settings:apiProfiles.validation.baseUrlInvalid'));
isValid = false;
} else {
setUrlError(null);
@@ -163,10 +181,10 @@ export function ProfileEditDialog({ open, onOpenChange, onSaved, profile }: Prof
// API Key validation (only in create mode or when changing key in edit mode)
if (!isEditMode || isChangingApiKey) {
if (!apiKey.trim()) {
setKeyError('API Key is required');
setKeyError(t('settings:apiProfiles.validation.apiKeyRequired'));
isValid = false;
} else if (!isValidApiKey(apiKey)) {
setKeyError('Invalid API Key format');
setKeyError(t('settings:apiProfiles.validation.apiKeyInvalid'));
isValid = false;
} else {
setKeyError(null);
@@ -187,11 +205,11 @@ export function ProfileEditDialog({ open, onOpenChange, onSaved, profile }: Prof
// Basic validation before testing
if (!baseUrl.trim()) {
setUrlError('Base URL is required');
setUrlError(t('settings:apiProfiles.validation.baseUrlRequired'));
return;
}
if (!apiKeyForTest.trim()) {
setKeyError('API Key is required');
setKeyError(t('settings:apiProfiles.validation.apiKeyRequired'));
return;
}
@@ -241,8 +259,10 @@ export function ProfileEditDialog({ open, onOpenChange, onSaved, profile }: Prof
const success = await updateProfile(updatedProfile);
if (success) {
toast({
title: 'Profile updated',
description: `"${name.trim()}" has been updated successfully.`,
title: t('settings:apiProfiles.toast.update.title'),
description: t('settings:apiProfiles.toast.update.description', {
name: name.trim()
}),
});
onOpenChange(false);
onSaved?.();
@@ -267,8 +287,10 @@ export function ProfileEditDialog({ open, onOpenChange, onSaved, profile }: Prof
const success = await saveProfile(profileData);
if (success) {
toast({
title: 'Profile created',
description: `"${name.trim()}" has been added successfully.`,
title: t('settings:apiProfiles.toast.create.title'),
description: t('settings:apiProfiles.toast.create.description', {
name: name.trim()
}),
});
onOpenChange(false);
onSaved?.();
@@ -278,99 +300,137 @@ export function ProfileEditDialog({ open, onOpenChange, onSaved, profile }: Prof
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md max-h-[90vh] overflow-y-auto" data-testid="profile-edit-dialog">
<DialogContent
className="w-[min(92vw,720px)] max-h-[90vh] overflow-y-auto"
data-testid="profile-edit-dialog"
>
<DialogHeader>
<DialogTitle>{isEditMode ? 'Edit Profile' : 'Add API Profile'}</DialogTitle>
<DialogTitle>
{isEditMode
? t('settings:apiProfiles.dialog.editTitle')
: t('settings:apiProfiles.dialog.createTitle')}
</DialogTitle>
<DialogDescription>
Configure a custom Anthropic-compatible API endpoint for your builds.
{t('settings:apiProfiles.dialog.description')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Name field (required) */}
<div className="space-y-2">
<Label htmlFor="profile-name">
Name <span className="text-destructive">*</span>
</Label>
<Input
id="profile-name"
placeholder="My Custom API"
value={name}
onChange={(e) => setName(e.target.value)}
className={nameError ? 'border-destructive' : ''}
/>
{nameError && <p className="text-sm text-destructive">{nameError}</p>}
</div>
<div className="grid gap-4 md:grid-cols-2">
{/* Name field (required) */}
<div className={`space-y-2 ${isEditMode ? 'md:col-span-2' : ''}`}>
<Label htmlFor="profile-name">
{t('settings:apiProfiles.fields.name')} <span className="text-destructive">*</span>
</Label>
<Input
id="profile-name"
placeholder={t('settings:apiProfiles.placeholders.name')}
value={name}
onChange={(e) => setName(e.target.value)}
className={nameError ? 'border-destructive' : ''}
/>
{nameError && <p className="text-sm text-destructive">{nameError}</p>}
</div>
{/* Base URL field (required) */}
<div className="space-y-2">
<Label htmlFor="profile-url">
Base URL <span className="text-destructive">*</span>
</Label>
<Input
id="profile-url"
placeholder="https://api.anthropic.com"
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
className={urlError ? 'border-destructive' : ''}
/>
{urlError && <p className="text-sm text-destructive">{urlError}</p>}
<p className="text-xs text-muted-foreground">
Example: https://api.anthropic.com or http://localhost:8080
</p>
</div>
{/* API Key field (required for create, masked in edit mode) */}
<div className="space-y-2">
<Label htmlFor="profile-key">
API Key <span className="text-destructive">*</span>
</Label>
{isEditMode && !isChangingApiKey && profile ? (
// Edit mode: show masked API key
<div className="flex items-center gap-2">
<Input
id="profile-key"
value={maskApiKey(profile.apiKey)}
disabled
className="flex-1"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setIsChangingApiKey(true)}
>
Change
</Button>
</div>
) : (
// Create mode or changing key: show password input
<>
<Input
id="profile-key"
type="password"
placeholder="sk-ant-..."
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
className={keyError ? 'border-destructive' : ''}
/>
{isEditMode && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setIsChangingApiKey(false);
setApiKey('');
setKeyError(null);
{!isEditMode && (
<div className="space-y-2">
<Label htmlFor="profile-preset">{t('settings:apiProfiles.fields.preset')}</Label>
<Select value={presetId} onValueChange={applyPreset}>
<SelectTrigger id="profile-preset">
<SelectValue placeholder={t('settings:apiProfiles.placeholders.preset')} />
</SelectTrigger>
<SelectContent
onCloseAutoFocus={(event) => {
event.preventDefault();
baseUrlInputRef.current?.focus();
}}
>
Cancel
</Button>
)}
</>
{API_PROVIDER_PRESETS.map((preset) => (
<SelectItem key={preset.id} value={preset.id}>
{t(preset.labelKey)}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t('settings:apiProfiles.hints.preset')}
</p>
</div>
)}
{keyError && <p className="text-sm text-destructive">{keyError}</p>}
</div>
<div className="grid gap-4 md:grid-cols-2">
{/* Base URL field (required) */}
<div className="space-y-2">
<Label htmlFor="profile-url">
{t('settings:apiProfiles.fields.baseUrl')} <span className="text-destructive">*</span>
</Label>
<Input
id="profile-url"
placeholder={t('settings:apiProfiles.placeholders.baseUrl')}
value={baseUrl}
ref={baseUrlInputRef}
onChange={(e) => setBaseUrl(e.target.value)}
className={urlError ? 'border-destructive' : ''}
/>
{urlError && <p className="text-sm text-destructive">{urlError}</p>}
<p className="text-xs text-muted-foreground">
{t('settings:apiProfiles.hints.baseUrl')}
</p>
</div>
{/* API Key field (required for create, masked in edit mode) */}
<div className="space-y-2">
<Label htmlFor="profile-key">
{t('settings:apiProfiles.fields.apiKey')} <span className="text-destructive">*</span>
</Label>
{isEditMode && !isChangingApiKey && profile ? (
// Edit mode: show masked API key
<div className="flex items-center gap-2">
<Input
id="profile-key"
value={maskApiKey(profile.apiKey)}
disabled
className="flex-1"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setIsChangingApiKey(true)}
>
{t('settings:apiProfiles.actions.changeKey')}
</Button>
</div>
) : (
// Create mode or changing key: show password input
<>
<Input
id="profile-key"
type="password"
placeholder={t('settings:apiProfiles.placeholders.apiKey')}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
className={keyError ? 'border-destructive' : ''}
/>
{isEditMode && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setIsChangingApiKey(false);
setApiKey('');
setKeyError(null);
}}
>
{t('settings:apiProfiles.actions.cancelKeyChange')}
</Button>
)}
</>
)}
{keyError && <p className="text-sm text-destructive">{keyError}</p>}
</div>
</div>
{/* Test Connection button */}
@@ -384,10 +444,10 @@ export function ProfileEditDialog({ open, onOpenChange, onSaved, profile }: Prof
{isTestingConnection ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Testing...
{t('settings:apiProfiles.testConnection.testing')}
</>
) : (
'Test Connection'
t('settings:apiProfiles.testConnection.label')
)}
</Button>
@@ -410,8 +470,8 @@ export function ProfileEditDialog({ open, onOpenChange, onSaved, profile }: Prof
: 'text-red-800 dark:text-red-200'
}`}>
{testConnectionResult.success
? 'Connection Successful'
: 'Connection Failed'}
? t('settings:apiProfiles.testConnection.success')
: t('settings:apiProfiles.testConnection.failure')}
</p>
<p className={`text-sm ${
testConnectionResult.success
@@ -426,61 +486,63 @@ export function ProfileEditDialog({ open, onOpenChange, onSaved, profile }: Prof
{/* Optional model mappings */}
<div className="space-y-3 pt-2 border-t">
<Label className="text-base">Optional: Model Name Mappings</Label>
<Label className="text-base">{t('settings:apiProfiles.models.title')}</Label>
<p className="text-xs text-muted-foreground">
Select models from your API provider. Leave blank to use defaults.
{t('settings:apiProfiles.models.description')}
</p>
<div className="space-y-2">
<Label htmlFor="model-default" className="text-sm text-muted-foreground">
Default Model (Optional)
</Label>
<ModelSearchableSelect
value={defaultModel}
onChange={setDefaultModel}
placeholder="e.g., claude-3-5-sonnet-20241022"
baseUrl={baseUrl}
apiKey={isEditMode && !isChangingApiKey && profile ? profile.apiKey : apiKey}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="model-default" className="text-sm text-muted-foreground">
{t('settings:apiProfiles.models.defaultLabel')}
</Label>
<ModelSearchableSelect
value={defaultModel}
onChange={setDefaultModel}
placeholder={t('settings:apiProfiles.models.defaultPlaceholder')}
baseUrl={baseUrl}
apiKey={isEditMode && !isChangingApiKey && profile ? profile.apiKey : apiKey}
/>
</div>
<div className="space-y-2">
<Label htmlFor="model-haiku" className="text-sm text-muted-foreground">
Haiku Model (Optional)
</Label>
<ModelSearchableSelect
value={haikuModel}
onChange={setHaikuModel}
placeholder="e.g., claude-3-5-haiku-20241022"
baseUrl={baseUrl}
apiKey={isEditMode && !isChangingApiKey && profile ? profile.apiKey : apiKey}
/>
</div>
<div className="space-y-2">
<Label htmlFor="model-haiku" className="text-sm text-muted-foreground">
{t('settings:apiProfiles.models.haikuLabel')}
</Label>
<ModelSearchableSelect
value={haikuModel}
onChange={setHaikuModel}
placeholder={t('settings:apiProfiles.models.haikuPlaceholder')}
baseUrl={baseUrl}
apiKey={isEditMode && !isChangingApiKey && profile ? profile.apiKey : apiKey}
/>
</div>
<div className="space-y-2">
<Label htmlFor="model-sonnet" className="text-sm text-muted-foreground">
Sonnet Model (Optional)
</Label>
<ModelSearchableSelect
value={sonnetModel}
onChange={setSonnetModel}
placeholder="e.g., claude-3-5-sonnet-20241022"
baseUrl={baseUrl}
apiKey={isEditMode && !isChangingApiKey && profile ? profile.apiKey : apiKey}
/>
</div>
<div className="space-y-2">
<Label htmlFor="model-sonnet" className="text-sm text-muted-foreground">
{t('settings:apiProfiles.models.sonnetLabel')}
</Label>
<ModelSearchableSelect
value={sonnetModel}
onChange={setSonnetModel}
placeholder={t('settings:apiProfiles.models.sonnetPlaceholder')}
baseUrl={baseUrl}
apiKey={isEditMode && !isChangingApiKey && profile ? profile.apiKey : apiKey}
/>
</div>
<div className="space-y-2">
<Label htmlFor="model-opus" className="text-sm text-muted-foreground">
Opus Model (Optional)
</Label>
<ModelSearchableSelect
value={opusModel}
onChange={setOpusModel}
placeholder="e.g., claude-3-5-opus-20241022"
baseUrl={baseUrl}
apiKey={isEditMode && !isChangingApiKey && profile ? profile.apiKey : apiKey}
/>
<div className="space-y-2">
<Label htmlFor="model-opus" className="text-sm text-muted-foreground">
{t('settings:apiProfiles.models.opusLabel')}
</Label>
<ModelSearchableSelect
value={opusModel}
onChange={setOpusModel}
placeholder={t('settings:apiProfiles.models.opusPlaceholder')}
baseUrl={baseUrl}
apiKey={isEditMode && !isChangingApiKey && profile ? profile.apiKey : apiKey}
/>
</div>
</div>
</div>
@@ -499,7 +561,7 @@ export function ProfileEditDialog({ open, onOpenChange, onSaved, profile }: Prof
onClick={() => onOpenChange(false)}
disabled={profilesLoading}
>
Cancel
{t('settings:apiProfiles.actions.cancel')}
</Button>
<Button
type="button"
@@ -509,10 +571,10 @@ export function ProfileEditDialog({ open, onOpenChange, onSaved, profile }: Prof
{profilesLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
{t('settings:apiProfiles.actions.saving')}
</>
) : (
'Save Profile'
t('settings:apiProfiles.actions.save')
)}
</Button>
</DialogFooter>
@@ -13,6 +13,7 @@ import { maskApiKey } from '../../lib/profile-utils';
import { useSettingsStore } from '../../stores/settings-store';
import type { APIProfile } from '@shared/types/profile';
import { TooltipProvider } from '../ui/tooltip';
import i18n from '../../../shared/i18n';
// Wrapper for components that need TooltipProvider
function TestWrapper({ children }: { children: React.ReactNode }) {
@@ -204,7 +205,9 @@ describe('ProfileList - Active Profile Logic', () => {
// Test 1: Delete confirmation dialog shows profile name correctly
describe('ProfileList - Delete Confirmation Dialog', () => {
beforeEach(() => {
vi.mocked(useSettingsStore).mockReturnValue(createSettingsStoreMock());
vi.mocked(useSettingsStore).mockReturnValue(
createSettingsStoreMock({ activeProfileId: 'profile-2' })
);
});
it('should show delete confirmation dialog with profile name', () => {
@@ -215,16 +218,17 @@ describe('ProfileList - Delete Confirmation Dialog', () => {
fireEvent.click(deleteButton);
// Check dialog appears with profile name
expect(screen.getByText(/Delete Profile\?/i)).toBeInTheDocument();
expect(screen.getByText(/Are you sure you want to delete "Production API"\?/i)).toBeInTheDocument();
expect(screen.getByText(/Cancel/i)).toBeInTheDocument();
// Use getAllByText since there are multiple "Delete" elements (title + button)
expect(screen.getAllByText(/Delete/i).length).toBeGreaterThan(0);
expect(screen.getByText(i18n.t('settings:apiProfiles.dialog.deleteTitle'))).toBeInTheDocument();
expect(screen.getByText(
i18n.t('settings:apiProfiles.dialog.deleteDescription', { name: 'Production API' })
)).toBeInTheDocument();
expect(screen.getByText(i18n.t('settings:apiProfiles.dialog.cancel'))).toBeInTheDocument();
expect(screen.getByText(i18n.t('settings:apiProfiles.dialog.delete'))).toBeInTheDocument();
});
// Test 5: Cancel delete → dialog closes, profile remains in list
it('should close dialog when cancel is clicked', () => {
const mockStore = createSettingsStoreMock();
it('should close dialog when cancel is clicked', async () => {
const mockStore = createSettingsStoreMock({ activeProfileId: 'profile-2' });
vi.mocked(useSettingsStore).mockReturnValue(mockStore);
renderWithWrapper(<ProfileList />);
@@ -234,10 +238,13 @@ describe('ProfileList - Delete Confirmation Dialog', () => {
fireEvent.click(deleteButton);
// Click cancel
fireEvent.click(screen.getByText(/Cancel/i));
const cancelButton = await screen.findByText(i18n.t('settings:apiProfiles.dialog.cancel'));
fireEvent.click(cancelButton);
// Dialog should be closed
expect(screen.queryByText(/Delete Profile\?/i)).not.toBeInTheDocument();
expect(screen.queryByText(
i18n.t('settings:apiProfiles.dialog.deleteTitle')
)).not.toBeInTheDocument();
// Profiles should still be visible
expect(screen.getByText('Production API')).toBeInTheDocument();
expect(mockStore.deleteProfile).not.toHaveBeenCalled();
@@ -256,8 +263,8 @@ describe('ProfileList - Delete Confirmation Dialog', () => {
fireEvent.click(deleteButton);
// Dialog should have Delete elements (title "Delete Profile?" and "Delete" button)
const deleteElements = screen.getAllByText(/Delete/i);
expect(deleteElements.length).toBeGreaterThan(1); // At least title + button
expect(screen.getByText(i18n.t('settings:apiProfiles.dialog.deleteTitle'))).toBeInTheDocument();
expect(screen.getByText(i18n.t('settings:apiProfiles.dialog.delete'))).toBeInTheDocument();
});
});
@@ -270,7 +277,7 @@ describe('ProfileList - Switch to OAuth Button', () => {
renderWithWrapper(<ProfileList />);
// Button should be visible when activeProfileId is set
expect(screen.getByText(/Switch to OAuth/i)).toBeInTheDocument();
expect(screen.getByText(i18n.t('settings:apiProfiles.switchToOauth.label'))).toBeInTheDocument();
});
it('should NOT show "Switch to OAuth" button when no profile is active', () => {
@@ -281,7 +288,9 @@ describe('ProfileList - Switch to OAuth Button', () => {
renderWithWrapper(<ProfileList />);
// Button should NOT be visible when activeProfileId is null
expect(screen.queryByText(/Switch to OAuth/i)).not.toBeInTheDocument();
expect(screen.queryByText(
i18n.t('settings:apiProfiles.switchToOauth.label')
)).not.toBeInTheDocument();
});
it('should call setActiveProfile with null when "Switch to OAuth" is clicked', () => {
@@ -291,7 +300,7 @@ describe('ProfileList - Switch to OAuth Button', () => {
renderWithWrapper(<ProfileList />);
// Click the "Switch to OAuth" button
const switchButton = screen.getByText(/Switch to OAuth/i);
const switchButton = screen.getByText(i18n.t('settings:apiProfiles.switchToOauth.label'));
fireEvent.click(switchButton);
// Should call setActiveProfile with null to switch to OAuth
@@ -7,6 +7,7 @@
*/
import { useState } from 'react';
import { Plus, Trash2, Check, Server, Globe, Pencil } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '../ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { useSettingsStore } from '../../stores/settings-store';
@@ -32,6 +33,7 @@ interface ProfileListProps {
}
export function ProfileList({ onProfileSaved }: ProfileListProps) {
const { t } = useTranslation();
const {
profiles,
activeProfileId,
@@ -57,8 +59,10 @@ export function ProfileList({ onProfileSaved }: ProfileListProps) {
if (success) {
toast({
title: 'Profile deleted',
description: `"${deleteConfirmProfile.name}" has been removed.`,
title: t('settings:apiProfiles.toast.delete.title'),
description: t('settings:apiProfiles.toast.delete.description', {
name: deleteConfirmProfile.name
}),
});
setDeleteConfirmProfile(null);
if (onProfileSaved) {
@@ -68,8 +72,8 @@ export function ProfileList({ onProfileSaved }: ProfileListProps) {
// Show error toast - handles both active profile error and other errors
toast({
variant: 'destructive',
title: 'Failed to delete profile',
description: profilesError || 'An error occurred while deleting the profile.',
title: t('settings:apiProfiles.toast.delete.errorTitle'),
description: profilesError || t('settings:apiProfiles.toast.delete.errorFallback'),
});
}
};
@@ -91,16 +95,18 @@ export function ProfileList({ onProfileSaved }: ProfileListProps) {
if (profileId === null) {
// Switched to OAuth
toast({
title: 'Switched to OAuth',
description: 'Now using OAuth authentication',
title: t('settings:apiProfiles.toast.switch.oauthTitle'),
description: t('settings:apiProfiles.toast.switch.oauthDescription'),
});
} else {
// Switched to profile
const activeProfile = profiles.find(p => p.id === profileId);
if (activeProfile) {
toast({
title: 'Profile activated',
description: `Now using ${activeProfile.name}`,
title: t('settings:apiProfiles.toast.switch.profileTitle'),
description: t('settings:apiProfiles.toast.switch.profileDescription', {
name: activeProfile.name
}),
});
}
}
@@ -111,8 +117,8 @@ export function ProfileList({ onProfileSaved }: ProfileListProps) {
// Show error toast on failure
toast({
variant: 'destructive',
title: 'Failed to switch authentication',
description: profilesError || 'An error occurred while switching authentication method.',
title: t('settings:apiProfiles.toast.switch.errorTitle'),
description: profilesError || t('settings:apiProfiles.toast.switch.errorFallback'),
});
}
};
@@ -131,14 +137,14 @@ export function ProfileList({ onProfileSaved }: ProfileListProps) {
{/* Header with Add button */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold">API Profiles</h3>
<h3 className="text-lg font-semibold">{t('settings:apiProfiles.title')}</h3>
<p className="text-sm text-muted-foreground">
Configure custom Anthropic-compatible API endpoints
{t('settings:apiProfiles.description')}
</p>
</div>
<Button onClick={() => setIsAddDialogOpen(true)} size="sm">
<Plus className="h-4 w-4 mr-2" />
Add Profile
{t('settings:apiProfiles.addButton')}
</Button>
</div>
@@ -146,13 +152,13 @@ export function ProfileList({ onProfileSaved }: ProfileListProps) {
{profiles.length === 0 && (
<div className="flex flex-col items-center justify-center py-12 px-4 border border-dashed rounded-lg">
<Server className="h-12 w-12 text-muted-foreground mb-4" />
<h4 className="text-lg font-medium mb-2">No API profiles configured</h4>
<h4 className="text-lg font-medium mb-2">{t('settings:apiProfiles.empty.title')}</h4>
<p className="text-sm text-muted-foreground text-center max-w-sm mb-4">
Create a profile to configure custom API endpoints for your builds.
{t('settings:apiProfiles.empty.description')}
</p>
<Button onClick={() => setIsAddDialogOpen(true)} variant="outline">
<Plus className="h-4 w-4 mr-2" />
Create First Profile
{t('settings:apiProfiles.empty.action')}
</Button>
</div>
)}
@@ -169,27 +175,31 @@ export function ProfileList({ onProfileSaved }: ProfileListProps) {
onClick={() => handleSetActiveProfile(null)}
disabled={isSettingActive}
>
{isSettingActive ? 'Switching...' : 'Switch to OAuth'}
{isSettingActive
? t('settings:apiProfiles.switchToOauth.loading')
: t('settings:apiProfiles.switchToOauth.label')}
</Button>
</div>
)}
{profiles.map((profile) => (
<div
key={profile.id}
className={cn(
'flex items-center justify-between p-4 rounded-lg border transition-colors',
activeProfileId === profile.id
? 'border-primary bg-primary/5'
: 'border-border hover:bg-accent/50'
)}
>
{profiles.map((profile) => {
const isActive = activeProfileId === profile.id;
return (
<div
key={profile.id}
className={cn(
'flex items-center justify-between p-4 rounded-lg border transition-colors',
isActive
? 'border-primary bg-primary/5'
: 'border-border hover:bg-accent/50'
)}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h4 className="font-medium truncate">{profile.name}</h4>
{activeProfileId === profile.id && (
<span className="flex items-center text-xs text-primary">
<Check className="h-3 w-3 mr-1" />
Active
{t('settings:apiProfiles.activeBadge')}
</span>
)}
</div>
@@ -213,7 +223,9 @@ export function ProfileList({ onProfileSaved }: ProfileListProps) {
</div>
{profile.models && Object.keys(profile.models).length > 0 && (
<div className="mt-2 text-xs text-muted-foreground">
Custom models: {Object.keys(profile.models).join(', ')}
{t('settings:apiProfiles.customModels', {
models: Object.keys(profile.models).join(', ')
})}
</div>
)}
</div>
@@ -226,7 +238,9 @@ export function ProfileList({ onProfileSaved }: ProfileListProps) {
onClick={() => handleSetActiveProfile(profile.id)}
disabled={isSettingActive}
>
{isSettingActive ? 'Setting...' : 'Set Active'}
{isSettingActive
? t('settings:apiProfiles.setActive.loading')
: t('settings:apiProfiles.setActive.label')}
</Button>
)}
<Tooltip>
@@ -239,7 +253,7 @@ export function ProfileList({ onProfileSaved }: ProfileListProps) {
<Pencil className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Edit profile</TooltipContent>
<TooltipContent>{t('settings:apiProfiles.tooltips.edit')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
@@ -247,18 +261,26 @@ export function ProfileList({ onProfileSaved }: ProfileListProps) {
variant="ghost"
size="sm"
onClick={() => setDeleteConfirmProfile(profile)}
disabled={isActive}
className="text-destructive hover:text-destructive"
data-testid={`profile-delete-button-${profile.id}`}
aria-label={`Delete profile ${profile.name}`}
aria-label={t('settings:apiProfiles.deleteAriaLabel', {
name: profile.name
})}
>
<Trash2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Delete profile</TooltipContent>
<TooltipContent>
{isActive
? t('settings:apiProfiles.tooltips.deleteActive')
: t('settings:apiProfiles.tooltips.deleteInactive')}
</TooltipContent>
</Tooltip>
</div>
</div>
))}
</div>
);
})}
</div>
)}
@@ -286,19 +308,25 @@ export function ProfileList({ onProfileSaved }: ProfileListProps) {
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Profile?</AlertDialogTitle>
<AlertDialogTitle>{t('settings:apiProfiles.dialog.deleteTitle')}</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete "{deleteConfirmProfile?.name}"? This action cannot be undone.
{t('settings:apiProfiles.dialog.deleteDescription', {
name: deleteConfirmProfile?.name ?? ''
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogCancel disabled={isDeleting}>
{t('settings:apiProfiles.dialog.cancel')}
</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteProfile}
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting ? 'Deleting...' : 'Delete'}
{isDeleting
? t('settings:apiProfiles.dialog.deleting')
: t('settings:apiProfiles.dialog.delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -0,0 +1,33 @@
export type ApiProviderPreset = {
id: string;
baseUrl: string;
labelKey: string;
};
export const API_PROVIDER_PRESETS: readonly ApiProviderPreset[] = [
{
id: 'anthropic',
baseUrl: 'https://api.anthropic.com',
labelKey: 'settings:apiProfiles.presets.anthropic'
},
{
id: 'openrouter',
baseUrl: 'https://openrouter.ai/api/v1',
labelKey: 'settings:apiProfiles.presets.openrouter'
},
{
id: 'groq',
baseUrl: 'https://api.groq.com/openai/v1',
labelKey: 'settings:apiProfiles.presets.groq'
},
{
id: 'glm-global',
baseUrl: 'https://api.z.ai/api/anthropic',
labelKey: 'settings:apiProfiles.presets.glmGlobal'
},
{
id: 'glm-cn',
baseUrl: 'https://open.bigmodel.cn/api/paas/v4',
labelKey: 'settings:apiProfiles.presets.glmChina'
}
];
@@ -30,5 +30,8 @@ export * from './themes';
// GitHub integration constants
export * from './github';
// API profile presets
export * from './api-profiles';
// Configuration and paths
export * from './config';
@@ -50,6 +50,128 @@
"description": "Troubleshooting tools"
}
},
"apiProfiles": {
"title": "API Profiles",
"description": "Configure custom Anthropic-compatible API endpoints",
"addButton": "Add Profile",
"presets": {
"anthropic": "Anthropic",
"openrouter": "OpenRouter",
"groq": "Groq",
"glmGlobal": "GLM (Global)",
"glmChina": "GLM (China)"
},
"fields": {
"name": "Name",
"preset": "Preset",
"baseUrl": "Base URL",
"apiKey": "API Key"
},
"placeholders": {
"name": "My Custom API",
"preset": "Choose a provider preset",
"baseUrl": "https://api.anthropic.com",
"apiKey": "sk-ant-..."
},
"hints": {
"preset": "Presets fill the base URL; you still need to paste your API key.",
"baseUrl": "Example: https://api.anthropic.com or http://localhost:8080"
},
"validation": {
"nameRequired": "Name is required",
"baseUrlRequired": "Base URL is required",
"baseUrlInvalid": "Invalid URL format (must be http:// or https://)",
"apiKeyRequired": "API Key is required",
"apiKeyInvalid": "Invalid API Key format"
},
"actions": {
"save": "Save Profile",
"saving": "Saving...",
"cancel": "Cancel",
"changeKey": "Change",
"cancelKeyChange": "Cancel"
},
"testConnection": {
"label": "Test Connection",
"testing": "Testing...",
"success": "Connection Successful",
"failure": "Connection Failed"
},
"models": {
"title": "Optional: Model Name Mappings",
"description": "Select models from your API provider. Leave blank to use defaults.",
"defaultLabel": "Default Model (Optional)",
"haikuLabel": "Haiku Model (Optional)",
"sonnetLabel": "Sonnet Model (Optional)",
"opusLabel": "Opus Model (Optional)",
"defaultPlaceholder": "e.g., claude-3-5-sonnet-20241022",
"haikuPlaceholder": "e.g., claude-3-5-haiku-20241022",
"sonnetPlaceholder": "e.g., claude-3-5-sonnet-20241022",
"opusPlaceholder": "e.g., claude-3-5-opus-20241022"
},
"empty": {
"title": "No API profiles configured",
"description": "Create a profile to configure custom API endpoints for your builds.",
"action": "Create First Profile"
},
"switchToOauth": {
"label": "Switch to OAuth",
"loading": "Switching..."
},
"activeBadge": "Active",
"customModels": "Custom models: {{models}}",
"setActive": {
"label": "Set Active",
"loading": "Setting..."
},
"tooltips": {
"edit": "Edit profile",
"deleteActive": "Switch to OAuth before deleting",
"deleteInactive": "Delete profile"
},
"deleteAriaLabel": "Delete profile {{name}}",
"toast": {
"create": {
"title": "Profile created",
"description": "\"{{name}}\" has been added successfully."
},
"update": {
"title": "Profile updated",
"description": "\"{{name}}\" has been updated successfully."
},
"delete": {
"title": "Profile deleted",
"description": "\"{{name}}\" has been removed.",
"errorTitle": "Failed to delete profile",
"errorFallback": "An error occurred while deleting the profile."
},
"switch": {
"oauthTitle": "Switched to OAuth",
"oauthDescription": "Now using OAuth authentication",
"profileTitle": "Profile activated",
"profileDescription": "Now using {{name}}",
"errorTitle": "Failed to switch authentication",
"errorFallback": "An error occurred while switching authentication method."
}
},
"dialog": {
"createTitle": "Add API Profile",
"editTitle": "Edit Profile",
"description": "Configure a custom Anthropic-compatible API endpoint for your builds.",
"deleteTitle": "Delete Profile?",
"deleteDescription": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
"cancel": "Cancel",
"delete": "Delete",
"deleting": "Deleting..."
}
},
"modelSelect": {
"placeholder": "Select a model or type manually",
"placeholderManual": "Enter model name (e.g., claude-3-5-sonnet-20241022)",
"searchPlaceholder": "Search models...",
"noResults": "No models match your search",
"discoveryNotAvailable": "Model discovery not available. Enter model name manually."
},
"language": {
"label": "Interface Language",
"description": "Select the language for the application interface"
@@ -50,6 +50,128 @@
"description": "Outils de dépannage"
}
},
"apiProfiles": {
"title": "Profils API",
"description": "Configurez des endpoints API compatibles Anthropic personnalisés",
"addButton": "Ajouter un profil",
"presets": {
"anthropic": "Anthropic",
"openrouter": "OpenRouter",
"groq": "Groq",
"glmGlobal": "GLM (Global)",
"glmChina": "GLM (China)"
},
"fields": {
"name": "Nom",
"preset": "Préréglage",
"baseUrl": "URL de base",
"apiKey": "Clé API"
},
"placeholders": {
"name": "Mon API personnalisée",
"preset": "Choisir un préréglage de fournisseur",
"baseUrl": "https://api.anthropic.com",
"apiKey": "sk-ant-..."
},
"hints": {
"preset": "Les préréglages remplissent l'URL de base ; vous devez toujours coller votre clé API.",
"baseUrl": "Exemple : https://api.anthropic.com ou http://localhost:8080"
},
"validation": {
"nameRequired": "Le nom est requis",
"baseUrlRequired": "L'URL de base est requise",
"baseUrlInvalid": "Format d'URL invalide (doit être http:// ou https://)",
"apiKeyRequired": "La clé API est requise",
"apiKeyInvalid": "Format de clé API invalide"
},
"actions": {
"save": "Enregistrer le profil",
"saving": "Enregistrement...",
"cancel": "Annuler",
"changeKey": "Modifier",
"cancelKeyChange": "Annuler"
},
"testConnection": {
"label": "Tester la connexion",
"testing": "Test en cours...",
"success": "Connexion réussie",
"failure": "Échec de la connexion"
},
"models": {
"title": "Optionnel : correspondance des noms de modèles",
"description": "Sélectionnez des modèles auprès de votre fournisseur d'API. Laissez vide pour utiliser les valeurs par défaut.",
"defaultLabel": "Modèle par défaut (optionnel)",
"haikuLabel": "Modèle Haiku (optionnel)",
"sonnetLabel": "Modèle Sonnet (optionnel)",
"opusLabel": "Modèle Opus (optionnel)",
"defaultPlaceholder": "ex. : claude-3-5-sonnet-20241022",
"haikuPlaceholder": "ex. : claude-3-5-haiku-20241022",
"sonnetPlaceholder": "ex. : claude-3-5-sonnet-20241022",
"opusPlaceholder": "ex. : claude-3-5-opus-20241022"
},
"empty": {
"title": "Aucun profil API configuré",
"description": "Créez un profil pour configurer des endpoints API personnalisés pour vos builds.",
"action": "Créer le premier profil"
},
"switchToOauth": {
"label": "Passer à OAuth",
"loading": "Basculement..."
},
"activeBadge": "Actif",
"customModels": "Modèles personnalisés : {{models}}",
"setActive": {
"label": "Définir comme actif",
"loading": "Activation..."
},
"tooltips": {
"edit": "Modifier le profil",
"deleteActive": "Passez à OAuth avant de supprimer",
"deleteInactive": "Supprimer le profil"
},
"deleteAriaLabel": "Supprimer le profil {{name}}",
"toast": {
"create": {
"title": "Profil créé",
"description": "\"{{name}}\" a été ajouté avec succès."
},
"update": {
"title": "Profil mis à jour",
"description": "\"{{name}}\" a été mis à jour avec succès."
},
"delete": {
"title": "Profil supprimé",
"description": "\"{{name}}\" a été supprimé.",
"errorTitle": "Échec de la suppression du profil",
"errorFallback": "Une erreur s'est produite lors de la suppression du profil."
},
"switch": {
"oauthTitle": "Passé à OAuth",
"oauthDescription": "Authentification OAuth utilisée",
"profileTitle": "Profil activé",
"profileDescription": "Utilisation de {{name}}",
"errorTitle": "Échec du changement d'authentification",
"errorFallback": "Une erreur s'est produite lors du changement de méthode d'authentification."
}
},
"dialog": {
"createTitle": "Ajouter un profil API",
"editTitle": "Modifier le profil",
"description": "Configurez un endpoint API compatible Anthropic personnalisé pour vos builds.",
"deleteTitle": "Supprimer le profil ?",
"deleteDescription": "Êtes-vous sûr de vouloir supprimer \"{{name}}\" ? Cette action est irréversible.",
"cancel": "Annuler",
"delete": "Supprimer",
"deleting": "Suppression..."
}
},
"modelSelect": {
"placeholder": "Sélectionner un modèle ou saisir manuellement",
"placeholderManual": "Saisir le nom du modèle (ex. : claude-3-5-sonnet-20241022)",
"searchPlaceholder": "Rechercher des modèles...",
"noResults": "Aucun modèle ne correspond à votre recherche",
"discoveryNotAvailable": "Découverte de modèles indisponible. Saisissez le nom du modèle manuellement."
},
"language": {
"label": "Langue de l'interface",
"description": "Sélectionnez la langue de l'interface de l'application"
+32
View File
@@ -0,0 +1,32 @@
interface SentryErrorEvent {
[key: string]: unknown;
}
interface SentryScope {
setContext: (key: string, value: Record<string, unknown>) => void;
}
interface SentryInitOptions {
beforeSend?: (event: SentryErrorEvent) => SentryErrorEvent | null;
tracesSampleRate?: number;
profilesSampleRate?: number;
dsn?: string;
environment?: string;
release?: string;
debug?: boolean;
enabled?: boolean;
}
declare module '@sentry/electron/main' {
export type ErrorEvent = SentryErrorEvent;
export function init(options: SentryInitOptions): void;
export function captureException(error: Error): void;
export function withScope(callback: (scope: SentryScope) => void): void;
}
declare module '@sentry/electron/renderer' {
export type ErrorEvent = SentryErrorEvent;
export function init(options: SentryInitOptions): void;
export function captureException(error: Error): void;
export function withScope(callback: (scope: SentryScope) => void): void;
}
+3 -1
View File
@@ -15,7 +15,9 @@ export default defineConfig({
},
// Mock Electron modules for unit tests
alias: {
electron: resolve(__dirname, 'src/__mocks__/electron.ts')
electron: resolve(__dirname, 'src/__mocks__/electron.ts'),
'@sentry/electron/main': resolve(__dirname, 'src/__mocks__/sentry-electron-main.ts'),
'@sentry/electron/renderer': resolve(__dirname, 'src/__mocks__/sentry-electron-renderer.ts')
},
// Setup files for test environment
setupFiles: ['src/__tests__/setup.ts']