diff --git a/apps/frontend/src/__mocks__/sentry-electron-main.ts b/apps/frontend/src/__mocks__/sentry-electron-main.ts new file mode 100644 index 0000000000..697d392257 --- /dev/null +++ b/apps/frontend/src/__mocks__/sentry-electron-main.ts @@ -0,0 +1 @@ +export * from './sentry-electron-shared'; diff --git a/apps/frontend/src/__mocks__/sentry-electron-renderer.ts b/apps/frontend/src/__mocks__/sentry-electron-renderer.ts new file mode 100644 index 0000000000..697d392257 --- /dev/null +++ b/apps/frontend/src/__mocks__/sentry-electron-renderer.ts @@ -0,0 +1 @@ +export * from './sentry-electron-shared'; diff --git a/apps/frontend/src/__mocks__/sentry-electron-shared.ts b/apps/frontend/src/__mocks__/sentry-electron-shared.ts new file mode 100644 index 0000000000..e2c97e98fe --- /dev/null +++ b/apps/frontend/src/__mocks__/sentry-electron-shared.ts @@ -0,0 +1,26 @@ +export type SentryErrorEvent = Record; + +export type SentryScope = { + setContext: (key: string, value: Record) => 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: () => {} + }); +} diff --git a/apps/frontend/src/__tests__/setup.ts b/apps/frontend/src/__tests__/setup.ts index 730adebf94..dc2c99dd91 100644 --- a/apps/frontend/src/__tests__/setup.ts +++ b/apps/frontend/src/__tests__/setup.ts @@ -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'; diff --git a/apps/frontend/src/main/__tests__/insights-config.test.ts b/apps/frontend/src/main/__tests__/insights-config.test.ts new file mode 100644 index 0000000000..5775d65ab0 --- /dev/null +++ b/apps/frontend/src/main/__tests__/insights-config.test.ts @@ -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'); + }); +}); diff --git a/apps/frontend/src/main/insights/config.ts b/apps/frontend/src/main/insights/config.ts index d69a70d5a9..b34bf8580f 100644 --- a/apps/frontend/src/main/insights/config.ts +++ b/apps/frontend/src/main/insights/config.ts @@ -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 { + async getProcessEnv(): Promise> { 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 } : {}) }; } } diff --git a/apps/frontend/src/main/insights/insights-executor.ts b/apps/frontend/src/main/insights/insights-executor.ts index 4cae6e4a6d..0c349b3480 100644 --- a/apps/frontend/src/main/insights/insights-executor.ts +++ b/apps/frontend/src/main/insights/insights-executor.ts @@ -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( diff --git a/apps/frontend/src/main/ipc-handlers/github/__tests__/runner-env-handlers.test.ts b/apps/frontend/src/main/ipc-handlers/github/__tests__/runner-env-handlers.test.ts new file mode 100644 index 0000000000..7d9ff082cb --- /dev/null +++ b/apps/frontend/src/main/ipc-handlers/github/__tests__/runner-env-handlers.test.ts @@ -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(); + listeners = new Map(); + + 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 { + 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 { + 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) => ({ + 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) => + mockCreateIPCCommunicators(...args), +})); + +vi.mock('../utils/project-middleware', () => ({ + withProjectOrNull: async (_projectId: string, handler: (project: Project) => Promise) => { + 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' }, + }) + ); + }); +}); diff --git a/apps/frontend/src/main/ipc-handlers/github/autofix-handlers.ts b/apps/frontend/src/main/ipc-handlers/github/autofix-handlers.ts index 578ebace52..94bfa62738 100644 --- a/apps/frontend/src/main/ipc-handlers/github/autofix-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/github/autofix-handlers.ts @@ -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 const backendPath = validation.backendPath!; const args = buildRunnerArgs(getRunnerPath(backendPath), project.path, 'check-new'); + const subprocessEnv = await getRunnerEnv(); const { promise } = runPythonSubprocess>({ pythonPath: getPythonPath(backendPath), args, cwd: backendPath, + env: subprocessEnv, onComplete: (stdout) => { return parseJSONFromOutput>(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({ pythonPath: getPythonPath(backendPath), args, cwd: backendPath, + env: subprocessEnv, onProgress: (percent, message) => { sendProgress({ phase: 'analyzing', progress: percent, message }); }, diff --git a/apps/frontend/src/main/ipc-handlers/github/pr-handlers.ts b/apps/frontend/src/main/ipc-handlers/github/pr-handlers.ts index aaf9fb29de..30be9c66e6 100644 --- a/apps/frontend/src/main/ipc-handlers/github/pr-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/github/pr-handlers.ts @@ -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 | 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 = {}; - if (project.settings?.useClaudeMd !== false) { - subprocessEnv['USE_CLAUDE_MD'] = 'true'; - } + const subprocessEnv = await getRunnerEnv( + getClaudeMdEnv(project) + ); const { process: childProcess, promise } = runPythonSubprocess({ 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 = {}; - if (project.settings?.useClaudeMd !== false) { - followupEnv['USE_CLAUDE_MD'] = 'true'; - } + const followupEnv = await getRunnerEnv( + getClaudeMdEnv(project) + ); const { process: childProcess, promise } = runPythonSubprocess({ pythonPath: getPythonPath(backendPath), diff --git a/apps/frontend/src/main/ipc-handlers/github/triage-handlers.ts b/apps/frontend/src/main/ipc-handlers/github/triage-handlers.ts index 7e0f960be5..a84e44a79c 100644 --- a/apps/frontend/src/main/ipc-handlers/github/triage-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/github/triage-handlers.ts @@ -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({ pythonPath: getPythonPath(backendPath), args, cwd: backendPath, + env: subprocessEnv, onProgress: (percent, message) => { debugLog('Progress update', { percent, message }); sendProgress({ diff --git a/apps/frontend/src/main/ipc-handlers/github/utils/__tests__/runner-env.test.ts b/apps/frontend/src/main/ipc-handlers/github/utils/__tests__/runner-env.test.ts new file mode 100644 index 0000000000..d2a2546892 --- /dev/null +++ b/apps/frontend/src/main/ipc-handlers/github/utils/__tests__/runner-env.test.ts @@ -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', + }); + }); +}); diff --git a/apps/frontend/src/main/ipc-handlers/github/utils/runner-env.ts b/apps/frontend/src/main/ipc-handlers/github/utils/runner-env.ts new file mode 100644 index 0000000000..0b20945b3b --- /dev/null +++ b/apps/frontend/src/main/ipc-handlers/github/utils/runner-env.ts @@ -0,0 +1,15 @@ +import { getOAuthModeClearVars } from '../../../agent/env-utils'; +import { getAPIProfileEnv } from '../../../services/profile'; + +export async function getRunnerEnv( + extraEnv?: Record +): Promise> { + const apiProfileEnv = await getAPIProfileEnv(); + const oauthModeClearVars = getOAuthModeClearVars(apiProfileEnv); + + return { + ...apiProfileEnv, + ...oauthModeClearVars, + ...extraEnv, + }; +} diff --git a/apps/frontend/src/renderer/components/ideation/hooks/__tests__/useIdeation.test.ts b/apps/frontend/src/renderer/components/ideation/hooks/__tests__/useIdeation.test.ts new file mode 100644 index 0000000000..902afa9bbd --- /dev/null +++ b/apps/frontend/src/renderer/components/ideation/hooks/__tests__/useIdeation.test.ts @@ -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); + }); +}); diff --git a/apps/frontend/src/renderer/components/ideation/hooks/__tests__/useIdeationAuth.test.ts b/apps/frontend/src/renderer/components/ideation/hooks/__tests__/useIdeationAuth.test.ts index e41f859e6d..82c5afdc3a 100644 --- a/apps/frontend/src/renderer/components/ideation/hooks/__tests__/useIdeationAuth.test.ts +++ b/apps/frontend/src/renderer/components/ideation/hooks/__tests__/useIdeationAuth.test.ts @@ -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); }); diff --git a/apps/frontend/src/renderer/components/ideation/hooks/useIdeation.ts b/apps/frontend/src/renderer/components/ideation/hooks/useIdeation.ts index ce5b8553a5..71359c98b3 100644 --- a/apps/frontend/src/renderer/components/ideation/hooks/useIdeation.ts +++ b/apps/frontend/src/renderer/components/ideation/hooks/useIdeation.ts @@ -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([]); - 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') { diff --git a/apps/frontend/src/renderer/components/ideation/hooks/useIdeationAuth.ts b/apps/frontend/src/renderer/components/ideation/hooks/useIdeationAuth.ts index 3fe4fcc2e8..11962de949 100644 --- a/apps/frontend/src/renderer/components/ideation/hooks/useIdeationAuth.ts +++ b/apps/frontend/src/renderer/components/ideation/hooks/useIdeationAuth.ts @@ -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 => { + // 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'); diff --git a/apps/frontend/src/renderer/components/settings/ModelSearchableSelect.test.tsx b/apps/frontend/src/renderer/components/settings/ModelSearchableSelect.test.tsx index 5802c6cba4..ba6837a39d 100644 --- a/apps/frontend/src/renderer/components/settings/ModelSearchableSelect.test.tsx +++ b/apps/frontend/src/renderer/components/settings/ModelSearchableSelect.test.tsx @@ -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( + + ); + + 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', () => { }); }); }); - diff --git a/apps/frontend/src/renderer/components/settings/ModelSearchableSelect.tsx b/apps/frontend/src/renderer/components/settings/ModelSearchableSelect.tsx index ef37ae6110..260c9f7300 100644 --- a/apps/frontend/src/renderer/components/settings/ModelSearchableSelect.tsx +++ b/apps/frontend/src/renderer/components/settings/ModelSearchableSelect.tsx @@ -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 && ( -
+
{/* Search input */}
@@ -262,7 +270,7 @@ export function ModelSearchableSelect({ setSearchQuery(e.target.value)} - placeholder="Search models..." + placeholder={t('settings:modelSelect.searchPlaceholder')} className="pl-8" autoFocus /> @@ -273,7 +281,7 @@ export function ModelSearchableSelect({
{filteredModels.length === 0 ? (
- No models match your search + {t('settings:modelSelect.noResults')}
) : ( filteredModels.map((model) => ( @@ -304,7 +312,7 @@ export function ModelSearchableSelect({ {modelDiscoveryNotSupported && (

- Model discovery not available. Enter model name manually. + {t('settings:modelSelect.discoveryNotAvailable')}

)} {error && !modelDiscoveryNotSupported && ( diff --git a/apps/frontend/src/renderer/components/settings/ProfileEditDialog.test.tsx b/apps/frontend/src/renderer/components/settings/ProfileEditDialog.test.tsx index 54d7f8d9e9..044a248558 100644 --- a/apps/frontend/src/renderer/components/settings/ProfileEditDialog.test.tsx +++ b/apps/frontend/src/renderer/components/settings/ProfileEditDialog.test.tsx @@ -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( + + ); + + 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( + + ); + + 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( + + ); + + 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', () => { diff --git a/apps/frontend/src/renderer/components/settings/ProfileEditDialog.tsx b/apps/frontend/src/renderer/components/settings/ProfileEditDialog.tsx index b5e4c5629c..ed7ce46507 100644 --- a/apps/frontend/src/renderer/components/settings/ProfileEditDialog.tsx +++ b/apps/frontend/src/renderer/components/settings/ProfileEditDialog.tsx @@ -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(''); // 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(null); + const baseUrlInputRef = useRef(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 ( - + - {isEditMode ? 'Edit Profile' : 'Add API Profile'} + + {isEditMode + ? t('settings:apiProfiles.dialog.editTitle') + : t('settings:apiProfiles.dialog.createTitle')} + - Configure a custom Anthropic-compatible API endpoint for your builds. + {t('settings:apiProfiles.dialog.description')}
- {/* Name field (required) */} -
- - setName(e.target.value)} - className={nameError ? 'border-destructive' : ''} - /> - {nameError &&

{nameError}

} -
+
+ {/* Name field (required) */} +
+ + setName(e.target.value)} + className={nameError ? 'border-destructive' : ''} + /> + {nameError &&

{nameError}

} +
- {/* Base URL field (required) */} -
- - setBaseUrl(e.target.value)} - className={urlError ? 'border-destructive' : ''} - /> - {urlError &&

{urlError}

} -

- Example: https://api.anthropic.com or http://localhost:8080 -

+ {!isEditMode && ( +
+ + +

+ {t('settings:apiProfiles.hints.preset')} +

+
+ )}
- {/* API Key field (required for create, masked in edit mode) */} -
- - {isEditMode && !isChangingApiKey && profile ? ( - // Edit mode: show masked API key -
- - -
- ) : ( - // Create mode or changing key: show password input - <> - setApiKey(e.target.value)} - className={keyError ? 'border-destructive' : ''} - /> - {isEditMode && ( +
+ {/* Base URL field (required) */} +
+ + setBaseUrl(e.target.value)} + className={urlError ? 'border-destructive' : ''} + /> + {urlError &&

{urlError}

} +

+ {t('settings:apiProfiles.hints.baseUrl')} +

+
+ + {/* API Key field (required for create, masked in edit mode) */} +
+ + {isEditMode && !isChangingApiKey && profile ? ( + // Edit mode: show masked API key +
+ - )} - - )} - {keyError &&

{keyError}

} +
+ ) : ( + // Create mode or changing key: show password input + <> + setApiKey(e.target.value)} + className={keyError ? 'border-destructive' : ''} + /> + {isEditMode && ( + + )} + + )} + {keyError &&

{keyError}

} +
{/* Test Connection button */} @@ -384,10 +444,10 @@ export function ProfileEditDialog({ open, onOpenChange, onSaved, profile }: Prof {isTestingConnection ? ( <> - Testing... + {t('settings:apiProfiles.testConnection.testing')} ) : ( - 'Test Connection' + t('settings:apiProfiles.testConnection.label') )} @@ -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')}

- +

- Select models from your API provider. Leave blank to use defaults. + {t('settings:apiProfiles.models.description')}

-
- - -
+
+
+ + +
-
- - -
+
+ + +
-
- - -
+
+ + +
-
- - +
+ + +
@@ -499,7 +561,7 @@ export function ProfileEditDialog({ open, onOpenChange, onSaved, profile }: Prof onClick={() => onOpenChange(false)} disabled={profilesLoading} > - Cancel + {t('settings:apiProfiles.actions.cancel')} diff --git a/apps/frontend/src/renderer/components/settings/ProfileList.test.tsx b/apps/frontend/src/renderer/components/settings/ProfileList.test.tsx index 7237d6bc3e..85725dc15a 100644 --- a/apps/frontend/src/renderer/components/settings/ProfileList.test.tsx +++ b/apps/frontend/src/renderer/components/settings/ProfileList.test.tsx @@ -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(); @@ -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(); // 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(); // 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(); // 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 diff --git a/apps/frontend/src/renderer/components/settings/ProfileList.tsx b/apps/frontend/src/renderer/components/settings/ProfileList.tsx index 11e12bef96..e01e71efea 100644 --- a/apps/frontend/src/renderer/components/settings/ProfileList.tsx +++ b/apps/frontend/src/renderer/components/settings/ProfileList.tsx @@ -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 */}
-

API Profiles

+

{t('settings:apiProfiles.title')}

- Configure custom Anthropic-compatible API endpoints + {t('settings:apiProfiles.description')}

@@ -146,13 +152,13 @@ export function ProfileList({ onProfileSaved }: ProfileListProps) { {profiles.length === 0 && (
-

No API profiles configured

+

{t('settings:apiProfiles.empty.title')}

- Create a profile to configure custom API endpoints for your builds. + {t('settings:apiProfiles.empty.description')}

)} @@ -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')}
)} - {profiles.map((profile) => ( -
+ {profiles.map((profile) => { + const isActive = activeProfileId === profile.id; + return ( +

{profile.name}

{activeProfileId === profile.id && ( - Active + {t('settings:apiProfiles.activeBadge')} )}
@@ -213,7 +223,9 @@ export function ProfileList({ onProfileSaved }: ProfileListProps) {
{profile.models && Object.keys(profile.models).length > 0 && (
- Custom models: {Object.keys(profile.models).join(', ')} + {t('settings:apiProfiles.customModels', { + models: Object.keys(profile.models).join(', ') + })}
)}
@@ -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')} )} @@ -239,7 +253,7 @@ export function ProfileList({ onProfileSaved }: ProfileListProps) { - Edit profile + {t('settings:apiProfiles.tooltips.edit')} @@ -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 + })} > - Delete profile + + {isActive + ? t('settings:apiProfiles.tooltips.deleteActive') + : t('settings:apiProfiles.tooltips.deleteInactive')} +
-
- ))} +
+ ); + })}
)} @@ -286,19 +308,25 @@ export function ProfileList({ onProfileSaved }: ProfileListProps) { > - Delete Profile? + {t('settings:apiProfiles.dialog.deleteTitle')} - Are you sure you want to delete "{deleteConfirmProfile?.name}"? This action cannot be undone. + {t('settings:apiProfiles.dialog.deleteDescription', { + name: deleteConfirmProfile?.name ?? '' + })} - Cancel + + {t('settings:apiProfiles.dialog.cancel')} + - {isDeleting ? 'Deleting...' : 'Delete'} + {isDeleting + ? t('settings:apiProfiles.dialog.deleting') + : t('settings:apiProfiles.dialog.delete')} diff --git a/apps/frontend/src/shared/constants/api-profiles.ts b/apps/frontend/src/shared/constants/api-profiles.ts new file mode 100644 index 0000000000..99c72138ce --- /dev/null +++ b/apps/frontend/src/shared/constants/api-profiles.ts @@ -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' + } +]; diff --git a/apps/frontend/src/shared/constants/index.ts b/apps/frontend/src/shared/constants/index.ts index ea90dce632..5b3f49872f 100644 --- a/apps/frontend/src/shared/constants/index.ts +++ b/apps/frontend/src/shared/constants/index.ts @@ -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'; diff --git a/apps/frontend/src/shared/i18n/locales/en/settings.json b/apps/frontend/src/shared/i18n/locales/en/settings.json index c139991503..d8293ba8f1 100644 --- a/apps/frontend/src/shared/i18n/locales/en/settings.json +++ b/apps/frontend/src/shared/i18n/locales/en/settings.json @@ -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" diff --git a/apps/frontend/src/shared/i18n/locales/fr/settings.json b/apps/frontend/src/shared/i18n/locales/fr/settings.json index dfe33ce6ce..03be3fae89 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/settings.json +++ b/apps/frontend/src/shared/i18n/locales/fr/settings.json @@ -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" diff --git a/apps/frontend/src/types/sentry-electron.d.ts b/apps/frontend/src/types/sentry-electron.d.ts new file mode 100644 index 0000000000..12d62fa0f4 --- /dev/null +++ b/apps/frontend/src/types/sentry-electron.d.ts @@ -0,0 +1,32 @@ +interface SentryErrorEvent { + [key: string]: unknown; +} + +interface SentryScope { + setContext: (key: string, value: Record) => 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; +} diff --git a/apps/frontend/vitest.config.ts b/apps/frontend/vitest.config.ts index 6eb2f5ee49..199ca6efc4 100644 --- a/apps/frontend/vitest.config.ts +++ b/apps/frontend/vitest.config.ts @@ -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']