Patchpass V1
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
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="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="text-2xl font-bold tracking-tight">{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="space-y-4">
|
||||
{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 items-start justify-between gap-3">
|
||||
<span className="shrink-0 text-faint">{label}</span>
|
||||
<span className="text-right text-text">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user