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:
@@ -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: () => {}
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
+69
-2
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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']
|
||||
|
||||
Reference in New Issue
Block a user