feat: updade workspace

This commit is contained in:
thanhnv
2026-07-11 15:56:31 +09:00
parent 4fc72332f5
commit 193a449829
120 changed files with 868 additions and 350 deletions
@@ -273,7 +273,7 @@ export interface GoalJob {
id: string;
trace_id: string;
goal: string;
status: 'queued' | 'running' | 'completed' | 'degraded' | 'failed';
status: 'queued' | 'running' | 'completed' | 'degraded' | 'failed' | 'requires_approval';
actor: string;
tenant: string;
project: string;
@@ -290,6 +290,16 @@ export interface GoalJob {
result?: string;
error?: string;
audit_hash?: string;
workspace?: GoalProject;
context_manifest?: { files: number; characters: number; truncated: boolean; path?: string };
approval?: { id: string; status: string; action: string };
}
export interface GoalProject {
project_id: string;
domain: string;
domain_root: string;
context_roots: string[];
}
export interface ChatReplay {
@@ -336,9 +346,28 @@ export interface SettingsState {
can_write_sensitive: boolean;
can_rollback: boolean;
};
policy: Record<string, { securitySensitive: boolean; description: string }>;
policy: Record<string, {
securitySensitive: boolean;
description: string;
type: 'boolean' | 'string' | 'number' | 'integer' | 'enum';
options?: string[];
min?: number;
max?: number;
minLength?: number;
maxLength?: number;
}>;
settings: Record<string, { value: unknown; version: number; updatedAt: string; actor: string; reason: string }>;
audit: any[];
audit: Array<{
seq: number;
key: string;
action: 'set' | 'rollback';
value: unknown;
prevValue: unknown;
actor: string;
reason: string;
at: string;
hash: string;
}>;
audit_verify: { ok: boolean; output: string };
}
@@ -450,8 +479,10 @@ export const api = {
getWithHeaders<{ success: boolean; providers: ProviderAuthStatus[] }>('provider-auth', actorHeaders(actor)),
startProviderLogin: (actor: SettingsActor, provider: ProviderAuthStatus['id']) =>
post<{ success: boolean; reason: string; provider: ProviderAuthStatus }>(`provider-auth/${provider}/login`, {}, actorHeaders(actor)),
startGoal: (actor: SettingsActor, goal: string) =>
post<GoalJob>('goals', { goal }, actorHeaders(actor)),
goalProjects: (actor: SettingsActor) =>
getWithHeaders<{ count: number; projects: GoalProject[] }>('goals/projects', actorHeaders(actor)),
startGoal: (actor: SettingsActor, goal: string, projectId: string) =>
post<GoalJob>('goals', { goal, projectId }, actorHeaders(actor)),
goal: (actor: SettingsActor, id: string) =>
getWithHeaders<GoalJob>(`goals/${encodeURIComponent(id)}`, actorHeaders(actor)),
goals: (actor: SettingsActor, limit = 20) =>
@@ -1,14 +1,14 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useSearchParams } from 'react-router-dom';
import { Link, useSearchParams } from 'react-router-dom';
import { api, type GoalJob, type SettingsActor } from '../lib/api';
import { Card, StatusBadge } from '../components/ui/Card';
import { TraceExplorer } from '../components/trace/TraceExplorer';
import { MarkdownText } from '../components/ui/MarkdownText';
import { ModelInteractionDiagram } from '../components/goals/ModelInteractionDiagram';
const DEFAULT_ACTOR: SettingsActor = { actor: 'local-operator', role: 'project-admin', project: 'default', tenant: 'default' };
const TERMINAL = new Set<GoalJob['status']>(['completed', 'degraded', 'failed']);
const DEFAULT_ACTOR: SettingsActor = { actor: 'local-operator', role: 'org-admin', project: 'default', tenant: 'default' };
const TERMINAL = new Set<GoalJob['status']>(['completed', 'degraded', 'failed', 'requires_approval']);
function errorMessage(error: unknown): string {
if (typeof error === 'object' && error !== null) {
@@ -45,10 +45,14 @@ function WorkerCard({ title, subtitle, status, detail, provider, model }: {
export function Goals() {
const [goal, setGoal] = useState('');
const [projectId, setProjectId] = useState('');
const [actor] = useState(DEFAULT_ACTOR);
const [searchParams, setSearchParams] = useSearchParams();
const selectedId = searchParams.get('id') ?? '';
const queryClient = useQueryClient();
const projectsQuery = useQuery({ queryKey: ['goal-projects', actor], queryFn: () => api.goalProjects(actor) });
const projects = projectsQuery.data?.projects ?? [];
const effectiveProject = projectId || projects[0]?.project_id || '';
const listQuery = useQuery({ queryKey: ['goals', actor], queryFn: () => api.goals(actor, 20) });
const selectedQuery = useQuery({
@@ -61,7 +65,7 @@ export function Goals() {
},
});
const start = useMutation({
mutationFn: () => api.startGoal(actor, goal.trim()),
mutationFn: () => api.startGoal({ ...actor, project: effectiveProject }, goal.trim(), effectiveProject),
onSuccess: (job) => {
setSearchParams({ id: job.id });
setGoal('');
@@ -85,6 +89,15 @@ export function Goals() {
</section>
<Card title="Give CASAN an objective">
<label className="mb-4 block">
<span className="text-sm font-semibold text-slate-800">Project workspace</span>
<span className="mt-1 block text-xs leading-5 text-slate-500">Only server-registered, allowlisted roots are available. Models receive a bounded redacted snapshot—not filesystem access.</span>
<select value={effectiveProject} onChange={(event) => setProjectId(event.target.value)} disabled={projectsQuery.isLoading || projects.length === 0} className="mt-2 w-full rounded-xl border border-slate-300 bg-white px-4 py-3 text-sm outline-none transition focus:border-indigo-400 focus:ring-4 focus:ring-indigo-100 disabled:bg-slate-100">
{projects.map((project) => <option key={project.project_id} value={project.project_id}>{project.domain} · {project.project_id}</option>)}
</select>
{projectsQuery.isError && <span role="alert" className="mt-2 block text-xs font-medium text-rose-700">Could not load the allowlisted project registry.</span>}
{effectiveProject && <span className="mt-2 block font-mono text-[11px] text-slate-400">Context roots: {projects.find((project) => project.project_id === effectiveProject)?.context_roots.join(', ')}</span>}
</label>
<textarea
value={goal}
onChange={(event) => setGoal(event.target.value)}
@@ -93,10 +106,10 @@ export function Goals() {
maxLength={8000}
/>
<div className="mt-3 flex flex-wrap items-center justify-between gap-3">
<p className="text-xs text-slate-500">CASAN selects the connected local and cloud models automatically. No IDE or CLI is required.</p>
<p className="text-xs text-slate-500">Read-only goals receive the same evidence snapshot in both models. Side-effect requests become approval proposals and never write automatically.</p>
<button
type="button"
disabled={goal.trim().length < 10 || start.isPending}
disabled={goal.trim().length < 10 || !effectiveProject || start.isPending}
onClick={() => start.mutate()}
className="rounded-xl bg-indigo-600 px-5 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-indigo-700 disabled:cursor-not-allowed disabled:bg-slate-300"
>
@@ -116,6 +129,7 @@ export function Goals() {
<ModelInteractionDiagram goal={selected} localStage={localStage} cloudStage={cloudStage} />
<Card title="Governed outcome" right={<StatusBadge value={selected.status} />}>
{selected.workspace && <div className="mb-4 flex flex-wrap items-center gap-2 rounded-xl border border-indigo-200 bg-indigo-50 p-3 text-xs text-indigo-900"><strong>{selected.workspace.domain}</strong><span>·</span><span className="font-mono">{selected.workspace.project_id}</span>{selected.context_manifest && <><span>·</span><span>{selected.context_manifest.files} files / {selected.context_manifest.characters} chars</span>{selected.context_manifest.truncated && <StatusBadge value="bounded snapshot" />}</>}</div>}
<div className="rounded-xl border border-slate-200 bg-slate-50 p-4">
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-slate-400">Objective</div>
<div className="mt-2"><MarkdownText text={selected.goal} /></div>
@@ -127,6 +141,7 @@ export function Goals() {
</div>
)}
{selected.error && <div className="mt-4 rounded-xl border border-rose-200 bg-rose-50 p-3 text-sm text-rose-700">{selected.error}</div>}
{selected.approval && <div className="mt-4 rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900"><div className="font-semibold">Workspace side effect withheld</div><p className="mt-1 leading-6">Proposal <span className="font-mono">{selected.approval.id}</span> is {selected.approval.status}. No source file or runtime action was changed.</p><Link to="/approvals" className="mt-3 inline-flex rounded-lg bg-amber-700 px-3 py-2 text-xs font-semibold text-white hover:bg-amber-800 focus:outline-none focus:ring-2 focus:ring-amber-500">Open Approvals</Link></div>}
{selected.local_draft && <details className="mt-5 rounded-xl border border-slate-200 p-4"><summary className="cursor-pointer text-sm font-semibold text-slate-700">Inspect local worker draft</summary><div className="mt-4"><MarkdownText text={selected.local_draft} /></div></details>}
</Card>
@@ -1,181 +1,209 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { api, SettingsActor } from '../lib/api';
import { Card, StatusBadge } from '../components/ui/Card';
import axios from 'axios';
import { api, SettingsActor, SettingsState } from '../lib/api';
import { StatusBadge } from '../components/ui/Card';
const ROLES = ['viewer', 'operator', 'project-admin', 'org-admin', 'auditor'];
type Category = 'all' | 'runtime' | 'models' | 'cost' | 'security';
type Notice = { tone: 'success' | 'error'; text: string } | null;
function parseValue(raw: string): unknown {
if (raw.trim() === '') return '';
try {
return JSON.parse(raw);
} catch {
return raw;
}
const LOCAL_ACTOR: SettingsActor = { actor: 'local-operator', role: 'org-admin', project: 'default', tenant: 'default' };
const CATEGORY_META: Array<{ id: Category; label: string; description: string }> = [
{ id: 'all', label: 'All settings', description: 'Every governed control' },
{ id: 'runtime', label: 'Runtime & loops', description: 'Execution limits and compression' },
{ id: 'models', label: 'Models', description: 'Primary model routing' },
{ id: 'cost', label: 'Cost controls', description: 'Per-call spend boundaries' },
{ id: 'security', label: 'Security', description: 'Fail-closed and emergency controls' },
];
const DISPLAY: Record<string, { name: string; impact: string }> = {
'compression.enabled': { name: 'Context compression', impact: 'Changes token usage and the context passed to every new model call.' },
'compression.mode': { name: 'Compression strategy', impact: 'Changes how CASAN reduces context before model execution.' },
'cost.absolute_cap_usd': { name: 'Absolute cost cap', impact: 'Blocks a model call when its estimated cost exceeds this USD limit.' },
'model.primary': { name: 'Primary model', impact: 'Routes new eligible model calls to this provider and model.' },
'security.strict': { name: 'Strict security mode', impact: 'Controls whether H4 security checks fail closed. Disabling it reduces protection.' },
'kill_switch.global': { name: 'Global kill switch', impact: 'Stops governed runtime actions across every project in this control plane.' },
'loop.max_steps': { name: 'Maximum loop steps', impact: 'Caps autonomous steps in each run.' },
'loop.max_tokens': { name: 'Maximum loop tokens', impact: 'Caps total model tokens consumed by a run.' },
'loop.max_wall_clock_sec': { name: 'Maximum run time', impact: 'Stops a run after this many seconds.' },
'loop.max_cost_usd': { name: 'Maximum loop cost', impact: 'Stops a run when cumulative model cost reaches this USD limit.' },
'loop.max_corrections_per_step': { name: 'Corrections per step', impact: 'Limits retry and self-correction attempts for one step.' },
'loop.oscillation_repeat': { name: 'Oscillation threshold', impact: 'Marks a run as oscillating after this many repeated actions.' },
'loop.no_progress_window': { name: 'No-progress window', impact: 'Marks a run as stalled after this many steps without progress.' },
};
function categoryFor(key: string): Exclude<Category, 'all'> {
if (key.startsWith('compression.') || key.startsWith('loop.')) return 'runtime';
if (key.startsWith('model.')) return 'models';
if (key.startsWith('cost.')) return 'cost';
return 'security';
}
function valuePreview(value: unknown): string {
function valueText(value: unknown): string {
if (value === undefined) return '';
return typeof value === 'string' ? value : JSON.stringify(value);
}
function apiError(error: unknown): string {
if (axios.isAxiosError<{ message?: string }>(error)) return error.response?.data?.message || error.message;
return error instanceof Error ? error.message : 'The settings request failed.';
}
function validate(raw: string, policy: SettingsState['policy'][string]): string | null {
if (!raw.trim()) return 'Enter a value before saving.';
if (policy.type === 'boolean' && raw !== 'true' && raw !== 'false') return 'Choose true or false.';
if (policy.type === 'enum' && !policy.options?.includes(raw)) return `Choose one of: ${policy.options?.join(', ')}.`;
if (policy.type === 'number' || policy.type === 'integer') {
const number = Number(raw);
if (!Number.isFinite(number)) return 'Enter a valid number.';
if (policy.type === 'integer' && !Number.isInteger(number)) return 'Enter a whole number.';
if (policy.min !== undefined && number < policy.min) return `Value must be at least ${policy.min}.`;
if (policy.max !== undefined && number > policy.max) return `Value must be at most ${policy.max}.`;
}
if (policy.type === 'string' && policy.minLength !== undefined && raw.length < policy.minLength) return `Enter at least ${policy.minLength} characters.`;
return null;
}
function parsedValue(raw: string, type: SettingsState['policy'][string]['type']): unknown {
if (type === 'boolean') return raw === 'true';
if (type === 'number' || type === 'integer') return Number(raw);
return raw;
}
export function Settings() {
const queryClient = useQueryClient();
const [actor, setActor] = useState<SettingsActor>({
actor: 'local-operator',
role: 'viewer',
project: 'default',
tenant: 'default',
});
const [category, setCategory] = useState<Category>('all');
const [search, setSearch] = useState('');
const [selectedKey, setSelectedKey] = useState('');
const [rawValue, setRawValue] = useState('true');
const [reason, setReason] = useState('operator change from Ops Console');
const [rawValue, setRawValue] = useState('');
const [reason, setReason] = useState('');
const [approval, setApproval] = useState('');
const [message, setMessage] = useState<string | null>(null);
const [notice, setNotice] = useState<Notice>(null);
const [confirmAction, setConfirmAction] = useState<'save' | 'rollback' | null>(null);
const settingsQuery = useQuery({
queryKey: ['settings', actor],
queryFn: () => api.settings(actor),
retry: false,
});
const settingsQuery = useQuery({ queryKey: ['settings'], queryFn: () => api.settings(LOCAL_ACTOR), retry: false });
const data = settingsQuery.data;
const keys = useMemo(() => Object.keys(data?.policy ?? {}).sort(), [data]);
const filteredKeys = useMemo(() => keys.filter((key) => {
const label = DISPLAY[key]?.name ?? key;
const matchesCategory = category === 'all' || categoryFor(key) === category;
const query = search.trim().toLowerCase();
return matchesCategory && (!query || `${label} ${key} ${data?.policy[key].description}`.toLowerCase().includes(query));
}), [category, data, keys, search]);
const keys = useMemo(() => Object.keys(settingsQuery.data?.policy ?? {}).sort(), [settingsQuery.data]);
const effectiveKey = selectedKey || keys[0] || '';
const policy = effectiveKey ? settingsQuery.data?.policy[effectiveKey] : undefined;
useEffect(() => {
if (!selectedKey && keys[0]) {
setSelectedKey(keys[0]);
setRawValue(valueText(data?.settings[keys[0]]?.value));
}
}, [data, keys, selectedKey]);
const setMutation = useMutation({
mutationFn: () => api.setSetting(actor, {
key: effectiveKey,
value: parseValue(rawValue),
reason,
approval: approval || undefined,
const policy = selectedKey ? data?.policy[selectedKey] : undefined;
const current = selectedKey ? data?.settings[selectedKey] : undefined;
const initialValue = valueText(current?.value);
const dirty = Boolean(selectedKey) && rawValue !== initialValue;
const validationError = policy && dirty ? validate(rawValue, policy) : null;
const canWrite = Boolean(policy && (policy.securitySensitive ? data?.capabilities.can_write_sensitive : data?.capabilities.can_write_standard));
const audit = data?.audit.filter((entry) => entry.key === selectedKey) ?? [];
function selectSetting(key: string) {
if (dirty && !window.confirm('Discard your unsaved change and open another setting?')) return;
setSelectedKey(key);
setRawValue(valueText(data?.settings[key]?.value));
setReason('');
setApproval('');
setNotice(null);
}
function discard() {
setRawValue(initialValue);
setReason('');
setApproval('');
setNotice(null);
}
const saveMutation = useMutation({
mutationFn: () => api.setSetting(LOCAL_ACTOR, {
key: selectedKey,
value: parsedValue(rawValue, policy!.type),
reason: reason.trim(),
approval: approval.trim() || undefined,
}),
onSuccess: (res) => {
setMessage(`SET ${res.key} v${res.setting.version} audit=${res.audit_verify.ok ? 'ok' : 'failed'}`);
onSuccess: (result) => {
setNotice({ tone: 'success', text: `${DISPLAY[result.key]?.name ?? result.key} saved as version ${result.setting.version}. Audit chain verified.` });
setReason(''); setApproval(''); setConfirmAction(null);
void queryClient.invalidateQueries({ queryKey: ['settings'] });
},
onError: (err: any) => setMessage(err?.response?.data?.message || err.message || 'SET failed'),
onError: (error) => { setNotice({ tone: 'error', text: apiError(error) }); setConfirmAction(null); },
});
const rollbackMutation = useMutation({
mutationFn: () => api.rollbackSetting(actor, { key: effectiveKey, reason }),
onSuccess: (res) => {
setMessage(`ROLLBACK ${res.key} v${res.setting.version} audit=${res.audit_verify.ok ? 'ok' : 'failed'}`);
mutationFn: () => api.rollbackSetting(LOCAL_ACTOR, { key: selectedKey, reason: reason.trim() }),
onSuccess: (result) => {
setNotice({ tone: 'success', text: `${DISPLAY[result.key]?.name ?? result.key} rolled back as version ${result.setting.version}. Audit chain verified.` });
setReason(''); setConfirmAction(null);
void queryClient.invalidateQueries({ queryKey: ['settings'] });
},
onError: (err: any) => setMessage(err?.response?.data?.message || err.message || 'ROLLBACK failed'),
onError: (error) => { setNotice({ tone: 'error', text: apiError(error) }); setConfirmAction(null); },
});
if (settingsQuery.isLoading) return <div className="text-gray-500">Loading…</div>;
if (settingsQuery.isError || !settingsQuery.data) return <div className="text-red-600">Cannot reach settings API.</div>;
if (settingsQuery.isLoading) return <div role="status" className="rounded-2xl border border-slate-200 bg-white p-8 text-sm text-slate-600">Loading governed settings…</div>;
if (settingsQuery.isError || !data) return <div role="alert" className="rounded-2xl border border-rose-200 bg-rose-50 p-6 text-sm text-rose-800"><strong>Settings are unavailable.</strong><div className="mt-1">{apiError(settingsQuery.error)}</div><button className="mt-4 rounded-lg border border-rose-300 px-3 py-2 font-medium hover:bg-rose-100 focus:outline-none focus:ring-2 focus:ring-rose-500" onClick={() => void settingsQuery.refetch()}>Try again</button></div>;
const data = settingsQuery.data;
const current = effectiveKey ? data.settings[effectiveKey] : undefined;
const canWrite = policy?.securitySensitive ? data.capabilities.can_write_sensitive : data.capabilities.can_write_standard;
const accessLabel = canWrite ? (policy?.securitySensitive ? 'Admin + approval' : 'Editable') : 'Read only';
const saveDisabled = !dirty || Boolean(validationError) || !reason.trim() || !canWrite || saveMutation.isPending;
const rollbackDisabled = !data.capabilities.can_rollback || !current || audit.length < 2 || !reason.trim() || rollbackMutation.isPending;
return (
<>
<Card title="Management identity" right={<StatusBadge value={data.audit_verify.ok ? 'audit ok' : 'audit fail'} />}>
<div className="grid grid-cols-1 md:grid-cols-4 gap-3 text-sm">
<label className="space-y-1">
<span className="text-gray-500">Actor</span>
<input className="w-full rounded border border-gray-300 px-3 py-2" value={actor.actor}
onChange={(e) => setActor({ ...actor, actor: e.target.value })} />
</label>
<label className="space-y-1">
<span className="text-gray-500">Role</span>
<select className="w-full rounded border border-gray-300 px-3 py-2" value={actor.role}
onChange={(e) => setActor({ ...actor, role: e.target.value })}>
{ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
</select>
</label>
<label className="space-y-1">
<span className="text-gray-500">Project</span>
<input className="w-full rounded border border-gray-300 px-3 py-2" value={actor.project}
onChange={(e) => setActor({ ...actor, project: e.target.value })} />
</label>
<label className="space-y-1">
<span className="text-gray-500">Tenant</span>
<input className="w-full rounded border border-gray-300 px-3 py-2" value={actor.tenant}
onChange={(e) => setActor({ ...actor, tenant: e.target.value })} />
</label>
<div className="space-y-5 pb-44 lg:pb-28">
<section className="rounded-2xl border border-slate-200/80 bg-white p-5 shadow-sm sm:p-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div><p className="text-xs font-semibold uppercase tracking-[0.14em] text-indigo-600">Runtime configuration</p><h2 className="mt-2 text-2xl font-semibold tracking-tight text-slate-950">Settings you can understand before you change</h2><p className="mt-2 max-w-2xl text-sm leading-6 text-slate-600">Changes apply to new governed runs in tenant <strong>{data.actor.tenant}</strong> and project <strong>{data.actor.project}</strong>. Every save and rollback is RBAC-checked and audit anchored.</p></div>
<div className="flex flex-wrap items-center gap-2"><StatusBadge value={data.audit_verify.ok ? 'audit verified' : 'audit failed'} /><span className="rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs font-semibold text-slate-700">{data.actor.actor} · {data.actor.role}</span></div>
</div>
</Card>
</section>
<Card title="Governed settings">
<div className="overflow-auto">
<table className="min-w-full text-sm">
<thead className="text-left text-xs uppercase text-gray-500 border-b border-gray-200">
<tr><th className="py-2 pr-4">Key</th><th className="py-2 pr-4">Current</th><th className="py-2 pr-4">Version</th><th className="py-2">Policy</th></tr>
</thead>
<tbody>
{keys.map((key) => {
const row = data.settings[key];
const p = data.policy[key];
return (
<tr key={key} className={`border-b border-gray-100 cursor-pointer ${effectiveKey === key ? 'bg-blue-50' : ''}`}
onClick={() => { setSelectedKey(key); setRawValue(valuePreview(row?.value ?? '')); }}>
<td className="py-3 pr-4 font-medium text-gray-800">{key}</td>
<td className="py-3 pr-4 text-gray-600"><code>{row ? valuePreview(row.value) : 'unset'}</code></td>
<td className="py-3 pr-4 text-gray-600">{row?.version ?? '—'}</td>
<td className="py-3">{p.securitySensitive ? <StatusBadge value="sensitive" /> : <StatusBadge value="standard" />}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Card>
<div className="grid gap-5 xl:grid-cols-[240px_minmax(0,1fr)_360px]">
<aside className="space-y-4 xl:sticky xl:top-24 xl:self-start">
<label className="block"><span className="sr-only">Search settings</span><input type="search" value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Search settings…" className="w-full rounded-xl border border-slate-300 bg-white px-4 py-3 text-sm shadow-sm outline-none transition focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200" /></label>
<nav aria-label="Settings categories" className="rounded-2xl border border-slate-200 bg-white p-2 shadow-sm">
{CATEGORY_META.map((item) => <button key={item.id} type="button" onClick={() => setCategory(item.id)} aria-current={category === item.id ? 'page' : undefined} className={`w-full rounded-xl px-3 py-3 text-left transition focus:outline-none focus:ring-2 focus:ring-indigo-500 ${category === item.id ? 'bg-indigo-50 text-indigo-950' : 'text-slate-700 hover:bg-slate-50'}`}><span className="block text-sm font-semibold">{item.label}</span><span className="mt-0.5 block text-xs leading-5 text-slate-500">{item.description}</span></button>)}
</nav>
</aside>
<Card title="Apply or rollback">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 text-sm">
<div className="space-y-3">
<div>
<div className="text-gray-500">Selected key</div>
<div className="font-medium text-gray-800">{effectiveKey || 'none'}</div>
{policy && <div className="text-gray-500 mt-1">{policy.description}</div>}
{current && <div className="text-gray-500 mt-1">current v{current.version} by {current.actor}</div>}
</div>
<label className="block space-y-1">
<span className="text-gray-500">Value (JSON or string)</span>
<input className="w-full rounded border border-gray-300 px-3 py-2" value={rawValue} onChange={(e) => setRawValue(e.target.value)} />
</label>
<label className="block space-y-1">
<span className="text-gray-500">Reason</span>
<input className="w-full rounded border border-gray-300 px-3 py-2" value={reason} onChange={(e) => setReason(e.target.value)} />
</label>
<label className="block space-y-1">
<span className="text-gray-500">Approval token</span>
<input className="w-full rounded border border-gray-300 px-3 py-2" value={approval} onChange={(e) => setApproval(e.target.value)} />
</label>
<div className="flex flex-wrap gap-2">
<button className="rounded bg-blue-600 px-4 py-2 text-white disabled:bg-gray-300"
disabled={!effectiveKey || !canWrite || setMutation.isPending}
onClick={() => setMutation.mutate()}>
Apply
</button>
<button className="rounded border border-gray-300 px-4 py-2 text-gray-700 disabled:text-gray-300"
disabled={!effectiveKey || !data.capabilities.can_rollback || rollbackMutation.isPending}
onClick={() => rollbackMutation.mutate()}>
Rollback
</button>
</div>
{message && <div className="rounded border border-gray-200 bg-gray-50 p-3 text-gray-700">{message}</div>}
<section aria-labelledby="settings-list-title" className="min-w-0 rounded-2xl border border-slate-200 bg-white shadow-sm">
<div className="border-b border-slate-200 px-5 py-4"><div className="flex items-center justify-between gap-3"><h2 id="settings-list-title" className="font-semibold text-slate-950">{CATEGORY_META.find((item) => item.id === category)?.label}</h2><span className="text-xs font-medium text-slate-500">{filteredKeys.length} settings</span></div></div>
<div className="divide-y divide-slate-100">
{filteredKeys.map((key) => {
const entry = data.settings[key]; const itemPolicy = data.policy[key]; const display = DISPLAY[key] ?? { name: key, impact: itemPolicy.description };
return <button type="button" key={key} onClick={() => selectSetting(key)} aria-pressed={selectedKey === key} className={`group w-full px-5 py-4 text-left transition focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-500 ${selectedKey === key ? 'bg-indigo-50/70' : 'hover:bg-slate-50'}`}><div className="flex items-start justify-between gap-4"><div className="min-w-0"><div className="flex flex-wrap items-center gap-2"><span className="font-semibold text-slate-900">{display.name}</span>{itemPolicy.securitySensitive && <span className="rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-[11px] font-semibold text-amber-800">Security-sensitive</span>}</div><p className="mt-1 text-sm leading-5 text-slate-600">{itemPolicy.description}</p><p className="mt-2 font-mono text-[11px] text-slate-400">{key}</p></div><div className="shrink-0 text-right"><div className="max-w-32 truncate font-mono text-sm font-semibold text-slate-800">{entry ? valueText(entry.value) : 'Not set'}</div><div className="mt-1 text-xs text-slate-500">{entry ? `v${entry.version}` : 'Uses runtime default'}</div></div></div></button>;
})}
{filteredKeys.length === 0 && <div className="px-6 py-12 text-center"><div className="font-medium text-slate-800">No settings found</div><p className="mt-1 text-sm text-slate-500">Try another category or search phrase.</p></div>}
</div>
<div>
<div className="mb-2 text-xs font-semibold uppercase text-gray-500">Recent settings audit</div>
<div className="space-y-2 max-h-80 overflow-auto">
{data.audit.map((a) => (
<div key={`${a.seq}-${a.hash}`} className="rounded border border-gray-200 p-3">
<div className="font-medium text-gray-800">{a.action} {a.key}</div>
<div className="text-xs text-gray-500">{a.actor} · {a.at} · vhash {String(a.hash).slice(0, 12)}</div>
<div className="text-xs text-gray-600 mt-1">{a.reason}</div>
</div>
))}
{data.audit.length === 0 && <div className="text-gray-500">No settings audit records yet.</div>}
</section>
<aside aria-label="Setting editor" className="xl:sticky xl:top-24 xl:self-start">
{policy ? <div className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm">
<div className="border-b border-slate-200 px-5 py-4"><div className="flex items-start justify-between gap-3"><div><p className="text-xs font-semibold uppercase tracking-[0.12em] text-slate-500">Current selection</p><h2 className="mt-1 text-lg font-semibold text-slate-950">{DISPLAY[selectedKey]?.name ?? selectedKey}</h2></div><StatusBadge value={accessLabel} /></div></div>
<div className="space-y-5 p-5">
<div className="rounded-xl border border-blue-200 bg-blue-50 p-4"><div className="text-xs font-semibold uppercase tracking-[0.1em] text-blue-700">Impact</div><p className="mt-1 text-sm leading-6 text-blue-950">{DISPLAY[selectedKey]?.impact ?? policy.description}</p></div>
<dl className="grid grid-cols-2 gap-3 text-sm"><div><dt className="text-xs text-slate-500">Current value</dt><dd className="mt-1 break-words font-mono font-semibold text-slate-900">{current ? valueText(current.value) : 'Runtime default'}</dd></div><div><dt className="text-xs text-slate-500">Last changed</dt><dd className="mt-1 text-slate-800">{current ? new Date(current.updatedAt).toLocaleString() : 'Never'}</dd></div></dl>
{!canWrite && <div role="note" className="rounded-xl border border-slate-200 bg-slate-50 p-3 text-sm leading-5 text-slate-700">Your <strong>{data.actor.role}</strong> role can view this setting but cannot change it. No editable-looking controls are shown.</div>}
{canWrite && <>
<label className="block"><span className="text-sm font-semibold text-slate-800">New value</span><span className="mt-0.5 block text-xs text-slate-500">{policy.type === 'enum' ? `Allowed: ${policy.options?.join(', ')}` : policy.type === 'boolean' ? 'Choose true or false.' : `Expected type: ${policy.type}${policy.min !== undefined ? ` · ${policy.min}–${policy.max}` : ''}`}</span>{policy.type === 'boolean' || policy.type === 'enum' ? <select value={rawValue} onChange={(event) => { setRawValue(event.target.value); setNotice(null); }} className="mt-2 w-full rounded-xl border border-slate-300 bg-white px-3 py-2.5 text-sm outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"><option value="">Select a value</option>{(policy.type === 'boolean' ? ['true', 'false'] : policy.options ?? []).map((option) => <option key={option} value={option}>{option}</option>)}</select> : <input value={rawValue} onChange={(event) => { setRawValue(event.target.value); setNotice(null); }} inputMode={policy.type === 'number' || policy.type === 'integer' ? 'decimal' : 'text'} className="mt-2 w-full rounded-xl border border-slate-300 px-3 py-2.5 text-sm outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200" />}{validationError && <span role="alert" className="mt-1.5 block text-xs font-medium text-rose-700">{validationError}</span>}</label>
<label className="block"><span className="text-sm font-semibold text-slate-800">Reason for change</span><span className="mt-0.5 block text-xs text-slate-500">Required and recorded in the audit log.</span><textarea rows={3} value={reason} onChange={(event) => setReason(event.target.value)} className="mt-2 w-full resize-y rounded-xl border border-slate-300 px-3 py-2.5 text-sm outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200" placeholder="What operational need does this change address?" /></label>
{policy.securitySensitive && <label className="block"><span className="text-sm font-semibold text-slate-800">Approval token</span><span className="mt-0.5 block text-xs text-slate-500">Required by the harness approval gate.</span><input type="password" autoComplete="off" value={approval} onChange={(event) => setApproval(event.target.value)} className="mt-2 w-full rounded-xl border border-amber-300 bg-amber-50/50 px-3 py-2.5 text-sm outline-none focus:border-amber-500 focus:ring-2 focus:ring-amber-200" /></label>}
</>}
{notice && <div role={notice.tone === 'error' ? 'alert' : 'status'} className={`rounded-xl border p-3 text-sm leading-5 ${notice.tone === 'success' ? 'border-emerald-200 bg-emerald-50 text-emerald-800' : 'border-rose-200 bg-rose-50 text-rose-800'}`}>{notice.text}</div>}
<div><div className="flex items-center justify-between"><h3 className="text-xs font-semibold uppercase tracking-[0.12em] text-slate-500">Change history</h3><span className="text-xs text-slate-400">{audit.length} events</span></div><div className="mt-2 max-h-52 space-y-2 overflow-auto">{audit.map((entry) => <div key={`${entry.seq}-${entry.hash}`} className="rounded-xl border border-slate-200 p-3"><div className="flex justify-between gap-2 text-xs"><span className="font-semibold capitalize text-slate-800">{entry.action} · v{entry.seq}</span><time className="text-slate-500">{new Date(entry.at).toLocaleString()}</time></div><p className="mt-1 text-xs text-slate-600">{entry.reason}</p><p className="mt-1 text-[11px] text-slate-400">{entry.actor} · {entry.hash.slice(0, 10)}</p></div>)}{audit.length === 0 && <p className="rounded-xl bg-slate-50 p-3 text-xs text-slate-500">No changes have been recorded for this setting.</p>}</div></div>
</div>
</div>
</div>
</Card>
</>
</div> : <div className="rounded-2xl border border-slate-200 bg-white p-6 text-sm text-slate-500">Select a setting to inspect it.</div>}
</aside>
</div>
{canWrite && <div className="fixed inset-x-0 bottom-20 z-20 border-t border-slate-200 bg-white/95 px-4 py-3 shadow-[0_-12px_30px_rgba(15,23,42,0.08)] backdrop-blur lg:bottom-0 lg:left-[252px]"><div className="mx-auto flex max-w-7xl flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"><div><div className={`text-sm font-semibold ${dirty ? 'text-amber-800' : 'text-slate-700'}`}>{dirty ? 'Unsaved changes' : 'No unsaved changes'}</div><div className="text-xs text-slate-500">{dirty ? `New value: ${rawValue || 'empty'}` : 'Select a setting and change its value to enable Save.'}</div></div><div className="flex flex-wrap gap-2"><button type="button" onClick={discard} disabled={!dirty} className="rounded-lg border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 transition hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:cursor-not-allowed disabled:opacity-40">Discard</button><button type="button" onClick={() => setConfirmAction('rollback')} disabled={rollbackDisabled} title={audit.length < 2 ? 'Rollback requires a prior version.' : undefined} className="rounded-lg border border-amber-300 px-4 py-2 text-sm font-semibold text-amber-800 transition hover:bg-amber-50 focus:outline-none focus:ring-2 focus:ring-amber-500 disabled:cursor-not-allowed disabled:opacity-40">Rollback</button><button type="button" onClick={() => policy?.securitySensitive ? setConfirmAction('save') : saveMutation.mutate()} disabled={saveDisabled} className="rounded-lg bg-indigo-600 px-5 py-2 text-sm font-semibold text-white transition hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:bg-slate-300">{saveMutation.isPending ? 'Saving…' : 'Save changes'}</button></div></div></div>}
{confirmAction && <div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/50 p-4" role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget) setConfirmAction(null); }}><div role="alertdialog" aria-modal="true" aria-labelledby="confirm-title" aria-describedby="confirm-description" className="w-full max-w-md rounded-2xl bg-white p-6 shadow-2xl"><div className="inline-flex rounded-full border border-amber-200 bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-800">High-impact action</div><h2 id="confirm-title" className="mt-4 text-xl font-semibold text-slate-950">{confirmAction === 'rollback' ? 'Rollback this setting?' : 'Apply this security-sensitive change?'}</h2><p id="confirm-description" className="mt-2 text-sm leading-6 text-slate-600">{confirmAction === 'rollback' ? 'CASAN will restore the immediately previous value and record a new audited version. This does not erase history.' : DISPLAY[selectedKey]?.impact}</p><div className="mt-4 rounded-xl bg-slate-50 p-3 text-sm"><span className="text-slate-500">Setting</span><div className="mt-1 font-semibold text-slate-900">{DISPLAY[selectedKey]?.name}</div></div><div className="mt-6 flex justify-end gap-2"><button autoFocus type="button" onClick={() => setConfirmAction(null)} className="rounded-lg border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 focus:outline-none focus:ring-2 focus:ring-indigo-500">Cancel</button><button type="button" onClick={() => confirmAction === 'rollback' ? rollbackMutation.mutate() : saveMutation.mutate()} className="rounded-lg bg-amber-600 px-4 py-2 text-sm font-semibold text-white hover:bg-amber-700 focus:outline-none focus:ring-2 focus:ring-amber-500">Confirm {confirmAction}</button></div></div></div>}
</div>
);
}