Files
ccs/ui/src/pages/codex.tsx
T

288 lines
11 KiB
TypeScript

import { useMemo, useState } from 'react';
import { toast } from 'sonner';
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
import { GripVertical, Loader2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { CodexAuthProfilesCard } from '@/components/compatible-cli/codex-auth-profiles-card';
import { CodexControlCenterTab } from '@/components/compatible-cli/codex-control-center-tab';
import { CodexDocsTab } from '@/components/compatible-cli/codex-docs-tab';
import { useCodex } from '@/hooks/use-codex';
import { isApiConflictError } from '@/lib/api-client';
import { CodexOverviewTab } from '@/components/compatible-cli/codex-overview-tab';
import { RawConfigEditorPanel } from '@/components/compatible-cli/raw-json-settings-editor-panel';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
getKnownCodexFeatures,
readCodexFeatureState,
readCodexMcpServers,
readCodexModelProviders,
readCodexProfiles,
readCodexProjectTrust,
readCodexTopLevelSettings,
} from '@/lib/codex-config';
import { safeParseTomlObject } from '@shared/toml-object';
export function CodexPage() {
const { t } = useTranslation();
const featureCatalog = getKnownCodexFeatures();
const {
diagnostics,
diagnosticsLoading,
diagnosticsError,
refetchDiagnostics,
rawConfig,
rawConfigLoading,
rawConfigError,
refetchRawConfig,
saveRawConfigAsync,
isSavingRawConfig,
patchConfigAsync,
isPatchingConfig,
} = useCodex();
const [rawDraftText, setRawDraftText] = useState<string | null>(null);
const rawBaseText = rawConfig?.rawText ?? '';
const rawEditorText = rawDraftText ?? rawBaseText;
const rawConfigDirty = rawDraftText !== null && rawDraftText !== rawBaseText;
const rawEditorParsed = safeParseTomlObject(rawEditorText);
const rawEditorValidation = rawEditorParsed.parseError
? { valid: false as const, error: rawEditorParsed.parseError }
: { valid: true as const };
const controlsConfig = rawConfig?.config ?? null;
const structuredControlsDisabled =
rawConfigLoading ||
!rawConfig ||
rawConfigDirty ||
rawConfig?.parseError !== null ||
rawConfig?.readError !== null;
const controlsDisabledReason = rawConfigError
? /* TODO i18n: missing key for "Structured controls unavailable: failed to load the current config.toml." */ 'Structured controls unavailable: failed to load the current config.toml.'
: rawConfig?.readError
? /* TODO i18n: missing key for controls unavailable with read error */ `Structured controls unavailable: ${rawConfig.readError}`
: rawConfigDirty
? rawEditorValidation.valid
? /* TODO i18n: missing key for "Save or discard raw TOML edits before using structured controls." */ 'Save or discard raw TOML edits before using structured controls.'
: /* TODO i18n: missing key for "Fix or discard raw TOML edits before using structured controls." */ 'Fix or discard raw TOML edits before using structured controls.'
: rawConfig?.parseError
? /* TODO i18n: missing key for controls disabled with parse error */ `Structured controls disabled: ${rawConfig.parseError}`
: null;
const topLevelSettings = useMemo(
() => readCodexTopLevelSettings(controlsConfig),
[controlsConfig]
);
const projectTrustEntries = useMemo(
() => readCodexProjectTrust(controlsConfig),
[controlsConfig]
);
const profileEntries = useMemo(() => readCodexProfiles(controlsConfig), [controlsConfig]);
const modelProviderEntries = useMemo(
() => readCodexModelProviders(controlsConfig),
[controlsConfig]
);
const mcpServerEntries = useMemo(() => readCodexMcpServers(controlsConfig), [controlsConfig]);
const featureState = useMemo(() => readCodexFeatureState(controlsConfig), [controlsConfig]);
const setRawEditorDraftText = (nextText: string) => {
if (nextText === rawBaseText) {
setRawDraftText(null);
return;
}
setRawDraftText(nextText);
};
const refreshAll = async () => {
try {
const results = await Promise.all([refetchDiagnostics(), refetchRawConfig()]);
const refreshFailed = results.some(
(result) => !result || result.status === 'error' || result.isError || result.error
);
if (refreshFailed) {
toast.error(t('toasts.codexRefreshFailed'));
return;
}
setRawDraftText(null);
} catch (error) {
toast.error((error as Error).message || t('toasts.codexRefreshError'));
}
};
const handleSaveRawConfig = async () => {
if (!rawEditorValidation.valid) {
toast.error(t('toasts.codexFixToml'));
return;
}
try {
await saveRawConfigAsync({
rawText: rawEditorText,
expectedMtime: rawConfig?.exists ? rawConfig.mtime : undefined,
});
setRawDraftText(null);
toast.success(t('toasts.codexSaved'));
await refetchDiagnostics();
} catch (error) {
if (isApiConflictError(error)) {
toast.error(t('toasts.codexChangedExternally'));
} else {
toast.error((error as Error).message || t('toasts.codexSaveFailed'));
}
}
};
const runConfigPatch = async (
patch: Parameters<typeof patchConfigAsync>[0],
successMessage: string
) => {
try {
await patchConfigAsync({
...patch,
expectedMtime: rawConfig?.exists ? rawConfig.mtime : undefined,
});
setRawDraftText(null);
toast.success(successMessage);
} catch (error) {
if (isApiConflictError(error)) {
toast.error(t('toasts.codexChangedExternally'));
} else {
toast.error((error as Error).message || t('toasts.codexUpdateFailed'));
}
}
};
const tabContentClassName = 'mt-0 h-full border-0 p-0 data-[state=inactive]:hidden';
const renderSidebar = () => {
if (diagnosticsLoading) {
return (
<div className="flex h-full items-center justify-center text-muted-foreground">
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
{
/* TODO i18n: missing key for "Loading Codex diagnostics..." */ 'Loading Codex diagnostics...'
}
</div>
);
}
if (diagnosticsError || !diagnostics) {
return (
<div className="flex h-full items-center justify-center px-6 text-center text-destructive">
{
/* TODO i18n: missing key for "Failed to load Codex diagnostics." */ 'Failed to load Codex diagnostics.'
}
</div>
);
}
return (
<Tabs defaultValue="overview" className="flex h-full flex-col">
<div className="shrink-0 px-4 pt-4">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="overview">{t('codexPage.overview')}</TabsTrigger>
<TabsTrigger value="controls">{t('codexPage.controlCenter')}</TabsTrigger>
<TabsTrigger value="docs">{t('codexPage.docs')}</TabsTrigger>
<TabsTrigger value="auth-profiles">{t('codexPage.authProfiles')}</TabsTrigger>
</TabsList>
</div>
<div className="flex-1 min-h-0 overflow-hidden px-4 pb-4 pt-3">
<TabsContent value="overview" className={tabContentClassName}>
<CodexOverviewTab diagnostics={diagnostics} />
</TabsContent>
<TabsContent value="controls" className={tabContentClassName}>
<CodexControlCenterTab
workspacePath={diagnostics.workspacePath}
activeProfile={diagnostics.config.activeProfile}
topLevelSettings={topLevelSettings}
projectTrustEntries={projectTrustEntries}
profileEntries={profileEntries}
modelProviderEntries={modelProviderEntries}
mcpServerEntries={mcpServerEntries}
featureCatalog={featureCatalog}
featureState={featureState}
disabled={structuredControlsDisabled}
disabledReason={controlsDisabledReason}
saving={isPatchingConfig}
onPatch={runConfigPatch}
/>
</TabsContent>
<TabsContent value="docs" className={tabContentClassName}>
<CodexDocsTab diagnostics={diagnostics} />
</TabsContent>
<TabsContent value="auth-profiles" className={tabContentClassName}>
<div className="h-full overflow-auto p-1">
<CodexAuthProfilesCard />
</div>
</TabsContent>
</div>
</Tabs>
);
};
return (
<div className="h-full min-h-0 overflow-hidden">
<PanelGroup direction="horizontal" className="h-full">
<Panel defaultSize={45} minSize={35}>
<div className="h-full border-r bg-muted/20">{renderSidebar()}</div>
</Panel>
<PanelResizeHandle className="group flex w-2 cursor-col-resize items-center justify-center bg-border transition-colors hover:bg-primary/20">
<GripVertical className="h-3 w-3 text-muted-foreground group-hover:text-primary" />
</PanelResizeHandle>
<Panel defaultSize={55} minSize={35}>
<RawConfigEditorPanel
/* TODO i18n: missing key for "Codex config.toml" title */
title="Codex config.toml"
pathLabel={rawConfig?.path || diagnostics?.file.path || '$CODEX_HOME/config.toml'}
loading={rawConfigLoading}
parseWarning={
rawEditorValidation.valid ? rawConfig?.parseError : rawEditorValidation.error
}
readWarning={rawConfig?.readError}
value={rawEditorText}
dirty={rawConfigDirty}
readOnly={Boolean(rawConfig?.readError)}
saving={isSavingRawConfig}
saveDisabled={
!rawConfigDirty ||
isSavingRawConfig ||
rawConfigLoading ||
!rawEditorValidation.valid ||
Boolean(rawConfig?.readError)
}
onChange={(next) => {
setRawEditorDraftText(next);
}}
onSave={handleSaveRawConfig}
onRefresh={refreshAll}
onDiscard={() => setRawDraftText(null)}
language="toml"
exactText
/* TODO i18n: missing key for "Loading config.toml..." */
loadingLabel="Loading config.toml..."
/* TODO i18n: missing key for "TOML warning" */
parseWarningLabel="TOML warning"
ownershipNotice={
<div className="rounded-md border border-amber-200 bg-amber-50/60 px-3 py-2 text-sm text-amber-900 dark:bg-amber-950/20 dark:text-amber-300">
{/* TODO i18n: missing keys for ownership notice paragraphs */}
<p className="font-medium">This file is upstream-owned by Codex CLI.</p>
<p>
CCS does not keep <code>~/.codex/config.toml</code> in sync for you.
</p>
<p>
CCS-backed Codex launches may apply transient <code>-c</code> overrides and
<code> CCS_CODEX_API_KEY</code>; those effective values may not appear here.
</p>
</div>
}
/>
</Panel>
</PanelGroup>
</div>
);
}