206 lines
7.4 KiB
TypeScript
206 lines
7.4 KiB
TypeScript
import { useCallback, useEffect, useState } from "react";
|
|
import { Link, useParams } from "react-router-dom";
|
|
import { requests as requestsApi, ApiError } from "../api/client";
|
|
import type { ChangeRequest } from "../api/types";
|
|
import { useNotifications } from "../context/NotificationsContext";
|
|
import { Button, Card, Spinner } from "../components/ui";
|
|
import { StateBadge } from "../components/StateBadge";
|
|
import { ChangeList } from "../components/ChangeRenderer";
|
|
import { AgentAvatar } from "../components/AgentAvatar";
|
|
import { DecisionModal } from "../components/DecisionModal";
|
|
import { expiresIn, formatDateTime, relativeTime } from "../utils";
|
|
|
|
type Decision = "APPROVE" | "REJECT" | "REQUEST_CHANGES";
|
|
|
|
export function RequestDetailPage() {
|
|
const { id } = useParams<{ id: string }>();
|
|
const { requestEventTick } = useNotifications();
|
|
const [request, setRequest] = useState<ChangeRequest | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [decision, setDecision] = useState<Decision | null>(null);
|
|
|
|
const load = useCallback(async () => {
|
|
if (!id) return;
|
|
try {
|
|
setRequest(await requestsApi.get(id));
|
|
setError(null);
|
|
} catch (err) {
|
|
setError(err instanceof ApiError ? err.message : "Failed to load");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [id]);
|
|
|
|
useEffect(() => {
|
|
load();
|
|
}, [load, requestEventTick]);
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex justify-center py-20 text-primary">
|
|
<Spinner className="h-7 w-7" />
|
|
</div>
|
|
);
|
|
}
|
|
if (error || !request) {
|
|
return (
|
|
<Card className="p-10 text-center">
|
|
<p className="text-muted">{error ?? "Not found"}</p>
|
|
<Link to="/requests" className="mt-4 inline-block text-primary hover:underline">
|
|
← Back to requests
|
|
</Link>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
const exp = expiresIn(request.expires_at);
|
|
const canDecide = request.state === "PENDING";
|
|
|
|
return (
|
|
<div className="animate-fade-in">
|
|
<Link to="/requests" className="mb-4 inline-block text-sm text-muted hover:text-text">
|
|
← Back to requests
|
|
</Link>
|
|
|
|
{request.resubmitted && request.state === "PENDING" && (
|
|
<div className="mb-4 flex items-center gap-3 rounded-xl border border-changes/40 bg-changes/10 px-4 py-3">
|
|
<span className="animate-pulse-ring rounded-full bg-changes/25 px-2 py-0.5 text-xs font-bold text-changes">
|
|
UPDATED
|
|
</span>
|
|
<p className="text-sm text-text">
|
|
This request was revised by the agent after you requested changes (update #{request.update_count}).
|
|
Please re-review the changes below.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid gap-6 lg:grid-cols-3">
|
|
<div className="order-2 lg:order-1 lg:col-span-2">
|
|
<div className="mb-4 flex items-start justify-between gap-4">
|
|
<div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<h1 className="break-words text-xl font-bold tracking-tight sm:text-2xl">{request.title}</h1>
|
|
<StateBadge state={request.state} />
|
|
</div>
|
|
{request.description && <p className="mt-2 text-muted">{request.description}</p>}
|
|
</div>
|
|
</div>
|
|
|
|
{request.comment && (
|
|
<div className="mb-5 rounded-xl border border-border bg-surface-raised/40 p-4">
|
|
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-faint">
|
|
{request.state === "CHANGES_REQUESTED" ? "Changes requested" : "Reviewer note"}
|
|
</p>
|
|
<p className="text-sm text-text">{request.comment}</p>
|
|
</div>
|
|
)}
|
|
|
|
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-faint">
|
|
Proposed changes ({request.changes.length})
|
|
</h2>
|
|
<ChangeList changes={request.changes} />
|
|
</div>
|
|
|
|
<div className="order-1 space-y-4 lg:order-2">
|
|
{canDecide && (
|
|
<Card className="space-y-2 p-4">
|
|
<p className="mb-1 text-sm font-semibold">Your decision</p>
|
|
<Button variant="success" className="w-full" onClick={() => setDecision("APPROVE")}>
|
|
Approve
|
|
</Button>
|
|
<Button variant="primary" className="w-full" onClick={() => setDecision("REQUEST_CHANGES")}>
|
|
Request changes
|
|
</Button>
|
|
<Button variant="danger" className="w-full" onClick={() => setDecision("REJECT")}>
|
|
Reject
|
|
</Button>
|
|
<p className={`pt-1 text-center text-xs ${exp.urgent ? "text-pending" : "text-faint"}`}>
|
|
{exp.text}
|
|
</p>
|
|
</Card>
|
|
)}
|
|
|
|
<Card className="p-4">
|
|
<p className="mb-3 text-sm font-semibold">Agent</p>
|
|
{request.agent ? (
|
|
<div className="flex items-center gap-3">
|
|
<AgentAvatar name={request.agent.name} iconUrl={request.agent.icon_url} />
|
|
<div className="min-w-0">
|
|
<p className="truncate text-sm font-medium">{request.agent.name}</p>
|
|
{request.agent.website && (
|
|
<a
|
|
href={request.agent.website}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="truncate text-xs text-primary hover:underline"
|
|
>
|
|
{request.agent.website}
|
|
</a>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-muted">Unknown</p>
|
|
)}
|
|
{request.agent?.description && (
|
|
<p className="mt-2 text-xs text-muted">{request.agent.description}</p>
|
|
)}
|
|
</Card>
|
|
|
|
<Card className="space-y-2 p-4 text-xs">
|
|
<Row label="Request ID" value={<code className="text-[11px]">{request.request_id}</code>} />
|
|
<Row
|
|
label="Content hash"
|
|
value={<code className="text-[11px] break-all text-muted">{request.content_hash}</code>}
|
|
/>
|
|
<Row label="Created" value={relativeTime(request.created_at)} />
|
|
<Row label="Expires" value={formatDateTime(request.expires_at)} />
|
|
{request.decided_at && <Row label="Decided" value={formatDateTime(request.decided_at)} />}
|
|
{request.consumed_at && <Row label="Consumed" value={formatDateTime(request.consumed_at)} />}
|
|
{request.update_count > 0 && <Row label="Updates" value={String(request.update_count)} />}
|
|
</Card>
|
|
|
|
{request.metadata && Object.keys(request.metadata).length > 0 && (
|
|
<Card className="p-4">
|
|
<p className="mb-2 text-sm font-semibold">Metadata</p>
|
|
<div className="space-y-1 text-xs">
|
|
{Object.entries(request.metadata).map(([k, v]) => (
|
|
<Row key={k} label={k} value={<span className="text-muted">{String(v)}</span>} />
|
|
))}
|
|
</div>
|
|
</Card>
|
|
)}
|
|
|
|
{request.receipt && (
|
|
<Card className="border-approved/30 bg-approved/5 p-4">
|
|
<p className="mb-2 flex items-center gap-2 text-sm font-semibold text-approved">
|
|
<span>🔏</span> Signed receipt
|
|
</p>
|
|
<div className="space-y-1 text-xs">
|
|
<Row label="Decision" value={request.receipt.payload.decision} />
|
|
<Row label="Algorithm" value={request.receipt.algorithm} />
|
|
<Row
|
|
label="Signature"
|
|
value={<code className="text-[11px] break-all text-muted">{request.receipt.signature}</code>}
|
|
/>
|
|
</div>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<DecisionModal request={request} decision={decision} onClose={() => setDecision(null)} onDone={setRequest} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
|
return (
|
|
<div className="flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between sm:gap-3">
|
|
<span className="shrink-0 text-faint">{label}</span>
|
|
<span className="break-all text-text sm:text-right">{value}</span>
|
|
</div>
|
|
);
|
|
}
|