feat(codex): add dashboard control center

- add guided editors for top-level settings, trust, profiles, providers, MCP, and features

- refresh raw snapshots after patch saves to avoid stale mtime conflicts

- block structured saves while raw TOML is dirty and add route plus hook coverage
This commit is contained in:
Tam Nhu Tran
2026-03-29 13:14:15 -04:00
parent 3c52b1ab6d
commit b47aa0d28d
18 changed files with 2959 additions and 57 deletions
@@ -0,0 +1,42 @@
import type { ReactNode } from 'react';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
interface CodexConfigCardShellProps {
title: string;
icon?: ReactNode;
badge?: string;
description?: string;
disabledReason?: string | null;
children: ReactNode;
}
export function CodexConfigCardShell({
title,
icon,
badge,
description,
disabledReason,
children,
}: CodexConfigCardShellProps) {
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
{icon}
{title}
{badge ? (
<Badge variant="outline" className="text-[10px] font-normal">
{badge}
</Badge>
) : null}
</CardTitle>
{description ? <p className="text-xs text-muted-foreground">{description}</p> : null}
</CardHeader>
<CardContent className="space-y-3">
{disabledReason ? <p className="text-xs text-amber-600">{disabledReason}</p> : null}
{children}
</CardContent>
</Card>
);
}
@@ -0,0 +1,129 @@
import { Sparkles } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Badge } from '@/components/ui/badge';
import type { CodexFeatureCatalogEntry } from '@/lib/codex-config';
import { CodexConfigCardShell } from './codex-config-card-shell';
interface CodexFeaturesCardProps {
catalog: CodexFeatureCatalogEntry[];
state: Record<string, boolean | null>;
disabled?: boolean;
disabledReason?: string | null;
onToggle: (feature: string, enabled: boolean | null) => Promise<void> | void;
}
export function CodexFeaturesCard({
catalog,
state,
disabled = false,
disabledReason,
onToggle,
}: CodexFeaturesCardProps) {
const knownFeatureNames = new Set(catalog.map((feature) => feature.name));
const configOnlyFeatures = Object.entries(state)
.filter(([name]) => !knownFeatureNames.has(name))
.sort(([left], [right]) => left.localeCompare(right));
return (
<CodexConfigCardShell
title="Features"
badge="features"
icon={<Sparkles className="h-4 w-4" />}
description="Toggle the supported Codex feature flags CCS can safely manage."
disabledReason={disabledReason}
>
<div className="space-y-2">
{catalog.map((feature) => {
const current = state[feature.name] ?? null;
return (
<div
key={feature.name}
className="flex items-center justify-between gap-3 rounded-md border px-3 py-2"
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium">{feature.label}</p>
<Badge variant="outline" className="font-mono text-[10px]">
{feature.name}
</Badge>
</div>
<p className="text-xs text-muted-foreground">{feature.description}</p>
</div>
<div className="flex items-center gap-2">
{current !== null ? (
<Button
variant="outline"
size="sm"
onClick={() => onToggle(feature.name, null)}
disabled={disabled}
>
Use default
</Button>
) : null}
<Switch
checked={current === true}
onCheckedChange={(next) => onToggle(feature.name, next)}
disabled={disabled}
/>
</div>
</div>
);
})}
</div>
{configOnlyFeatures.length > 0 ? (
<div className="space-y-2">
<div className="space-y-1">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Existing config-only flags
</p>
<p className="text-xs text-muted-foreground">
These feature keys already exist in your `config.toml`, so CCS can surface them
without claiming full catalog coverage.
</p>
</div>
{configOnlyFeatures.map(([name, current]) => (
<div
key={name}
className="flex items-center justify-between gap-3 rounded-md border border-dashed px-3 py-2"
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium">{name}</p>
<Badge variant="secondary" className="text-[10px]">
existing
</Badge>
</div>
<p className="text-xs text-muted-foreground">
{current === null
? 'Stored in a non-boolean form. Use raw TOML if you need to edit it.'
: "Discovered from the current file instead of CCS's built-in catalog."}
</p>
</div>
{current === null ? (
<Badge variant="outline">Raw only</Badge>
) : (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => onToggle(name, null)}
disabled={disabled}
>
Use default
</Button>
<Switch
checked={current === true}
onCheckedChange={(next) => onToggle(name, next)}
disabled={disabled}
/>
</div>
)}
</div>
))}
</div>
) : null}
</CodexConfigCardShell>
);
}
@@ -0,0 +1,278 @@
import { useMemo, useState } from 'react';
import { Loader2, PlugZap, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { CodexMcpServerPatchValues } from '@/hooks/use-codex-types';
import type { CodexMcpServerEntry } from '@/lib/codex-config';
import { CodexConfigCardShell } from './codex-config-card-shell';
interface CodexMcpServersCardProps {
entries: CodexMcpServerEntry[];
disabled?: boolean;
disabledReason?: string | null;
saving?: boolean;
onSave: (name: string, values: CodexMcpServerPatchValues) => Promise<void> | void;
onDelete: (name: string) => Promise<void> | void;
}
const EMPTY_MCP_SERVER_DRAFT: CodexMcpServerEntry = {
name: '',
transport: 'stdio',
command: null,
args: [],
url: null,
enabled: true,
required: false,
startupTimeoutSec: null,
toolTimeoutSec: null,
enabledTools: [],
disabledTools: [],
};
function toCsv(value: string[]) {
return value.join(', ');
}
function fromCsv(value: string) {
return value
.split(',')
.map((entry) => entry.trim())
.filter(Boolean);
}
interface McpServerEditorProps {
initialDraft: CodexMcpServerEntry;
isNew: boolean;
disabled: boolean;
saving: boolean;
canDelete: boolean;
onSave: (name: string, values: CodexMcpServerPatchValues) => Promise<void> | void;
onDelete: () => Promise<void> | void;
}
function McpServerEditor({
initialDraft,
isNew,
disabled,
saving,
canDelete,
onSave,
onDelete,
}: McpServerEditorProps) {
const [draft, setDraft] = useState<CodexMcpServerEntry>(initialDraft);
return (
<>
<div className="grid gap-3 sm:grid-cols-2">
<Input
value={draft.name}
onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))}
placeholder="playwright"
disabled={disabled || !isNew}
/>
<Select
value={draft.transport}
onValueChange={(next) =>
setDraft((current) => ({
...current,
transport: next as CodexMcpServerEntry['transport'],
}))
}
disabled={disabled}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="stdio">stdio</SelectItem>
<SelectItem value="streamable-http">streamable-http</SelectItem>
</SelectContent>
</Select>
{draft.transport === 'stdio' ? (
<>
<Input
value={draft.command ?? ''}
onChange={(event) =>
setDraft((current) => ({ ...current, command: event.target.value || null }))
}
placeholder="npx"
disabled={disabled}
/>
<Input
value={toCsv(draft.args)}
onChange={(event) =>
setDraft((current) => ({ ...current, args: fromCsv(event.target.value) }))
}
placeholder="@playwright/mcp@latest, --flag"
disabled={disabled}
/>
</>
) : (
<Input
className="sm:col-span-2"
value={draft.url ?? ''}
onChange={(event) =>
setDraft((current) => ({ ...current, url: event.target.value || null }))
}
placeholder="https://example.test/mcp"
disabled={disabled}
/>
)}
<Input
type="number"
min={1}
value={draft.startupTimeoutSec ?? ''}
onChange={(event) =>
setDraft((current) => ({
...current,
startupTimeoutSec: event.target.value ? Number(event.target.value) : null,
}))
}
placeholder="Startup timeout (sec)"
disabled={disabled}
/>
<Input
type="number"
min={1}
value={draft.toolTimeoutSec ?? ''}
onChange={(event) =>
setDraft((current) => ({
...current,
toolTimeoutSec: event.target.value ? Number(event.target.value) : null,
}))
}
placeholder="Tool timeout (sec)"
disabled={disabled}
/>
<Input
value={toCsv(draft.enabledTools)}
onChange={(event) =>
setDraft((current) => ({ ...current, enabledTools: fromCsv(event.target.value) }))
}
placeholder="enabled_tools"
disabled={disabled}
/>
<Input
value={toCsv(draft.disabledTools)}
onChange={(event) =>
setDraft((current) => ({ ...current, disabledTools: fromCsv(event.target.value) }))
}
placeholder="disabled_tools"
disabled={disabled}
/>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<label className="flex items-center justify-between rounded-md border px-3 py-2 text-sm">
Enabled
<Switch
checked={draft.enabled}
onCheckedChange={(next) => setDraft((current) => ({ ...current, enabled: next }))}
disabled={disabled}
/>
</label>
<label className="flex items-center justify-between rounded-md border px-3 py-2 text-sm">
Required
<Switch
checked={draft.required}
onCheckedChange={(next) => setDraft((current) => ({ ...current, required: next }))}
disabled={disabled}
/>
</label>
</div>
<div className="flex justify-between gap-2">
<Button variant="outline" onClick={onDelete} disabled={disabled || saving || !canDelete}>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</Button>
<Button
onClick={() =>
onSave(draft.name, {
transport: draft.transport,
command: draft.command,
args: draft.args,
url: draft.url,
enabled: draft.enabled,
required: draft.required,
startupTimeoutSec: draft.startupTimeoutSec,
toolTimeoutSec: draft.toolTimeoutSec,
enabledTools: draft.enabledTools,
disabledTools: draft.disabledTools,
})
}
disabled={disabled || saving || draft.name.trim().length === 0}
>
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Save MCP server
</Button>
</div>
</>
);
}
export function CodexMcpServersCard({
entries,
disabled = false,
disabledReason,
saving = false,
onSave,
onDelete,
}: CodexMcpServersCardProps) {
const [selectedName, setSelectedName] = useState('new');
const selectedEntry = useMemo(
() => entries.find((entry) => entry.name === selectedName) ?? null,
[entries, selectedName]
);
const draftSeed = selectedEntry ?? EMPTY_MCP_SERVER_DRAFT;
const draftKey = JSON.stringify(draftSeed);
return (
<CodexConfigCardShell
title="MCP servers"
badge="mcp_servers"
icon={<PlugZap className="h-4 w-4" />}
description="Manage the safe MCP transport fields. Keep auth headers and bearer tokens in raw TOML."
disabledReason={disabledReason}
>
<Select value={selectedName} onValueChange={setSelectedName} disabled={disabled}>
<SelectTrigger>
<SelectValue placeholder="Select MCP server" />
</SelectTrigger>
<SelectContent>
<SelectItem value="new">Create new MCP server</SelectItem>
{entries.map((entry) => (
<SelectItem key={entry.name} value={entry.name}>
{entry.name}
</SelectItem>
))}
</SelectContent>
</Select>
<McpServerEditor
key={draftKey}
initialDraft={draftSeed}
isNew={selectedName === 'new'}
disabled={disabled}
saving={saving}
canDelete={selectedEntry !== null}
onDelete={async () => {
if (!selectedEntry) return;
await onDelete(selectedEntry.name);
setSelectedName('new');
}}
onSave={async (name, values) => {
await onSave(name, values);
setSelectedName(name);
}}
/>
</CodexConfigCardShell>
);
}
@@ -0,0 +1,209 @@
import { useMemo, useState } from 'react';
import { KeyRound, Loader2, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { CodexModelProviderPatchValues } from '@/hooks/use-codex-types';
import type { CodexModelProviderEntry } from '@/lib/codex-config';
import { CodexConfigCardShell } from './codex-config-card-shell';
interface CodexModelProvidersCardProps {
entries: CodexModelProviderEntry[];
disabled?: boolean;
disabledReason?: string | null;
saving?: boolean;
onSave: (name: string, values: CodexModelProviderPatchValues) => Promise<void> | void;
onDelete: (name: string) => Promise<void> | void;
}
const EMPTY_MODEL_PROVIDER_DRAFT: CodexModelProviderEntry = {
name: '',
displayName: null,
baseUrl: null,
envKey: null,
wireApi: 'responses',
requiresOpenaiAuth: false,
supportsWebsockets: false,
};
interface ModelProviderEditorProps {
initialDraft: CodexModelProviderEntry;
isNew: boolean;
disabled: boolean;
saving: boolean;
canDelete: boolean;
onSave: (name: string, values: CodexModelProviderPatchValues) => Promise<void> | void;
onDelete: () => Promise<void> | void;
}
function ModelProviderEditor({
initialDraft,
isNew,
disabled,
saving,
canDelete,
onSave,
onDelete,
}: ModelProviderEditorProps) {
const [draft, setDraft] = useState<CodexModelProviderEntry>(initialDraft);
return (
<>
<div className="grid gap-3 sm:grid-cols-2">
<Input
value={draft.name}
onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))}
placeholder="Provider id"
disabled={disabled || !isNew}
/>
<Input
value={draft.displayName ?? ''}
onChange={(event) =>
setDraft((current) => ({ ...current, displayName: event.target.value || null }))
}
placeholder="Display name"
disabled={disabled}
/>
<Input
value={draft.baseUrl ?? ''}
onChange={(event) =>
setDraft((current) => ({ ...current, baseUrl: event.target.value || null }))
}
placeholder="http://127.0.0.1:8317/api/provider/codex"
disabled={disabled}
/>
<Input
value={draft.envKey ?? ''}
onChange={(event) =>
setDraft((current) => ({ ...current, envKey: event.target.value || null }))
}
placeholder="CLIPROXY_API_KEY"
disabled={disabled}
/>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<Select
value={draft.wireApi ?? 'responses'}
onValueChange={(next) => setDraft((current) => ({ ...current, wireApi: next }))}
disabled={disabled}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="responses">responses</SelectItem>
</SelectContent>
</Select>
<label className="flex items-center justify-between rounded-md border px-3 py-2 text-sm">
Requires OpenAI auth
<Switch
checked={draft.requiresOpenaiAuth}
onCheckedChange={(next) =>
setDraft((current) => ({ ...current, requiresOpenaiAuth: next }))
}
disabled={disabled}
/>
</label>
<label className="flex items-center justify-between rounded-md border px-3 py-2 text-sm">
Supports websockets
<Switch
checked={draft.supportsWebsockets}
onCheckedChange={(next) =>
setDraft((current) => ({ ...current, supportsWebsockets: next }))
}
disabled={disabled}
/>
</label>
</div>
<div className="flex justify-between gap-2">
<Button variant="outline" onClick={onDelete} disabled={disabled || saving || !canDelete}>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</Button>
<Button
onClick={() =>
onSave(draft.name, {
displayName: draft.displayName,
baseUrl: draft.baseUrl,
envKey: draft.envKey,
wireApi: draft.wireApi,
requiresOpenaiAuth: draft.requiresOpenaiAuth,
supportsWebsockets: draft.supportsWebsockets,
})
}
disabled={disabled || saving || draft.name.trim().length === 0}
>
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Save provider
</Button>
</div>
</>
);
}
export function CodexModelProvidersCard({
entries,
disabled = false,
disabledReason,
saving = false,
onSave,
onDelete,
}: CodexModelProvidersCardProps) {
const [selectedName, setSelectedName] = useState<string>('new');
const selectedEntry = useMemo(
() => entries.find((entry) => entry.name === selectedName) ?? null,
[entries, selectedName]
);
const draftSeed = selectedEntry ?? EMPTY_MODEL_PROVIDER_DRAFT;
const draftKey = JSON.stringify(draftSeed);
return (
<CodexConfigCardShell
title="Model providers"
badge="model_providers"
icon={<KeyRound className="h-4 w-4" />}
description="Edit the common provider fields CCS can support safely. Keep secret migration and inline bearer tokens in raw TOML."
disabledReason={disabledReason}
>
<Select value={selectedName} onValueChange={setSelectedName} disabled={disabled}>
<SelectTrigger>
<SelectValue placeholder="Select provider" />
</SelectTrigger>
<SelectContent>
<SelectItem value="new">Create new provider</SelectItem>
{entries.map((entry) => (
<SelectItem key={entry.name} value={entry.name}>
{entry.name}
</SelectItem>
))}
</SelectContent>
</Select>
<ModelProviderEditor
key={draftKey}
initialDraft={draftSeed}
isNew={selectedName === 'new'}
disabled={disabled}
saving={saving}
canDelete={selectedEntry !== null}
onDelete={async () => {
if (!selectedEntry) return;
await onDelete(selectedEntry.name);
setSelectedName('new');
}}
onSave={async (name, values) => {
await onSave(name, values);
setSelectedName(name);
}}
/>
</CodexConfigCardShell>
);
}
@@ -0,0 +1,250 @@
import { useMemo, useState } from 'react';
import { Layers3, Loader2, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { CodexProfilePatchValues } from '@/hooks/use-codex-types';
import type { CodexProfileEntry } from '@/lib/codex-config';
import { CodexConfigCardShell } from './codex-config-card-shell';
interface CodexProfilesCardProps {
activeProfile: string | null;
entries: CodexProfileEntry[];
providerNames: string[];
disabled?: boolean;
disabledReason?: string | null;
saving?: boolean;
onSave: (
name: string,
values: CodexProfilePatchValues,
setAsActive: boolean
) => Promise<void> | void;
onDelete: (name: string) => Promise<void> | void;
onSetActive: (name: string) => Promise<void> | void;
}
interface ProfileEditorProps {
initialName: string;
initialModel: string | null;
initialProvider: string | null;
initialEffort: string | null;
providerNames: string[];
activeProfile: string | null;
selectedEntryName: string | null;
disabled: boolean;
saving: boolean;
onSave: (
name: string,
values: CodexProfilePatchValues,
setAsActive: boolean
) => Promise<void> | void;
onDelete: () => Promise<void> | void;
onSetActive: () => Promise<void> | void;
}
function ProfileEditor({
initialName,
initialModel,
initialProvider,
initialEffort,
providerNames,
activeProfile,
selectedEntryName,
disabled,
saving,
onSave,
onDelete,
onSetActive,
}: ProfileEditorProps) {
const [nameDraft, setNameDraft] = useState(initialName);
const [modelDraft, setModelDraft] = useState<string | null>(initialModel);
const [providerDraft, setProviderDraft] = useState<string | null>(initialProvider);
const [effortDraft, setEffortDraft] = useState<string | null>(initialEffort);
return (
<>
<div className="grid gap-3 sm:grid-cols-2">
<Input
value={nameDraft}
onChange={(event) => setNameDraft(event.target.value)}
placeholder="deep-review"
disabled={disabled || selectedEntryName !== null}
/>
<Input
value={modelDraft ?? ''}
onChange={(event) => setModelDraft(event.target.value || null)}
placeholder="gpt-5.4"
disabled={disabled}
/>
<Select
value={providerDraft ?? '__unset__'}
onValueChange={(next) => setProviderDraft(next === '__unset__' ? null : next)}
disabled={disabled}
>
<SelectTrigger>
<SelectValue placeholder="Use global provider" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__unset__">Use global provider</SelectItem>
{providerNames.map((name) => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={effortDraft ?? '__unset__'}
onValueChange={(next) => setEffortDraft(next === '__unset__' ? null : next)}
disabled={disabled}
>
<SelectTrigger>
<SelectValue placeholder="Use global effort" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__unset__">Use global effort</SelectItem>
{['minimal', 'low', 'medium', 'high', 'xhigh'].map((value) => (
<SelectItem key={value} value={value}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex justify-between gap-2">
<div className="flex gap-2">
<Button
variant="outline"
onClick={onDelete}
disabled={disabled || saving || !selectedEntryName}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</Button>
<Button
variant="outline"
onClick={onSetActive}
disabled={
disabled || saving || !selectedEntryName || selectedEntryName === activeProfile
}
>
Set active
</Button>
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() =>
onSave(
nameDraft,
{
model: modelDraft,
modelProvider: providerDraft,
modelReasoningEffort: effortDraft,
},
false
)
}
disabled={disabled || saving || nameDraft.trim().length === 0}
>
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Save profile
</Button>
<Button
onClick={() =>
onSave(
nameDraft,
{
model: modelDraft,
modelProvider: providerDraft,
modelReasoningEffort: effortDraft,
},
true
)
}
disabled={disabled || saving || nameDraft.trim().length === 0}
>
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Save + activate
</Button>
</div>
</div>
</>
);
}
export function CodexProfilesCard({
activeProfile,
entries,
providerNames,
disabled = false,
disabledReason,
saving = false,
onSave,
onDelete,
onSetActive,
}: CodexProfilesCardProps) {
const [selectedName, setSelectedName] = useState('new');
const selectedEntry = useMemo(
() => entries.find((entry) => entry.name === selectedName) ?? null,
[entries, selectedName]
);
const draftKey = JSON.stringify(selectedEntry ?? { name: '', values: {} });
return (
<CodexConfigCardShell
title="Profiles"
badge="profiles"
icon={<Layers3 className="h-4 w-4" />}
description="Create reusable Codex overlays and set the active default profile."
disabledReason={disabledReason}
>
<Select value={selectedName} onValueChange={setSelectedName} disabled={disabled}>
<SelectTrigger>
<SelectValue placeholder="Select profile" />
</SelectTrigger>
<SelectContent>
<SelectItem value="new">Create new profile</SelectItem>
{entries.map((entry) => (
<SelectItem key={entry.name} value={entry.name}>
{entry.name}
{entry.name === activeProfile ? ' (active)' : ''}
</SelectItem>
))}
</SelectContent>
</Select>
<ProfileEditor
key={draftKey}
initialName={selectedEntry?.name ?? ''}
initialModel={selectedEntry?.values.model ?? null}
initialProvider={selectedEntry?.values.modelProvider ?? null}
initialEffort={selectedEntry?.values.modelReasoningEffort ?? null}
providerNames={providerNames}
activeProfile={activeProfile}
selectedEntryName={selectedEntry?.name ?? null}
disabled={disabled}
saving={saving}
onDelete={async () => {
if (!selectedEntry) return;
await onDelete(selectedEntry.name);
setSelectedName('new');
}}
onSetActive={async () => {
if (!selectedEntry) return;
await onSetActive(selectedEntry.name);
}}
onSave={async (name, values, setAsActive) => {
await onSave(name, values, setAsActive);
setSelectedName(name);
}}
/>
</CodexConfigCardShell>
);
}
@@ -0,0 +1,141 @@
import { useState } from 'react';
import { FolderCheck, Loader2, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { CodexProjectTrustEntry } from '@/lib/codex-config';
import { CodexConfigCardShell } from './codex-config-card-shell';
interface CodexProjectTrustCardProps {
workspacePath: string;
entries: CodexProjectTrustEntry[];
disabled?: boolean;
disabledReason?: string | null;
saving?: boolean;
onSave: (path: string, trustLevel: string | null) => Promise<void> | void;
}
interface ProjectTrustComposerProps {
workspacePath: string;
disabled: boolean;
saving: boolean;
onSave: (path: string, trustLevel: string | null) => Promise<void> | void;
}
function ProjectTrustComposer({
workspacePath,
disabled,
saving,
onSave,
}: ProjectTrustComposerProps) {
const [pathDraft, setPathDraft] = useState(workspacePath);
const [trustLevel, setTrustLevel] = useState('trusted');
return (
<div className="grid gap-2 sm:grid-cols-[1fr_160px_auto]">
<Input
value={pathDraft}
onChange={(event) => setPathDraft(event.target.value)}
placeholder="~/repo or /absolute/path"
disabled={disabled}
/>
<Select value={trustLevel} onValueChange={setTrustLevel} disabled={disabled}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="trusted">trusted</SelectItem>
<SelectItem value="ask">ask</SelectItem>
</SelectContent>
</Select>
<Button onClick={() => onSave(pathDraft, trustLevel)} disabled={disabled || saving}>
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Save trust
</Button>
</div>
);
}
export function CodexProjectTrustCard({
workspacePath,
entries,
disabled = false,
disabledReason,
saving = false,
onSave,
}: CodexProjectTrustCardProps) {
return (
<CodexConfigCardShell
title="Project trust"
badge="projects"
icon={<FolderCheck className="h-4 w-4" />}
description="Trust current workspaces or remove stale trust entries without opening raw TOML."
disabledReason={disabledReason}
>
<p className="text-xs text-muted-foreground">
Paths must be absolute or start with <code>~/</code>. Relative paths are rejected so CCS
does not trust the wrong folder.
</p>
<ProjectTrustComposer
key={workspacePath}
workspacePath={workspacePath}
disabled={disabled}
saving={saving}
onSave={onSave}
/>
<Button
variant="outline"
className="w-full justify-start"
onClick={() => onSave(workspacePath, 'trusted')}
disabled={disabled || saving}
>
Trust current workspace
</Button>
<div className="space-y-2">
{entries.length === 0 ? (
<p className="text-xs text-muted-foreground">No explicit project trust entries saved.</p>
) : (
entries.map((entry) => (
<div
key={entry.path}
className="flex items-center justify-between gap-2 rounded-md border px-3 py-2"
>
<div className="min-w-0">
<p className="truncate text-sm font-medium">{entry.path}</p>
<p className="text-xs text-muted-foreground">trust_level = {entry.trustLevel}</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() =>
onSave(entry.path, entry.trustLevel === 'trusted' ? 'ask' : 'trusted')
}
disabled={disabled || saving}
>
Toggle
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => onSave(entry.path, null)}
disabled={disabled || saving}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))
)}
</div>
</CodexConfigCardShell>
);
}
@@ -0,0 +1,280 @@
import { useState } from 'react';
import { Loader2, SlidersHorizontal } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { CodexTopLevelSettingsPatch } from '@/hooks/use-codex-types';
import type { CodexTopLevelSettingsView } from '@/lib/codex-config';
import { CodexConfigCardShell } from './codex-config-card-shell';
const UNSET = '__unset__';
interface CodexTopLevelControlsCardProps {
values: CodexTopLevelSettingsView;
providerNames: string[];
disabled?: boolean;
disabledReason?: string | null;
saving?: boolean;
onSave: (values: CodexTopLevelSettingsPatch) => Promise<void> | void;
}
function toSelectValue(value: string | null | undefined) {
return value ?? UNSET;
}
function withCurrentValue(options: string[], current: string | null | undefined) {
return current && !options.includes(current) ? [current, ...options] : options;
}
interface TopLevelControlsFormProps {
initialValues: CodexTopLevelSettingsView;
providerNames: string[];
disabled: boolean;
saving: boolean;
onSave: (values: CodexTopLevelSettingsPatch) => Promise<void> | void;
}
function TopLevelControlsForm({
initialValues,
providerNames,
disabled,
saving,
onSave,
}: TopLevelControlsFormProps) {
const [draft, setDraft] = useState<CodexTopLevelSettingsView>(initialValues);
const reasoningOptions = withCurrentValue(
['minimal', 'low', 'medium', 'high', 'xhigh'],
draft.modelReasoningEffort
);
const providerOptions = withCurrentValue(providerNames, draft.modelProvider);
const approvalOptions = withCurrentValue(
['on-request', 'never', 'untrusted'],
draft.approvalPolicy
);
const sandboxOptions = withCurrentValue(
['read-only', 'workspace-write', 'danger-full-access'],
draft.sandboxMode
);
const webSearchOptions = withCurrentValue(['cached', 'live', 'disabled'], draft.webSearch);
const personalityOptions = withCurrentValue(
['default', 'pragmatic', 'concise', 'direct'],
draft.personality
);
return (
<>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1">
<p className="text-xs font-medium">Model</p>
<Input
value={draft.model ?? ''}
onChange={(event) =>
setDraft((current) => ({ ...current, model: event.target.value || null }))
}
placeholder="gpt-5.4"
disabled={disabled}
/>
</div>
<div className="space-y-1">
<p className="text-xs font-medium">Reasoning effort</p>
<Select
value={toSelectValue(draft.modelReasoningEffort)}
onValueChange={(next) =>
setDraft((current) => ({
...current,
modelReasoningEffort: next === UNSET ? null : next,
}))
}
disabled={disabled}
>
<SelectTrigger>
<SelectValue placeholder="Use default" />
</SelectTrigger>
<SelectContent>
<SelectItem value={UNSET}>Use default</SelectItem>
{reasoningOptions.map((value) => (
<SelectItem key={value} value={value}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<p className="text-xs font-medium">Default provider</p>
<Select
value={toSelectValue(draft.modelProvider)}
onValueChange={(next) =>
setDraft((current) => ({ ...current, modelProvider: next === UNSET ? null : next }))
}
disabled={disabled}
>
<SelectTrigger>
<SelectValue placeholder="Use Codex default" />
</SelectTrigger>
<SelectContent>
<SelectItem value={UNSET}>Use Codex default</SelectItem>
{providerOptions.map((name) => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<p className="text-xs font-medium">Approval policy</p>
<Select
value={toSelectValue(draft.approvalPolicy)}
onValueChange={(next) =>
setDraft((current) => ({ ...current, approvalPolicy: next === UNSET ? null : next }))
}
disabled={disabled}
>
<SelectTrigger>
<SelectValue placeholder="Use default" />
</SelectTrigger>
<SelectContent>
<SelectItem value={UNSET}>Use default</SelectItem>
{approvalOptions.map((value) => (
<SelectItem key={value} value={value}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<p className="text-xs font-medium">Sandbox mode</p>
<Select
value={toSelectValue(draft.sandboxMode)}
onValueChange={(next) =>
setDraft((current) => ({ ...current, sandboxMode: next === UNSET ? null : next }))
}
disabled={disabled}
>
<SelectTrigger>
<SelectValue placeholder="Use default" />
</SelectTrigger>
<SelectContent>
<SelectItem value={UNSET}>Use default</SelectItem>
{sandboxOptions.map((value) => (
<SelectItem key={value} value={value}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<p className="text-xs font-medium">Web search</p>
<Select
value={toSelectValue(draft.webSearch)}
onValueChange={(next) =>
setDraft((current) => ({ ...current, webSearch: next === UNSET ? null : next }))
}
disabled={disabled}
>
<SelectTrigger>
<SelectValue placeholder="Use default" />
</SelectTrigger>
<SelectContent>
<SelectItem value={UNSET}>Use default</SelectItem>
{webSearchOptions.map((value) => (
<SelectItem key={value} value={value}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<p className="text-xs font-medium">Tool output token limit</p>
<Input
type="number"
min={1}
value={draft.toolOutputTokenLimit ?? ''}
onChange={(event) =>
setDraft((current) => ({
...current,
toolOutputTokenLimit: event.target.value ? Number(event.target.value) : null,
}))
}
placeholder="25000"
disabled={disabled}
/>
</div>
<div className="space-y-1">
<p className="text-xs font-medium">Personality</p>
<Select
value={toSelectValue(draft.personality)}
onValueChange={(next) =>
setDraft((current) => ({ ...current, personality: next === UNSET ? null : next }))
}
disabled={disabled}
>
<SelectTrigger>
<SelectValue placeholder="Use default" />
</SelectTrigger>
<SelectContent>
<SelectItem value={UNSET}>Use default</SelectItem>
{personalityOptions.map((value) => (
<SelectItem key={value} value={value}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex justify-end">
<Button onClick={() => onSave(draft)} disabled={disabled || saving}>
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Save top-level settings
</Button>
</div>
</>
);
}
export function CodexTopLevelControlsCard({
values,
providerNames,
disabled = false,
disabledReason,
saving = false,
onSave,
}: CodexTopLevelControlsCardProps) {
return (
<CodexConfigCardShell
title="Top-level controls"
badge="config.toml"
icon={<SlidersHorizontal className="h-4 w-4" />}
description="Structured controls for the stable top-level Codex settings users touch most often."
disabledReason={disabledReason}
>
<TopLevelControlsForm
key={JSON.stringify(values)}
initialValues={values}
providerNames={providerNames}
disabled={disabled}
saving={saving}
onSave={onSave}
/>
</CodexConfigCardShell>
);
}