Files
patchpass/UI/src/pages/RequestDetail.tsx
T

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>
);
}