fix template, remove okr, use casan.*
This commit is contained in:
@@ -1,10 +1,18 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { api, SettingsActor } from '../lib/api';
|
||||
import { api, type ApprovalProposal, type SettingsActor } from '../lib/api';
|
||||
import { Card, StatusBadge } from '../components/ui/Card';
|
||||
|
||||
const ROLES = ['viewer', 'project-admin', 'approver', 'org-admin', 'auditor'];
|
||||
const STATUS = ['pending', 'approved', 'rejected', 'auto_allowed', 'all'];
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'pending', label: 'Needs review' },
|
||||
{ value: 'approved', label: 'Approved' },
|
||||
{ value: 'rejected', label: 'Rejected' },
|
||||
{ value: 'auto_allowed', label: 'Auto allowed' },
|
||||
{ value: 'all', label: 'All decisions' },
|
||||
] as const;
|
||||
|
||||
type ApprovalStatus = (typeof STATUS_OPTIONS)[number]['value'];
|
||||
type Notice = { tone: 'success' | 'error'; text: string };
|
||||
|
||||
function parseValue(raw: string): unknown {
|
||||
try {
|
||||
@@ -14,166 +22,239 @@ function parseValue(raw: string): unknown {
|
||||
}
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown, fallback: string): string {
|
||||
if (typeof error === 'object' && error !== null) {
|
||||
const candidate = error as { message?: string; response?: { data?: { message?: string } } };
|
||||
return candidate.response?.data?.message || candidate.message || fallback;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function formatTimestamp(value: string): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(date);
|
||||
}
|
||||
|
||||
function canReview(actor: SettingsActor): boolean {
|
||||
return actor.role === 'approver' || actor.role === 'org-admin';
|
||||
}
|
||||
|
||||
function canSubmitSettings(actor: SettingsActor): boolean {
|
||||
return actor.role === 'project-admin' || actor.role === 'org-admin';
|
||||
}
|
||||
|
||||
function reviewEligibility(actor: SettingsActor, proposal: ApprovalProposal): { allowed: boolean; reason: string } {
|
||||
if (proposal.status !== 'pending') return { allowed: false, reason: 'Decision recorded' };
|
||||
if (!canReview(actor)) return { allowed: false, reason: 'Independent Reviewer access required' };
|
||||
if (proposal.proposer === actor.actor) return { allowed: false, reason: 'A separate reviewer must decide' };
|
||||
return { allowed: true, reason: 'Ready for independent review' };
|
||||
}
|
||||
|
||||
export function Approvals() {
|
||||
const queryClient = useQueryClient();
|
||||
const [actor, setActor] = useState<SettingsActor>({ actor: 'alice', role: 'project-admin', project: 'default', tenant: 'default' });
|
||||
const [status, setStatus] = useState('pending');
|
||||
const [key, setKey] = useState('security.strict');
|
||||
const [value, setValue] = useState('true');
|
||||
const [status, setStatus] = useState<ApprovalStatus>('pending');
|
||||
const [decisionReason, setDecisionReason] = useState('Reviewed scope, evidence and execution controls');
|
||||
const [settingKey, setSettingKey] = useState('security.strict');
|
||||
const [settingValue, setSettingValue] = useState('true');
|
||||
const [sensitive, setSensitive] = useState(true);
|
||||
const [reason, setReason] = useState('review requested from Control Panel');
|
||||
const [decisionReason, setDecisionReason] = useState('reviewed in approval inbox');
|
||||
const [approvalJwt, setApprovalJwt] = useState('');
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [requestReason, setRequestReason] = useState('Security-sensitive setting change requested');
|
||||
const [notice, setNotice] = useState<Notice | null>(null);
|
||||
|
||||
const session = useQuery({
|
||||
queryKey: ['session'],
|
||||
queryFn: api.session,
|
||||
staleTime: 0,
|
||||
refetchOnMount: 'always',
|
||||
retry: false,
|
||||
});
|
||||
const actor = session.data;
|
||||
|
||||
const inbox = useQuery({
|
||||
queryKey: ['approvals', actor, status],
|
||||
queryFn: () => api.approvals(actor, status),
|
||||
queryKey: ['approvals', actor?.actor, actor?.role, actor?.project, actor?.tenant, status],
|
||||
queryFn: () => api.approvals(actor as SettingsActor, status),
|
||||
enabled: Boolean(actor),
|
||||
retry: false,
|
||||
refetchOnMount: 'always',
|
||||
});
|
||||
|
||||
const submit = useMutation({
|
||||
mutationFn: () => api.submitApproval(actor, {
|
||||
action: 'settings.write',
|
||||
target: key,
|
||||
risk: sensitive ? 'high' : 'standard',
|
||||
sensitive,
|
||||
reason,
|
||||
payload: { key, value: parseValue(value) },
|
||||
}),
|
||||
onSuccess: (res) => {
|
||||
setMessage(`submitted ${res.proposal.id} status=${res.proposal.status}`);
|
||||
mutationFn: () => {
|
||||
if (!actor) throw new Error('Authenticated session is unavailable.');
|
||||
return api.submitApproval(actor, {
|
||||
action: 'settings.write',
|
||||
target: settingKey.trim(),
|
||||
risk: sensitive ? 'high' : 'standard',
|
||||
sensitive,
|
||||
reason: requestReason.trim(),
|
||||
payload: { key: settingKey.trim(), value: parseValue(settingValue) },
|
||||
});
|
||||
},
|
||||
onSuccess: (response) => {
|
||||
setNotice({ tone: 'success', text: `Request ${response.proposal.id} is ready for independent review.` });
|
||||
setStatus('pending');
|
||||
void queryClient.invalidateQueries({ queryKey: ['approvals'] });
|
||||
},
|
||||
onError: (err: any) => setMessage(err?.response?.data?.message || err.message || 'submit failed'),
|
||||
onError: (error) => setNotice({ tone: 'error', text: errorMessage(error, 'The change request could not be submitted.') }),
|
||||
});
|
||||
|
||||
const decide = useMutation({
|
||||
mutationFn: ({ id, decision }: { id: string; decision: 'approve' | 'reject' }) =>
|
||||
api.decideApproval(actor, { id, decision, reason: decisionReason, approvalJwt: approvalJwt || undefined }),
|
||||
onSuccess: (res) => {
|
||||
setMessage(`${res.proposal.status} ${res.proposal.id}${res.applied ? ` applied v${res.applied.version}` : ''}`);
|
||||
mutationFn: ({ id, decision }: { id: string; decision: 'approve' | 'reject' }) => {
|
||||
if (!actor) throw new Error('Authenticated session is unavailable.');
|
||||
return api.decideApproval(actor, { id, decision, reason: decisionReason.trim() });
|
||||
},
|
||||
onSuccess: (response) => {
|
||||
const verb = response.proposal.status === 'approved' ? 'approved' : 'rejected';
|
||||
setNotice({ tone: 'success', text: `Request ${response.proposal.id} was ${verb}.` });
|
||||
void queryClient.invalidateQueries({ queryKey: ['approvals'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['settings'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['goals'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['goal'] });
|
||||
},
|
||||
onError: (err: any) => setMessage(err?.response?.data?.message || err.message || 'decision failed'),
|
||||
onError: (error) => setNotice({ tone: 'error', text: errorMessage(error, 'The approval decision could not be recorded.') }),
|
||||
});
|
||||
|
||||
if (inbox.isLoading || !inbox.data) return <div className="text-gray-500">Loading…</div>;
|
||||
if (session.isLoading || (actor && inbox.isLoading)) {
|
||||
return (
|
||||
<div className="space-y-4" aria-label="Loading approval inbox">
|
||||
<div className="h-36 animate-pulse rounded-3xl bg-slate-200 motion-reduce:animate-none" />
|
||||
<div className="h-72 animate-pulse rounded-3xl bg-slate-100 motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (session.isError || !actor) {
|
||||
return (
|
||||
<div role="alert" className="rounded-2xl border border-rose-200 bg-rose-50 p-5 text-sm text-rose-800">
|
||||
<div className="font-semibold">Your authenticated session could not be loaded.</div>
|
||||
<p className="mt-1">Sign in again, then reopen the approval inbox.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (inbox.isError || !inbox.data) {
|
||||
return (
|
||||
<div role="alert" className="rounded-2xl border border-rose-200 bg-rose-50 p-5 text-sm text-rose-800">
|
||||
<div className="font-semibold">The approval inbox is unavailable for this session.</div>
|
||||
<p className="mt-1">{errorMessage(inbox.error, 'Verify that this identity has monitoring access.')}</p>
|
||||
<button type="button" onClick={() => void inbox.refetch()} className="mt-4 rounded-lg bg-rose-700 px-4 py-2 text-xs font-semibold text-white transition hover:bg-rose-800 focus:outline-none focus:ring-4 focus:ring-rose-200">Try again</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const pendingCount = status === 'pending' ? inbox.data.count : inbox.data.proposals.filter((proposal) => proposal.status === 'pending').length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card title="Approval identity" right={<StatusBadge value={inbox.data.audit_verify.ok ? 'audit ok' : 'audit fail'} />}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 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>
|
||||
<label className="space-y-1">
|
||||
<span className="text-gray-500">Status</span>
|
||||
<select className="w-full rounded border border-gray-300 px-3 py-2" value={status} onChange={(e) => setStatus(e.target.value)}>
|
||||
{STATUS.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Submit settings proposal">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
|
||||
<label className="space-y-1">
|
||||
<span className="text-gray-500">Setting key</span>
|
||||
<input className="w-full rounded border border-gray-300 px-3 py-2" value={key} onChange={(e) => setKey(e.target.value)} />
|
||||
</label>
|
||||
<label className="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={value} onChange={(e) => setValue(e.target.value)} />
|
||||
</label>
|
||||
<label className="space-y-1 md:col-span-2">
|
||||
<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="flex items-center gap-2">
|
||||
<input type="checkbox" checked={sensitive} onChange={(e) => setSensitive(e.target.checked)} />
|
||||
<span className="text-gray-700">Security-sensitive / high risk</span>
|
||||
</label>
|
||||
<div>
|
||||
<button className="rounded bg-blue-600 px-4 py-2 text-white disabled:bg-gray-300"
|
||||
disabled={submit.isPending} onClick={() => submit.mutate()}>
|
||||
Submit proposal
|
||||
</button>
|
||||
<div className="space-y-5 pb-6">
|
||||
<header className="overflow-hidden rounded-[1.6rem] border border-slate-200 bg-slate-950 px-6 py-7 text-white shadow-[0_22px_55px_rgba(15,23,42,0.16)] sm:px-8">
|
||||
<div className="flex flex-wrap items-end justify-between gap-6">
|
||||
<div className="max-w-2xl">
|
||||
<p className="text-[10px] font-bold uppercase tracking-[0.2em] text-teal-300">Independent control</p>
|
||||
<h1 className="mt-2 text-3xl font-semibold tracking-[-0.035em]">Approval inbox</h1>
|
||||
<p className="mt-3 text-sm leading-6 text-slate-300">Review governed changes using the identity from your authenticated session. Proposers cannot approve their own request.</p>
|
||||
</div>
|
||||
<div className="grid min-w-72 grid-cols-2 gap-px overflow-hidden rounded-xl border border-white/10 bg-white/10 text-center">
|
||||
<div className="bg-slate-900 px-4 py-3"><div className="font-mono text-2xl font-semibold text-amber-300">{pendingCount}</div><div className="text-[9px] font-bold uppercase tracking-wide text-slate-500">Needs review</div></div>
|
||||
<div className="bg-slate-900 px-4 py-3"><div className="truncate text-sm font-semibold text-white">{actor.actor}</div><div className="mt-1 text-[9px] font-bold uppercase tracking-wide text-teal-300">{actor.role}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
{message && <div className="mt-3 rounded border border-gray-200 bg-gray-50 p-3 text-sm text-gray-700">{message}</div>}
|
||||
</Card>
|
||||
</header>
|
||||
|
||||
<Card title="Inbox">
|
||||
<div className="space-y-3">
|
||||
<label className="block space-y-1 text-sm">
|
||||
<span className="text-gray-500">Decision reason</span>
|
||||
<input className="w-full rounded border border-gray-300 px-3 py-2" value={decisionReason} onChange={(e) => setDecisionReason(e.target.value)} />
|
||||
<Card
|
||||
title="Review queue"
|
||||
right={<div className="flex items-center gap-2"><StatusBadge value={inbox.data.audit_verify.ok ? 'audit ok' : 'audit fail'} /><span className="hidden text-xs text-slate-400 sm:inline">{actor.tenant} / {actor.project}</span></div>}
|
||||
>
|
||||
<div className="flex flex-wrap items-end justify-between gap-4 border-b border-slate-100 pb-5">
|
||||
<label className="block min-w-52 text-sm">
|
||||
<span className="font-semibold text-slate-700">Queue</span>
|
||||
<select value={status} onChange={(event) => setStatus(event.target.value as ApprovalStatus)} className="mt-2 w-full rounded-xl border border-slate-200 bg-white px-3 py-2.5 text-sm text-slate-800 outline-none transition hover:border-slate-300 focus:border-teal-500 focus:ring-4 focus:ring-teal-100">
|
||||
{STATUS_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block space-y-1 text-sm">
|
||||
<span className="text-gray-500">Approval JWT</span>
|
||||
<input className="w-full rounded border border-gray-300 px-3 py-2 font-mono text-xs" value={approvalJwt} onChange={(e) => setApprovalJwt(e.target.value)} />
|
||||
</label>
|
||||
{inbox.data.proposals.map((p) => (
|
||||
<div key={p.id} className="rounded border border-gray-200 p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-medium text-gray-800">{p.id} · {p.action} · {p.target}</div>
|
||||
<div className="text-xs text-gray-500">{p.proposer} · {p.created_at} · {p.delegation?.level} · {p.delegation?.reason}</div>
|
||||
{canReview(actor) && (
|
||||
<label className="block min-w-[280px] flex-1 text-sm sm:max-w-xl">
|
||||
<span className="font-semibold text-slate-700">Decision rationale</span>
|
||||
<input value={decisionReason} onChange={(event) => setDecisionReason(event.target.value)} maxLength={500} className="mt-2 w-full rounded-xl border border-slate-200 px-3 py-2.5 text-sm text-slate-800 outline-none transition hover:border-slate-300 focus:border-teal-500 focus:ring-4 focus:ring-teal-100" />
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{notice && <div aria-live="polite" className={`mt-4 rounded-xl border p-3 text-sm font-medium ${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 className="mt-5 space-y-3">
|
||||
{inbox.data.proposals.map((proposal) => {
|
||||
const eligibility = reviewEligibility(actor, proposal);
|
||||
const isCurrentDecision = decide.isPending && decide.variables?.id === proposal.id;
|
||||
return (
|
||||
<article key={proposal.id} className="rounded-2xl border border-slate-200 bg-white p-4 transition hover:border-slate-300 hover:shadow-[0_12px_28px_rgba(15,23,42,0.055)] sm:p-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2"><StatusBadge value={proposal.status} /><span className="font-mono text-[10px] text-slate-400">{proposal.id}</span>{proposal.risk && <span className="rounded-full bg-slate-100 px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-slate-500">{proposal.risk} risk</span>}</div>
|
||||
<h2 className="mt-3 break-words text-base font-semibold text-slate-950">{proposal.action}</h2>
|
||||
<p className="mt-1 break-words text-sm text-slate-600">{proposal.target}</p>
|
||||
</div>
|
||||
<div className="text-right text-xs text-slate-400"><div>{formatTimestamp(proposal.created_at)}</div><div className="mt-1">Project <span className="font-mono text-slate-600">{proposal.project}</span></div></div>
|
||||
</div>
|
||||
<StatusBadge value={p.status} />
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600">{p.reason}</div>
|
||||
<pre className="mt-2 overflow-auto rounded bg-gray-50 p-2 text-xs text-gray-700">{JSON.stringify(p.payload, null, 2)}</pre>
|
||||
{p.status === 'pending' && (
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button className="rounded bg-green-600 px-3 py-2 text-sm text-white disabled:bg-gray-300"
|
||||
disabled={decide.isPending} onClick={() => decide.mutate({ id: p.id, decision: 'approve' })}>
|
||||
Approve
|
||||
</button>
|
||||
<button className="rounded border border-gray-300 px-3 py-2 text-sm text-gray-700 disabled:text-gray-300"
|
||||
disabled={decide.isPending} onClick={() => decide.mutate({ id: p.id, decision: 'reject' })}>
|
||||
Reject
|
||||
</button>
|
||||
|
||||
<div className="mt-4 grid gap-3 rounded-xl bg-slate-50 p-3 text-xs sm:grid-cols-[minmax(150px,0.35fr)_minmax(0,1fr)]">
|
||||
<div><div className="font-semibold uppercase tracking-wide text-slate-400">Requested by</div><div className="mt-1 font-mono text-slate-700">{proposal.proposer}</div></div>
|
||||
<div><div className="font-semibold uppercase tracking-wide text-slate-400">Reason</div><div className="mt-1 leading-5 text-slate-700">{proposal.reason}</div></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<details className="mt-3 rounded-xl border border-slate-200 bg-white">
|
||||
<summary className="cursor-pointer px-3 py-2.5 text-xs font-semibold text-slate-600 transition hover:bg-slate-50">Inspect request payload</summary>
|
||||
<pre className="max-h-72 overflow-auto border-t border-slate-200 bg-slate-950 p-4 text-xs leading-5 text-slate-100">{JSON.stringify(proposal.payload, null, 2)}</pre>
|
||||
</details>
|
||||
|
||||
{proposal.status === 'pending' && (
|
||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 border-t border-slate-100 pt-4">
|
||||
<span className={`text-xs font-medium ${eligibility.allowed ? 'text-emerald-700' : 'text-amber-700'}`}>{eligibility.reason}</span>
|
||||
{eligibility.allowed && (
|
||||
<div className="flex gap-2">
|
||||
<button type="button" disabled={decide.isPending || decisionReason.trim().length < 5} onClick={() => decide.mutate({ id: proposal.id, decision: 'reject' })} className="rounded-lg border border-rose-200 bg-white px-3.5 py-2 text-xs font-semibold text-rose-700 transition hover:bg-rose-50 focus:outline-none focus:ring-4 focus:ring-rose-100 disabled:cursor-not-allowed disabled:opacity-40">{isCurrentDecision && decide.variables?.decision === 'reject' ? 'Rejecting…' : 'Reject'}</button>
|
||||
<button type="button" disabled={decide.isPending || decisionReason.trim().length < 5} onClick={() => decide.mutate({ id: proposal.id, decision: 'approve' })} className="rounded-lg bg-emerald-700 px-4 py-2 text-xs font-semibold text-white transition hover:bg-emerald-800 focus:outline-none focus:ring-4 focus:ring-emerald-100 disabled:cursor-not-allowed disabled:bg-slate-300">{isCurrentDecision && decide.variables?.decision === 'approve' ? 'Approving…' : 'Approve'}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
|
||||
{inbox.data.proposals.length === 0 && (
|
||||
<div className="rounded-2xl border border-dashed border-slate-200 bg-slate-50 px-5 py-10 text-center">
|
||||
<div className="text-sm font-semibold text-slate-700">No requests in this queue</div>
|
||||
<p className="mt-1 text-xs text-slate-500">New governed requests will appear here for the authenticated reviewer.</p>
|
||||
</div>
|
||||
))}
|
||||
{inbox.data.proposals.length === 0 && <div className="text-sm text-gray-500">No proposals for this status.</div>}
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Oversight log">
|
||||
<div className="space-y-2 max-h-96 overflow-auto">
|
||||
{inbox.data.oversight.slice().reverse().map((o: any) => (
|
||||
<div key={`${o.seq}-${o.hash}`} className="rounded border border-gray-200 p-3 text-sm">
|
||||
<div className="font-medium text-gray-800">{o.event} · {o.proposal_id}</div>
|
||||
<div className="text-xs text-gray-500">{o.actor} · {o.at} · hash {String(o.hash).slice(0, 12)}</div>
|
||||
<div className="text-xs text-gray-600 mt-1">{o.reason}</div>
|
||||
{canSubmitSettings(actor) && (
|
||||
<details className="overflow-hidden rounded-2xl border border-slate-200 bg-white">
|
||||
<summary className="cursor-pointer px-5 py-4 text-sm font-semibold text-slate-700 transition hover:bg-slate-50">Create a settings change request</summary>
|
||||
<div className="grid gap-4 border-t border-slate-200 p-5 text-sm md:grid-cols-2">
|
||||
<label><span className="font-semibold text-slate-700">Setting key</span><input value={settingKey} onChange={(event) => setSettingKey(event.target.value)} className="mt-2 w-full rounded-xl border border-slate-200 px-3 py-2.5 outline-none focus:border-teal-500 focus:ring-4 focus:ring-teal-100" /></label>
|
||||
<label><span className="font-semibold text-slate-700">Value (JSON or text)</span><input value={settingValue} onChange={(event) => setSettingValue(event.target.value)} className="mt-2 w-full rounded-xl border border-slate-200 px-3 py-2.5 outline-none focus:border-teal-500 focus:ring-4 focus:ring-teal-100" /></label>
|
||||
<label className="md:col-span-2"><span className="font-semibold text-slate-700">Reason</span><input value={requestReason} onChange={(event) => setRequestReason(event.target.value)} maxLength={500} className="mt-2 w-full rounded-xl border border-slate-200 px-3 py-2.5 outline-none focus:border-teal-500 focus:ring-4 focus:ring-teal-100" /></label>
|
||||
<label className="flex items-center gap-2 text-slate-700"><input type="checkbox" checked={sensitive} onChange={(event) => setSensitive(event.target.checked)} className="h-4 w-4 rounded border-slate-300 text-teal-700 focus:ring-teal-500" />Security-sensitive / high risk</label>
|
||||
<div className="flex justify-end"><button type="button" disabled={submit.isPending || settingKey.trim().length === 0 || requestReason.trim().length < 5} onClick={() => submit.mutate()} className="rounded-xl bg-slate-950 px-4 py-2.5 text-xs font-semibold text-white transition hover:bg-teal-700 focus:outline-none focus:ring-4 focus:ring-teal-100 disabled:cursor-not-allowed disabled:bg-slate-300">{submit.isPending ? 'Submitting…' : 'Submit for review'}</button></div>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<details className="overflow-hidden rounded-2xl border border-slate-200 bg-white">
|
||||
<summary className="cursor-pointer px-5 py-4 text-sm font-semibold text-slate-700 transition hover:bg-slate-50">Oversight log · {inbox.data.oversight.length} records</summary>
|
||||
<div className="max-h-96 space-y-2 overflow-auto border-t border-slate-200 p-4">
|
||||
{inbox.data.oversight.slice().reverse().map((record) => (
|
||||
<div key={`${record.seq}-${record.hash ?? record.proposal_id}`} className="rounded-xl border border-slate-200 p-3 text-sm">
|
||||
<div className="font-medium text-slate-800">{record.event} · {record.proposal_id}</div>
|
||||
<div className="mt-1 text-xs text-slate-400">{record.actor} · {formatTimestamp(record.at)}{record.hash ? ` · hash ${record.hash.slice(0, 12)}` : ''}</div>
|
||||
{record.reason && <div className="mt-1 text-xs leading-5 text-slate-600">{record.reason}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { api, type GoalJob, type SettingsActor } from '../lib/api';
|
||||
import { api, type GoalJob, type GoalProject, 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 { GovernedOutcomePulse, ModelInteractionDiagram } from '../components/goals/ModelInteractionDiagram';
|
||||
import { RichTextGoalEditor } from '../components/goals/RichTextGoalEditor';
|
||||
|
||||
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']);
|
||||
const PROJECT_ID = /^[a-z][a-z0-9-]{1,62}$/;
|
||||
const PROJECT_NAME = /^[\p{L}\p{N}][\p{L}\p{N} .&()'_-]{1,99}$/u;
|
||||
|
||||
const EXAMPLES = [
|
||||
'Audit the authentication flow, identify production risks, and propose a verified remediation plan.',
|
||||
'Design a release plan with rollback, observability, security controls, and measurable acceptance criteria.',
|
||||
'Review the current architecture against the requirements and return a traceable implementation backlog.',
|
||||
];
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (typeof error === 'object' && error !== null) {
|
||||
@@ -18,28 +26,139 @@ function errorMessage(error: unknown): string {
|
||||
return 'Goal orchestration could not start.';
|
||||
}
|
||||
|
||||
function WorkerCard({ title, subtitle, status, detail, provider, model }: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
status: string;
|
||||
detail: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
function formatTimestamp(value: string): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(date);
|
||||
}
|
||||
|
||||
function WorkspacePanel({
|
||||
projects,
|
||||
selectedId,
|
||||
onSelect,
|
||||
loading,
|
||||
error,
|
||||
creating,
|
||||
onCreatingChange,
|
||||
newProjectId,
|
||||
newProjectDomain,
|
||||
onProjectIdChange,
|
||||
onProjectDomainChange,
|
||||
onCreate,
|
||||
createPending,
|
||||
createError,
|
||||
}: {
|
||||
projects: GoalProject[];
|
||||
selectedId: string;
|
||||
onSelect: (value: string) => void;
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
creating: boolean;
|
||||
onCreatingChange: (value: boolean) => void;
|
||||
newProjectId: string;
|
||||
newProjectDomain: string;
|
||||
onProjectIdChange: (value: string) => void;
|
||||
onProjectDomainChange: (value: string) => void;
|
||||
onCreate: () => void;
|
||||
createPending: boolean;
|
||||
createError: unknown;
|
||||
}) {
|
||||
const busy = status === 'running';
|
||||
const selected = projects.find((project) => project.project_id === selectedId);
|
||||
const validProject = PROJECT_ID.test(newProjectId.trim()) && PROJECT_NAME.test(newProjectDomain.trim());
|
||||
return (
|
||||
<div className="relative overflow-hidden rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||
{busy && <div className="absolute inset-x-0 top-0 h-0.5 overflow-hidden bg-blue-100"><div className="h-full w-1/3 animate-pulse rounded-full bg-blue-500" /></div>}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div><h3 className="font-semibold text-slate-900">{title}</h3><p className="mt-1 text-xs text-slate-500">{subtitle}</p></div>
|
||||
<StatusBadge value={status} />
|
||||
<aside className="overflow-hidden rounded-[1.35rem] border border-slate-200 bg-slate-950 text-white shadow-[0_22px_55px_rgba(15,23,42,0.16)]">
|
||||
<div className="border-b border-white/10 px-5 pb-5 pt-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-[10px] font-bold uppercase tracking-[0.18em] text-teal-300">Bounded workspace</p>
|
||||
<h2 className="mt-1 text-base font-semibold tracking-tight">Choose the evidence boundary</h2>
|
||||
</div>
|
||||
<span className="rounded-lg bg-white/10 px-2 py-1 font-mono text-[10px] text-slate-300">{projects.length} active</span>
|
||||
</div>
|
||||
<p className="mt-2 max-w-[45ch] text-xs leading-5 text-slate-400">Models receive a redacted snapshot of the selected allowlisted roots. They never receive direct filesystem access.</p>
|
||||
</div>
|
||||
<p className="mt-4 min-h-10 text-sm leading-5 text-slate-600">{detail}</p>
|
||||
<div className="mt-4 border-t border-slate-100 pt-3 text-[11px] text-slate-400">
|
||||
<div className="font-medium text-slate-500">{provider || 'not selected'}</div>
|
||||
<div className="mt-1 break-all font-mono">{model || 'model unavailable'}</div>
|
||||
|
||||
<div className="p-5">
|
||||
<label htmlFor="goal-project" className="text-xs font-semibold text-slate-200">Project</label>
|
||||
<select
|
||||
id="goal-project"
|
||||
value={selectedId}
|
||||
onChange={(event) => onSelect(event.target.value)}
|
||||
disabled={loading || projects.length === 0}
|
||||
className="mt-2 w-full rounded-xl border border-white/15 bg-slate-900 px-3.5 py-3 text-sm text-white outline-none transition hover:border-white/25 focus:border-teal-400 focus:ring-4 focus:ring-teal-400/10 disabled:opacity-50"
|
||||
>
|
||||
{projects.map((project) => <option key={project.project_id} value={project.project_id}>{project.domain} · {project.project_id}</option>)}
|
||||
</select>
|
||||
{error && <p role="alert" className="mt-2 text-xs font-medium text-rose-300">The allowlisted project registry is unavailable.</p>}
|
||||
|
||||
{selected && (
|
||||
<div className="mt-4 space-y-3 rounded-xl border border-white/10 bg-white/[0.045] p-3.5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-xs font-semibold text-white">{selected.domain}</span>
|
||||
<span className="flex items-center gap-1.5 text-[10px] font-semibold text-emerald-300"><i className="h-1.5 w-1.5 rounded-full bg-emerald-400" />Ready</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] font-bold uppercase tracking-wide text-slate-500">Context roots</div>
|
||||
<div className="mt-1 break-all font-mono text-[10px] leading-5 text-slate-300">{selected.context_roots.join(', ')}</div>
|
||||
</div>
|
||||
{selected.manifest && <div className="break-all border-t border-white/10 pt-2 font-mono text-[10px] leading-5 text-slate-500">{selected.manifest}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCreatingChange(!creating)}
|
||||
className="mt-4 flex w-full items-center justify-between rounded-xl border border-dashed border-slate-600 px-3.5 py-2.5 text-left text-xs font-semibold text-slate-300 transition hover:border-teal-400/70 hover:bg-teal-400/5 hover:text-white focus:outline-none focus:ring-2 focus:ring-teal-400"
|
||||
aria-expanded={creating}
|
||||
>
|
||||
<span>{creating ? 'Close project setup' : 'Create production project shell'}</span>
|
||||
<span aria-hidden="true" className="text-lg font-light leading-none">{creating ? '−' : '+'}</span>
|
||||
</button>
|
||||
|
||||
{creating && (
|
||||
<div className="mt-4 space-y-3 border-t border-white/10 pt-4">
|
||||
<div className="rounded-xl bg-teal-400/10 p-3 text-[11px] leading-5 text-teal-100">
|
||||
Creates an isolated NestJS + React shell, manifest, quality profile, harness, CI workflow and tests under <span className="font-mono">apps/projects/<id></span>. The repository-level <span className="font-mono">.github</span> remains unchanged.
|
||||
</div>
|
||||
<label className="block">
|
||||
<span className="text-[11px] font-semibold text-slate-300">Project ID</span>
|
||||
<input value={newProjectId} onChange={(event) => onProjectIdChange(event.target.value.toLowerCase())} placeholder="customer-portal" maxLength={63} className="mt-1.5 w-full rounded-xl border border-white/15 bg-slate-900 px-3 py-2.5 text-sm text-white outline-none placeholder:text-slate-600 focus:border-teal-400 focus:ring-4 focus:ring-teal-400/10" />
|
||||
{newProjectId && !PROJECT_ID.test(newProjectId.trim()) && <span className="mt-1 block text-[10px] text-amber-300">Use lowercase letters, numbers and hyphens.</span>}
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-[11px] font-semibold text-slate-300">Display name</span>
|
||||
<input value={newProjectDomain} onChange={(event) => onProjectDomainChange(event.target.value)} placeholder="Customer Portal" maxLength={100} className="mt-1.5 w-full rounded-xl border border-white/15 bg-slate-900 px-3 py-2.5 text-sm text-white outline-none placeholder:text-slate-600 focus:border-teal-400 focus:ring-4 focus:ring-teal-400/10" />
|
||||
{newProjectDomain && !PROJECT_NAME.test(newProjectDomain.trim()) && <span className="mt-1 block text-[10px] text-amber-300">Use letters, numbers, spaces and common name punctuation.</span>}
|
||||
</label>
|
||||
<button type="button" disabled={!validProject || createPending} onClick={onCreate} className="w-full rounded-xl bg-teal-500 px-4 py-2.5 text-sm font-semibold text-slate-950 transition hover:bg-teal-400 active:translate-y-px disabled:cursor-not-allowed disabled:bg-slate-700 disabled:text-slate-500">
|
||||
{createPending ? 'Building governed shell…' : 'Create and select project'}
|
||||
</button>
|
||||
{Boolean(createError) && <div role="alert" className="rounded-xl border border-rose-400/25 bg-rose-400/10 p-3 text-xs font-medium text-rose-200">{errorMessage(createError)}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function RecentGoals({ jobs, selectedId, loading, onSelect }: { jobs: GoalJob[]; selectedId: string; loading: boolean; onSelect: (id: string) => void }) {
|
||||
return (
|
||||
<section className="rounded-[1.35rem] border border-slate-200 bg-white p-4 shadow-[0_14px_36px_rgba(15,23,42,0.045)]" aria-labelledby="recent-goals-heading">
|
||||
<div className="flex items-center justify-between gap-3 px-1">
|
||||
<h2 id="recent-goals-heading" className="text-sm font-semibold text-slate-900">Recent objectives</h2>
|
||||
<span className="font-mono text-[10px] text-slate-400">{jobs.length.toString().padStart(2, '0')}</span>
|
||||
</div>
|
||||
<div className="mt-3 space-y-1.5">
|
||||
{jobs.slice(0, 8).map((item) => (
|
||||
<button key={item.id} type="button" onClick={() => onSelect(item.id)} className={`group w-full rounded-xl px-3 py-3 text-left transition focus:outline-none focus:ring-2 focus:ring-teal-500 ${selectedId === item.id ? 'bg-teal-50 shadow-[inset_3px_0_0_#0f766e]' : 'hover:bg-slate-50'}`}>
|
||||
<div className="line-clamp-2 text-xs font-medium leading-5 text-slate-700 group-hover:text-slate-950">{item.goal.replace(/[#*_`>\[\]]/g, '').slice(0, 135)}</div>
|
||||
<div className="mt-2 flex items-center justify-between gap-2"><span className="text-[10px] text-slate-400">{formatTimestamp(item.created_at)}</span><StatusBadge value={item.status} /></div>
|
||||
</button>
|
||||
))}
|
||||
{!loading && jobs.length === 0 && <div className="rounded-xl bg-slate-50 px-4 py-6 text-center"><p className="text-sm font-medium text-slate-700">No objectives yet</p><p className="mt-1 text-xs leading-5 text-slate-400">Your first governed run will appear here.</p></div>}
|
||||
{loading && <div className="space-y-2" aria-label="Loading recent objectives">{[0, 1, 2].map((item) => <div key={item} className="h-16 animate-pulse rounded-xl bg-slate-100 motion-reduce:animate-none" />)}</div>}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,21 +168,30 @@ export function Goals() {
|
||||
const [creatingProject, setCreatingProject] = useState(false);
|
||||
const [newProjectId, setNewProjectId] = useState('');
|
||||
const [newProjectDomain, setNewProjectDomain] = useState('');
|
||||
const [approver, setApprover] = useState('goal-reviewer');
|
||||
const [approvalReason, setApprovalReason] = useState('Reviewed patch scope and verification plan');
|
||||
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 sessionQuery = useQuery({ queryKey: ['session'], queryFn: api.session, staleTime: 0, refetchOnMount: 'always', retry: false });
|
||||
const actor = sessionQuery.data;
|
||||
|
||||
const projectsQuery = useQuery({
|
||||
queryKey: ['goal-projects', actor?.actor, actor?.role, actor?.tenant],
|
||||
queryFn: () => api.goalProjects(actor as SettingsActor),
|
||||
enabled: Boolean(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 listQuery = useQuery({
|
||||
queryKey: ['goals', actor?.actor, actor?.role, actor?.tenant],
|
||||
queryFn: () => api.goals(actor as SettingsActor, 20),
|
||||
enabled: Boolean(actor),
|
||||
});
|
||||
const selectedQuery = useQuery({
|
||||
queryKey: ['goal', actor, selectedId],
|
||||
queryFn: () => api.goal(actor, selectedId),
|
||||
enabled: Boolean(selectedId),
|
||||
queryKey: ['goal', actor?.actor, actor?.role, actor?.tenant, selectedId],
|
||||
queryFn: () => api.goal(actor as SettingsActor, selectedId),
|
||||
enabled: Boolean(actor && selectedId),
|
||||
refetchInterval: (query) => {
|
||||
const current = query.state.data as GoalJob | undefined;
|
||||
return current && TERMINAL.has(current.status) ? false : 1500;
|
||||
@@ -76,16 +204,22 @@ export function Goals() {
|
||||
refetchInterval: (query) => query.state.data?.terminal ? false : 1200,
|
||||
});
|
||||
const start = useMutation({
|
||||
mutationFn: () => api.startGoal({ ...actor, project: effectiveProject }, goal.trim(), effectiveProject),
|
||||
mutationFn: () => {
|
||||
if (!actor) throw new Error('Authenticated session is unavailable.');
|
||||
return api.startGoal({ ...actor, project: effectiveProject }, goal.trim(), effectiveProject);
|
||||
},
|
||||
onSuccess: (job) => {
|
||||
setSearchParams({ id: job.id });
|
||||
setGoal('');
|
||||
queryClient.setQueryData(['goal', actor, job.id], job);
|
||||
queryClient.setQueryData(['goal', actor?.actor, actor?.role, actor?.tenant, job.id], job);
|
||||
void queryClient.invalidateQueries({ queryKey: ['goals'] });
|
||||
},
|
||||
});
|
||||
const createProject = useMutation({
|
||||
mutationFn: () => api.createGoalProject(actor, { projectId: newProjectId.trim(), domain: newProjectDomain.trim() }),
|
||||
mutationFn: () => {
|
||||
if (!actor) throw new Error('Authenticated session is unavailable.');
|
||||
return api.createGoalProject(actor, { projectId: newProjectId.trim(), domain: newProjectDomain.trim() });
|
||||
},
|
||||
onSuccess: (project) => {
|
||||
setProjectId(project.project_id);
|
||||
setNewProjectId('');
|
||||
@@ -96,21 +230,25 @@ export function Goals() {
|
||||
});
|
||||
const approveAndApply = useMutation({
|
||||
mutationFn: async (job: GoalJob) => {
|
||||
if (!actor) throw new Error('Authenticated session is unavailable.');
|
||||
if (!job.approval) throw new Error('Approval proposal is unavailable.');
|
||||
const reviewer: SettingsActor = { actor: approver.trim(), role: 'org-admin', project: job.project, tenant: job.tenant };
|
||||
const reviewer: SettingsActor = { ...actor, project: job.project, tenant: job.tenant };
|
||||
await api.decideApproval(reviewer, { id: job.approval.id, decision: 'approve', reason: approvalReason.trim() });
|
||||
return api.applyGoal(reviewer, job.id);
|
||||
},
|
||||
onSuccess: (job) => {
|
||||
queryClient.setQueryData(['goal', actor, job.id], job);
|
||||
queryClient.setQueryData(['goal', actor?.actor, actor?.role, actor?.tenant, job.id], job);
|
||||
void queryClient.invalidateQueries({ queryKey: ['goals'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['approvals'] });
|
||||
},
|
||||
});
|
||||
const retryApply = useMutation({
|
||||
mutationFn: (job: GoalJob) => api.applyGoal({ actor: approver.trim(), role: 'org-admin', project: job.project, tenant: job.tenant }, job.id),
|
||||
mutationFn: (job: GoalJob) => {
|
||||
if (!actor) throw new Error('Authenticated session is unavailable.');
|
||||
return api.applyGoal({ ...actor, project: job.project, tenant: job.tenant }, job.id);
|
||||
},
|
||||
onSuccess: (job) => {
|
||||
queryClient.setQueryData(['goal', actor, job.id], job);
|
||||
queryClient.setQueryData(['goal', actor?.actor, actor?.role, actor?.tenant, job.id], job);
|
||||
void queryClient.invalidateQueries({ queryKey: ['goals'] });
|
||||
},
|
||||
});
|
||||
@@ -118,111 +256,155 @@ export function Goals() {
|
||||
const selected = selectedQuery.data;
|
||||
const localStage = selected?.stages.find((stage) => stage.id === 'local-worker');
|
||||
const cloudStage = selected?.stages.find((stage) => stage.id === 'cloud-reviewer');
|
||||
const recent = listQuery.data?.goals ?? [];
|
||||
const reviewerCanDecide = Boolean(actor && (actor.role === 'approver' || actor.role === 'org-admin') && selected && actor.actor !== selected.actor);
|
||||
|
||||
if (sessionQuery.isLoading) {
|
||||
return <div className="h-72 animate-pulse rounded-3xl bg-slate-100 motion-reduce:animate-none" aria-label="Loading authenticated Goal Orchestrator" />;
|
||||
}
|
||||
|
||||
if (sessionQuery.isError || !actor) {
|
||||
return <div role="alert" className="rounded-2xl border border-rose-200 bg-rose-50 p-5 text-sm text-rose-800"><div className="font-semibold">Your authenticated session could not be loaded.</div><p className="mt-1">Sign in again before opening Goal Orchestrator.</p></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<section className="overflow-hidden rounded-2xl border border-indigo-200/70 bg-gradient-to-br from-slate-950 via-indigo-950 to-indigo-800 p-6 text-white shadow-[0_20px_45px_rgba(30,41,89,0.2)]">
|
||||
<div className="max-w-3xl">
|
||||
<div className="text-[11px] font-bold uppercase tracking-[0.16em] text-indigo-200">Goal orchestrator</div>
|
||||
<h1 className="mt-2 text-2xl font-semibold tracking-tight sm:text-3xl">One objective. Two models. One governed outcome.</h1>
|
||||
<p className="mt-2 text-sm leading-6 text-indigo-100/80">A local worker develops the primary solution. A cloud reviewer challenges it, closes gaps, and returns the final answer through the same H1–H7 controls.</p>
|
||||
<div className="space-y-6 pb-6">
|
||||
<header className="relative overflow-hidden rounded-[1.6rem] border border-slate-200 bg-[#f8faf9] px-6 py-7 shadow-[0_18px_48px_rgba(15,23,42,0.055)] sm:px-8">
|
||||
<div className="absolute inset-y-0 left-0 w-1 bg-teal-600" />
|
||||
<div className="relative flex flex-wrap items-end justify-between gap-6">
|
||||
<div className="max-w-3xl">
|
||||
<p className="text-[10px] font-bold uppercase tracking-[0.2em] text-teal-700">Goal orchestrator · H1—H7 governed</p>
|
||||
<h1 className="mt-2 text-3xl font-semibold leading-tight tracking-[-0.035em] text-slate-950 sm:text-[2.35rem]">Define the outcome. Keep every decision inspectable.</h1>
|
||||
<p className="mt-3 max-w-[68ch] text-sm leading-6 text-slate-600">CASAN frames one objective against a bounded project, challenges it with independent models, and returns an evidence-backed outcome or a human approval request.</p>
|
||||
</div>
|
||||
<div className="grid min-w-64 grid-cols-3 gap-px overflow-hidden rounded-xl border border-slate-200 bg-slate-200 text-center">
|
||||
<div className="bg-white px-3 py-3"><div className="font-mono text-lg font-semibold tabular-nums text-slate-900">{projects.length}</div><div className="text-[9px] font-bold uppercase tracking-wide text-slate-400">Projects</div></div>
|
||||
<div className="bg-white px-3 py-3"><div className="font-mono text-lg font-semibold tabular-nums text-slate-900">{listQuery.data?.count ?? 0}</div><div className="text-[9px] font-bold uppercase tracking-wide text-slate-400">Objectives</div></div>
|
||||
<div className="bg-white px-3 py-3"><div className="mt-0.5 flex justify-center"><span className={`h-2.5 w-2.5 rounded-full ${selected && !TERMINAL.has(selected.status) ? 'animate-pulse bg-teal-500 motion-reduce:animate-none' : 'bg-slate-300'}`} /></div><div className="mt-1 text-[9px] font-bold uppercase tracking-wide text-slate-400">Live run</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="grid items-start gap-5 xl:grid-cols-[minmax(0,1.55fr)_minmax(310px,0.72fr)]">
|
||||
<article className="rounded-[1.35rem] border border-slate-200 bg-white p-5 shadow-[0_16px_42px_rgba(15,23,42,0.055)] sm:p-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-[10px] font-bold uppercase tracking-[0.18em] text-teal-700">Objective composer</p>
|
||||
<h2 className="mt-1 text-xl font-semibold tracking-tight text-slate-950">What should CASAN accomplish?</h2>
|
||||
<p className="mt-1 text-xs leading-5 text-slate-500">Add constraints, acceptance criteria, links and technical context. Formatting is preserved in the final evidence.</p>
|
||||
</div>
|
||||
<span className="rounded-lg bg-slate-100 px-2.5 py-1.5 font-mono text-[10px] text-slate-500">{effectiveProject || 'no project'}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<RichTextGoalEditor
|
||||
value={goal}
|
||||
onChange={setGoal}
|
||||
placeholder={'Describe the intended outcome…\n\nInclude:\n- scope and constraints\n- measurable acceptance criteria\n- required verification and rollback'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{EXAMPLES.map((example, index) => <button key={example} type="button" onClick={() => setGoal(example)} className="rounded-lg border border-slate-200 bg-slate-50 px-2.5 py-1.5 text-[11px] font-medium text-slate-500 transition hover:border-teal-300 hover:bg-teal-50 hover:text-teal-800 focus:outline-none focus:ring-2 focus:ring-teal-500">Example {index + 1}</button>)}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-wrap items-center justify-between gap-4 border-t border-slate-100 pt-5">
|
||||
<div className="flex max-w-xl items-start gap-2.5 text-xs leading-5 text-slate-500"><span className="mt-1 grid h-4 w-4 shrink-0 place-items-center rounded-full bg-teal-100 text-[9px] font-bold text-teal-700">✓</span><span>Read-only goals return evidence directly. Any proposed write remains blocked until a separate approver reviews the patch.</span></div>
|
||||
<button type="button" disabled={goal.trim().length < 10 || !effectiveProject || start.isPending} onClick={() => start.mutate()} className="group inline-flex items-center gap-2 rounded-xl bg-slate-950 px-5 py-3 text-sm font-semibold text-white shadow-[0_10px_24px_rgba(15,23,42,0.22)] transition hover:-translate-y-0.5 hover:bg-teal-700 hover:shadow-[0_14px_28px_rgba(15,118,110,0.22)] active:translate-y-0 focus:outline-none focus:ring-4 focus:ring-teal-200 disabled:cursor-not-allowed disabled:bg-slate-300 disabled:shadow-none">
|
||||
{start.isPending ? 'Starting governed run…' : 'Run objective'}
|
||||
{!start.isPending && <span aria-hidden="true" className="transition group-hover:translate-x-0.5">→</span>}
|
||||
</button>
|
||||
</div>
|
||||
{start.isError && <div role="alert" className="mt-4 rounded-xl border border-rose-200 bg-rose-50 p-3 text-sm text-rose-700">{errorMessage(start.error)}</div>}
|
||||
</article>
|
||||
|
||||
<div className="space-y-5">
|
||||
<WorkspacePanel
|
||||
projects={projects}
|
||||
selectedId={effectiveProject}
|
||||
onSelect={setProjectId}
|
||||
loading={projectsQuery.isLoading}
|
||||
error={projectsQuery.isError}
|
||||
creating={creatingProject}
|
||||
onCreatingChange={setCreatingProject}
|
||||
newProjectId={newProjectId}
|
||||
newProjectDomain={newProjectDomain}
|
||||
onProjectIdChange={setNewProjectId}
|
||||
onProjectDomainChange={setNewProjectDomain}
|
||||
onCreate={() => createProject.mutate()}
|
||||
createPending={createProject.isPending}
|
||||
createError={createProject.error}
|
||||
/>
|
||||
<RecentGoals jobs={recent} selectedId={selectedId} loading={listQuery.isLoading} onSelect={(id) => setSearchParams({ id })} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Card title="Give CASAN an objective">
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-sm font-semibold text-slate-800">Project workspace</span>
|
||||
<button type="button" onClick={() => setCreatingProject((value) => !value)} className="rounded-lg border border-indigo-200 bg-indigo-50 px-3 py-1.5 text-xs font-semibold text-indigo-700 transition hover:border-indigo-300 hover:bg-indigo-100 focus:outline-none focus:ring-2 focus:ring-indigo-400">
|
||||
{creatingProject ? 'Cancel' : '+ New project'}
|
||||
</button>
|
||||
</div>
|
||||
<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>}
|
||||
{creatingProject && (
|
||||
<div className="mt-4 rounded-2xl border border-indigo-200 bg-indigo-50/60 p-4">
|
||||
<div className="text-sm font-semibold text-indigo-950">Register a new governed workspace</div>
|
||||
<p className="mt-1 text-xs leading-5 text-indigo-800/75">CASAN creates an empty directory under <span className="font-mono">apps/projects/<project-id></span>. Absolute paths and external roots are never accepted.</p>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
<label><span className="text-xs font-semibold text-slate-700">Project ID</span><input value={newProjectId} onChange={(event) => setNewProjectId(event.target.value)} placeholder="customer-portal" maxLength={64} className="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2.5 text-sm outline-none transition focus:border-indigo-400 focus:ring-4 focus:ring-indigo-100" /></label>
|
||||
<label><span className="text-xs font-semibold text-slate-700">Display name</span><input value={newProjectDomain} onChange={(event) => setNewProjectDomain(event.target.value)} placeholder="Customer Portal" maxLength={100} className="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2.5 text-sm outline-none transition focus:border-indigo-400 focus:ring-4 focus:ring-indigo-100" /></label>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end"><button type="button" disabled={!/^[A-Za-z][A-Za-z0-9._-]{2,63}$/.test(newProjectId.trim()) || newProjectDomain.trim().length < 3 || createProject.isPending} onClick={() => createProject.mutate()} className="rounded-xl bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-indigo-700 disabled:cursor-not-allowed disabled:bg-slate-300">{createProject.isPending ? 'Creating…' : 'Create and select'}</button></div>
|
||||
{createProject.isError && <div role="alert" className="mt-3 rounded-xl border border-rose-200 bg-rose-50 p-3 text-xs font-medium text-rose-700">{errorMessage(createProject.error)}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<textarea
|
||||
value={goal}
|
||||
onChange={(event) => setGoal(event.target.value)}
|
||||
placeholder="Ví dụ: Thiết kế kế hoạch đưa ứng dụng OKR hiện tại lên production, có rollback và tiêu chí nghiệm thu rõ ràng."
|
||||
className="min-h-32 w-full resize-y rounded-xl border border-slate-300 px-4 py-3 text-sm leading-6 text-slate-800 outline-none transition focus:border-indigo-400 focus:ring-4 focus:ring-indigo-100"
|
||||
maxLength={8000}
|
||||
/>
|
||||
<div className="mt-3 flex flex-wrap items-center justify-between gap-3">
|
||||
<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 || !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"
|
||||
>
|
||||
{start.isPending ? 'Starting…' : 'Solve objective'}
|
||||
</button>
|
||||
</div>
|
||||
{start.isError && <div role="alert" className="mt-3 rounded-xl border border-rose-200 bg-rose-50 p-3 text-sm text-rose-700">{errorMessage(start.error)}</div>}
|
||||
</Card>
|
||||
{selectedQuery.isLoading && selectedId && <div className="grid gap-4" aria-label="Loading selected objective"><div className="h-24 animate-pulse rounded-2xl bg-slate-200 motion-reduce:animate-none" /><div className="h-96 animate-pulse rounded-3xl bg-slate-900/90 motion-reduce:animate-none" /></div>}
|
||||
{selectedQuery.isError && <div role="alert" className="rounded-2xl border border-rose-200 bg-rose-50 p-5 text-sm text-rose-800">The selected objective could not be loaded. Choose another objective from the recent list.</div>}
|
||||
|
||||
{selected && (
|
||||
<>
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<WorkerCard title="Local worker" subtitle="Private first-pass solution" status={localStage?.status ?? 'queued'} detail={localStage?.detail ?? 'Waiting'} provider={localStage?.provider ?? selected.local_provider} model={localStage?.model ?? selected.local_model} />
|
||||
<WorkerCard title="Cloud reviewer" subtitle="Independent critique and refinement" status={cloudStage?.status ?? 'queued'} detail={cloudStage?.detail ?? 'Waiting'} provider={cloudStage?.provider ?? selected.cloud_provider} model={cloudStage?.model ?? selected.cloud_model} />
|
||||
<section className="space-y-5" aria-labelledby="selected-objective-title">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4 rounded-2xl border border-slate-200 bg-white px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2"><span className="text-[10px] font-bold uppercase tracking-[0.16em] text-teal-700">Active outcome</span><span className="text-slate-300">/</span><span className="font-mono text-[10px] text-slate-400">{selected.id.slice(0, 8)}</span></div>
|
||||
<h2 id="selected-objective-title" className="mt-1 line-clamp-2 max-w-4xl text-lg font-semibold tracking-tight text-slate-950">{selected.goal.replace(/[#*_`>\[\]]/g, '').split('\n')[0]}</h2>
|
||||
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-slate-400"><span>{selected.workspace?.domain ?? selected.project}</span><span>{formatTimestamp(selected.created_at)}</span><span>{selected.local_provider} + {selected.cloud_provider}</span></div>
|
||||
</div>
|
||||
<StatusBadge value={selected.status} />
|
||||
</div>
|
||||
|
||||
<ModelInteractionDiagram goal={selected} localStage={localStage} cloudStage={cloudStage} trace={traceQuery.data} />
|
||||
|
||||
<Card title="Governed outcome" right={<StatusBadge value={selected.status} />}>
|
||||
<Card className="!rounded-[1.35rem]" title="Governed outcome" right={<StatusBadge value={selected.status} />}>
|
||||
<GovernedOutcomePulse goal={selected} trace={traceQuery.data} />
|
||||
{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>
|
||||
<div className="mt-5 grid gap-4 xl:grid-cols-[minmax(250px,0.58fr)_minmax(0,1.42fr)]">
|
||||
<div className="rounded-2xl bg-slate-50 p-4">
|
||||
<div className="text-[10px] font-bold uppercase tracking-[0.14em] text-slate-400">Original objective</div>
|
||||
<div className="mt-2"><MarkdownText text={selected.goal} /></div>
|
||||
{selected.workspace && <div className="mt-4 border-t border-slate-200 pt-3"><div className="text-[10px] font-bold uppercase tracking-wide text-slate-400">Evidence boundary</div><div className="mt-1 text-xs font-semibold text-slate-700">{selected.workspace.domain}</div>{selected.context_manifest && <div className="mt-1 font-mono text-[10px] text-slate-400">{selected.context_manifest.files} files · {selected.context_manifest.characters.toLocaleString()} chars{selected.context_manifest.truncated ? ' · bounded' : ''}</div>}</div>}
|
||||
</div>
|
||||
<div className="min-w-0 rounded-2xl border border-slate-200 p-5">
|
||||
<div className="text-[10px] font-bold uppercase tracking-[0.14em] text-teal-700">Final response</div>
|
||||
{selected.result ? <div className="mt-2"><MarkdownText text={selected.result} /></div> : <div className="mt-3 flex items-center gap-3 rounded-xl bg-teal-50 p-4 text-sm text-teal-900"><span className="h-2.5 w-2.5 animate-pulse rounded-full bg-teal-500 motion-reduce:animate-none" />Models are working. Evidence refreshes automatically.</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>}
|
||||
</div>
|
||||
</div>
|
||||
{selected.result ? <div className="mt-5"><MarkdownText text={selected.result} /></div> : (
|
||||
<div className="mt-5 flex items-center gap-3 rounded-xl border border-blue-200 bg-blue-50 p-4 text-sm text-blue-800">
|
||||
<span className="h-2.5 w-2.5 animate-pulse rounded-full bg-blue-500" />
|
||||
Models are working. This page refreshes automatically.
|
||||
|
||||
{selected.approval && selected.status === 'requires_approval' && (
|
||||
<div className="mt-5 rounded-2xl border border-amber-200 bg-amber-50 p-5 text-sm text-amber-950">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3"><div><div className="font-semibold">Reviewed patch awaiting a separate approver</div><p className="mt-1 leading-6">Proposal <span className="font-mono">{selected.approval.id}</span> was created by <span className="font-mono">{selected.actor}</span>. Current session: <span className="font-mono">{actor.actor}</span> ({actor.role}).</p></div><Link to="/approvals" className="text-xs font-semibold text-amber-800 underline decoration-amber-300 underline-offset-4 hover:text-amber-950">Open approval inbox</Link></div>
|
||||
{reviewerCanDecide ? (
|
||||
<>
|
||||
<label className="mt-4 block"><span className="text-xs font-semibold">Decision reason</span><input value={approvalReason} onChange={(event) => setApprovalReason(event.target.value)} maxLength={500} className="mt-1 w-full rounded-lg border border-amber-300 bg-white px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-amber-500" /></label>
|
||||
<div className="mt-4 flex flex-wrap gap-2"><button type="button" disabled={approveAndApply.isPending || retryApply.isPending || approvalReason.trim().length < 5} onClick={() => approveAndApply.mutate(selected)} className="rounded-lg bg-amber-800 px-4 py-2 text-xs font-semibold text-white transition hover:bg-amber-900 disabled:cursor-not-allowed disabled:bg-amber-300">{approveAndApply.isPending ? 'Approving, applying and verifying…' : 'Approve, apply & verify'}</button><button type="button" disabled={approveAndApply.isPending || retryApply.isPending} onClick={() => retryApply.mutate(selected)} className="rounded-lg border border-amber-300 bg-white px-3 py-2 text-xs font-semibold text-amber-900 transition hover:bg-amber-100 disabled:text-amber-300">{retryApply.isPending ? 'Applying…' : 'Retry approved patch'}</button></div>
|
||||
</>
|
||||
) : (
|
||||
<div className="mt-4 rounded-xl border border-amber-200 bg-white/70 p-3 text-xs leading-5 text-amber-900">Sign in as an Independent Reviewer who is different from the proposer to record this decision.</div>
|
||||
)}
|
||||
{(approveAndApply.isError || retryApply.isError) && <div role="alert" className="mt-3 rounded-lg border border-rose-200 bg-rose-50 p-3 text-xs font-medium text-rose-700">{errorMessage(approveAndApply.error || retryApply.error)}</div>}
|
||||
</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.patch_repair_attempts && selected.patch_repair_attempts.length > 0 && <details open className="mt-4 overflow-hidden rounded-xl border border-amber-200 bg-amber-50/50"><summary className="cursor-pointer px-4 py-3 text-sm font-semibold text-amber-950">H2 patch-repair ledger ({selected.patch_repair_attempts.length})</summary><div className="space-y-2 border-t border-amber-200 p-4">{selected.patch_repair_attempts.map((attempt) => <div key={`${attempt.attempt}-${attempt.model}`} className="rounded-lg border border-amber-100 bg-white px-3 py-2.5 text-xs"><div className="flex flex-wrap items-center justify-between gap-2"><span className="font-semibold text-slate-700">Attempt {attempt.attempt}</span><span className="font-mono text-slate-500">{attempt.provider} · {attempt.model}</span><StatusBadge value={attempt.status} /></div><div className="mt-1.5 break-words font-mono text-[11px] leading-5 text-slate-600">{attempt.reason}</div></div>)}</div></details>}
|
||||
{selected.patch_artifact && <details open={selected.status === 'requires_approval'} className="mt-5 overflow-hidden rounded-xl border border-violet-200 bg-violet-50/40"><summary className="cursor-pointer px-4 py-3 text-sm font-semibold text-violet-950">Reviewed implementation patch · {selected.patch_artifact.files.length} file(s)</summary><div className="border-t border-violet-200 p-4"><div className="mb-3 flex flex-wrap gap-2">{selected.patch_artifact.files.map((file) => <span key={file} className="rounded-full bg-white px-2.5 py-1 font-mono text-[11px] text-violet-800 shadow-sm">{file}</span>)}</div><pre className="max-h-96 overflow-auto rounded-xl bg-slate-950 p-4 text-xs leading-5 text-slate-100">{selected.patch_artifact.preview || 'Patch preview unavailable'}</pre><div className="mt-3 font-mono text-[10px] text-violet-600">SHA-256 {selected.patch_artifact.sha256}</div></div></details>}
|
||||
{selected.approval && selected.status === 'requires_approval' && <div className="mt-4 rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-950"><div className="font-semibold">Reviewed patch awaiting approval</div><p className="mt-1 leading-6">Proposal <span className="font-mono">{selected.approval.id}</span> requires an approver different from proposer <span className="font-mono">{selected.actor}</span>.</p><div className="mt-4 grid gap-3 sm:grid-cols-2"><label><span className="text-xs font-semibold">Approver identity</span><input value={approver} onChange={(event) => setApprover(event.target.value)} className="mt-1 w-full rounded-lg border border-amber-300 bg-white px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-amber-500" /></label><label><span className="text-xs font-semibold">Decision reason</span><input value={approvalReason} onChange={(event) => setApprovalReason(event.target.value)} className="mt-1 w-full rounded-lg border border-amber-300 bg-white px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-amber-500" /></label></div><div className="mt-4 flex flex-wrap gap-2"><button type="button" disabled={approveAndApply.isPending || retryApply.isPending || approver.trim().length < 3 || approver.trim() === selected.actor || approvalReason.trim().length < 5} onClick={() => approveAndApply.mutate(selected)} className="rounded-lg bg-amber-700 px-4 py-2 text-xs font-semibold text-white transition hover:bg-amber-800 disabled:cursor-not-allowed disabled:bg-amber-300">{approveAndApply.isPending ? 'Approving, applying and verifying…' : 'Approve & Apply patch'}</button><button type="button" disabled={approveAndApply.isPending || retryApply.isPending || approver.trim().length < 3} onClick={() => retryApply.mutate(selected)} className="rounded-lg border border-amber-300 bg-white px-3 py-2 text-xs font-semibold text-amber-900 hover:bg-amber-100 disabled:text-amber-300">{retryApply.isPending ? 'Applying…' : 'Retry already-approved patch'}</button><Link to="/approvals" className="inline-flex rounded-lg border border-amber-300 bg-white px-3 py-2 text-xs font-semibold text-amber-900 hover:bg-amber-100">Open approval inbox</Link></div>{(approveAndApply.isError || retryApply.isError) && <div role="alert" className="mt-3 rounded-lg border border-rose-200 bg-rose-50 p-3 text-xs font-medium text-rose-700">{errorMessage(approveAndApply.error || retryApply.error)}</div>}</div>}
|
||||
{selected.verification && selected.verification.length > 0 && <div className="mt-4 rounded-xl border border-emerald-200 bg-emerald-50 p-4"><div className="text-sm font-semibold text-emerald-900">Patch applied and verified</div>{selected.verification.map((check) => <div key={check.command} className="mt-2 flex items-center justify-between gap-3 rounded-lg bg-white px-3 py-2 text-xs"><span className="font-mono text-emerald-800">{check.command}</span><StatusBadge value={check.exit_code === 0 ? 'pass' : 'failed'} /></div>)}</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>}
|
||||
{selected.reviewer_attempts && selected.reviewer_attempts.length > 0 && <details className="mt-5 rounded-xl border border-slate-200 p-4"><summary className="cursor-pointer text-sm font-semibold text-slate-700">Fallback attempt ledger ({selected.reviewer_attempts.length})</summary><div className="mt-4 space-y-2">{selected.reviewer_attempts.map((attempt) => <div key={attempt.attempt} className="flex flex-wrap items-center justify-between gap-2 rounded-lg bg-slate-50 px-3 py-2 text-xs"><span className="font-semibold text-slate-700">{attempt.attempt}. reviewer</span><span className="font-mono text-slate-500">{attempt.provider} · {attempt.model}</span><StatusBadge value={attempt.status} /></div>)}</div></details>}
|
||||
|
||||
{selected.verification && selected.verification.length > 0 && <div className="mt-5 rounded-2xl border border-emerald-200 bg-emerald-50 p-4"><div className="text-sm font-semibold text-emerald-900">Patch applied and verified</div>{selected.verification.map((check) => <div key={check.command} className="mt-2 flex items-center justify-between gap-3 rounded-lg bg-white px-3 py-2 text-xs"><span className="min-w-0 truncate font-mono text-emerald-800">{check.command}</span><StatusBadge value={check.exit_code === 0 ? 'pass' : 'failed'} /></div>)}</div>}
|
||||
|
||||
<details className="mt-5 overflow-hidden rounded-2xl border border-slate-200 bg-slate-50/60">
|
||||
<summary className="cursor-pointer px-4 py-3 text-sm font-semibold text-slate-700 transition hover:bg-slate-100">Technical evidence and model ledgers</summary>
|
||||
<div className="space-y-4 border-t border-slate-200 p-4">
|
||||
{selected.patch_repair_attempts && selected.patch_repair_attempts.length > 0 && <div><div className="text-xs font-semibold text-slate-700">Patch-repair ledger</div><div className="mt-2 space-y-2">{selected.patch_repair_attempts.map((attempt) => <div key={`${attempt.attempt}-${attempt.model}`} className="flex flex-wrap items-center justify-between gap-2 rounded-lg bg-white px-3 py-2.5 text-xs"><span className="font-semibold text-slate-700">Attempt {attempt.attempt}</span><span className="font-mono text-slate-500">{attempt.provider} · {attempt.model}</span><StatusBadge value={attempt.status} /></div>)}</div></div>}
|
||||
{selected.patch_artifact && <div><div className="flex flex-wrap items-center justify-between gap-2 text-xs font-semibold text-slate-700"><span>Implementation patch · {selected.patch_artifact.files.length} files</span><span className="font-mono text-[10px] font-normal text-slate-400">{selected.patch_artifact.sha256.slice(0, 16)}…</span></div><div className="mt-2 flex flex-wrap gap-1.5">{selected.patch_artifact.files.map((file) => <span key={file} className="rounded-md bg-white px-2 py-1 font-mono text-[10px] text-slate-600">{file}</span>)}</div><pre className="mt-2 max-h-80 overflow-auto rounded-xl bg-slate-950 p-4 text-xs leading-5 text-slate-100">{selected.patch_artifact.preview || 'Patch preview unavailable'}</pre></div>}
|
||||
{selected.local_draft && <details className="rounded-xl bg-white p-3"><summary className="cursor-pointer text-xs font-semibold text-slate-700">Local worker draft</summary><div className="mt-3"><MarkdownText text={selected.local_draft} /></div></details>}
|
||||
{selected.reviewer_attempts && selected.reviewer_attempts.length > 0 && <div><div className="text-xs font-semibold text-slate-700">Reviewer fallback attempts</div><div className="mt-2 space-y-2">{selected.reviewer_attempts.map((attempt) => <div key={attempt.attempt} className="flex flex-wrap items-center justify-between gap-2 rounded-lg bg-white px-3 py-2 text-xs"><span>{attempt.attempt}. reviewer</span><span className="font-mono text-slate-500">{attempt.provider} · {attempt.model}</span><StatusBadge value={attempt.status} /></div>)}</div></div>}
|
||||
{!selected.patch_artifact && !selected.local_draft && !selected.reviewer_attempts?.length && !selected.patch_repair_attempts?.length && <p className="text-xs text-slate-500">No additional technical evidence has been emitted yet.</p>}
|
||||
</div>
|
||||
</details>
|
||||
</Card>
|
||||
|
||||
<TraceExplorer traceId={selected.trace_id} />
|
||||
</>
|
||||
<details className="overflow-hidden rounded-[1.35rem] border border-slate-200 bg-white">
|
||||
<summary className="cursor-pointer px-5 py-4 text-sm font-semibold text-slate-800 transition hover:bg-slate-50">Inspect complete H1—H7 trace</summary>
|
||||
<div className="border-t border-slate-200 p-4"><TraceExplorer traceId={selected.trace_id} /></div>
|
||||
</details>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<Card title={`Recent objectives (${listQuery.data?.count ?? 0})`}>
|
||||
<div className="space-y-2">
|
||||
{(listQuery.data?.goals ?? []).map((item) => (
|
||||
<button key={item.id} type="button" onClick={() => setSearchParams({ id: item.id })} className={`flex w-full items-center justify-between gap-4 rounded-xl border p-3 text-left transition hover:bg-slate-50 ${selectedId === item.id ? 'border-indigo-300 bg-indigo-50/50' : 'border-slate-200'}`}>
|
||||
<div className="min-w-0 flex-1"><MarkdownText text={item.goal} compact /><div className="mt-1 text-xs text-slate-400">{item.created_at.replace('T', ' ').replace('Z', '')}</div></div>
|
||||
<StatusBadge value={item.status} />
|
||||
</button>
|
||||
))}
|
||||
{!listQuery.isLoading && (listQuery.data?.count ?? 0) === 0 && <p className="py-6 text-center text-sm text-slate-500">No objective has been orchestrated yet.</p>}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user