mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-15 06:16:03 +00:00
1 parent
da6c99e3df
commit
77e73dd091
4 files changed
+513
-27
No files matched your search
+70
-26
@@ -10,6 +10,7 @@ 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 { Button } from '@/components/ui/button';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
getKnownCodexFeatures,
|
||||
@@ -20,7 +21,13 @@ import {
|
||||
readCodexProjectTrust,
|
||||
readCodexTopLevelSettings,
|
||||
} from '@/lib/codex-config';
|
||||
import { safeParseTomlObject } from '@shared/toml-object';
|
||||
import { proposeJoinedCodexModelMigrationRepair, safeParseTomlObject } from '@shared/toml-object';
|
||||
|
||||
interface RawConfigDraft {
|
||||
text: string;
|
||||
baseText: string;
|
||||
expectedMtime?: number;
|
||||
}
|
||||
|
||||
export function CodexPage() {
|
||||
const { t } = useTranslation();
|
||||
@@ -40,10 +47,14 @@ export function CodexPage() {
|
||||
isPatchingConfig,
|
||||
} = useCodex();
|
||||
|
||||
const [rawDraftText, setRawDraftText] = useState<string | null>(null);
|
||||
const [rawDraft, setRawDraft] = useState<RawConfigDraft | null>(null);
|
||||
const rawBaseText = rawConfig?.rawText ?? '';
|
||||
const rawEditorText = rawDraftText ?? rawBaseText;
|
||||
const rawConfigDirty = rawDraftText !== null && rawDraftText !== rawBaseText;
|
||||
const rawEditorText = rawDraft?.text ?? rawBaseText;
|
||||
const rawConfigDirty = rawDraft !== null;
|
||||
const rawBaseRepairProposal = useMemo(() => {
|
||||
if (rawConfigDirty || !rawConfig?.parseError || rawConfig.readError) return null;
|
||||
return proposeJoinedCodexModelMigrationRepair(rawBaseText);
|
||||
}, [rawBaseText, rawConfig?.parseError, rawConfig?.readError, rawConfigDirty]);
|
||||
const rawEditorParsed = safeParseTomlObject(rawEditorText);
|
||||
const rawEditorValidation = rawEditorParsed.parseError
|
||||
? { valid: false as const, error: rawEditorParsed.parseError }
|
||||
@@ -84,11 +95,20 @@ export function CodexPage() {
|
||||
const featureState = useMemo(() => readCodexFeatureState(controlsConfig), [controlsConfig]);
|
||||
|
||||
const setRawEditorDraftText = (nextText: string) => {
|
||||
if (nextText === rawBaseText) {
|
||||
setRawDraftText(null);
|
||||
return;
|
||||
}
|
||||
setRawDraftText(nextText);
|
||||
setRawDraft((currentDraft) => {
|
||||
const baseText = currentDraft?.baseText ?? rawBaseText;
|
||||
if (nextText === baseText) return null;
|
||||
|
||||
return {
|
||||
text: nextText,
|
||||
baseText,
|
||||
expectedMtime: currentDraft
|
||||
? currentDraft.expectedMtime
|
||||
: rawConfig?.exists
|
||||
? rawConfig.mtime
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const refreshAll = async () => {
|
||||
@@ -103,24 +123,24 @@ export function CodexPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
setRawDraftText(null);
|
||||
setRawDraft(null);
|
||||
} catch (error) {
|
||||
toast.error((error as Error).message || t('toasts.codexRefreshError'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveRawConfig = async () => {
|
||||
if (!rawEditorValidation.valid) {
|
||||
if (!rawDraft || !rawEditorValidation.valid) {
|
||||
toast.error(t('toasts.codexFixToml'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await saveRawConfigAsync({
|
||||
rawText: rawEditorText,
|
||||
expectedMtime: rawConfig?.exists ? rawConfig.mtime : undefined,
|
||||
rawText: rawDraft.text,
|
||||
expectedMtime: rawDraft.expectedMtime,
|
||||
});
|
||||
setRawDraftText(null);
|
||||
setRawDraft(null);
|
||||
toast.success(t('toasts.codexSaved'));
|
||||
await refetchDiagnostics();
|
||||
} catch (error) {
|
||||
@@ -141,7 +161,7 @@ export function CodexPage() {
|
||||
...patch,
|
||||
expectedMtime: rawConfig?.exists ? rawConfig.mtime : undefined,
|
||||
});
|
||||
setRawDraftText(null);
|
||||
setRawDraft(null);
|
||||
toast.success(successMessage);
|
||||
} catch (error) {
|
||||
if (isApiConflictError(error)) {
|
||||
@@ -259,7 +279,7 @@ export function CodexPage() {
|
||||
}}
|
||||
onSave={handleSaveRawConfig}
|
||||
onRefresh={refreshAll}
|
||||
onDiscard={() => setRawDraftText(null)}
|
||||
onDiscard={() => setRawDraft(null)}
|
||||
language="toml"
|
||||
exactText
|
||||
/* TODO i18n: missing key for "Loading config.toml..." */
|
||||
@@ -267,16 +287,40 @@ export function CodexPage() {
|
||||
/* 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 className="space-y-3">
|
||||
{rawBaseRepairProposal ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-900 dark:bg-amber-950/20 dark:text-amber-300"
|
||||
>
|
||||
<p className="font-medium">Safe repair available</p>
|
||||
<p className="mt-1">
|
||||
Codex joined a model migration to the next table header. Previewing inserts
|
||||
exactly one newline in this unsaved editor draft. Review it, then select Save
|
||||
to write <code>config.toml</code>.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
onClick={() => setRawEditorDraftText(rawBaseRepairProposal.repairedText)}
|
||||
>
|
||||
Preview one-newline repair
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<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>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -40,7 +40,11 @@ vi.mock('@/components/shared/code-editor', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/components/compatible-cli/codex-control-center-tab', () => ({
|
||||
CodexControlCenterTab: () => <div>Control Center</div>,
|
||||
CodexControlCenterTab: ({ disabled }: { disabled?: boolean }) => (
|
||||
<div data-testid="codex-control-center" data-disabled={String(Boolean(disabled))}>
|
||||
Control Center
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/compatible-cli/codex-docs-tab', () => ({
|
||||
@@ -53,6 +57,15 @@ vi.mock('@/components/compatible-cli/codex-overview-tab', () => ({
|
||||
|
||||
import { CodexPage } from '@/pages/codex';
|
||||
|
||||
const joinedMigrationRawText = `[notice.model_migrations]
|
||||
"gpt-5.3-codex" = "gpt-5.4"[agents.code_simplifier]
|
||||
description = "Keep exact text"
|
||||
`;
|
||||
const repairedMigrationRawText = joinedMigrationRawText.replace(
|
||||
'"gpt-5.4"[agents.code_simplifier]',
|
||||
'"gpt-5.4"\n[agents.code_simplifier]'
|
||||
);
|
||||
|
||||
const diagnostics = {
|
||||
binary: {
|
||||
installed: true,
|
||||
@@ -228,5 +241,184 @@ describe('CodexPage', () => {
|
||||
|
||||
expect(screen.getByText(/Read-only: Refusing symlink file for safety\./)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('codex raw editor')).toHaveAttribute('readonly');
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Preview one-newline repair' })
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('previews the one-newline repair as an unsaved draft before explicitly saving it', async () => {
|
||||
mocks.saveRawConfigAsync.mockResolvedValue({ success: true, mtime: 101 });
|
||||
mocks.useCodex.mockReturnValue(
|
||||
buildUseCodexResult({
|
||||
rawConfig: {
|
||||
path: '$CODEX_HOME/config.toml',
|
||||
resolvedPath: '/tmp/.codex/config.toml',
|
||||
exists: true,
|
||||
mtime: 100,
|
||||
rawText: joinedMigrationRawText,
|
||||
config: null,
|
||||
parseError: 'Invalid TOML',
|
||||
readError: null,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
render(<CodexPage />);
|
||||
|
||||
await userEvent.click(screen.getByRole('tab', { name: 'Control Center' }));
|
||||
expect(screen.getByTestId('codex-control-center')).toHaveAttribute('data-disabled', 'true');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Preview one-newline repair' }));
|
||||
|
||||
expect(screen.getByLabelText('codex raw editor')).toHaveValue(repairedMigrationRawText);
|
||||
expect(screen.getByText('Unsaved')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled();
|
||||
expect(screen.getByTestId('codex-control-center')).toHaveAttribute('data-disabled', 'true');
|
||||
expect(mocks.saveRawConfigAsync).not.toHaveBeenCalled();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mocks.saveRawConfigAsync).toHaveBeenCalledWith({
|
||||
rawText: repairedMigrationRawText,
|
||||
expectedMtime: 100,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the repair draft bound to its original mtime across a fetched snapshot update', async () => {
|
||||
mocks.saveRawConfigAsync.mockResolvedValue({ success: true, mtime: 201 });
|
||||
let rawConfig = {
|
||||
path: '$CODEX_HOME/config.toml',
|
||||
resolvedPath: '/tmp/.codex/config.toml',
|
||||
exists: true,
|
||||
mtime: 100,
|
||||
rawText: joinedMigrationRawText,
|
||||
config: null,
|
||||
parseError: 'Invalid TOML',
|
||||
readError: null,
|
||||
};
|
||||
mocks.useCodex.mockImplementation(() => buildUseCodexResult({ rawConfig }));
|
||||
|
||||
const view = render(<CodexPage />);
|
||||
await userEvent.click(screen.getByRole('tab', { name: 'Control Center' }));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Preview one-newline repair' }));
|
||||
|
||||
rawConfig = {
|
||||
...rawConfig,
|
||||
mtime: 200,
|
||||
rawText: 'model = "gpt-5.4-mini"\n',
|
||||
config: { model: 'gpt-5.4-mini' },
|
||||
parseError: null,
|
||||
};
|
||||
view.rerender(<CodexPage />);
|
||||
|
||||
expect(screen.getByLabelText('codex raw editor')).toHaveValue(repairedMigrationRawText);
|
||||
expect(screen.getByTestId('codex-control-center')).toHaveAttribute('data-disabled', 'true');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mocks.saveRawConfigAsync).toHaveBeenCalledWith({
|
||||
rawText: repairedMigrationRawText,
|
||||
expectedMtime: 100,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps manual raw edits bound to the snapshot mtime captured on first edit', async () => {
|
||||
mocks.saveRawConfigAsync.mockResolvedValue({ success: true, mtime: 201 });
|
||||
let rawConfig = {
|
||||
path: '$CODEX_HOME/config.toml',
|
||||
resolvedPath: '/tmp/.codex/config.toml',
|
||||
exists: true,
|
||||
mtime: 100,
|
||||
rawText: 'model = "gpt-5.4"\n',
|
||||
config: { model: 'gpt-5.4' },
|
||||
parseError: null,
|
||||
readError: null,
|
||||
};
|
||||
mocks.useCodex.mockImplementation(() => buildUseCodexResult({ rawConfig }));
|
||||
|
||||
const view = render(<CodexPage />);
|
||||
const editor = screen.getByLabelText('codex raw editor');
|
||||
await userEvent.clear(editor);
|
||||
await userEvent.type(editor, 'model = "gpt-5.4-mini"');
|
||||
|
||||
rawConfig = {
|
||||
...rawConfig,
|
||||
mtime: 200,
|
||||
rawText: 'model = "gpt-5.4-high"\n',
|
||||
config: { model: 'gpt-5.4-high' },
|
||||
};
|
||||
view.rerender(<CodexPage />);
|
||||
|
||||
expect(screen.getByLabelText('codex raw editor')).toHaveValue('model = "gpt-5.4-mini"');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mocks.saveRawConfigAsync).toHaveBeenCalledWith({
|
||||
rawText: 'model = "gpt-5.4-mini"',
|
||||
expectedMtime: 100,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('does not offer the targeted repair for generic invalid TOML', () => {
|
||||
mocks.useCodex.mockReturnValue(
|
||||
buildUseCodexResult({
|
||||
rawConfig: {
|
||||
path: '$CODEX_HOME/config.toml',
|
||||
resolvedPath: '/tmp/.codex/config.toml',
|
||||
exists: true,
|
||||
mtime: 100,
|
||||
rawText: 'model = "gpt-5.4"\n[features\n',
|
||||
config: null,
|
||||
parseError: 'Invalid TOML',
|
||||
readError: null,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
render(<CodexPage />);
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Preview one-newline repair' })
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('re-enables structured controls only after the saved valid snapshot is loaded', async () => {
|
||||
mocks.saveRawConfigAsync.mockResolvedValue({ success: true, mtime: 101 });
|
||||
let rawConfig = {
|
||||
path: '$CODEX_HOME/config.toml',
|
||||
resolvedPath: '/tmp/.codex/config.toml',
|
||||
exists: true,
|
||||
mtime: 100,
|
||||
rawText: joinedMigrationRawText,
|
||||
config: null,
|
||||
parseError: 'Invalid TOML',
|
||||
readError: null,
|
||||
};
|
||||
mocks.useCodex.mockImplementation(() => buildUseCodexResult({ rawConfig }));
|
||||
|
||||
const view = render(<CodexPage />);
|
||||
await userEvent.click(screen.getByRole('tab', { name: 'Control Center' }));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Preview one-newline repair' }));
|
||||
expect(screen.getByTestId('codex-control-center')).toHaveAttribute('data-disabled', 'true');
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await waitFor(() => expect(mocks.saveRawConfigAsync).toHaveBeenCalledTimes(1));
|
||||
|
||||
rawConfig = {
|
||||
...rawConfig,
|
||||
mtime: 101,
|
||||
rawText: repairedMigrationRawText,
|
||||
config: {
|
||||
notice: { model_migrations: { 'gpt-5.3-codex': 'gpt-5.4' } },
|
||||
agents: { code_simplifier: { description: 'Keep exact text' } },
|
||||
},
|
||||
parseError: null,
|
||||
};
|
||||
view.rerender(<CodexPage />);
|
||||
|
||||
expect(screen.getByTestId('codex-control-center')).toHaveAttribute('data-disabled', 'false');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user