diff --git a/apps/desktop/src/main/connection-ipc.ts b/apps/desktop/src/main/connection-ipc.ts index fc15e848..8ce79158 100644 --- a/apps/desktop/src/main/connection-ipc.ts +++ b/apps/desktop/src/main/connection-ipc.ts @@ -5,6 +5,7 @@ import { isSupportedOnboardingProvider, } from '@open-codesign/shared'; import { ipcMain } from './electron-runtime'; +import { getApiKeyForProvider, getBaseUrlForProvider, getCachedConfig } from './onboarding-ipc'; // --------------------------------------------------------------------------- // Payload schemas (plain validation, no zod in main to keep bundle lean) @@ -263,6 +264,47 @@ export function _getModelsCache(): Map { // IPC registration // --------------------------------------------------------------------------- +function buildDefaultBaseUrl(provider: SupportedOnboardingProvider): string { + switch (provider) { + case 'anthropic': + return 'https://api.anthropic.com'; + case 'openai': + return 'https://api.openai.com/v1'; + case 'openrouter': + return 'https://openrouter.ai/api/v1'; + } +} + +interface ActiveProviderCredentials { + provider: SupportedOnboardingProvider; + apiKey: string; + baseUrl: string; +} + +function resolveActiveCredentials(): ActiveProviderCredentials | ConnectionTestError { + const cfg = getCachedConfig(); + if (cfg === null || !isSupportedOnboardingProvider(cfg.provider)) { + return { + ok: false, + code: 'IPC_BAD_INPUT', + message: 'No active provider configured', + hint: 'Complete onboarding first', + }; + } + try { + const apiKey = getApiKeyForProvider(cfg.provider); + const baseUrl = getBaseUrlForProvider(cfg.provider) ?? buildDefaultBaseUrl(cfg.provider); + return { provider: cfg.provider, apiKey, baseUrl }; + } catch (err) { + return { + ok: false, + code: 'IPC_BAD_INPUT', + message: err instanceof Error ? err.message : String(err), + hint: 'Could not read active provider credentials', + }; + } +} + export function registerConnectionIpc(): void { ipcMain.handle( 'connection:v1:test', @@ -382,4 +424,32 @@ export function registerConnectionIpc(): void { setCachedModels(provider, baseUrl, apiKey, ids); return { ok: true, models: ids }; }); + + // Tests the currently active provider using the stored (encrypted) key — no key passed from renderer. + ipcMain.handle('connection:v1:test-active', async (): Promise => { + const creds = resolveActiveCredentials(); + if (!('provider' in creds)) return creds; + + const ep = buildModelsEndpoint(creds.provider, creds.baseUrl); + const authHeaders = buildAuthHeaders(creds.provider, creds.apiKey); + + let res: Response; + try { + res = await fetch(ep.url, { method: 'GET', headers: { ...ep.headers, ...authHeaders } }); + } catch (err) { + const { code, hint } = classifyNetworkError(err); + return { + ok: false, + code, + message: err instanceof Error ? err.message : 'Network request failed', + hint, + }; + } + + if (!res.ok) { + const { code, hint } = classifyHttpError(res.status); + return { ok: false, code, message: `HTTP ${res.status}`, hint }; + } + return { ok: true }; + }); } diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 26ced241..a1a6e824 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -170,6 +170,10 @@ const api = { ipcRenderer.invoke('connection:v1:test', input) as Promise< ConnectionTestResult | ConnectionTestError >, + testActive: () => + ipcRenderer.invoke('connection:v1:test-active') as Promise< + ConnectionTestResult | ConnectionTestError + >, }, models: { list: (input: { diff --git a/apps/desktop/src/renderer/src/components/ConnectionStatusDot.test.ts b/apps/desktop/src/renderer/src/components/ConnectionStatusDot.test.ts new file mode 100644 index 00000000..b981b991 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/ConnectionStatusDot.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import type { ConnectionState } from '../store'; + +// ── Pure helpers extracted for unit testing ──────────────────────────────── + +const DOT_COLORS: Record = { + connected: 'var(--color-success)', + untested: 'var(--color-warning)', + error: 'var(--color-error)', + no_provider: 'var(--color-text-muted)', +}; + +function formatRelativeTime(ts: number, now = Date.now()): string { + const diffSec = Math.round((now - ts) / 1000); + if (diffSec < 60) return `${diffSec}s ago`; + const diffMin = Math.round(diffSec / 60); + if (diffMin < 60) return `${diffMin}m ago`; + return `${Math.round(diffMin / 60)}h ago`; +} + +// ── Tests ────────────────────────────────────────────────────────────────── + +describe('ConnectionStatusDot colors', () => { + it('maps connected → success color', () => { + expect(DOT_COLORS['connected']).toBe('var(--color-success)'); + }); + + it('maps untested → warning color', () => { + expect(DOT_COLORS['untested']).toBe('var(--color-warning)'); + }); + + it('maps error → error color', () => { + expect(DOT_COLORS['error']).toBe('var(--color-error)'); + }); + + it('maps no_provider → muted color', () => { + expect(DOT_COLORS['no_provider']).toBe('var(--color-text-muted)'); + }); +}); + +describe('formatRelativeTime', () => { + const now = 1_000_000_000; + + it('shows seconds for fresh timestamps', () => { + expect(formatRelativeTime(now - 30_000, now)).toBe('30s ago'); + }); + + it('shows minutes for timestamps in the past 1–59 min', () => { + expect(formatRelativeTime(now - 3 * 60_000, now)).toBe('3m ago'); + }); + + it('shows hours for timestamps older than 59 min', () => { + expect(formatRelativeTime(now - 2 * 60 * 60_000, now)).toBe('2h ago'); + }); +}); diff --git a/apps/desktop/src/renderer/src/components/ConnectionStatusDot.tsx b/apps/desktop/src/renderer/src/components/ConnectionStatusDot.tsx new file mode 100644 index 00000000..62ad9a4a --- /dev/null +++ b/apps/desktop/src/renderer/src/components/ConnectionStatusDot.tsx @@ -0,0 +1,92 @@ +import { useT } from '@open-codesign/i18n'; +import { useEffect, useRef } from 'react'; +import { useCodesignStore } from '../store'; + +const STALE_MS = 5 * 60 * 1000; + +function formatRelativeTime(ts: number): string { + const diffSec = Math.round((Date.now() - ts) / 1000); + if (diffSec < 60) return `${diffSec}s ago`; + const diffMin = Math.round(diffSec / 60); + if (diffMin < 60) return `${diffMin}m ago`; + return `${Math.round(diffMin / 60)}h ago`; +} + +const DOT_COLORS: Record = { + connected: 'var(--color-success)', + untested: 'var(--color-warning)', + error: 'var(--color-error)', + no_provider: 'var(--color-text-muted)', +}; + +export function ConnectionStatusDot() { + const t = useT(); + const connectionStatus = useCodesignStore((s) => s.connectionStatus); + const testConnection = useCodesignStore((s) => s.testConnection); + const config = useCodesignStore((s) => s.config); + const configLoaded = useCodesignStore((s) => s.configLoaded); + + // Keep a stable ref so the effect doesn't re-run when testConnection identity changes. + const testConnectionRef = useRef(testConnection); + testConnectionRef.current = testConnection; + + const configRef = useRef(config); + configRef.current = config; + + const connectionStatusRef = useRef(connectionStatus); + connectionStatusRef.current = connectionStatus; + + // Auto-test once after config loads if provider is configured and status is stale. + useEffect(() => { + if (!configLoaded) return; + const cfg = configRef.current; + if (!cfg?.hasKey || cfg.provider === null) return; + const { state, lastTestedAt } = connectionStatusRef.current; + const isStale = lastTestedAt === null || Date.now() - lastTestedAt > STALE_MS; + if (state === 'untested' || state === 'no_provider' || isStale) { + void testConnectionRef.current(); + } + }, [configLoaded]); + + const { state, lastTestedAt, lastError } = connectionStatus; + const dotColor = DOT_COLORS[state] ?? 'var(--color-text-muted)'; + + function buildTooltip(): string { + const parts: string[] = []; + const stateLabel = t(`topbar.status.${state === 'no_provider' ? 'noProvider' : state}`); + parts.push(stateLabel); + if (lastTestedAt !== null) { + parts.push(t('topbar.status.lastTested', { time: formatRelativeTime(lastTestedAt) })); + } + if (state === 'error' && lastError) { + parts.push(lastError); + } + if (state !== 'no_provider') { + parts.push(t('topbar.status.tooltip.click')); + } + return parts.join(' · '); + } + + if (state === 'no_provider' && !config?.hasKey) { + return null; + } + + return ( + + + + {buildTooltip()} + + + ); +} diff --git a/apps/desktop/src/renderer/src/components/TopBar.tsx b/apps/desktop/src/renderer/src/components/TopBar.tsx index d5262087..c596e987 100644 --- a/apps/desktop/src/renderer/src/components/TopBar.tsx +++ b/apps/desktop/src/renderer/src/components/TopBar.tsx @@ -3,6 +3,7 @@ import { IconButton, Tooltip, Wordmark } from '@open-codesign/ui'; import { Command, Settings as SettingsIcon } from 'lucide-react'; import type { CSSProperties } from 'react'; import { useCodesignStore } from '../store'; +import { ConnectionStatusDot } from './ConnectionStatusDot'; import { LanguageToggle } from './LanguageToggle'; import { ThemeToggle } from './ThemeToggle'; @@ -33,6 +34,7 @@ export function TopBar() { {crumb} +
diff --git a/apps/desktop/src/renderer/src/connection-status.test.ts b/apps/desktop/src/renderer/src/connection-status.test.ts new file mode 100644 index 00000000..e6635dd6 --- /dev/null +++ b/apps/desktop/src/renderer/src/connection-status.test.ts @@ -0,0 +1,115 @@ +import { initI18n } from '@open-codesign/i18n'; +import type { OnboardingState } from '@open-codesign/shared'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useCodesignStore } from './store'; + +const READY_CONFIG: OnboardingState = { + hasKey: true, + provider: 'anthropic', + modelPrimary: 'claude-sonnet-4-6', + modelFast: 'claude-haiku-3', + baseUrl: null, + designSystem: null, +}; + +const initialState = useCodesignStore.getState(); + +function resetStore(config: OnboardingState | null = READY_CONFIG) { + useCodesignStore.setState({ + ...initialState, + messages: [], + previewHtml: null, + isGenerating: false, + activeGenerationId: null, + errorMessage: null, + lastError: null, + config, + configLoaded: true, + toastMessage: null, + iframeErrors: [], + toasts: [], + connectionStatus: { state: 'untested', lastTestedAt: null, lastError: null }, + }); +} + +beforeAll(async () => { + await initI18n('en'); +}); + +beforeEach(() => { + resetStore(); + vi.unstubAllGlobals(); +}); + +describe('connectionStatus store', () => { + it('setConnectionStatus updates the connectionStatus slice', () => { + useCodesignStore.getState().setConnectionStatus({ + state: 'connected', + lastTestedAt: 12345, + lastError: null, + }); + expect(useCodesignStore.getState().connectionStatus).toEqual({ + state: 'connected', + lastTestedAt: 12345, + lastError: null, + }); + }); + + it('testConnection sets state=no_provider when config is null', async () => { + resetStore(null); + vi.stubGlobal('window', {}); + await useCodesignStore.getState().testConnection(); + expect(useCodesignStore.getState().connectionStatus.state).toBe('no_provider'); + }); + + it('testConnection sets state=no_provider when window.codesign is missing', async () => { + vi.stubGlobal('window', {}); + await useCodesignStore.getState().testConnection(); + expect(useCodesignStore.getState().connectionStatus.state).toBe('no_provider'); + }); + + it('testConnection sets state=connected on ok result', async () => { + vi.stubGlobal('window', { + codesign: { + connection: { + testActive: vi.fn(() => Promise.resolve({ ok: true })), + }, + }, + }); + await useCodesignStore.getState().testConnection(); + const { state, lastTestedAt, lastError } = useCodesignStore.getState().connectionStatus; + expect(state).toBe('connected'); + expect(lastTestedAt).not.toBeNull(); + expect(lastError).toBeNull(); + }); + + it('testConnection sets state=error on failure result', async () => { + vi.stubGlobal('window', { + codesign: { + connection: { + testActive: vi.fn(() => + Promise.resolve({ ok: false, code: '401', message: 'Unauthorized', hint: '' }), + ), + }, + }, + }); + await useCodesignStore.getState().testConnection(); + const { state, lastError } = useCodesignStore.getState().connectionStatus; + expect(state).toBe('error'); + expect(lastError).toBe('Unauthorized'); + }); + + it('testConnection sets state=error when testActive throws', async () => { + vi.stubGlobal('window', { + codesign: { + connection: { + testActive: vi.fn(() => Promise.reject(new Error('Network failure'))), + }, + }, + }); + await useCodesignStore.getState().testConnection(); + const { state, lastError } = useCodesignStore.getState().connectionStatus; + expect(state).toBe('error'); + expect(lastError).toBe('Network failure'); + }); +}); diff --git a/apps/desktop/src/renderer/src/store.ts b/apps/desktop/src/renderer/src/store.ts index 15c84440..f15e97b9 100644 --- a/apps/desktop/src/renderer/src/store.ts +++ b/apps/desktop/src/renderer/src/store.ts @@ -26,6 +26,14 @@ export interface Toast { description?: string; } +export type ConnectionState = 'connected' | 'untested' | 'error' | 'no_provider'; + +export interface ConnectionStatus { + state: ConnectionState; + lastTestedAt: number | null; + lastError: string | null; +} + export type Theme = 'light' | 'dark'; interface PromptRequest { @@ -44,6 +52,7 @@ interface CodesignState { config: OnboardingState | null; configLoaded: boolean; toastMessage: string | null; + connectionStatus: ConnectionStatus; theme: Theme; settingsOpen: boolean; @@ -58,6 +67,8 @@ interface CodesignState { loadConfig: () => Promise; completeOnboarding: (next: OnboardingState) => void; + setConnectionStatus: (status: ConnectionStatus) => void; + testConnection: () => Promise; sendPrompt: (input: { prompt: string; attachments?: LocalInputFile[] | undefined; @@ -210,6 +221,7 @@ export const useCodesignStore = create((set, get) => ({ config: null, configLoaded: false, toastMessage: null, + connectionStatus: { state: 'no_provider', lastTestedAt: null, lastError: null }, theme: readInitialTheme(), settingsOpen: false, @@ -251,6 +263,29 @@ export const useCodesignStore = create((set, get) => ({ set({ config: next }); }, + setConnectionStatus(status: ConnectionStatus) { + set({ connectionStatus: status }); + }, + + async testConnection() { + const cfg = get().config; + if (!window.codesign || cfg === null || !cfg.hasKey || cfg.provider === null) { + set({ connectionStatus: { state: 'no_provider', lastTestedAt: null, lastError: null } }); + return; + } + const result = await window.codesign.connection.testActive().catch((err: unknown) => { + const msg = err instanceof Error ? err.message : tr('errors.unknown'); + return { ok: false as const, code: 'NETWORK' as const, message: msg, hint: msg }; + }); + if (result.ok) { + set({ connectionStatus: { state: 'connected', lastTestedAt: Date.now(), lastError: null } }); + } else { + set({ + connectionStatus: { state: 'error', lastTestedAt: Date.now(), lastError: result.message }, + }); + } + }, + async pickInputFiles() { if (!window.codesign) return; const files = await window.codesign.pickInputFiles(); diff --git a/packages/i18n/src/locales/en.json b/packages/i18n/src/locales/en.json index 34977d3d..c8719404 100644 --- a/packages/i18n/src/locales/en.json +++ b/packages/i18n/src/locales/en.json @@ -132,6 +132,18 @@ "start": "Start designing" } }, + "topbar": { + "status": { + "connected": "Connected", + "untested": "Not tested", + "error": "Connection error", + "noProvider": "No provider configured", + "lastTested": "Last tested {{time}}", + "tooltip": { + "click": "Click to re-test" + } + } + }, "commands": { "title": "Commands", "placeholder": "Type a command or search\u2026", diff --git a/packages/i18n/src/locales/zh-CN.json b/packages/i18n/src/locales/zh-CN.json index a7feddbe..c2568ad7 100644 --- a/packages/i18n/src/locales/zh-CN.json +++ b/packages/i18n/src/locales/zh-CN.json @@ -132,6 +132,18 @@ "start": "开始设计" } }, + "topbar": { + "status": { + "connected": "已连接", + "untested": "未测试", + "error": "连接错误", + "noProvider": "未配置提供商", + "lastTested": "上次测试:{{time}}", + "tooltip": { + "click": "点击重新测试" + } + } + }, "commands": { "title": "命令面板", "placeholder": "输入命令或搜索\u2026",