Submatter/apps/web/app/(app)/[workspaceSlug]/artifacts/page.tsx
Randall Stillwell 6f139d6af2 feat(owner-cockpit): forge webhooks + artifacts + copilot (Epic 8/11/12)
- Forge webhook POST /api/webhooks/forge HMAC sha256 verify, marker echodo-convoy → pr_review→merged as automation
- Completion artifacts table 0017, deterministic summary template, gallery /artifacts
- Ops copilot tRPC copilot + context tools (fleet, convoy, backlog) + panel + Draft Task → backlog.createTask
- Doc-writer SOPs: webhook rotation, copilot prompt, published flow examples
- Allow pr_review→merged as automation for webhook

Convoy: .convoys/owner-cockpit-v02

Co-authored-by: orchestrator
2026-08-25 17:49:38 -05:00

204 lines
9.1 KiB
TypeScript

"use client";
import * as React from "react";
import { useParams } from "next/navigation";
import { ExternalLink, ImageOff, Loader2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { api } from "@/lib/trpc";
export default function ArtifactsPage() {
const params = useParams();
const workspaceSlug =
typeof params?.workspaceSlug === "string" ? params.workspaceSlug : undefined;
const [selectedId, setSelectedId] = React.useState<string | null>(null);
const artifactsQuery = api.convoys.listArtifacts.useQuery(
{ workspace: workspaceSlug! },
{ enabled: Boolean(workspaceSlug) },
);
const artifacts = artifactsQuery.data ?? [];
const selected = artifacts.find((a) => a.id === selectedId) ?? null;
if (!workspaceSlug) return null;
return (
<div className="flex h-full min-h-0 flex-col">
<header className="shrink-0 border-b border-border px-6 py-4">
<h1 className="text-lg font-semibold">Completion artifacts</h1>
<p className="text-xs text-muted-foreground">
Gallery of released convoys — summary + PR link + timeline
</p>
</header>
<div className="grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[1fr_380px]">
<section className="min-h-0 overflow-y-auto p-6">
{artifactsQuery.isLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading artifacts…
</div>
) : artifacts.length === 0 ? (
<div className="rounded-md border border-dashed border-border p-8 text-center">
<p className="text-sm font-medium">No completion artifacts yet</p>
<p className="mt-1 text-xs text-muted-foreground">
Complete a convoy from merged or published via human-gate-3 to generate one.
</p>
<div className="mt-4 inline-flex items-center gap-2 rounded-md border border-border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
<ImageOff className="size-4" />
Screenshot upload placeholder — shows when no image is attached
</div>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{artifacts.map((artifact) => {
const isSelected = artifact.id === selectedId;
return (
<button
key={artifact.id}
type="button"
onClick={() => setSelectedId(artifact.id)}
className={`text-left ${isSelected ? "ring-2 ring-primary" : ""} rounded-lg`}
>
<Card className={`h-full ${isSelected ? "border-primary" : ""}`}>
<CardHeader className="pb-2">
<div className="flex items-center justify-between gap-2">
<CardTitle className="truncate text-sm">{artifact.convoySlug}</CardTitle>
<Badge variant="secondary" className="shrink-0 text-[10px]">
{artifact.convoyStatus}
</Badge>
</div>
<p className="text-xs text-muted-foreground">
{new Date(artifact.createdAt).toLocaleString()}
</p>
</CardHeader>
<CardContent className="space-y-3">
{artifact.screenshotUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={artifact.screenshotUrl}
alt="screenshot"
className="h-32 w-full rounded-md object-cover"
/>
) : (
<div className="flex h-32 w-full items-center justify-center rounded-md border border-dashed border-border bg-muted/20 p-3 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
<ImageOff className="size-4" />
Screenshot upload placeholder
</span>
</div>
)}
<pre className="line-clamp-3 whitespace-pre-wrap break-words rounded bg-muted/30 p-2 text-xs">
{artifact.summaryMd.slice(0, 180)}
{artifact.summaryMd.length > 180 ? "…" : ""}
</pre>
{artifact.pr.prUrl && (
<a
href={artifact.pr.prUrl}
target="_blank"
rel="noreferrer"
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-1 text-xs text-primary underline"
>
PR <ExternalLink className="size-3" />
</a>
)}
</CardContent>
</Card>
</button>
);
})}
</div>
)}
</section>
<aside className="min-h-0 overflow-y-auto border-l border-border bg-background p-4">
{!selected ? (
<p className="py-8 text-center text-sm text-muted-foreground">
Select an artifact to see detail summary + PR link + convoy timeline.
</p>
) : (
<div className="space-y-4">
<div>
<h2 className="text-sm font-semibold">{selected.convoySlug}</h2>
<p className="text-xs text-muted-foreground">{selected.convoyTitle}</p>
<p className="text-xs text-muted-foreground">{selected.convoyStatus}</p>
</div>
<div className="rounded-md border border-border bg-muted/30 p-3">
<p className="mb-1 text-xs font-medium uppercase text-muted-foreground">Summary</p>
<pre className="whitespace-pre-wrap break-words text-sm">
{selected.summaryMd}
</pre>
</div>
<div className="rounded-md border border-border p-3">
<p className="mb-1 text-xs font-medium uppercase text-muted-foreground">PR link</p>
{selected.pr.prUrl ? (
<a
href={selected.pr.prUrl}
target="_blank"
rel="noreferrer"
className="break-all text-sm text-primary underline"
>
{selected.pr.prUrl}
{selected.pr.prNumber ? ` (#${selected.pr.prNumber})` : ""}
</a>
) : (
<p className="text-xs text-muted-foreground">No PR linked</p>
)}
</div>
{selected.screenshotUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={selected.screenshotUrl}
alt="screenshot"
className="w-full rounded-md border border-border object-cover"
/>
) : (
<div className="flex h-32 w-full items-center justify-center rounded-md border border-dashed border-border bg-muted/20 p-3 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
<ImageOff className="size-4" />
Screenshot upload placeholder — no screenshot yet
</span>
</div>
)}
<div>
<p className="mb-2 text-xs font-medium uppercase text-muted-foreground">
Convoy timeline ({selected.timeline.length})
</p>
{selected.timeline.length === 0 ? (
<p className="text-xs text-muted-foreground">No events</p>
) : (
<ul className="space-y-1">
{selected.timeline.map((ev) => (
<li
key={ev.id}
className="rounded border border-border bg-muted/10 px-2 py-1.5 text-xs"
>
<div className="flex items-center justify-between gap-2">
<span className="font-medium">{ev.eventType}</span>
<span className="text-muted-foreground">
{new Date(ev.occurredAt).toLocaleString()}
</span>
</div>
<p className="text-muted-foreground">
{ev.role}
{ev.actor ? ` · ${ev.actor}` : ""}
</p>
</li>
))}
</ul>
)}
</div>
</div>
)}
</aside>
</div>
</div>
);
}