mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 20:03:37 +00:00
Merge branch 'v0.9.0rc' into feat/subagents-skills
This commit is contained in:
@@ -33,9 +33,31 @@ export const DEFAULT_MODEL = 'claude-opus-4-5-20251101';
|
||||
* Formats a model name for display
|
||||
*/
|
||||
export function formatModelName(model: string): string {
|
||||
// Claude models
|
||||
if (model.includes('opus')) return 'Opus 4.5';
|
||||
if (model.includes('sonnet')) return 'Sonnet 4.5';
|
||||
if (model.includes('haiku')) return 'Haiku 4.5';
|
||||
|
||||
// Codex/GPT models
|
||||
if (model === 'gpt-5.2') return 'GPT-5.2';
|
||||
if (model === 'gpt-5.1-codex-max') return 'GPT-5.1 Max';
|
||||
if (model === 'gpt-5.1-codex') return 'GPT-5.1 Codex';
|
||||
if (model === 'gpt-5.1-codex-mini') return 'GPT-5.1 Mini';
|
||||
if (model === 'gpt-5.1') return 'GPT-5.1';
|
||||
if (model.startsWith('gpt-')) return model.toUpperCase();
|
||||
if (model.match(/^o\d/)) return model.toUpperCase(); // o1, o3, etc.
|
||||
|
||||
// Cursor models
|
||||
if (model === 'cursor-auto' || model === 'auto') return 'Cursor Auto';
|
||||
if (model === 'cursor-composer-1' || model === 'composer-1') return 'Composer 1';
|
||||
if (model.startsWith('cursor-sonnet')) return 'Cursor Sonnet';
|
||||
if (model.startsWith('cursor-opus')) return 'Cursor Opus';
|
||||
if (model.startsWith('cursor-gpt')) return model.replace('cursor-', '').replace('gpt-', 'GPT-');
|
||||
if (model.startsWith('cursor-gemini'))
|
||||
return model.replace('cursor-', 'Cursor ').replace('gemini', 'Gemini');
|
||||
if (model.startsWith('cursor-grok')) return 'Cursor Grok';
|
||||
|
||||
// Default: split by dash and capitalize
|
||||
return model.split('-').slice(1, 3).join(' ');
|
||||
}
|
||||
|
||||
|
||||
86
apps/ui/src/lib/codex-usage-format.ts
Normal file
86
apps/ui/src/lib/codex-usage-format.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { type CodexCreditsSnapshot, type CodexPlanType } from '@/store/app-store';
|
||||
|
||||
const WINDOW_DEFAULT_LABEL = 'Usage window';
|
||||
const RESET_LABEL = 'Resets';
|
||||
const UNKNOWN_LABEL = 'Unknown';
|
||||
const UNAVAILABLE_LABEL = 'Unavailable';
|
||||
const UNLIMITED_LABEL = 'Unlimited';
|
||||
const AVAILABLE_LABEL = 'Available';
|
||||
const NONE_LABEL = 'None';
|
||||
const DAY_UNIT = 'day';
|
||||
const HOUR_UNIT = 'hour';
|
||||
const MINUTE_UNIT = 'min';
|
||||
const WINDOW_SUFFIX = 'window';
|
||||
const MINUTES_PER_HOUR = 60;
|
||||
const MINUTES_PER_DAY = 24 * MINUTES_PER_HOUR;
|
||||
const MILLISECONDS_PER_SECOND = 1000;
|
||||
const SESSION_HOURS = 5;
|
||||
const DAYS_PER_WEEK = 7;
|
||||
const SESSION_WINDOW_MINS = SESSION_HOURS * MINUTES_PER_HOUR;
|
||||
const WEEKLY_WINDOW_MINS = DAYS_PER_WEEK * MINUTES_PER_DAY;
|
||||
const SESSION_TITLE = 'Session Usage';
|
||||
const SESSION_SUBTITLE = '5-hour rolling window';
|
||||
const WEEKLY_TITLE = 'Weekly';
|
||||
const WEEKLY_SUBTITLE = 'All models';
|
||||
const FALLBACK_TITLE = 'Usage Window';
|
||||
const PLAN_TYPE_LABELS: Record<CodexPlanType, string> = {
|
||||
free: 'Free',
|
||||
plus: 'Plus',
|
||||
pro: 'Pro',
|
||||
team: 'Team',
|
||||
business: 'Business',
|
||||
enterprise: 'Enterprise',
|
||||
edu: 'Education',
|
||||
unknown: UNKNOWN_LABEL,
|
||||
};
|
||||
|
||||
export function formatCodexWindowDuration(minutes: number | null): string {
|
||||
if (!minutes || minutes <= 0) return WINDOW_DEFAULT_LABEL;
|
||||
if (minutes % MINUTES_PER_DAY === 0) {
|
||||
const days = minutes / MINUTES_PER_DAY;
|
||||
return `${days} ${DAY_UNIT}${days === 1 ? '' : 's'} ${WINDOW_SUFFIX}`;
|
||||
}
|
||||
if (minutes % MINUTES_PER_HOUR === 0) {
|
||||
const hours = minutes / MINUTES_PER_HOUR;
|
||||
return `${hours} ${HOUR_UNIT}${hours === 1 ? '' : 's'} ${WINDOW_SUFFIX}`;
|
||||
}
|
||||
return `${minutes} ${MINUTE_UNIT} ${WINDOW_SUFFIX}`;
|
||||
}
|
||||
|
||||
export type CodexWindowLabel = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
isPrimary: boolean;
|
||||
};
|
||||
|
||||
export function getCodexWindowLabel(windowDurationMins: number | null): CodexWindowLabel {
|
||||
if (windowDurationMins === SESSION_WINDOW_MINS) {
|
||||
return { title: SESSION_TITLE, subtitle: SESSION_SUBTITLE, isPrimary: true };
|
||||
}
|
||||
if (windowDurationMins === WEEKLY_WINDOW_MINS) {
|
||||
return { title: WEEKLY_TITLE, subtitle: WEEKLY_SUBTITLE, isPrimary: false };
|
||||
}
|
||||
return {
|
||||
title: FALLBACK_TITLE,
|
||||
subtitle: formatCodexWindowDuration(windowDurationMins),
|
||||
isPrimary: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatCodexResetTime(resetsAt: number | null): string | null {
|
||||
if (!resetsAt) return null;
|
||||
const date = new Date(resetsAt * MILLISECONDS_PER_SECOND);
|
||||
return `${RESET_LABEL} ${date.toLocaleString()}`;
|
||||
}
|
||||
|
||||
export function formatCodexPlanType(plan: CodexPlanType | null): string {
|
||||
if (!plan) return UNKNOWN_LABEL;
|
||||
return PLAN_TYPE_LABELS[plan] ?? plan;
|
||||
}
|
||||
|
||||
export function formatCodexCredits(snapshot: CodexCreditsSnapshot | null): string {
|
||||
if (!snapshot) return UNAVAILABLE_LABEL;
|
||||
if (snapshot.unlimited) return UNLIMITED_LABEL;
|
||||
if (snapshot.balance) return snapshot.balance;
|
||||
return snapshot.hasCredits ? AVAILABLE_LABEL : NONE_LABEL;
|
||||
}
|
||||
@@ -459,7 +459,9 @@ export interface FeaturesAPI {
|
||||
update: (
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
updates: Partial<Feature>
|
||||
updates: Partial<Feature>,
|
||||
descriptionHistorySource?: 'enhance' | 'edit',
|
||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance'
|
||||
) => Promise<{ success: boolean; feature?: Feature; error?: string }>;
|
||||
delete: (projectPath: string, featureId: string) => Promise<{ success: boolean; error?: string }>;
|
||||
getAgentOutput: (
|
||||
@@ -566,6 +568,7 @@ export interface ElectronAPI {
|
||||
mimeType: string,
|
||||
projectPath?: string
|
||||
) => Promise<SaveImageResult>;
|
||||
isElectron?: boolean;
|
||||
checkClaudeCli?: () => Promise<{
|
||||
success: boolean;
|
||||
status?: string;
|
||||
@@ -612,79 +615,43 @@ export interface ElectronAPI {
|
||||
error?: string;
|
||||
}>;
|
||||
};
|
||||
setup?: {
|
||||
getClaudeStatus: () => Promise<{
|
||||
success: boolean;
|
||||
status?: string;
|
||||
installed?: boolean;
|
||||
method?: string;
|
||||
version?: string;
|
||||
path?: string;
|
||||
auth?: {
|
||||
authenticated: boolean;
|
||||
method: string;
|
||||
hasCredentialsFile?: boolean;
|
||||
hasToken?: boolean;
|
||||
hasStoredOAuthToken?: boolean;
|
||||
hasStoredApiKey?: boolean;
|
||||
hasEnvApiKey?: boolean;
|
||||
hasEnvOAuthToken?: boolean;
|
||||
};
|
||||
error?: string;
|
||||
}>;
|
||||
installClaude: () => Promise<{
|
||||
success: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
authClaude: () => Promise<{
|
||||
success: boolean;
|
||||
token?: string;
|
||||
requiresManualAuth?: boolean;
|
||||
terminalOpened?: boolean;
|
||||
command?: string;
|
||||
error?: string;
|
||||
message?: string;
|
||||
output?: string;
|
||||
}>;
|
||||
storeApiKey: (
|
||||
provider: string,
|
||||
apiKey: string
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
deleteApiKey: (
|
||||
provider: string
|
||||
) => Promise<{ success: boolean; error?: string; message?: string }>;
|
||||
getApiKeys: () => Promise<{
|
||||
success: boolean;
|
||||
hasAnthropicKey: boolean;
|
||||
hasGoogleKey: boolean;
|
||||
}>;
|
||||
getPlatform: () => Promise<{
|
||||
success: boolean;
|
||||
platform: string;
|
||||
arch: string;
|
||||
homeDir: string;
|
||||
isWindows: boolean;
|
||||
isMac: boolean;
|
||||
isLinux: boolean;
|
||||
}>;
|
||||
verifyClaudeAuth: (authMethod?: 'cli' | 'api_key') => Promise<{
|
||||
success: boolean;
|
||||
authenticated: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
getGhStatus?: () => Promise<{
|
||||
success: boolean;
|
||||
installed: boolean;
|
||||
authenticated: boolean;
|
||||
version: string | null;
|
||||
path: string | null;
|
||||
user: string | null;
|
||||
error?: string;
|
||||
}>;
|
||||
onInstallProgress?: (callback: (progress: any) => void) => () => void;
|
||||
onAuthProgress?: (callback: (progress: any) => void) => () => void;
|
||||
templates?: {
|
||||
clone: (
|
||||
repoUrl: string,
|
||||
projectName: string,
|
||||
parentDir: string
|
||||
) => Promise<{ success: boolean; projectPath?: string; error?: string }>;
|
||||
};
|
||||
backlogPlan?: {
|
||||
generate: (
|
||||
projectPath: string,
|
||||
prompt: string,
|
||||
model?: string
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
stop: () => Promise<{ success: boolean; error?: string }>;
|
||||
status: () => Promise<{ success: boolean; isRunning?: boolean; error?: string }>;
|
||||
apply: (
|
||||
projectPath: string,
|
||||
plan: {
|
||||
changes: Array<{
|
||||
type: 'add' | 'update' | 'delete';
|
||||
featureId?: string;
|
||||
feature?: Record<string, unknown>;
|
||||
reason: string;
|
||||
}>;
|
||||
summary: string;
|
||||
dependencyUpdates: Array<{
|
||||
featureId: string;
|
||||
removedDependencies: string[];
|
||||
addedDependencies: string[];
|
||||
}>;
|
||||
}
|
||||
) => Promise<{ success: boolean; appliedChanges?: string[]; error?: string }>;
|
||||
onEvent: (callback: (data: unknown) => void) => () => void;
|
||||
};
|
||||
// Setup API surface is implemented by the main process and mirrored by HttpApiClient.
|
||||
// Keep this intentionally loose to avoid tight coupling between front-end and server types.
|
||||
setup?: any;
|
||||
agent?: {
|
||||
start: (
|
||||
sessionId: string,
|
||||
@@ -866,11 +833,13 @@ export const isElectron = (): boolean => {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((window as any).isElectron === true) {
|
||||
const w = window as any;
|
||||
|
||||
if (w.isElectron === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return window.electronAPI?.isElectron === true;
|
||||
return !!w.electronAPI?.isElectron;
|
||||
};
|
||||
|
||||
// Check if backend server is available
|
||||
|
||||
@@ -34,7 +34,7 @@ import type {
|
||||
ConvertToFeatureOptions,
|
||||
} from './electron';
|
||||
import type { Message, SessionListItem } from '@/types/electron';
|
||||
import type { Feature, ClaudeUsageResponse } from '@/store/app-store';
|
||||
import type { Feature, ClaudeUsageResponse, CodexUsageResponse } from '@/store/app-store';
|
||||
import type { WorktreeAPI, GitAPI, ModelDefinition, ProviderStatus } from '@/types/electron';
|
||||
import { getGlobalFileBrowser } from '@/contexts/file-browser-context';
|
||||
|
||||
@@ -43,6 +43,36 @@ const logger = createLogger('HttpClient');
|
||||
// Cached server URL (set during initialization in Electron mode)
|
||||
let cachedServerUrl: string | null = null;
|
||||
|
||||
/**
|
||||
* Notify the UI that the current session is no longer valid.
|
||||
* Used to redirect the user to a logged-out route on 401/403 responses.
|
||||
*/
|
||||
const notifyLoggedOut = (): void => {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent('automaker:logged-out'));
|
||||
} catch {
|
||||
// Ignore - navigation will still be handled by failed requests in most cases
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle an unauthorized response in cookie/session auth flows.
|
||||
* Clears in-memory token and attempts to clear the cookie (best-effort),
|
||||
* then notifies the UI to redirect.
|
||||
*/
|
||||
const handleUnauthorized = (): void => {
|
||||
clearSessionToken();
|
||||
// Best-effort cookie clear (avoid throwing)
|
||||
fetch(`${getServerUrl()}/api/auth/logout`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: '{}',
|
||||
}).catch(() => {});
|
||||
notifyLoggedOut();
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize server URL from Electron IPC.
|
||||
* Must be called early in Electron mode before making API requests.
|
||||
@@ -86,6 +116,7 @@ let apiKeyInitialized = false;
|
||||
let apiKeyInitPromise: Promise<void> | null = null;
|
||||
|
||||
// Cached session token for authentication (Web mode - explicit header auth)
|
||||
// Only used in-memory after fresh login; on refresh we rely on HTTP-only cookies
|
||||
let cachedSessionToken: string | null = null;
|
||||
|
||||
// Get API key for Electron mode (returns cached value after initialization)
|
||||
@@ -103,10 +134,10 @@ export const waitForApiKeyInit = (): Promise<void> => {
|
||||
return initApiKey();
|
||||
};
|
||||
|
||||
// Get session token for Web mode (returns cached value after login or token fetch)
|
||||
// Get session token for Web mode (returns cached value after login)
|
||||
export const getSessionToken = (): string | null => cachedSessionToken;
|
||||
|
||||
// Set session token (called after login or token fetch)
|
||||
// Set session token (called after login)
|
||||
export const setSessionToken = (token: string | null): void => {
|
||||
cachedSessionToken = token;
|
||||
};
|
||||
@@ -129,6 +160,39 @@ export const isElectronMode = (): boolean => {
|
||||
return api?.isElectron === true || !!api?.getApiKey;
|
||||
};
|
||||
|
||||
// Cached external server mode flag
|
||||
let cachedExternalServerMode: boolean | null = null;
|
||||
|
||||
/**
|
||||
* Check if running in external server mode (Docker API)
|
||||
* In this mode, Electron uses session-based auth like web mode
|
||||
*/
|
||||
export const checkExternalServerMode = async (): Promise<boolean> => {
|
||||
if (cachedExternalServerMode !== null) {
|
||||
return cachedExternalServerMode;
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
const api = window.electronAPI as any;
|
||||
if (api?.isExternalServerMode) {
|
||||
try {
|
||||
cachedExternalServerMode = Boolean(await api.isExternalServerMode());
|
||||
return cachedExternalServerMode;
|
||||
} catch (error) {
|
||||
logger.warn('Failed to check external server mode:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cachedExternalServerMode = false;
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get cached external server mode (synchronous, returns null if not yet checked)
|
||||
*/
|
||||
export const isExternalServerMode = (): boolean | null => cachedExternalServerMode;
|
||||
|
||||
/**
|
||||
* Initialize API key and server URL for Electron mode authentication.
|
||||
* In web mode, authentication uses HTTP-only cookies instead.
|
||||
@@ -276,6 +340,7 @@ export const logout = async (): Promise<{ success: boolean }> => {
|
||||
try {
|
||||
const response = await fetch(`${getServerUrl()}/api/auth/logout`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
@@ -296,52 +361,52 @@ export const logout = async (): Promise<{ success: boolean }> => {
|
||||
* This should be called:
|
||||
* 1. After login to verify the cookie was set correctly
|
||||
* 2. On app load to verify the session hasn't expired
|
||||
*
|
||||
* Returns:
|
||||
* - true: Session is valid
|
||||
* - false: Session is definitively invalid (401/403 auth failure)
|
||||
* - throws: Network error or server not ready (caller should retry)
|
||||
*/
|
||||
export const verifySession = async (): Promise<boolean> => {
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
// Add session token header if available
|
||||
const sessionToken = getSessionToken();
|
||||
if (sessionToken) {
|
||||
headers['X-Session-Token'] = sessionToken;
|
||||
}
|
||||
// Add session token header if available
|
||||
const sessionToken = getSessionToken();
|
||||
if (sessionToken) {
|
||||
headers['X-Session-Token'] = sessionToken;
|
||||
}
|
||||
|
||||
// Make a request to an authenticated endpoint to verify the session
|
||||
// We use /api/settings/status as it requires authentication and is lightweight
|
||||
const response = await fetch(`${getServerUrl()}/api/settings/status`, {
|
||||
headers,
|
||||
credentials: 'include',
|
||||
});
|
||||
// Make a request to an authenticated endpoint to verify the session
|
||||
// We use /api/settings/status as it requires authentication and is lightweight
|
||||
// Note: fetch throws on network errors, which we intentionally let propagate
|
||||
const response = await fetch(`${getServerUrl()}/api/settings/status`, {
|
||||
headers,
|
||||
credentials: 'include',
|
||||
// Avoid hanging indefinitely during backend reloads or network issues
|
||||
signal: AbortSignal.timeout(2500),
|
||||
});
|
||||
|
||||
// Check for authentication errors
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
logger.warn('Session verification failed - session expired or invalid');
|
||||
// Clear the session since it's no longer valid
|
||||
clearSessionToken();
|
||||
// Try to clear the cookie via logout (fire and forget)
|
||||
fetch(`${getServerUrl()}/api/auth/logout`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: '{}',
|
||||
}).catch(() => {});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn('Session verification failed with status:', response.status);
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.info('Session verified successfully');
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Session verification error:', error);
|
||||
// Check for authentication errors - these are definitive "invalid session" responses
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
logger.warn('Session verification failed - session expired or invalid');
|
||||
// Clear the in-memory/localStorage session token since it's no longer valid
|
||||
// Note: We do NOT call logout here - that would destroy a potentially valid
|
||||
// cookie if the issue was transient (e.g., token not sent due to timing)
|
||||
clearSessionToken();
|
||||
return false;
|
||||
}
|
||||
|
||||
// For other non-ok responses (5xx, etc.), throw to trigger retry
|
||||
if (!response.ok) {
|
||||
const error = new Error(`Session verification failed with status: ${response.status}`);
|
||||
logger.warn('Session verification failed with status:', response.status);
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.info('Session verified successfully');
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -355,6 +420,7 @@ export const checkSandboxEnvironment = async (): Promise<{
|
||||
try {
|
||||
const response = await fetch(`${getServerUrl()}/api/health/environment`, {
|
||||
method: 'GET',
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -437,6 +503,11 @@ export class HttpApiClient implements ElectronAPI {
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
handleUnauthorized();
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn('Failed to fetch wsToken:', response.status);
|
||||
return null;
|
||||
@@ -461,19 +532,29 @@ export class HttpApiClient implements ElectronAPI {
|
||||
|
||||
this.isConnecting = true;
|
||||
|
||||
// Electron mode must authenticate with the injected API key.
|
||||
// If the key isn't ready yet, do NOT fall back to /api/auth/token (web-mode flow).
|
||||
// Electron mode typically authenticates with the injected API key.
|
||||
// However, in external-server/cookie-auth flows, the API key may be unavailable.
|
||||
// In that case, fall back to the same wsToken/cookie authentication used in web mode
|
||||
// so the UI still receives real-time events (running tasks, logs, etc.).
|
||||
if (isElectronMode()) {
|
||||
const apiKey = getApiKey();
|
||||
if (!apiKey) {
|
||||
logger.warn('Electron mode: API key not ready, delaying WebSocket connect');
|
||||
this.isConnecting = false;
|
||||
if (!this.reconnectTimer) {
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
this.connectWebSocket();
|
||||
}, 250);
|
||||
}
|
||||
logger.warn('Electron mode: API key missing, attempting wsToken/cookie auth for WebSocket');
|
||||
this.fetchWsToken()
|
||||
.then((wsToken) => {
|
||||
const wsUrl = this.serverUrl.replace(/^http/, 'ws') + '/api/events';
|
||||
if (wsToken) {
|
||||
this.establishWebSocket(`${wsUrl}?wsToken=${encodeURIComponent(wsToken)}`);
|
||||
} else {
|
||||
// Fallback: try connecting without token (will fail if not authenticated)
|
||||
logger.warn('No wsToken available, attempting WebSocket connection anyway');
|
||||
this.establishWebSocket(wsUrl);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('Failed to prepare WebSocket connection (electron fallback):', error);
|
||||
this.isConnecting = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -608,6 +689,11 @@ export class HttpApiClient implements ElectronAPI {
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
handleUnauthorized();
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = `HTTP ${response.status}: ${response.statusText}`;
|
||||
try {
|
||||
@@ -632,6 +718,11 @@ export class HttpApiClient implements ElectronAPI {
|
||||
credentials: 'include', // Include cookies for session auth
|
||||
});
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
handleUnauthorized();
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = `HTTP ${response.status}: ${response.statusText}`;
|
||||
try {
|
||||
@@ -658,6 +749,11 @@ export class HttpApiClient implements ElectronAPI {
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
handleUnauthorized();
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = `HTTP ${response.status}: ${response.statusText}`;
|
||||
try {
|
||||
@@ -683,6 +779,11 @@ export class HttpApiClient implements ElectronAPI {
|
||||
credentials: 'include', // Include cookies for session auth
|
||||
});
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
handleUnauthorized();
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = `HTTP ${response.status}: ${response.statusText}`;
|
||||
try {
|
||||
@@ -1135,6 +1236,52 @@ export class HttpApiClient implements ElectronAPI {
|
||||
`/api/setup/cursor-permissions/example${profileId ? `?profileId=${profileId}` : ''}`
|
||||
),
|
||||
|
||||
// Codex CLI methods
|
||||
getCodexStatus: (): Promise<{
|
||||
success: boolean;
|
||||
status?: string;
|
||||
installed?: boolean;
|
||||
method?: string;
|
||||
version?: string;
|
||||
path?: string;
|
||||
auth?: {
|
||||
authenticated: boolean;
|
||||
method: string;
|
||||
hasAuthFile?: boolean;
|
||||
hasOAuthToken?: boolean;
|
||||
hasApiKey?: boolean;
|
||||
hasStoredApiKey?: boolean;
|
||||
hasEnvApiKey?: boolean;
|
||||
};
|
||||
error?: string;
|
||||
}> => this.get('/api/setup/codex-status'),
|
||||
|
||||
installCodex: (): Promise<{
|
||||
success: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}> => this.post('/api/setup/install-codex'),
|
||||
|
||||
authCodex: (): Promise<{
|
||||
success: boolean;
|
||||
token?: string;
|
||||
requiresManualAuth?: boolean;
|
||||
terminalOpened?: boolean;
|
||||
command?: string;
|
||||
error?: string;
|
||||
message?: string;
|
||||
output?: string;
|
||||
}> => this.post('/api/setup/auth-codex'),
|
||||
|
||||
verifyCodexAuth: (
|
||||
authMethod: 'cli' | 'api_key',
|
||||
apiKey?: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
authenticated: boolean;
|
||||
error?: string;
|
||||
}> => this.post('/api/setup/verify-codex-auth', { authMethod, apiKey }),
|
||||
|
||||
onInstallProgress: (callback: (progress: unknown) => void) => {
|
||||
return this.subscribeToEvent('agent:stream', callback);
|
||||
},
|
||||
@@ -1145,20 +1292,47 @@ export class HttpApiClient implements ElectronAPI {
|
||||
};
|
||||
|
||||
// Features API
|
||||
features: FeaturesAPI = {
|
||||
features: FeaturesAPI & {
|
||||
bulkUpdate: (
|
||||
projectPath: string,
|
||||
featureIds: string[],
|
||||
updates: Partial<Feature>
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
updatedCount?: number;
|
||||
failedCount?: number;
|
||||
results?: Array<{ featureId: string; success: boolean; error?: string }>;
|
||||
features?: Feature[];
|
||||
error?: string;
|
||||
}>;
|
||||
} = {
|
||||
getAll: (projectPath: string) => this.post('/api/features/list', { projectPath }),
|
||||
get: (projectPath: string, featureId: string) =>
|
||||
this.post('/api/features/get', { projectPath, featureId }),
|
||||
create: (projectPath: string, feature: Feature) =>
|
||||
this.post('/api/features/create', { projectPath, feature }),
|
||||
update: (projectPath: string, featureId: string, updates: Partial<Feature>) =>
|
||||
this.post('/api/features/update', { projectPath, featureId, updates }),
|
||||
update: (
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
updates: Partial<Feature>,
|
||||
descriptionHistorySource?: 'enhance' | 'edit',
|
||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance'
|
||||
) =>
|
||||
this.post('/api/features/update', {
|
||||
projectPath,
|
||||
featureId,
|
||||
updates,
|
||||
descriptionHistorySource,
|
||||
enhancementMode,
|
||||
}),
|
||||
delete: (projectPath: string, featureId: string) =>
|
||||
this.post('/api/features/delete', { projectPath, featureId }),
|
||||
getAgentOutput: (projectPath: string, featureId: string) =>
|
||||
this.post('/api/features/agent-output', { projectPath, featureId }),
|
||||
generateTitle: (description: string) =>
|
||||
this.post('/api/features/generate-title', { description }),
|
||||
bulkUpdate: (projectPath: string, featureIds: string[], updates: Partial<Feature>) =>
|
||||
this.post('/api/features/bulk-update', { projectPath, featureIds, updates }),
|
||||
};
|
||||
|
||||
// Auto Mode API
|
||||
@@ -1746,6 +1920,11 @@ export class HttpApiClient implements ElectronAPI {
|
||||
getUsage: (): Promise<ClaudeUsageResponse> => this.get('/api/claude/usage'),
|
||||
};
|
||||
|
||||
// Codex API
|
||||
codex = {
|
||||
getUsage: (): Promise<CodexUsageResponse> => this.get('/api/codex/usage'),
|
||||
};
|
||||
|
||||
// Context API
|
||||
context = {
|
||||
describeImage: (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import type { ModelAlias } from '@/store/app-store';
|
||||
import type { ModelAlias, ModelProvider } from '@/store/app-store';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
@@ -14,6 +14,33 @@ export function modelSupportsThinking(_model?: ModelAlias | string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the provider from a model string
|
||||
* Mirrors the logic in apps/server/src/providers/provider-factory.ts
|
||||
*/
|
||||
export function getProviderFromModel(model?: string): ModelProvider {
|
||||
if (!model) return 'claude';
|
||||
|
||||
// Check for Cursor models (cursor- prefix)
|
||||
if (model.startsWith('cursor-') || model.startsWith('cursor:')) {
|
||||
return 'cursor';
|
||||
}
|
||||
|
||||
// Check for Codex/OpenAI models (gpt- prefix or o-series)
|
||||
const CODEX_MODEL_PREFIXES = ['gpt-'];
|
||||
const OPENAI_O_SERIES_PATTERN = /^o\d/;
|
||||
if (
|
||||
CODEX_MODEL_PREFIXES.some((prefix) => model.startsWith(prefix)) ||
|
||||
OPENAI_O_SERIES_PATTERN.test(model) ||
|
||||
model.startsWith('codex:')
|
||||
) {
|
||||
return 'codex';
|
||||
}
|
||||
|
||||
// Default to Claude
|
||||
return 'claude';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display name for a model
|
||||
*/
|
||||
@@ -22,6 +49,15 @@ export function getModelDisplayName(model: ModelAlias | string): string {
|
||||
haiku: 'Claude Haiku',
|
||||
sonnet: 'Claude Sonnet',
|
||||
opus: 'Claude Opus',
|
||||
// Codex models
|
||||
'gpt-5.2': 'GPT-5.2',
|
||||
'gpt-5.1-codex-max': 'GPT-5.1 Codex Max',
|
||||
'gpt-5.1-codex': 'GPT-5.1 Codex',
|
||||
'gpt-5.1-codex-mini': 'GPT-5.1 Codex Mini',
|
||||
'gpt-5.1': 'GPT-5.1',
|
||||
// Cursor models (common ones)
|
||||
'cursor-auto': 'Cursor Auto',
|
||||
'cursor-composer-1': 'Composer 1',
|
||||
};
|
||||
return displayNames[model] || model;
|
||||
}
|
||||
|
||||
@@ -6,12 +6,10 @@
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { getHttpApiClient } from './http-api-client';
|
||||
import { getElectronAPI } from './electron';
|
||||
import { getItem, setItem } from './storage';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
|
||||
const logger = createLogger('WorkspaceConfig');
|
||||
|
||||
const LAST_PROJECT_DIR_KEY = 'automaker:lastProjectDir';
|
||||
|
||||
/**
|
||||
* Browser-compatible path join utility
|
||||
* Works in both Node.js and browser environments
|
||||
@@ -67,10 +65,10 @@ export async function getDefaultWorkspaceDirectory(): Promise<string | null> {
|
||||
}
|
||||
|
||||
// If ALLOWED_ROOT_DIRECTORY is not set, use priority:
|
||||
// 1. Last used directory
|
||||
// 1. Last used directory (from store, synced via API)
|
||||
// 2. Documents/Automaker
|
||||
// 3. DATA_DIR as fallback
|
||||
const lastUsedDir = getItem(LAST_PROJECT_DIR_KEY);
|
||||
const lastUsedDir = useAppStore.getState().lastProjectDir;
|
||||
|
||||
if (lastUsedDir) {
|
||||
return lastUsedDir;
|
||||
@@ -89,7 +87,7 @@ export async function getDefaultWorkspaceDirectory(): Promise<string | null> {
|
||||
}
|
||||
|
||||
// If API call failed, still try last used dir and Documents
|
||||
const lastUsedDir = getItem(LAST_PROJECT_DIR_KEY);
|
||||
const lastUsedDir = useAppStore.getState().lastProjectDir;
|
||||
|
||||
if (lastUsedDir) {
|
||||
return lastUsedDir;
|
||||
@@ -101,7 +99,7 @@ export async function getDefaultWorkspaceDirectory(): Promise<string | null> {
|
||||
logger.error('Failed to get default workspace directory:', error);
|
||||
|
||||
// On error, try last used dir and Documents
|
||||
const lastUsedDir = getItem(LAST_PROJECT_DIR_KEY);
|
||||
const lastUsedDir = useAppStore.getState().lastProjectDir;
|
||||
|
||||
if (lastUsedDir) {
|
||||
return lastUsedDir;
|
||||
@@ -113,9 +111,9 @@ export async function getDefaultWorkspaceDirectory(): Promise<string | null> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the last used project directory to localStorage
|
||||
* Saves the last used project directory to the store (synced via API)
|
||||
* @param path - The directory path to save
|
||||
*/
|
||||
export function saveLastProjectDirectory(path: string): void {
|
||||
setItem(LAST_PROJECT_DIR_KEY, path);
|
||||
useAppStore.getState().setLastProjectDir(path);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user