Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions apps/desktop/src/main/connection-ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -263,6 +264,47 @@ export function _getModelsCache(): Map<string, CacheEntry> {
// 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',
Expand Down Expand Up @@ -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<ConnectionTestResponse> => {
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 };
});
}
4 changes: 4 additions & 0 deletions apps/desktop/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest';
import type { ConnectionState } from '../store';

// ── Pure helpers extracted for unit testing ────────────────────────────────
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Minor] This test file redefines helper logic locally instead of importing/testing the production component logic, so regressions in ConnectionStatusDot.tsx can slip through. Please switch to component-level assertions or exported helpers from the source file.


const DOT_COLORS: Record<ConnectionState, string> = {
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');
});
});
92 changes: 92 additions & 0 deletions apps/desktop/src/renderer/src/components/ConnectionStatusDot.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Major] Relative-time text is hardcoded in English (s/m/h ago) and bypasses i18n, so zh-CN users will see mixed-language tooltip content. Please localize these units/phrases via translation keys.

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<string, string> = {
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 (
<span className="relative inline-flex items-center group">
<button
type="button"
aria-label={buildTooltip()}
onClick={() => void testConnection()}
className="flex items-center justify-center w-5 h-5 rounded-full hover:bg-[var(--color-surface-hover)] transition-colors"
>
<span style={{ backgroundColor: dotColor }} className="block w-2.5 h-2.5 rounded-full" />
</button>
<span
role="tooltip"
className="pointer-events-none absolute bottom-full mb-1.5 left-1/2 -translate-x-1/2 z-50 whitespace-nowrap max-w-xs rounded-[var(--radius-sm)] bg-[var(--color-text-primary)] px-2 py-1 text-[11px] font-medium text-[var(--color-background)] opacity-0 transition-opacity duration-150 delay-[400ms] group-hover:opacity-100 shadow-[var(--shadow-card)]"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Major] text-[11px] introduces a raw pixel typography value in app code. Per token-only UI constraints, use a tokenized text size (e.g., var(--text-*)) from packages/ui tokens.

>
{buildTooltip()}
</span>
</span>
);
}
2 changes: 2 additions & 0 deletions apps/desktop/src/renderer/src/components/TopBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -33,6 +34,7 @@ export function TopBar() {
<span className="text-[var(--text-sm)] text-[var(--color-text-secondary)] truncate">
{crumb}
</span>
<ConnectionStatusDot />
</div>

<div className="flex items-center gap-1" style={noDragStyle}>
Expand Down
115 changes: 115 additions & 0 deletions apps/desktop/src/renderer/src/connection-status.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading
Loading