feat: chat + optmz control panel

This commit is contained in:
thanhnv
2026-07-10 16:26:30 +09:00
parent d882a9dc23
commit 7cea023dce
28 changed files with 1702 additions and 402 deletions
@@ -1,78 +1,118 @@
import { useState } from 'react';
import { type KeyboardEvent, useMemo, useState } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { api, ChatAction, ChatAgent, ChatAnswer, ChatStreamPhase, SettingsActor } from '../lib/api';
import { api, ChatAction, ChatAgent, ChatAnswer, ChatHistoryTurn, ChatStreamPhase, SettingsActor } from '../lib/api';
import { Card, StatusBadge } from '../components/ui/Card';
const ROLES = ['viewer', 'auditor', 'operator', 'project-admin', 'org-admin'];
function badgeValue(res: ChatAnswer | undefined, fallback = 'idle') {
if (!res) return fallback;
return `${res.mode} / ${res.risk}`;
type MessageKind = 'user' | 'assistant' | 'draft';
interface WorkspaceMessage {
id: string;
kind: MessageKind;
body: string;
timestamp: string;
mode?: string;
decision?: string;
certified?: boolean;
preview?: boolean;
}
function finalToAnswer(p: ChatStreamPhase, actor: SettingsActor): ChatAnswer {
function finalToAnswer(phase: ChatStreamPhase, actor: SettingsActor): ChatAnswer {
return {
...(p as Partial<ChatAnswer>),
success: p.decision === 'ANSWERED',
mode: p.mode,
risk: (p.risk as string) ?? 'low',
decision: p.decision,
answer: p.answer,
sources: p.sources ?? [],
certified: p.certified,
audit: p.audit ?? {},
audit_verify: p.audit_verify ?? { ok: true, output: '' },
router: p.router ?? {},
...(phase as Partial<ChatAnswer>),
success: phase.decision === 'ANSWERED',
mode: phase.mode,
risk: phase.risk ?? 'low',
decision: phase.decision,
answer: phase.answer,
sources: phase.sources ?? [],
certified: phase.certified,
audit: phase.audit ?? {},
audit_verify: phase.audit_verify ?? { ok: true, output: '' },
router: phase.router ?? {},
actor,
} as ChatAnswer;
}
function auditHash(res: ChatAnswer) {
return res.audit?.hash || res.audit?.record_hash || res.audit?.head || 'n/a';
function errorMessage(error: unknown): string {
if (typeof error === 'object' && error !== null) {
const candidate = error as { message?: string; response?: { data?: { message?: string } } };
return candidate.response?.data?.message || candidate.message || 'The governed chat request could not be completed.';
}
return 'The governed chat request could not be completed.';
}
function sourceExcerpt(source: { preview?: string; excerpt?: string }) {
return source.preview || source.excerpt || '';
function formatTime(value: string) {
if (!value) return 'now';
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? value : new Intl.DateTimeFormat('en', { hour: '2-digit', minute: '2-digit' }).format(parsed);
}
function auditHash(answer: ChatAnswer | null) {
return answer?.audit?.hash || answer?.audit?.record_hash || answer?.audit?.head || 'n/a';
}
function turnMessages(turn: ChatHistoryTurn): WorkspaceMessage[] {
const messages: WorkspaceMessage[] = [];
if (turn.prompt_preview) {
messages.push({ id: `${turn.turn_id}-user`, kind: 'user', body: turn.prompt_preview, timestamp: turn.timestamp, mode: turn.mode, decision: turn.decision, preview: true });
}
if (turn.answer_preview) {
messages.push({ id: `${turn.turn_id}-assistant`, kind: 'assistant', body: turn.answer_preview, timestamp: turn.timestamp, mode: turn.mode, decision: turn.decision, certified: turn.certified, preview: true });
}
return messages;
}
function Glyph({ name }: { name: 'add' | 'send' | 'spark' | 'lock' | 'chevron' | 'bolt' | 'history' }) {
const paths = {
add: <><path d="M12 5v14M5 12h14" /></>,
send: <><path d="m21 3-7.5 18-3.8-7.7L2 9.5 21 3Z" /><path d="m9.7 13.3 4.6-4.6" /></>,
spark: <><path d="m12 3 1.7 5.3L19 10l-5.3 1.7L12 17l-1.7-5.3L5 10l5.3-1.7L12 3Z" /><path d="m19 15 .8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8L19 15Z" /></>,
lock: <><rect x="5" y="10" width="14" height="11" rx="2" /><path d="M8 10V7a4 4 0 0 1 8 0v3" /></>,
chevron: <path d="m9 18 6-6-6-6" />,
bolt: <path d="m13 2-9 12h7l-1 8 10-13h-7l0-7Z" />,
history: <><path d="M3 12a9 9 0 1 0 3-6.7" /><path d="M3 4v5h5M12 7v5l3 2" /></>,
};
return <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4">{paths[name]}</svg>;
}
export function Chat() {
const [actor, setActor] = useState<SettingsActor>({
actor: 'local-operator',
role: 'viewer',
project: 'default',
tenant: 'default',
});
const [actor, setActor] = useState<SettingsActor>({ actor: 'local-operator', role: 'viewer', project: 'default', tenant: 'default' });
const [chatId, setChatId] = useState('chat-default');
const [agentId, setAgentId] = useState('evidence-reader');
const [skillId, setSkillId] = useState('evidence-summary');
const [delegationLevel, setDelegationLevel] = useState(0);
const [message, setMessage] = useState('Summarize Plan 18 MVP-0 status');
const [message, setMessage] = useState('');
const [last, setLast] = useState<ChatAnswer | null>(null);
const [error, setError] = useState<string | null>(null);
const [streaming, setStreaming] = useState(false);
const [pendingMessage, setPendingMessage] = useState<string | null>(null);
const [draftText, setDraftText] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [streaming, setStreaming] = useState(true);
const [streamBusy, setStreamBusy] = useState(false);
const auditQuery = useQuery({
queryKey: ['chat-audit'],
queryFn: api.verifyChatAudit,
retry: false,
});
const actionsQuery = useQuery({
queryKey: ['chat-actions', actor],
queryFn: () => api.chatActions(actor),
retry: false,
});
const agentsQuery = useQuery({
queryKey: ['chat-agents', actor],
queryFn: () => api.chatAgents(actor),
retry: false,
});
const auditQuery = useQuery({ queryKey: ['chat-audit'], queryFn: api.verifyChatAudit, retry: false });
const conversationsQuery = useQuery({ queryKey: ['chat-history', actor], queryFn: () => api.chatHistory(actor), retry: false });
const historyQuery = useQuery({ queryKey: ['chat-history', actor, chatId], queryFn: () => api.chatHistory(actor, chatId, 100), retry: false });
const actionsQuery = useQuery({ queryKey: ['chat-actions', actor], queryFn: () => api.chatActions(actor), retry: false });
const agentsQuery = useQuery({ queryKey: ['chat-agents', actor], queryFn: () => api.chatAgents(actor), retry: false });
const agents = agentsQuery.data?.agents ?? [];
const selectedAgent = agents.find((a) => a.id === agentId) ?? agents.find((a) => a.allowed_for_role) ?? agents[0];
const selectedSkill = selectedAgent?.skills_allowed.includes(skillId)
? skillId
: (selectedAgent?.skills_allowed[0] ?? '');
const selectedAgent = agents.find((agent) => agent.id === agentId) ?? agents.find((agent) => agent.allowed_for_role) ?? agents[0];
const selectedSkill = selectedAgent?.skills_allowed.includes(skillId) ? skillId : (selectedAgent?.skills_allowed[0] ?? '');
const persistedMessages = useMemo(() => (historyQuery.data?.turns ?? []).flatMap(turnMessages), [historyQuery.data]);
const liveAlreadyStored = Boolean(last?.turn_id && historyQuery.data?.turns.some((turn) => turn.turn_id === last.turn_id));
const liveMessages: WorkspaceMessage[] = [];
if (pendingMessage) liveMessages.push({ id: 'pending-user', kind: 'user', body: pendingMessage, timestamp: '', mode: 'READ_ONLY' });
if (draftText) liveMessages.push({ id: 'draft', kind: 'draft', body: draftText, timestamp: '', mode: 'DRAFTING', certified: false });
if (last && !liveAlreadyStored && !pendingMessage) liveMessages.push({ id: last.turn_id ?? 'latest-answer', kind: 'assistant', body: last.answer, timestamp: '', mode: last.mode, decision: last.decision, certified: last.certified });
const messages = [...persistedMessages, ...liveMessages];
const refreshChat = () => {
void auditQuery.refetch();
void conversationsQuery.refetch();
void historyQuery.refetch();
};
const ask = useMutation({
mutationFn: (override?: { message?: string; agentId?: string; skillId?: string }) => api.askChat(actor, {
@@ -82,296 +122,165 @@ export function Chat() {
skillId: override?.skillId ?? selectedSkill,
delegationLevel,
}),
onSuccess: (res) => {
setLast(res);
onSuccess: (answer) => {
setLast(answer);
setPendingMessage(null);
setError(null);
void auditQuery.refetch();
setMessage('');
refreshChat();
},
onError: (err: any) => {
setError(err?.response?.data?.message || err.message || 'Ask CASAN failed');
onError: (reason: unknown) => {
setPendingMessage(null);
setError(errorMessage(reason));
},
});
const runStream = async () => {
const busy = ask.isPending || streamBusy;
const runStream = async (text: string) => {
setStreamBusy(true);
setError(null);
setDraftText(null);
setPendingMessage(text);
try {
await api.askChatStream(
actor,
{ message, chatId, agentId: selectedAgent?.id ?? agentId, skillId: selectedSkill, delegationLevel },
(phase: ChatStreamPhase) => {
if (phase.phase === 'draft') {
setDraftText(phase.answer);
} else {
setDraftText(null);
setLast(finalToAnswer(phase, actor));
}
},
);
void auditQuery.refetch();
} catch (err: any) {
setError(err?.message || 'Stream failed');
await api.askChatStream(actor, { message: text, chatId, agentId: selectedAgent?.id ?? agentId, skillId: selectedSkill, delegationLevel }, (phase) => {
if (phase.phase === 'draft') setDraftText(phase.answer);
if (phase.phase === 'final') {
setDraftText(null);
setLast(finalToAnswer(phase, actor));
setPendingMessage(null);
setMessage('');
}
});
refreshChat();
} catch (reason: unknown) {
setPendingMessage(null);
setDraftText(null);
setError(errorMessage(reason));
} finally {
setStreamBusy(false);
}
};
const onAsk = () => {
if (streaming) void runStream();
else ask.mutate({});
const submit = (override?: { message?: string; agentId?: string; skillId?: string }) => {
const text = (override?.message ?? message).trim();
if (!text || busy) return;
if (streaming && !override) void runStream(text);
else {
setPendingMessage(text);
ask.mutate(override ?? {});
}
};
const onComposerKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
submit();
}
};
const newConversation = () => {
const id = `chat-${Date.now().toString(36)}`;
setChatId(id);
setLast(null);
setMessage('');
setPendingMessage(null);
setDraftText(null);
setError(null);
};
const busy = ask.isPending || streamBusy;
return (
<>
<Card
title="Governed Chat"
right={<StatusBadge value={last ? badgeValue(last) : (auditQuery.data?.ok ? 'audit ok' : 'ready')} />}
>
<div className="grid grid-cols-1 xl:grid-cols-5 gap-4">
<div className="xl:col-span-3 space-y-3">
<label className="block space-y-1 text-sm">
<span className="text-gray-500">Ask CASAN</span>
<textarea
className="min-h-[132px] w-full rounded border border-gray-300 px-3 py-2 text-gray-800 focus:border-blue-400 focus:outline-none"
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
</label>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
className="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white disabled:bg-gray-300"
disabled={!message.trim() || busy}
onClick={onAsk}
>
{busy ? 'Asking...' : 'Ask'}
</button>
<label className="flex items-center gap-1 text-xs text-gray-600">
<input type="checkbox" checked={streaming} onChange={(e) => setStreaming(e.target.checked)} />
Stream
</label>
<StatusBadge value="read-only" />
</div>
{draftText && (
<div className="rounded border border-orange-200 bg-orange-50 p-3 text-sm text-gray-700">
<div className="mb-1 flex items-center gap-2">
<StatusBadge value="draft" />
<span className="text-xs text-orange-700">UNCERTIFIED — awaiting H4/certify</span>
</div>
<div className="whitespace-pre-wrap leading-6">{draftText}</div>
</div>
)}
{error && <div className="rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700">{error}</div>}
<div className="space-y-5">
<section className="relative overflow-hidden rounded-2xl border border-indigo-200/70 bg-gradient-to-br from-[#172554] via-[#1e2f68] to-[#334aa0] px-5 py-5 text-white shadow-[0_20px_40px_rgba(30,41,89,0.22)] sm:px-6">
<div className="absolute -right-12 -top-16 h-52 w-52 rounded-full bg-indigo-300/20 blur-3xl" />
<div className="relative flex flex-wrap items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2 text-[11px] font-bold uppercase tracking-[0.16em] text-indigo-200"><Glyph name="spark" />Evidence-first assistant</div>
<h2 className="mt-2 text-2xl font-semibold tracking-tight">Ask with context. Act only with proof.</h2>
<p className="mt-1.5 max-w-2xl text-sm leading-6 text-indigo-100/80">Every response is routed, checked and anchored to an auditable evidence trail before it reaches this workspace.</p>
</div>
<div className="xl:col-span-2 grid grid-cols-1 md:grid-cols-2 xl:grid-cols-1 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 md:col-span-2 xl:col-span-1">
<span className="text-gray-500">Chat ID</span>
<input className="w-full rounded border border-gray-300 px-3 py-2" value={chatId}
onChange={(e) => setChatId(e.target.value)} />
</label>
<label className="space-y-1 md:col-span-2 xl:col-span-1">
<span className="text-gray-500">Agent</span>
<select className="w-full rounded border border-gray-300 px-3 py-2" value={selectedAgent?.id ?? agentId}
onChange={(e) => {
const next = agents.find((a) => a.id === e.target.value);
setAgentId(e.target.value);
setSkillId(next?.skills_allowed[0] ?? '');
}}>
{agents.map((a: ChatAgent) => (
<option key={a.id} value={a.id}>
{a.label}{a.allowed_for_role ? '' : ' (locked)'}
</option>
))}
</select>
</label>
<label className="space-y-1">
<span className="text-gray-500">Skill</span>
<select className="w-full rounded border border-gray-300 px-3 py-2" value={selectedSkill}
onChange={(e) => setSkillId(e.target.value)}>
{(selectedAgent?.skills_allowed ?? []).map((s) => <option key={s} value={s}>{s}</option>)}
</select>
</label>
<label className="space-y-1">
<span className="text-gray-500">Delegation</span>
<input className="w-full rounded border border-gray-300 px-3 py-2" type="number" min={0} max={5}
value={delegationLevel} onChange={(e) => setDelegationLevel(Number(e.target.value || 0))} />
</label>
<div className="flex items-center gap-2 rounded-xl border border-white/15 bg-white/10 px-3 py-2 text-xs font-semibold text-indigo-50 backdrop-blur">
<span className={`h-2 w-2 rounded-full ${auditQuery.data?.ok ? 'bg-emerald-300' : 'bg-amber-300'}`} />
{auditQuery.data?.ok ? 'Audit chain verified' : 'Checking audit chain'}
</div>
</div>
</Card>
</section>
{last && (
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4">
<Card
title="Answer"
right={<StatusBadge value={last.certified ? 'certified' : 'uncertified'} />}
>
<div className="flex flex-wrap gap-2 mb-4">
<StatusBadge value={last.mode} />
<StatusBadge value={last.risk} />
<StatusBadge value={last.decision} />
{last.synthesis && (
<StatusBadge value={last.synthesis.mode === 'model'
? `model: ${last.synthesis.provider ?? 'provider'}`
: 'deterministic'} />
)}
{last.agent_binding && <StatusBadge value={last.agent_binding.agent_selected} />}
{last.agent_binding && <StatusBadge value={`L${last.agent_binding.delegation_level}`} />}
{last.loop_run && <StatusBadge value={last.loop_run.draft_certified ? 'loop certified' : 'loop held'} />}
<StatusBadge value={last.audit_verify.ok ? 'audit ok' : 'audit fail'} />
</div>
<div className="whitespace-pre-wrap text-sm leading-6 text-gray-800">{last.answer}</div>
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3 text-xs">
<div className="rounded border border-gray-200 p-3">
<div className="text-gray-400">audit hash</div>
<div className="font-medium text-gray-700 break-all">{auditHash(last)}</div>
</div>
<div className="rounded border border-gray-200 p-3">
<div className="text-gray-400">router</div>
<div className="font-medium text-gray-700">{last.router?.reason ?? 'n/a'}</div>
</div>
</div>
</Card>
<Card title="Evidence">
<div className="space-y-3">
{last.sources.map((s) => (
<div key={`${s.path}-${s.line ?? s.hash ?? s.score}`} className="rounded border border-gray-200 p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="font-medium text-gray-800 truncate">{s.title || s.path}</div>
<div className="text-xs text-gray-400 break-all">{s.path}{s.line ? `:${s.line}` : ''}</div>
</div>
<StatusBadge value={`score ${s.score}`} />
</div>
<div className="mt-2 text-xs leading-5 text-gray-600">{sourceExcerpt(s)}</div>
<div className="mt-2 text-xs text-gray-400 break-all">
{s.hash ? `hash ${s.hash}` : s.envelope?.verified ? 'verified source' : 'source'}
</div>
</div>
))}
{last.sources.length === 0 && <div className="text-sm text-gray-500">No evidence source returned.</div>}
</div>
</Card>
<Card title="Router">
<div className="space-y-3 text-sm">
{last.action && (
<div className="rounded border border-gray-200 p-3">
<div className="text-xs font-semibold uppercase text-gray-400">Operator action</div>
<div className="mt-1 font-medium text-gray-800">{last.action.label}</div>
<div className="mt-1 text-xs text-gray-500">{last.action.description}</div>
<div className="mt-2 flex flex-wrap gap-2">
<StatusBadge value={last.action.id} />
<StatusBadge value={last.action_gate?.outcome ?? 'gate'} />
</div>
</div>
)}
{last.agent_binding && (
<div className="rounded border border-gray-200 p-3">
<div className="text-xs font-semibold uppercase text-gray-400">Agent binding</div>
<div className="mt-1 font-medium text-gray-800">{last.agent_binding.agent_selected}</div>
<div className="mt-1 text-xs text-gray-500">{last.agent_binding.skill_selected || 'no skill'}</div>
<div className="mt-2 flex flex-wrap gap-2">
<StatusBadge value={last.agent_binding.decision} />
<StatusBadge value={last.agent_binding.model_role ?? 'model'} />
<StatusBadge value={`tools ${last.agent_binding.tool_allowlist.length}`} />
</div>
</div>
)}
{last.loop_run && (
<div className="rounded border border-gray-200 p-3">
<div className="text-xs font-semibold uppercase text-gray-400">Loop run</div>
<div className="mt-1 font-medium text-gray-800 break-all">{last.loop_run.run_id}</div>
<div className="mt-2 flex flex-wrap gap-2">
<StatusBadge value={last.loop_run.decision} />
<StatusBadge value={last.loop_run.draft_certified ? 'draft certified' : 'draft held'} />
<StatusBadge value={last.loop_run.side_effect_released ? 'released' : 'held'} />
<StatusBadge value={last.loop_run.replay?.ok ? 'replay ok' : 'replay pending'} />
</div>
</div>
)}
{last.codegen && (
<div className="rounded border border-gray-200 p-3">
<div className="text-xs font-semibold uppercase text-gray-400">Codegen draft</div>
<div className="mt-1 font-medium text-gray-800 break-all">{last.codegen.artifact ?? 'n/a'}</div>
<div className="mt-2 flex flex-wrap gap-2">
<StatusBadge value={last.codegen.artifact_scan?.ok ? 'artifact scan ok' : 'artifact scan held'} />
<StatusBadge value={last.codegen.tool_output_scan?.ok ? 'output scan ok' : 'output scan held'} />
</div>
</div>
)}
<div>
<div className="text-xs font-semibold uppercase text-gray-400">Matched rules</div>
<div className="mt-1 flex flex-wrap gap-2">
{(last.router?.matched_rules ?? []).map((r) => <StatusBadge key={r} value={r} />)}
{(last.router?.matched_rules ?? []).length === 0 && <span className="text-gray-500">none</span>}
</div>
</div>
<div>
<div className="text-xs font-semibold uppercase text-gray-400">Gates</div>
<div className="mt-1 text-gray-700">{(last.router?.gates ?? []).join(', ') || 'n/a'}</div>
</div>
<pre className="max-h-72 overflow-auto rounded bg-gray-950 p-3 text-xs text-gray-100">{JSON.stringify(last.router, null, 2)}</pre>
</div>
</Card>
</div>
)}
<Card title="Registered operator actions">
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{(actionsQuery.data?.actions ?? []).map((action: ChatAction) => {
const trigger = action.triggers[0] || action.id;
return (
<button
key={action.id}
type="button"
disabled={ask.isPending}
onClick={() => {
setMessage(trigger);
ask.mutate({ message: trigger, agentId: 'ops-operator', skillId: 'registered-actions' });
}}
className="text-left rounded border border-gray-200 p-3 hover:border-blue-300 hover:bg-blue-50 disabled:opacity-50"
>
<div className="font-medium text-gray-800">{action.label}</div>
<div className="mt-1 text-xs leading-5 text-gray-500">{action.description}</div>
<div className="mt-2 flex flex-wrap gap-1">
{action.triggers.slice(0, 2).map((t) => <StatusBadge key={t} value={t} />)}
</div>
<div className="grid min-h-[680px] grid-cols-1 overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-[0_18px_45px_rgba(15,23,42,0.07)] xl:grid-cols-[260px_minmax(0,1fr)_300px]">
<aside className="border-b border-slate-200 bg-slate-50/80 p-4 xl:border-b-0 xl:border-r">
<button type="button" onClick={newConversation} className="flex w-full items-center justify-center gap-2 rounded-xl bg-slate-900 px-3 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-slate-700 disabled:bg-slate-400" disabled={busy}>
<Glyph name="add" />New conversation
</button>
<div className="mt-5 flex items-center justify-between text-[10px] font-bold uppercase tracking-[0.15em] text-slate-400"><span>Recent threads</span><span>{conversationsQuery.data?.conversations.length ?? 0}</span></div>
<div className="mt-2 space-y-1.5">
{(conversationsQuery.data?.conversations ?? []).map((conversation) => (
<button key={conversation.chat_id} type="button" onClick={() => { setChatId(conversation.chat_id); setLast(null); setError(null); }} className={`w-full rounded-xl p-3 text-left transition ${chatId === conversation.chat_id ? 'bg-white shadow-sm ring-1 ring-indigo-200' : 'hover:bg-white/70'}`}>
<div className="flex items-start justify-between gap-2"><div className="line-clamp-2 text-sm font-medium leading-5 text-slate-800">{conversation.title}</div><StatusBadge value={conversation.last_decision} /></div>
<div className="mt-2 flex items-center justify-between text-[11px] text-slate-400"><span>{conversation.turns} turn{conversation.turns === 1 ? '' : 's'}</span><span>{formatTime(conversation.updated_at)}</span></div>
</button>
);
})}
{actionsQuery.isError && <div className="text-sm text-red-600">Cannot load registered operator actions.</div>}
{!actionsQuery.isLoading && !actionsQuery.isError && (actionsQuery.data?.actions ?? []).length === 0 && (
<div className="text-sm text-gray-500">No operator actions registered.</div>
)}
</div>
</Card>
</>
))}
{!conversationsQuery.isLoading && (conversationsQuery.data?.conversations.length ?? 0) === 0 && <div className="rounded-xl border border-dashed border-slate-200 p-4 text-xs leading-5 text-slate-500">Start a conversation to create an immutable, audit-safe thread.</div>}
</div>
<div className="mt-6 rounded-xl border border-indigo-100 bg-indigo-50/70 p-3 text-xs leading-5 text-indigo-800"><div className="flex items-center gap-1.5 font-semibold"><Glyph name="history" />Memory boundary</div><p className="mt-1 text-indigo-700/80">Only H4-scanned previews are restored. Raw prompts remain outside the UI history.</p></div>
</aside>
<section className="flex min-h-[620px] min-w-0 flex-col">
<div className="flex items-center justify-between border-b border-slate-200 px-5 py-3.5">
<div><div className="text-[10px] font-bold uppercase tracking-[0.15em] text-slate-400">Active thread</div><div className="mt-0.5 font-mono text-xs text-slate-700">{chatId}</div></div>
<div className="flex items-center gap-2"><StatusBadge value={streaming ? 'streaming' : 'certified only'} /><StatusBadge value={selectedAgent?.mode ?? 'READ_ONLY'} /></div>
</div>
<div className="flex-1 space-y-5 overflow-y-auto bg-[linear-gradient(180deg,#fff_0%,#fafcff_100%)] px-5 py-6">
{historyQuery.isLoading && <div className="text-sm text-slate-400">Loading audit-safe conversation history…</div>}
{messages.map((item) => (
<div key={item.id} className={`flex ${item.kind === 'user' ? 'justify-end' : 'justify-start'}`}>
<article className={`max-w-[92%] rounded-2xl px-4 py-3 sm:max-w-[78%] ${item.kind === 'user' ? 'rounded-br-md bg-slate-900 text-white shadow-md shadow-slate-900/10' : item.kind === 'draft' ? 'rounded-bl-md border border-amber-200 bg-amber-50 text-slate-700' : 'rounded-bl-md border border-slate-200 bg-white text-slate-800 shadow-sm'}`}>
<div className={`mb-2 flex items-center gap-2 text-[10px] font-bold uppercase tracking-[0.12em] ${item.kind === 'user' ? 'text-slate-300' : item.kind === 'draft' ? 'text-amber-700' : 'text-slate-400'}`}>
{item.kind === 'user' ? 'You' : item.kind === 'draft' ? 'Uncertified draft' : 'CASAN'}
{item.mode && <span className="font-medium normal-case tracking-normal">· {item.mode}</span>}
{item.preview && <span className="font-medium normal-case tracking-normal">· audit preview</span>}
</div>
<div className="whitespace-pre-wrap text-sm leading-6">{item.body}</div>
<div className={`mt-3 flex items-center gap-2 text-[10px] ${item.kind === 'user' ? 'text-slate-400' : 'text-slate-400'}`}>
<span>{formatTime(item.timestamp)}</span>
{item.decision && <span>· {item.decision}</span>}
{item.certified !== undefined && <span>· {item.certified ? 'certified' : 'held'}</span>}
</div>
</article>
</div>
))}
{!historyQuery.isLoading && messages.length === 0 && <div className="mx-auto flex max-w-md flex-col items-center py-20 text-center"><div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-indigo-50 text-indigo-600"><Glyph name="spark" /></div><h3 className="mt-4 font-semibold text-slate-800">A governed empty state</h3><p className="mt-2 text-sm leading-6 text-slate-500">Ask for a plan, a security posture, or an evidence-backed comparison. CASAN will cite what it knows and decline what it cannot govern.</p></div>}
</div>
<div className="border-t border-slate-200 bg-white p-4">
{error && <div role="alert" className="mb-3 rounded-xl border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div>}
<div className="rounded-2xl border border-slate-300 bg-white p-2 shadow-[0_8px_20px_rgba(15,23,42,0.05)] transition focus-within:border-indigo-400 focus-within:ring-4 focus-within:ring-indigo-100">
<textarea aria-label="Ask CASAN" value={message} onChange={(event) => setMessage(event.target.value)} onKeyDown={onComposerKeyDown} placeholder="Ask CASAN about your evidence, plans or governed actions…" className="min-h-[82px] w-full resize-none bg-transparent px-2 py-1.5 text-sm leading-6 text-slate-800 outline-none placeholder:text-slate-400" disabled={busy} />
<div className="flex items-center justify-between gap-3 px-1 pt-1">
<label className="flex cursor-pointer items-center gap-2 text-xs text-slate-500"><input type="checkbox" checked={streaming} onChange={(event) => setStreaming(event.target.checked)} className="h-3.5 w-3.5 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500" />Show safe draft first</label>
<button type="button" onClick={() => submit()} disabled={!message.trim() || busy} className="inline-flex items-center gap-2 rounded-xl bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow-sm transition hover:bg-indigo-700 disabled:cursor-not-allowed disabled:bg-slate-300"><Glyph name="send" />{busy ? 'Working…' : 'Ask CASAN'}</button>
</div>
</div>
<p className="mt-2 flex items-center gap-1.5 text-[11px] text-slate-400"><Glyph name="lock" />Enter sends · Shift + Enter adds a line · outputs pass governance before certification.</p>
</div>
</section>
<aside className="border-t border-slate-200 bg-slate-50/70 p-4 xl:border-l xl:border-t-0">
<div className="text-[10px] font-bold uppercase tracking-[0.15em] text-slate-400">Governance context</div>
<div className="mt-3 rounded-xl border border-slate-200 bg-white p-3.5">
<div className="flex items-center justify-between gap-2"><div className="text-sm font-semibold text-slate-800">{selectedAgent?.label ?? 'Evidence reader'}</div><StatusBadge value={selectedAgent?.allowed_for_role ? 'allowed' : 'locked'} /></div>
<div className="mt-1 text-xs text-slate-500">{selectedAgent?.model_role ?? 'read_only'} · {selectedSkill || 'no skill selected'}</div>
<details className="mt-3 border-t border-slate-100 pt-3 text-xs text-slate-600"><summary className="cursor-pointer font-medium text-slate-700">Session scope</summary><div className="mt-3 grid grid-cols-2 gap-2"><label className="col-span-2">Actor<input value={actor.actor} onChange={(event) => setActor({ ...actor, actor: event.target.value })} className="mt-1 w-full rounded-lg border border-slate-200 px-2 py-1.5 text-xs" /></label><label>Role<select value={actor.role} onChange={(event) => setActor({ ...actor, role: event.target.value })} className="mt-1 w-full rounded-lg border border-slate-200 px-2 py-1.5 text-xs">{ROLES.map((role) => <option key={role}>{role}</option>)}</select></label><label>Delegate<input type="number" min={0} max={5} value={delegationLevel} onChange={(event) => setDelegationLevel(Number(event.target.value || 0))} className="mt-1 w-full rounded-lg border border-slate-200 px-2 py-1.5 text-xs" /></label><label>Project<input value={actor.project} onChange={(event) => setActor({ ...actor, project: event.target.value })} className="mt-1 w-full rounded-lg border border-slate-200 px-2 py-1.5 text-xs" /></label><label>Tenant<input value={actor.tenant} onChange={(event) => setActor({ ...actor, tenant: event.target.value })} className="mt-1 w-full rounded-lg border border-slate-200 px-2 py-1.5 text-xs" /></label><label className="col-span-2">Agent<select value={selectedAgent?.id ?? agentId} onChange={(event) => { const next = agents.find((agent) => agent.id === event.target.value); setAgentId(event.target.value); setSkillId(next?.skills_allowed[0] ?? ''); }} className="mt-1 w-full rounded-lg border border-slate-200 px-2 py-1.5 text-xs">{agents.map((agent: ChatAgent) => <option key={agent.id} value={agent.id}>{agent.label}{agent.allowed_for_role ? '' : ' (locked)'}</option>)}</select></label><label className="col-span-2">Skill<select value={selectedSkill} onChange={(event) => setSkillId(event.target.value)} className="mt-1 w-full rounded-lg border border-slate-200 px-2 py-1.5 text-xs">{(selectedAgent?.skills_allowed ?? []).map((skill) => <option key={skill}>{skill}</option>)}</select></label></div></details>
</div>
<div className="mt-5 text-[10px] font-bold uppercase tracking-[0.15em] text-slate-400">Latest verification</div>
<div className="mt-2 space-y-2">
<div className="rounded-xl border border-slate-200 bg-white p-3"><div className="text-xs text-slate-400">Audit anchor</div><div className="mt-1 break-all font-mono text-[11px] text-slate-700">{auditHash(last).slice(0, 22)}{auditHash(last) !== 'n/a' ? '…' : ''}</div></div>
{last && <><div className="rounded-xl border border-slate-200 bg-white p-3"><div className="flex flex-wrap gap-1.5"><StatusBadge value={last.mode} /><StatusBadge value={last.risk} /><StatusBadge value={last.decision} /><StatusBadge value={last.certified ? 'certified' : 'held'} /></div><div className="mt-2 text-xs text-slate-500">{last.router?.reason ?? 'Policy route verified'}{last.synthesis?.fallback_from ? ` · fell back from ${last.synthesis.fallback_from} to local` : ''}</div></div><Card title="Evidence" className="p-3.5"><div className="space-y-2">{last.sources.slice(0, 3).map((source) => <div key={`${source.path}-${source.line ?? ''}`} className="rounded-lg bg-slate-50 p-2.5"><div className="truncate text-xs font-medium text-slate-700">{source.title || source.path}</div><div className="mt-1 text-[11px] leading-4 text-slate-500">{source.preview || source.excerpt || 'Verified source'}</div></div>)}{last.sources.length === 0 && <div className="text-xs text-slate-500">No source was returned for this decision.</div>}</div></Card></>}
</div>
<div className="mt-5 text-[10px] font-bold uppercase tracking-[0.15em] text-slate-400">Registered actions</div>
<div className="mt-2 space-y-2">{(actionsQuery.data?.actions ?? []).slice(0, 3).map((action: ChatAction) => <button key={action.id} type="button" disabled={busy} onClick={() => { const trigger = action.triggers[0] || action.id; setMessage(trigger); setPendingMessage(trigger); ask.mutate({ message: trigger, agentId: 'ops-operator', skillId: 'registered-actions' }); }} className="w-full rounded-xl border border-slate-200 bg-white p-3 text-left transition hover:border-indigo-200 hover:bg-indigo-50/50 disabled:opacity-50"><div className="flex items-center gap-2 text-sm font-semibold text-slate-800"><Glyph name="bolt" />{action.label}</div><p className="mt-1 text-xs leading-5 text-slate-500">{action.description}</p></button>)}{actionsQuery.isError && <div className="text-xs text-rose-600">Registered actions are unavailable.</div>}</div>
</aside>
</div>
</div>
);
}
@@ -2,36 +2,71 @@ import { useQuery } from '@tanstack/react-query';
import { api } from '../lib/api';
import { Card, StatTile, StatusBadge } from '../components/ui/Card';
function SignalMark({ tone }: { tone: 'indigo' | 'emerald' | 'amber' }) {
const color = { indigo: 'bg-indigo-500', emerald: 'bg-emerald-500', amber: 'bg-amber-500' }[tone];
return <span className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${color}`} />;
}
export function Overview() {
const { data, isLoading, isError } = useQuery({ queryKey: ['overview'], queryFn: api.overview });
if (isLoading) return <div className="text-gray-500">Loading…</div>;
if (isError || !data) return <div className="text-red-600">Cannot reach Ops Console API.</div>;
const t = data.totals;
if (isLoading) return <div className="rounded-2xl border border-slate-200 bg-white p-8 text-sm text-slate-500">Loading operational signals…</div>;
if (isError || !data) return <div role="alert" className="rounded-2xl border border-rose-200 bg-rose-50 p-5 text-sm text-rose-700">Cannot reach the Ops Console API.</div>;
const totals = data.totals;
const signalEntries = Object.entries(data.harness_signals);
const posture = totals.failures > 0 || totals.action_blocks > 0 ? 'attention' : 'verified';
return (
<>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatTile label="Runs" value={t.runs} />
<StatTile label="Failures" value={t.failures} />
<StatTile label="Total cost (est)" value={`$${t.total_cost.toFixed(4)}`} sub={`${t.provider_tokens} provider tokens`} />
<StatTile label="Avg latency" value={`${t.avg_latency_ms} ms`} />
<StatTile label="Fallback routes" value={t.fallback_routes} />
<StatTile label="Tool denies" value={t.tool_denies} />
<StatTile label="Action blocks" value={t.action_blocks} />
<StatTile label="Hallucination signals" value={t.hallucination_signals} />
</div>
<Card title="Harness signals (real counts)">
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 text-sm">
{Object.entries(data.harness_signals).map(([h, sig]) => (
<div key={h} className="border border-gray-200 rounded-lg p-3">
<div className="font-medium text-gray-700">{h}</div>
<div className="text-gray-500 mt-1">{Object.entries(sig).map(([k, v]) => `${k}: ${v}`).join(' · ')}</div>
<div className="space-y-5">
<section className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-[0_15px_35px_rgba(15,23,42,0.055)]">
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_300px]">
<div className="relative overflow-hidden px-5 py-6 sm:px-7">
<div className="absolute right-0 top-0 h-40 w-40 translate-x-1/3 -translate-y-1/3 rounded-full bg-indigo-100 blur-2xl" />
<div className="relative">
<div className="text-[11px] font-bold uppercase tracking-[0.16em] text-indigo-600">CASAN system posture</div>
<h2 className="mt-2 text-2xl font-semibold tracking-tight text-slate-900">Governance is visible. Evidence is actionable.</h2>
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-500">Monitor the health of every controlled run, policy decision and model interaction from one evidence-backed control plane.</p>
<div className="mt-5 flex flex-wrap items-center gap-2"><StatusBadge value={posture} /><span className="text-xs text-slate-500">{data.audit_chain.records} audit records · head anchored {data.audit_chain.head ? 'now' : 'pending'}</span></div>
</div>
))}
</div>
<div className="border-t border-slate-200 bg-slate-50/80 p-5 lg:border-l lg:border-t-0">
<div className="text-[10px] font-bold uppercase tracking-[0.14em] text-slate-400">Trust ribbon</div>
<div className="mt-4 space-y-3">
<div className="flex gap-3"><SignalMark tone="emerald" /><div><div className="text-sm font-semibold text-slate-800">Audit chain</div><div className="text-xs text-slate-500">{data.audit_chain.records ? 'Verified telemetry present' : 'Awaiting first record'}</div></div></div>
<div className="flex gap-3"><SignalMark tone={totals.provider_tokens > 0 ? 'indigo' : 'amber'} /><div><div className="text-sm font-semibold text-slate-800">Model telemetry</div><div className="text-xs text-slate-500">{totals.provider_tokens.toLocaleString()} provider tokens observed</div></div></div>
<div className="flex gap-3"><SignalMark tone={totals.failures > 0 ? 'amber' : 'emerald'} /><div><div className="text-sm font-semibold text-slate-800">Execution gate</div><div className="text-xs text-slate-500">{totals.failures ? `${totals.failures} run(s) need review` : 'No failed runs reported'}</div></div></div>
</div>
</div>
</div>
</Card>
<Card title="Audit chain" right={<StatusBadge value={data.audit_chain.last_decision ?? 'n/a'} />}>
<div className="text-sm text-gray-600">records: {data.audit_chain.records} · head: <code className="text-xs">{data.audit_chain.head?.slice(0, 16) ?? '—'}…</code></div>
</Card>
</>
</section>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4 xl:grid-cols-8">
<StatTile label="Runs" value={totals.runs} />
<StatTile label="Failures" value={totals.failures} />
<StatTile label="Model cost" value={`$${totals.total_cost.toFixed(4)}`} sub={`${totals.provider_tokens.toLocaleString()} tokens`} />
<StatTile label="Latency" value={`${totals.avg_latency_ms}ms`} sub="average" />
<StatTile label="Fallbacks" value={totals.fallback_routes} />
<StatTile label="Tool denies" value={totals.tool_denies} />
<StatTile label="Action blocks" value={totals.action_blocks} />
<StatTile label="H-signal flags" value={totals.hallucination_signals} />
</div>
<div className="grid grid-cols-1 gap-5 xl:grid-cols-[minmax(0,1fr)_330px]">
<Card title="Harness signal map" right={<span className="text-xs text-slate-400">Live aggregate</span>}>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
{signalEntries.map(([harness, signal], index) => (
<div key={harness} className="group rounded-xl border border-slate-200 bg-slate-50/70 p-4 transition hover:border-indigo-200 hover:bg-white hover:shadow-sm">
<div className="flex items-center justify-between gap-3"><span className="font-mono text-xs font-semibold text-indigo-600">{harness}</span><span className="text-[11px] text-slate-400">0{index + 1}</span></div>
<div className="mt-3 text-sm font-medium leading-6 text-slate-700">{Object.entries(signal).map(([key, value]) => `${key.replaceAll('_', ' ')}: ${value}`).join(' · ')}</div>
</div>
))}
{signalEntries.length === 0 && <div className="text-sm text-slate-500">No harness signal aggregate has been recorded yet.</div>}
</div>
</Card>
<Card title="Audit anchor" right={<StatusBadge value={data.audit_chain.last_decision ?? 'pending'} />}>
<div className="rounded-xl bg-slate-950 p-4 text-slate-100"><div className="text-[10px] font-bold uppercase tracking-[0.14em] text-slate-400">Current ledger head</div><div className="mt-2 break-all font-mono text-xs leading-5">{data.audit_chain.head ?? 'No audit head yet'}</div></div>
<p className="mt-4 text-sm leading-6 text-slate-500">This value changes only when the governed ledger accepts a new event. Use the Governance view to inspect the decision path.</p>
</Card>
</div>
</div>
);
}