mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 20:03:37 +00:00
Merge pull request #400 from AutoMaker-Org/feat/codex-usage
feat: improve codex plan and usage detection
This commit is contained in:
@@ -9,7 +9,9 @@ import { useAppStore } from '@/store/app-store';
|
||||
import { useSetupStore } from '@/store/setup-store';
|
||||
import { getModelProvider, PROVIDER_PREFIXES, stripProviderPrefix } from '@automaker/types';
|
||||
import type { ModelProvider } from '@automaker/types';
|
||||
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS, ModelOption } from './model-constants';
|
||||
import { CLAUDE_MODELS, CURSOR_MODELS, ModelOption } from './model-constants';
|
||||
import { useEffect } from 'react';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
|
||||
interface ModelSelectorProps {
|
||||
selectedModel: string; // Can be ModelAlias or "cursor-{id}"
|
||||
@@ -22,7 +24,14 @@ export function ModelSelector({
|
||||
onModelSelect,
|
||||
testIdPrefix = 'model-select',
|
||||
}: ModelSelectorProps) {
|
||||
const { enabledCursorModels, cursorDefaultModel } = useAppStore();
|
||||
const {
|
||||
enabledCursorModels,
|
||||
cursorDefaultModel,
|
||||
codexModels,
|
||||
codexModelsLoading,
|
||||
codexModelsError,
|
||||
fetchCodexModels,
|
||||
} = useAppStore();
|
||||
const { cursorCliStatus, codexCliStatus } = useSetupStore();
|
||||
|
||||
const selectedProvider = getModelProvider(selectedModel);
|
||||
@@ -33,6 +42,31 @@ export function ModelSelector({
|
||||
// Check if Codex CLI is available
|
||||
const isCodexAvailable = codexCliStatus?.installed && codexCliStatus?.auth?.authenticated;
|
||||
|
||||
// Fetch Codex models on mount
|
||||
useEffect(() => {
|
||||
if (isCodexAvailable && codexModels.length === 0 && !codexModelsLoading) {
|
||||
fetchCodexModels();
|
||||
}
|
||||
}, [isCodexAvailable, codexModels.length, codexModelsLoading, fetchCodexModels]);
|
||||
|
||||
// Transform codex models from store to ModelOption format
|
||||
const dynamicCodexModels: ModelOption[] = codexModels.map((model) => {
|
||||
// Infer badge based on tier
|
||||
let badge: string | undefined;
|
||||
if (model.tier === 'premium') badge = 'Premium';
|
||||
else if (model.tier === 'basic') badge = 'Speed';
|
||||
else if (model.tier === 'standard') badge = 'Balanced';
|
||||
|
||||
return {
|
||||
id: model.id,
|
||||
label: model.label,
|
||||
description: model.description,
|
||||
badge,
|
||||
provider: 'codex' as ModelProvider,
|
||||
hasThinking: model.hasThinking,
|
||||
};
|
||||
});
|
||||
|
||||
// Filter Cursor models based on enabled models from global settings
|
||||
const filteredCursorModels = CURSOR_MODELS.filter((model) => {
|
||||
// Extract the cursor model ID from the prefixed ID (e.g., "cursor-auto" -> "auto")
|
||||
@@ -45,8 +79,10 @@ export function ModelSelector({
|
||||
// Switch to Cursor's default model (from global settings)
|
||||
onModelSelect(`${PROVIDER_PREFIXES.cursor}${cursorDefaultModel}`);
|
||||
} else if (provider === 'codex' && selectedProvider !== 'codex') {
|
||||
// Switch to Codex's default model (codex-gpt-5.2-codex)
|
||||
onModelSelect('codex-gpt-5.2-codex');
|
||||
// Switch to Codex's default model (use isDefault flag from dynamic models)
|
||||
const defaultModel = codexModels.find((m) => m.isDefault);
|
||||
const defaultModelId = defaultModel?.id || codexModels[0]?.id || 'codex-gpt-5.2-codex';
|
||||
onModelSelect(defaultModelId);
|
||||
} else if (provider === 'claude' && selectedProvider !== 'claude') {
|
||||
// Switch to Claude's default model
|
||||
onModelSelect('sonnet');
|
||||
@@ -234,41 +270,91 @@ export function ModelSelector({
|
||||
CLI
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{CODEX_MODELS.map((option) => {
|
||||
const isSelected = selectedModel === option.id;
|
||||
return (
|
||||
|
||||
{/* Loading state */}
|
||||
{codexModelsLoading && dynamicCodexModels.length === 0 && (
|
||||
<div className="flex items-center justify-center gap-2 p-6 text-sm text-muted-foreground">
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
Loading models...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{codexModelsError && !codexModelsLoading && (
|
||||
<div className="flex items-start gap-2 p-3 rounded-lg bg-red-500/10 border border-red-500/20">
|
||||
<AlertTriangle className="w-4 h-4 text-red-400 mt-0.5 shrink-0" />
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm text-red-400">Failed to load Codex models</div>
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
onClick={() => onModelSelect(option.id)}
|
||||
title={option.description}
|
||||
className={cn(
|
||||
'w-full px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-between',
|
||||
isSelected
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-background hover:bg-accent border-border'
|
||||
)}
|
||||
data-testid={`${testIdPrefix}-${option.id}`}
|
||||
onClick={() => fetchCodexModels(true)}
|
||||
className="text-xs text-red-400 underline hover:no-underline"
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
{option.badge && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'text-xs',
|
||||
isSelected
|
||||
? 'border-primary-foreground/50 text-primary-foreground'
|
||||
: 'border-muted-foreground/50 text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{option.badge}
|
||||
</Badge>
|
||||
)}
|
||||
Retry
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Model list */}
|
||||
{!codexModelsLoading && !codexModelsError && dynamicCodexModels.length === 0 && (
|
||||
<div className="text-sm text-muted-foreground p-3 border border-dashed rounded-md text-center">
|
||||
No Codex models available
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!codexModelsLoading && dynamicCodexModels.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{dynamicCodexModels.map((option) => {
|
||||
const isSelected = selectedModel === option.id;
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
onClick={() => onModelSelect(option.id)}
|
||||
title={option.description}
|
||||
className={cn(
|
||||
'w-full px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-between',
|
||||
isSelected
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-background hover:bg-accent border-border'
|
||||
)}
|
||||
data-testid={`${testIdPrefix}-${option.id}`}
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
<div className="flex gap-1">
|
||||
{option.hasThinking && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'text-xs',
|
||||
isSelected
|
||||
? 'border-primary-foreground/50 text-primary-foreground'
|
||||
: 'border-emerald-500/50 text-emerald-600 dark:text-emerald-400'
|
||||
)}
|
||||
>
|
||||
Thinking
|
||||
</Badge>
|
||||
)}
|
||||
{option.badge && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'text-xs',
|
||||
isSelected
|
||||
? 'border-primary-foreground/50 text-primary-foreground'
|
||||
: 'border-muted-foreground/50 text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{option.badge}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,6 @@ import { OpenAIIcon } from '@/components/ui/provider-icon';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import {
|
||||
formatCodexCredits,
|
||||
formatCodexPlanType,
|
||||
formatCodexResetTime,
|
||||
getCodexWindowLabel,
|
||||
@@ -25,7 +24,6 @@ const UPDATED_LABEL = 'Updated';
|
||||
const CODEX_FETCH_ERROR = 'Failed to fetch usage';
|
||||
const CODEX_REFRESH_LABEL = 'Refresh Codex usage';
|
||||
const PLAN_LABEL = 'Plan';
|
||||
const CREDITS_LABEL = 'Credits';
|
||||
const WARNING_THRESHOLD = 75;
|
||||
const CAUTION_THRESHOLD = 50;
|
||||
const MAX_PERCENTAGE = 100;
|
||||
@@ -49,7 +47,6 @@ export function CodexUsageSection() {
|
||||
const rateLimits = codexUsage?.rateLimits ?? null;
|
||||
const primary = rateLimits?.primary ?? null;
|
||||
const secondary = rateLimits?.secondary ?? null;
|
||||
const credits = rateLimits?.credits ?? null;
|
||||
const planType = rateLimits?.planType ?? null;
|
||||
const rateLimitWindows = [primary, secondary].filter(isRateLimitWindow);
|
||||
const hasMetrics = rateLimitWindows.length > 0;
|
||||
@@ -206,20 +203,11 @@ export function CodexUsageSection() {
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{(planType || credits) && (
|
||||
{planType && (
|
||||
<div className="rounded-xl border border-border/60 bg-secondary/20 p-4 text-xs text-muted-foreground">
|
||||
{planType && (
|
||||
<div>
|
||||
{PLAN_LABEL}:{' '}
|
||||
<span className="text-foreground">{formatCodexPlanType(planType)}</span>
|
||||
</div>
|
||||
)}
|
||||
{credits && (
|
||||
<div>
|
||||
{CREDITS_LABEL}:{' '}
|
||||
<span className="text-foreground">{formatCodexCredits(credits)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
{PLAN_LABEL}: <span className="text-foreground">{formatCodexPlanType(planType)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!hasMetrics && !error && canFetchUsage && !isLoading && (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as React from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import type {
|
||||
@@ -8,8 +8,6 @@ import type {
|
||||
OpencodeModelId,
|
||||
GroupedModel,
|
||||
PhaseModelEntry,
|
||||
ThinkingLevel,
|
||||
ReasoningEffort,
|
||||
} from '@automaker/types';
|
||||
import {
|
||||
stripProviderPrefix,
|
||||
@@ -17,13 +15,11 @@ import {
|
||||
getModelGroup,
|
||||
isGroupSelected,
|
||||
getSelectedVariant,
|
||||
isCursorModel,
|
||||
codexModelHasThinking,
|
||||
} from '@automaker/types';
|
||||
import {
|
||||
CLAUDE_MODELS,
|
||||
CURSOR_MODELS,
|
||||
CODEX_MODELS,
|
||||
OPENCODE_MODELS,
|
||||
THINKING_LEVELS,
|
||||
THINKING_LEVEL_LABELS,
|
||||
@@ -73,23 +69,39 @@ export function PhaseModelSelector({
|
||||
align = 'end',
|
||||
disabled = false,
|
||||
}: PhaseModelSelectorProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [expandedGroup, setExpandedGroup] = React.useState<string | null>(null);
|
||||
const [expandedClaudeModel, setExpandedClaudeModel] = React.useState<ModelAlias | null>(null);
|
||||
const [expandedCodexModel, setExpandedCodexModel] = React.useState<CodexModelId | null>(null);
|
||||
const commandListRef = React.useRef<HTMLDivElement>(null);
|
||||
const expandedTriggerRef = React.useRef<HTMLDivElement>(null);
|
||||
const expandedClaudeTriggerRef = React.useRef<HTMLDivElement>(null);
|
||||
const expandedCodexTriggerRef = React.useRef<HTMLDivElement>(null);
|
||||
const { enabledCursorModels, favoriteModels, toggleFavoriteModel } = useAppStore();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [expandedGroup, setExpandedGroup] = useState<string | null>(null);
|
||||
const [expandedClaudeModel, setExpandedClaudeModel] = useState<ModelAlias | null>(null);
|
||||
const [expandedCodexModel, setExpandedCodexModel] = useState<CodexModelId | null>(null);
|
||||
const commandListRef = useRef<HTMLDivElement>(null);
|
||||
const expandedTriggerRef = useRef<HTMLDivElement>(null);
|
||||
const expandedClaudeTriggerRef = useRef<HTMLDivElement>(null);
|
||||
const expandedCodexTriggerRef = useRef<HTMLDivElement>(null);
|
||||
const {
|
||||
enabledCursorModels,
|
||||
favoriteModels,
|
||||
toggleFavoriteModel,
|
||||
codexModels,
|
||||
codexModelsLoading,
|
||||
fetchCodexModels,
|
||||
} = useAppStore();
|
||||
|
||||
// Extract model and thinking/reasoning levels from value
|
||||
const selectedModel = value.model;
|
||||
const selectedThinkingLevel = value.thinkingLevel || 'none';
|
||||
const selectedReasoningEffort = value.reasoningEffort || 'none';
|
||||
|
||||
// Fetch Codex models on mount
|
||||
useEffect(() => {
|
||||
if (codexModels.length === 0 && !codexModelsLoading) {
|
||||
fetchCodexModels().catch(() => {
|
||||
// Silently fail - user will see empty Codex section
|
||||
});
|
||||
}
|
||||
}, [codexModels.length, codexModelsLoading, fetchCodexModels]);
|
||||
|
||||
// Close expanded group when trigger scrolls out of view
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
const triggerElement = expandedTriggerRef.current;
|
||||
const listElement = commandListRef.current;
|
||||
if (!triggerElement || !listElement || !expandedGroup) return;
|
||||
@@ -112,7 +124,7 @@ export function PhaseModelSelector({
|
||||
}, [expandedGroup]);
|
||||
|
||||
// Close expanded Claude model popover when trigger scrolls out of view
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
const triggerElement = expandedClaudeTriggerRef.current;
|
||||
const listElement = commandListRef.current;
|
||||
if (!triggerElement || !listElement || !expandedClaudeModel) return;
|
||||
@@ -135,7 +147,7 @@ export function PhaseModelSelector({
|
||||
}, [expandedClaudeModel]);
|
||||
|
||||
// Close expanded Codex model popover when trigger scrolls out of view
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
const triggerElement = expandedCodexTriggerRef.current;
|
||||
const listElement = commandListRef.current;
|
||||
if (!triggerElement || !listElement || !expandedCodexModel) return;
|
||||
@@ -157,6 +169,17 @@ export function PhaseModelSelector({
|
||||
return () => observer.disconnect();
|
||||
}, [expandedCodexModel]);
|
||||
|
||||
// Transform dynamic Codex models from store to component format
|
||||
const transformedCodexModels = useMemo(() => {
|
||||
return codexModels.map((model) => ({
|
||||
id: model.id,
|
||||
label: model.label,
|
||||
description: model.description,
|
||||
provider: 'codex' as const,
|
||||
badge: model.tier === 'premium' ? 'Premium' : model.tier === 'basic' ? 'Speed' : undefined,
|
||||
}));
|
||||
}, [codexModels]);
|
||||
|
||||
// Filter Cursor models to only show enabled ones
|
||||
const availableCursorModels = CURSOR_MODELS.filter((model) => {
|
||||
const cursorId = stripProviderPrefix(model.id) as CursorModelId;
|
||||
@@ -164,7 +187,7 @@ export function PhaseModelSelector({
|
||||
});
|
||||
|
||||
// Helper to find current selected model details
|
||||
const currentModel = React.useMemo(() => {
|
||||
const currentModel = useMemo(() => {
|
||||
const claudeModel = CLAUDE_MODELS.find((m) => m.id === selectedModel);
|
||||
if (claudeModel) {
|
||||
// Add thinking level to label if not 'none'
|
||||
@@ -198,7 +221,7 @@ export function PhaseModelSelector({
|
||||
}
|
||||
|
||||
// Check Codex models
|
||||
const codexModel = CODEX_MODELS.find((m) => m.id === selectedModel);
|
||||
const codexModel = transformedCodexModels.find((m) => m.id === selectedModel);
|
||||
if (codexModel) return { ...codexModel, icon: OpenAIIcon };
|
||||
|
||||
// Check OpenCode models
|
||||
@@ -206,10 +229,10 @@ export function PhaseModelSelector({
|
||||
if (opencodeModel) return { ...opencodeModel, icon: OpenCodeIcon };
|
||||
|
||||
return null;
|
||||
}, [selectedModel, selectedThinkingLevel, availableCursorModels]);
|
||||
}, [selectedModel, selectedThinkingLevel, availableCursorModels, transformedCodexModels]);
|
||||
|
||||
// Compute grouped vs standalone Cursor models
|
||||
const { groupedModels, standaloneCursorModels } = React.useMemo(() => {
|
||||
const { groupedModels, standaloneCursorModels } = useMemo(() => {
|
||||
const grouped: GroupedModel[] = [];
|
||||
const standalone: typeof CURSOR_MODELS = [];
|
||||
const seenGroups = new Set<string>();
|
||||
@@ -242,11 +265,11 @@ export function PhaseModelSelector({
|
||||
}, [availableCursorModels, enabledCursorModels]);
|
||||
|
||||
// Group models
|
||||
const { favorites, claude, cursor, codex, opencode } = React.useMemo(() => {
|
||||
const { favorites, claude, cursor, codex, opencode } = useMemo(() => {
|
||||
const favs: typeof CLAUDE_MODELS = [];
|
||||
const cModels: typeof CLAUDE_MODELS = [];
|
||||
const curModels: typeof CURSOR_MODELS = [];
|
||||
const codModels: typeof CODEX_MODELS = [];
|
||||
const codModels: typeof transformedCodexModels = [];
|
||||
const ocModels: typeof OPENCODE_MODELS = [];
|
||||
|
||||
// Process Claude Models
|
||||
@@ -268,7 +291,7 @@ export function PhaseModelSelector({
|
||||
});
|
||||
|
||||
// Process Codex Models
|
||||
CODEX_MODELS.forEach((model) => {
|
||||
transformedCodexModels.forEach((model) => {
|
||||
if (favoriteModels.includes(model.id)) {
|
||||
favs.push(model);
|
||||
} else {
|
||||
@@ -292,10 +315,10 @@ export function PhaseModelSelector({
|
||||
codex: codModels,
|
||||
opencode: ocModels,
|
||||
};
|
||||
}, [favoriteModels, availableCursorModels]);
|
||||
}, [favoriteModels, availableCursorModels, transformedCodexModels]);
|
||||
|
||||
// Render Codex model item with secondary popover for reasoning effort (only for models that support it)
|
||||
const renderCodexModelItem = (model: (typeof CODEX_MODELS)[0]) => {
|
||||
const renderCodexModelItem = (model: (typeof transformedCodexModels)[0]) => {
|
||||
const isSelected = selectedModel === model.id;
|
||||
const isFavorite = favoriteModels.includes(model.id);
|
||||
const hasReasoning = codexModelHasThinking(model.id as CodexModelId);
|
||||
@@ -919,7 +942,7 @@ export function PhaseModelSelector({
|
||||
}
|
||||
// Codex model
|
||||
if (model.provider === 'codex') {
|
||||
return renderCodexModelItem(model);
|
||||
return renderCodexModelItem(model as (typeof transformedCodexModels)[0]);
|
||||
}
|
||||
// OpenCode model
|
||||
if (model.provider === 'opencode') {
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import { type CodexCreditsSnapshot, type CodexPlanType } from '@/store/app-store';
|
||||
import { 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';
|
||||
@@ -77,10 +73,3 @@ 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;
|
||||
}
|
||||
|
||||
@@ -727,6 +727,20 @@ export interface ElectronAPI {
|
||||
ideation?: IdeationAPI;
|
||||
codex?: {
|
||||
getUsage: () => Promise<CodexUsageResponse>;
|
||||
getModels: (refresh?: boolean) => Promise<{
|
||||
success: boolean;
|
||||
models?: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
hasThinking: boolean;
|
||||
supportsVision: boolean;
|
||||
tier: 'premium' | 'standard' | 'basic';
|
||||
isDefault: boolean;
|
||||
}>;
|
||||
cachedAt?: number;
|
||||
error?: string;
|
||||
}>;
|
||||
};
|
||||
settings?: {
|
||||
getStatus: () => Promise<{
|
||||
|
||||
@@ -2053,6 +2053,25 @@ export class HttpApiClient implements ElectronAPI {
|
||||
// Codex API
|
||||
codex = {
|
||||
getUsage: (): Promise<CodexUsageResponse> => this.get('/api/codex/usage'),
|
||||
getModels: (
|
||||
refresh = false
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
models?: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
hasThinking: boolean;
|
||||
supportsVision: boolean;
|
||||
tier: 'premium' | 'standard' | 'basic';
|
||||
isDefault: boolean;
|
||||
}>;
|
||||
cachedAt?: number;
|
||||
error?: string;
|
||||
}> => {
|
||||
const url = `/api/codex/models${refresh ? '?refresh=true' : ''}`;
|
||||
return this.get(url);
|
||||
},
|
||||
};
|
||||
|
||||
// Context API
|
||||
|
||||
@@ -68,8 +68,9 @@ function RootLayoutContent() {
|
||||
getEffectiveTheme,
|
||||
skipSandboxWarning,
|
||||
setSkipSandboxWarning,
|
||||
fetchCodexModels,
|
||||
} = useAppStore();
|
||||
const { setupComplete } = useSetupStore();
|
||||
const { setupComplete, codexCliStatus } = useSetupStore();
|
||||
const navigate = useNavigate();
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [streamerPanelOpen, setStreamerPanelOpen] = useState(false);
|
||||
@@ -431,6 +432,20 @@ function RootLayoutContent() {
|
||||
}
|
||||
}, [isMounted, currentProject, location.pathname, navigate]);
|
||||
|
||||
// Bootstrap Codex models on app startup (after auth completes)
|
||||
useEffect(() => {
|
||||
// Only fetch if authenticated and Codex CLI is available
|
||||
if (!authChecked || !isAuthenticated) return;
|
||||
|
||||
const isCodexAvailable = codexCliStatus?.installed && codexCliStatus?.auth?.authenticated;
|
||||
if (!isCodexAvailable) return;
|
||||
|
||||
// Fetch models in the background
|
||||
fetchCodexModels().catch((error) => {
|
||||
logger.warn('Failed to bootstrap Codex models:', error);
|
||||
});
|
||||
}, [authChecked, isAuthenticated, codexCliStatus, fetchCodexModels]);
|
||||
|
||||
// Apply theme class to document - use deferred value to avoid blocking UI
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { create } from 'zustand';
|
||||
// Note: persist middleware removed - settings now sync via API (use-settings-sync.ts)
|
||||
import type { Project, TrashedProject } from '@/lib/electron';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { setItem, getItem } from '@/lib/storage';
|
||||
import type {
|
||||
@@ -638,6 +639,20 @@ export interface AppState {
|
||||
codexUsage: CodexUsage | null;
|
||||
codexUsageLastUpdated: number | null;
|
||||
|
||||
// Codex Models (dynamically fetched)
|
||||
codexModels: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
hasThinking: boolean;
|
||||
supportsVision: boolean;
|
||||
tier: 'premium' | 'standard' | 'basic';
|
||||
isDefault: boolean;
|
||||
}>;
|
||||
codexModelsLoading: boolean;
|
||||
codexModelsError: string | null;
|
||||
codexModelsLastFetched: number | null;
|
||||
|
||||
// Pipeline Configuration (per-project, keyed by project path)
|
||||
pipelineConfigByProject: Record<string, PipelineConfig>;
|
||||
|
||||
@@ -690,12 +705,6 @@ export type CodexPlanType =
|
||||
| 'edu'
|
||||
| 'unknown';
|
||||
|
||||
export interface CodexCreditsSnapshot {
|
||||
balance?: string;
|
||||
unlimited?: boolean;
|
||||
hasCredits?: boolean;
|
||||
}
|
||||
|
||||
export interface CodexRateLimitWindow {
|
||||
limit: number;
|
||||
used: number;
|
||||
@@ -709,7 +718,6 @@ export interface CodexUsage {
|
||||
rateLimits: {
|
||||
primary?: CodexRateLimitWindow;
|
||||
secondary?: CodexRateLimitWindow;
|
||||
credits?: CodexCreditsSnapshot;
|
||||
planType?: CodexPlanType;
|
||||
} | null;
|
||||
lastUpdated: string;
|
||||
@@ -1068,6 +1076,20 @@ export interface AppActions {
|
||||
// Codex Usage Tracking actions
|
||||
setCodexUsage: (usage: CodexUsage | null) => void;
|
||||
|
||||
// Codex Models actions
|
||||
fetchCodexModels: (forceRefresh?: boolean) => Promise<void>;
|
||||
setCodexModels: (
|
||||
models: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
hasThinking: boolean;
|
||||
supportsVision: boolean;
|
||||
tier: 'premium' | 'standard' | 'basic';
|
||||
isDefault: boolean;
|
||||
}>
|
||||
) => void;
|
||||
|
||||
// Reset
|
||||
reset: () => void;
|
||||
}
|
||||
@@ -1159,6 +1181,10 @@ const initialState: AppState = {
|
||||
claudeUsageLastUpdated: null,
|
||||
codexUsage: null,
|
||||
codexUsageLastUpdated: null,
|
||||
codexModels: [],
|
||||
codexModelsLoading: false,
|
||||
codexModelsError: null,
|
||||
codexModelsLastFetched: null,
|
||||
pipelineConfigByProject: {},
|
||||
// UI State (previously in localStorage, now synced via API)
|
||||
worktreePanelCollapsed: false,
|
||||
@@ -2898,6 +2924,53 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
codexUsageLastUpdated: usage ? Date.now() : null,
|
||||
}),
|
||||
|
||||
// Codex Models actions
|
||||
fetchCodexModels: async (forceRefresh = false) => {
|
||||
const { codexModelsLastFetched, codexModelsLoading } = get();
|
||||
|
||||
// Skip if already loading
|
||||
if (codexModelsLoading) return;
|
||||
|
||||
// Skip if recently fetched (< 5 minutes ago) and not forcing refresh
|
||||
if (!forceRefresh && codexModelsLastFetched && Date.now() - codexModelsLastFetched < 300000) {
|
||||
return;
|
||||
}
|
||||
|
||||
set({ codexModelsLoading: true, codexModelsError: null });
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api.codex) {
|
||||
throw new Error('Codex API not available');
|
||||
}
|
||||
|
||||
const result = await api.codex.getModels(forceRefresh);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to fetch Codex models');
|
||||
}
|
||||
|
||||
set({
|
||||
codexModels: result.models || [],
|
||||
codexModelsLastFetched: Date.now(),
|
||||
codexModelsLoading: false,
|
||||
codexModelsError: null,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
set({
|
||||
codexModelsError: errorMessage,
|
||||
codexModelsLoading: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
setCodexModels: (models) =>
|
||||
set({
|
||||
codexModels: models,
|
||||
codexModelsLastFetched: Date.now(),
|
||||
}),
|
||||
|
||||
// Pipeline actions
|
||||
setPipelineConfig: (projectPath, config) => {
|
||||
set({
|
||||
|
||||
Reference in New Issue
Block a user