- 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
194 lines
8.8 KiB
TypeScript
194 lines
8.8 KiB
TypeScript
"use client";
|
||
|
||
import * as React from "react";
|
||
import { gateLabelForStatus } from "@tasks/shared/convoy-lifecycle";
|
||
import type { ConvoyStatus } from "@tasks/shared/convoy-lifecycle";
|
||
import { Loader2, Radar } from "lucide-react";
|
||
|
||
import { ConvoyDetailPanel } from "@/components/mission-control/convoy-detail-panel";
|
||
import { OpsCopilotFab } from "@/components/copilot/ops-copilot-panel";
|
||
import { FleetActivityFeed } from "@/components/fleet/fleet-activity-feed";
|
||
import { cn } from "@/lib/utils";
|
||
import { api } from "@/lib/trpc";
|
||
|
||
function formatAge(date: Date | string): string {
|
||
const d = new Date(date);
|
||
const diff = Date.now() - d.getTime();
|
||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||
if (days > 0) return `${days}d`;
|
||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||
if (hours > 0) return `${hours}h`;
|
||
const mins = Math.floor(diff / (1000 * 60));
|
||
return `${mins}m`;
|
||
}
|
||
|
||
export default function FleetPage() {
|
||
const fleetQuery = api.convoys.fleet.useQuery();
|
||
const [selected, setSelected] = React.useState<{ convoyId: string; workspaceSlug: string } | null>(null);
|
||
|
||
if (fleetQuery.isLoading) {
|
||
return (
|
||
<div className="flex items-center gap-2 p-6 text-sm text-muted-foreground">
|
||
<Loader2 className="size-4 animate-spin" />
|
||
Loading fleet…
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (fleetQuery.error) {
|
||
return <p className="p-6 text-sm text-destructive">{fleetQuery.error.message}</p>;
|
||
}
|
||
|
||
const data = fleetQuery.data;
|
||
const byWorkspace = data?.byWorkspace ?? [];
|
||
const gateInboxAcross = data?.gateInboxAcross ?? [];
|
||
const defaultWorkspaceSlug =
|
||
selected?.workspaceSlug ?? byWorkspace[0]?.workspace.slug ?? gateInboxAcross[0]?.workspaceSlug ?? "";
|
||
|
||
// Group gate inbox by status for "× pr_review 2d" count display
|
||
const gateByStatus = new Map<string, typeof gateInboxAcross>();
|
||
for (const c of gateInboxAcross) {
|
||
const arr = gateByStatus.get(c.status) ?? [];
|
||
arr.push(c);
|
||
gateByStatus.set(c.status, arr);
|
||
}
|
||
|
||
return (
|
||
<div className="flex h-full min-h-0 flex-col">
|
||
<header className="flex shrink-0 items-center gap-3 border-b border-border px-6 py-4">
|
||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
|
||
<Radar className="size-5 text-primary" />
|
||
</div>
|
||
<div>
|
||
<h1 className="text-lg font-semibold">Fleet</h1>
|
||
<p className="text-xs text-muted-foreground">All workspaces · gates needing you · live convoy events</p>
|
||
</div>
|
||
</header>
|
||
|
||
<div className="grid min-h-0 flex-1 grid-cols-1 gap-4 overflow-y-auto p-4 lg:grid-cols-[260px_1fr_340px]">
|
||
{/* Needs you */}
|
||
<div className="flex min-h-0 flex-col gap-4">
|
||
<section className="rounded-lg border border-border bg-card">
|
||
<div className="border-b border-border p-4">
|
||
<h2 className="text-sm font-semibold">Needs you</h2>
|
||
<p className="text-xs text-muted-foreground">Gate inbox across all workspaces</p>
|
||
{gateInboxAcross.length > 0 && (
|
||
<div className="mt-2 flex flex-wrap gap-2 text-xs">
|
||
{[...gateByStatus.entries()].map(([status, list]) => (
|
||
<span key={status} className="rounded bg-amber-500/10 px-2 py-0.5 text-amber-700 dark:text-amber-300">
|
||
× {status} {list.length} · {formatAge(list[0]?.updatedAt ?? new Date())}
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="p-3">
|
||
{gateInboxAcross.length === 0 ? (
|
||
<p className="text-sm text-muted-foreground">No pending gates.</p>
|
||
) : (
|
||
<ul className="space-y-2">
|
||
{gateInboxAcross.map((convoy) => (
|
||
<li key={`${convoy.workspaceId}-${convoy.id}`}>
|
||
<button
|
||
type="button"
|
||
onClick={() => setSelected({ convoyId: convoy.id, workspaceSlug: convoy.workspaceSlug })}
|
||
className={cn(
|
||
"w-full rounded-md border border-amber-500/30 bg-amber-500/5 p-3 text-left text-sm hover:bg-amber-500/10",
|
||
selected?.convoyId === convoy.id && "ring-2 ring-amber-500",
|
||
)}
|
||
>
|
||
<p className="font-medium">{convoy.content?.convoySlug ?? convoy.title}</p>
|
||
<p className="text-xs text-amber-700 dark:text-amber-300">{gateLabelForStatus(convoy.status as ConvoyStatus) ?? convoy.status}</p>
|
||
<p className="text-[10px] text-muted-foreground">
|
||
{convoy.workspaceName} · {convoy.workspaceSlug} · {formatAge(convoy.updatedAt)} ago
|
||
</p>
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
</section>
|
||
|
||
<FleetActivityFeed />
|
||
</div>
|
||
|
||
{/* byWorkspace cards grid */}
|
||
<div className="min-h-0 space-y-4">
|
||
{byWorkspace.length === 0 ? (
|
||
<p className="rounded-lg border border-dashed p-6 text-sm text-muted-foreground">No workspaces or no convoys yet.</p>
|
||
) : (
|
||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||
{byWorkspace.map(({ workspace, convoys }) => {
|
||
const workspaceGateCount = gateInboxAcross.filter((c) => c.workspaceId === workspace.id).length;
|
||
return (
|
||
<div key={workspace.id} className="rounded-lg border border-border bg-card">
|
||
<div className="border-b border-border px-4 py-3">
|
||
<h3 className="text-sm font-semibold">{workspace.name}</h3>
|
||
<p className="text-xs text-muted-foreground">
|
||
{workspace.slug} · {convoys.length} convoys
|
||
{workspaceGateCount > 0 ? ` · ${workspaceGateCount} needing you` : ""}
|
||
</p>
|
||
</div>
|
||
<ul className="space-y-2 p-3">
|
||
{convoys.length === 0 ? (
|
||
<li className="text-xs text-muted-foreground">No convoys.</li>
|
||
) : (
|
||
convoys.slice(0, 8).map((convoy) => (
|
||
<li key={convoy.id}>
|
||
<button
|
||
type="button"
|
||
onClick={() => setSelected({ convoyId: convoy.id, workspaceSlug: convoy.workspaceSlug })}
|
||
className={cn(
|
||
"w-full rounded-md border border-border bg-background p-2 text-left text-sm transition-colors hover:bg-muted",
|
||
selected?.convoyId === convoy.id && "ring-2 ring-primary",
|
||
)}
|
||
>
|
||
<p className="font-medium">{convoy.content?.convoySlug ?? convoy.title}</p>
|
||
<p className="text-[10px] text-muted-foreground">
|
||
{convoy.status} · {convoy.content?.classification ?? ""} · {formatAge(convoy.updatedAt)} ago
|
||
</p>
|
||
</button>
|
||
</li>
|
||
))
|
||
)}
|
||
{convoys.length > 8 && (
|
||
<li className="text-center text-xs text-muted-foreground">+{convoys.length - 8} more</li>
|
||
)}
|
||
</ul>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{/* byStatus summary */}
|
||
{data?.byStatus && Object.keys(data.byStatus).length > 0 && (
|
||
<div className="rounded-lg border border-border bg-muted/10 p-3">
|
||
<p className="mb-2 text-xs font-semibold uppercase text-muted-foreground">By status</p>
|
||
<div className="flex flex-wrap gap-2">
|
||
{Object.entries(data.byStatus).map(([status, list]) => (
|
||
<span key={status} className="rounded border border-border bg-background px-2 py-1 text-xs">
|
||
{status}: {(list as unknown[]).length}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Detail panel workspace-agnostic */}
|
||
<aside className="min-h-0 border border-border bg-background lg:rounded-lg">
|
||
{selected ? (
|
||
<ConvoyDetailPanel workspaceSlug={selected.workspaceSlug} convoyId={selected.convoyId} />
|
||
) : (
|
||
<div className="flex h-full items-center justify-center p-6 text-sm text-muted-foreground">
|
||
Select a convoy to review gates and co-pilot suggestions.
|
||
</div>
|
||
)}
|
||
</aside>
|
||
</div>
|
||
<OpsCopilotFab workspaceSlug={defaultWorkspaceSlug} />
|
||
</div>
|
||
);
|
||
}
|